Merge new app
@@ -0,0 +1 @@
|
|||||||
|
../tools/content/agent.json
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
tools/content/CONTEXT.md
|
||||||
@@ -49,6 +49,4 @@ app.*.map.json
|
|||||||
/flutter_data
|
/flutter_data
|
||||||
.fvm/
|
.fvm/
|
||||||
|
|
||||||
android/app/.cxx/
|
|
||||||
|
|
||||||
.env
|
.env
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
# This file should be version controlled and should not be manually edited.
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
version:
|
version:
|
||||||
revision: "bae5e49bc2a867403c43b2aae2de8f8c33b037e4"
|
revision: "fcf2c11572af6f390246c056bc905eca609533a0"
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
|
|
||||||
project_type: app
|
project_type: app
|
||||||
@@ -13,26 +13,26 @@ project_type: app
|
|||||||
migration:
|
migration:
|
||||||
platforms:
|
platforms:
|
||||||
- platform: root
|
- platform: root
|
||||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||||
- platform: android
|
- platform: android
|
||||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||||
- platform: ios
|
- platform: ios
|
||||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||||
- platform: linux
|
- platform: linux
|
||||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||||
- platform: macos
|
- platform: macos
|
||||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||||
- platform: web
|
- platform: web
|
||||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||||
- platform: windows
|
- platform: windows
|
||||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||||
|
|
||||||
# User provided section
|
# User provided section
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../.cursor/mcp.json
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2024 Zapstore
|
Copyright (c) 2025 Zapstore
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
# zapstore
|
# Zapstore
|
||||||
|
|
||||||
|
The open app store powered by your social network
|
||||||
|
|
||||||
|
[https://zapstore.dev](https://zapstore.dev)
|
||||||
|
|
||||||
## Build from source
|
## Build from source
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,7 @@
|
|||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
analyzer:
|
analyzer:
|
||||||
errors:
|
errors:
|
||||||
no_leading_underscores_for_local_identifiers: ignore
|
dangling_library_doc_comments: ignore
|
||||||
must_be_immutable: ignore
|
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
@@ -27,12 +26,5 @@ linter:
|
|||||||
rules:
|
rules:
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
prefer_const_constructors: false
|
|
||||||
prefer_const_constructors_in_immutables: false
|
|
||||||
avoid_print: false
|
|
||||||
prefer_const_literals_to_create_immutables: false
|
|
||||||
no_wildcard_variable_uses: false
|
|
||||||
omit_local_variable_types: true
|
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ gradle-wrapper.jar
|
|||||||
/gradlew.bat
|
/gradlew.bat
|
||||||
/local.properties
|
/local.properties
|
||||||
GeneratedPluginRegistrant.java
|
GeneratedPluginRegistrant.java
|
||||||
|
.cxx/
|
||||||
|
|
||||||
# Remember to never publicly share your keystore.
|
# Remember to never publicly share your keystore.
|
||||||
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
|
# See https://flutter.dev/to/reference-keystore
|
||||||
key.properties
|
key.properties
|
||||||
**/*.keystore
|
**/*.keystore
|
||||||
**/*.jks
|
**/*.jks
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id "com.android.application"
|
|
||||||
id "kotlin-android"
|
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
|
||||||
id "dev.flutter.flutter-gradle-plugin"
|
|
||||||
}
|
|
||||||
|
|
||||||
def localProperties = new Properties()
|
|
||||||
def localPropertiesFile = rootProject.file('local.properties')
|
|
||||||
if (localPropertiesFile.exists()) {
|
|
||||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
|
||||||
localProperties.load(reader)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
|
||||||
if (flutterVersionCode == null) {
|
|
||||||
flutterVersionCode = '1'
|
|
||||||
}
|
|
||||||
|
|
||||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
|
||||||
if (flutterVersionName == null) {
|
|
||||||
flutterVersionName = '1.0'
|
|
||||||
}
|
|
||||||
|
|
||||||
def keystoreProperties = new Properties()
|
|
||||||
def keystorePropertiesFile = rootProject.file('key.properties')
|
|
||||||
if (keystorePropertiesFile.exists()) {
|
|
||||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace "dev.zapstore.app"
|
|
||||||
compileSdk flutter.compileSdkVersion
|
|
||||||
ndkVersion flutter.ndkVersion
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
main.java.srcDirs += 'src/main/kotlin'
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId "dev.zapstore.app"
|
|
||||||
minSdkVersion 21
|
|
||||||
targetSdkVersion flutter.targetSdkVersion
|
|
||||||
versionCode flutterVersionCode.toInteger()
|
|
||||||
versionName flutterVersionName
|
|
||||||
}
|
|
||||||
|
|
||||||
signingConfigs {
|
|
||||||
release {
|
|
||||||
keyAlias keystoreProperties['keyAlias']
|
|
||||||
keyPassword keystoreProperties['keyPassword']
|
|
||||||
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
|
|
||||||
storePassword keystoreProperties['storePassword']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
signingConfig signingConfigs.debug
|
|
||||||
signingConfig signingConfigs.release
|
|
||||||
minifyEnabled true
|
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
flutter {
|
|
||||||
source '../..'
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {}
|
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("kotlin-android")
|
||||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load signing properties from key.properties (not checked into VCS)
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "dev.zapstore.alpha"
|
||||||
|
compileSdk = 36
|
||||||
|
ndkVersion = "27.0.12077973"
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
|
applicationId = "dev.zapstore.alpha"
|
||||||
|
// You can update the following values to match your application needs.
|
||||||
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
|
minSdk = 29
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = flutter.versionCode
|
||||||
|
versionName = flutter.versionName
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
val alias = keystoreProperties["keyAlias"] as String?
|
||||||
|
val keyPass = keystoreProperties["keyPassword"] as String?
|
||||||
|
val storePath = keystoreProperties["storeFile"] as String?
|
||||||
|
val storePass = keystoreProperties["storePassword"] as String?
|
||||||
|
|
||||||
|
if (!storePath.isNullOrBlank()) {
|
||||||
|
storeFile = file(storePath)
|
||||||
|
}
|
||||||
|
if (!alias.isNullOrBlank()) {
|
||||||
|
keyAlias = alias
|
||||||
|
}
|
||||||
|
if (!keyPass.isNullOrBlank()) {
|
||||||
|
keyPassword = keyPass
|
||||||
|
}
|
||||||
|
if (!storePass.isNullOrBlank()) {
|
||||||
|
storePassword = storePass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
// Use release signing config when key.properties is present
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
// Enable code shrinking and resource optimization
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependenciesInfo {
|
||||||
|
// Disables dependency metadata when building APKs (for IzzyOnDroid/F-Droid)
|
||||||
|
includeInApk = false
|
||||||
|
// Disables dependency metadata when building Android App Bundles (for Google Play)
|
||||||
|
includeInBundle = false
|
||||||
|
}
|
||||||
|
|
||||||
|
packagingOptions {
|
||||||
|
jniLibs {
|
||||||
|
// Compress native .so files inside the APK to reduce artifact size
|
||||||
|
useLegacyPackaging = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source = "../.."
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-process:2.7.0")
|
||||||
|
}
|
||||||
@@ -1,6 +1,36 @@
|
|||||||
-dontwarn com.google.errorprone.annotations.CanIgnoreReturnValue
|
# Flutter wrapper
|
||||||
-dontwarn com.google.errorprone.annotations.CheckReturnValue
|
-keep class io.flutter.app.** { *; }
|
||||||
-dontwarn com.google.errorprone.annotations.Immutable
|
-keep class io.flutter.plugin.** { *; }
|
||||||
-dontwarn com.google.errorprone.annotations.RestrictedApi
|
-keep class io.flutter.util.** { *; }
|
||||||
-dontwarn javax.annotation.Nullable
|
-keep class io.flutter.view.** { *; }
|
||||||
-dontwarn javax.annotation.concurrent.GuardedBy
|
-keep class io.flutter.** { *; }
|
||||||
|
-keep class io.flutter.plugins.** { *; }
|
||||||
|
|
||||||
|
# Keep native methods
|
||||||
|
-keepclasseswithmembernames class * {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep Kotlin metadata
|
||||||
|
-keep class kotlin.Metadata { *; }
|
||||||
|
-dontwarn kotlin.**
|
||||||
|
|
||||||
|
# Keep SQLite3 native libraries
|
||||||
|
-keep class org.sqlite.** { *; }
|
||||||
|
|
||||||
|
# Keep package manager plugin classes
|
||||||
|
-keep class dev.zapstore.alpha.plugins.** { *; }
|
||||||
|
|
||||||
|
# Ignore missing Google Play Core classes (optional dependency for dynamic features)
|
||||||
|
-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallException
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManager
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManagerFactory
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest$Builder
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallSessionState
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener
|
||||||
|
-dontwarn com.google.android.play.core.tasks.OnFailureListener
|
||||||
|
-dontwarn com.google.android.play.core.tasks.OnSuccessListener
|
||||||
|
-dontwarn com.google.android.play.core.tasks.Task
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
package="dev.zapstore.app">
|
tools:ignore="QueryAllPackagesPermission">
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||||
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.ENFORCE_UPDATE_OWNERSHIP" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="Zapstore"
|
android:label="Zapstore Alpha"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:allowBackup="false"
|
|
||||||
android:requestLegacyExternalStorage="true">
|
android:requestLegacyExternalStorage="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:enableOnBackInvokedCallback="false"
|
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<!-- !!! enableOnBackInvokedCallback needs to remain false
|
|
||||||
or it breaks the back button navigation -->
|
|
||||||
|
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
the Android process has started. This theme is visible to the user
|
the Android process has started. This theme is visible to the user
|
||||||
while the Flutter UI initializes. After that, this theme continues
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
@@ -27,61 +37,52 @@
|
|||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
android:resource="@style/NormalTheme"
|
android:resource="@style/NormalTheme"
|
||||||
/>
|
/>
|
||||||
<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.APP_MARKET"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
|
||||||
<action
|
<!-- App Links for https://zapstore.dev/apps/... -->
|
||||||
android:name="com.android_package_installer.content.SESSION_API_PACKAGE_INSTALLED"
|
|
||||||
android:exported="false"/>
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
<data android:scheme="zapstore" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter android:autoVerify="true">
|
<intent-filter android:autoVerify="true">
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="http" />
|
<data
|
||||||
<data android:scheme="https" />
|
android:scheme="https"
|
||||||
<data android:host="zapstore.dev" />
|
android:host="zapstore.dev"
|
||||||
<data android:pathPrefix="/download"/>
|
android:pathPrefix="/apps" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Handle market:// URIs (e.g., market://details?id=com.example.app) -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="market" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
<provider
|
|
||||||
android:name="androidx.core.content.FileProvider"
|
<receiver
|
||||||
android:authorities="${applicationId}"
|
android:name="dev.zapstore.alpha.plugins.InstallResultReceiver"
|
||||||
android:grantUriPermissions="true">
|
android:exported="false" />
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/file_paths"/>
|
|
||||||
</provider>
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
|
||||||
|
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility?hl=en and
|
https://developer.android.com/training/package-visibility and
|
||||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
<!-- <queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
</intent>
|
</intent>
|
||||||
</queries> -->
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 174 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
package dev.zapstore.alpha
|
||||||
|
|
||||||
|
import dev.zapstore.alpha.plugins.AndroidPackageManagerPlugin
|
||||||
|
import dev.zapstore.alpha.plugins.AppRestartPlugin
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
|
||||||
|
class MainActivity: FlutterActivity() {
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
|
// Register plugins
|
||||||
|
flutterEngine.plugins.add(AndroidPackageManagerPlugin())
|
||||||
|
flutterEngine.plugins.add(AppRestartPlugin())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,922 @@
|
|||||||
|
package dev.zapstore.alpha.plugins
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.UserManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.system.Os
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||||
|
import io.flutter.plugin.common.MethodChannel.Result
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
private const val TAG = "AndroidPackageManager"
|
||||||
|
|
||||||
|
/** Uninstall polling timeout - 30 seconds is plenty for user to confirm/cancel */
|
||||||
|
private const val UNINSTALL_TIMEOUT_MS = 30_000L
|
||||||
|
private const val UNINSTALL_POLL_INTERVAL_MS = 500L
|
||||||
|
|
||||||
|
/** Watchdog timeouts (bounded with backoff to avoid false negatives on slow devices) */
|
||||||
|
private const val VERIFY_WATCHDOG_MS = 10_000L
|
||||||
|
private const val INSTALL_WATCHDOG_MS = 10_000L
|
||||||
|
private const val MAX_INSTALL_WATCHDOG_MS = 120_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install status values emitted via EventChannel.
|
||||||
|
* These form a simple state machine with no hanging states.
|
||||||
|
*/
|
||||||
|
object InstallStatus {
|
||||||
|
const val STARTED = "started"
|
||||||
|
const val VERIFYING = "verifying"
|
||||||
|
const val PENDING_USER_ACTION = "pendingUserAction"
|
||||||
|
const val ALREADY_IN_PROGRESS = "alreadyInProgress"
|
||||||
|
const val SUCCESS = "success"
|
||||||
|
const val FAILED = "failed"
|
||||||
|
const val CANCELLED = "cancelled"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured error codes for failures.
|
||||||
|
* Dart uses these for reliable error categorization instead of parsing strings.
|
||||||
|
*/
|
||||||
|
object ErrorCode {
|
||||||
|
const val DOWNLOAD_FAILED = "downloadFailed"
|
||||||
|
const val HASH_MISMATCH = "hashMismatch"
|
||||||
|
const val INVALID_FILE = "invalidFile"
|
||||||
|
const val INSTALL_FAILED = "installFailed"
|
||||||
|
const val CERT_MISMATCH = "certMismatch"
|
||||||
|
const val PERMISSION_DENIED = "permissionDenied"
|
||||||
|
const val INSUFFICIENT_STORAGE = "insufficientStorage"
|
||||||
|
const val INCOMPATIBLE = "incompatible"
|
||||||
|
const val BLOCKED = "blocked"
|
||||||
|
const val ALREADY_IN_PROGRESS = "alreadyInProgress"
|
||||||
|
const val INSTALL_TIMEOUT = "installTimeout"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AndroidPackageManagerPlugin - Event-driven architecture for clean state management.
|
||||||
|
*
|
||||||
|
* Architecture:
|
||||||
|
* - MethodChannel: Fire-and-forget commands (install, uninstall, etc.)
|
||||||
|
* - EventChannel: Streams install status events to Dart
|
||||||
|
* - ProcessLifecycleOwner: Auto-detects app foreground state (no Dart involvement)
|
||||||
|
*
|
||||||
|
* The native side is the single source of truth for install state.
|
||||||
|
* Dart simply reacts to events - no polling, no probing, no hanging awaits.
|
||||||
|
*/
|
||||||
|
class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
||||||
|
EventChannel.StreamHandler, DefaultLifecycleObserver {
|
||||||
|
|
||||||
|
private lateinit var methodChannel: MethodChannel
|
||||||
|
private lateinit var eventChannel: EventChannel
|
||||||
|
private lateinit var context: Context
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
/** Per-app watchdog generation counters (cancels old scheduled callbacks) */
|
||||||
|
private val watchdogGen = mutableMapOf<String, Int>()
|
||||||
|
private val watchdogDeadlineMs = mutableMapOf<String, Long>()
|
||||||
|
|
||||||
|
/** Track verification threads so we can "ping" and avoid false timeouts */
|
||||||
|
private val verificationThreads = mutableMapOf<String, Thread>()
|
||||||
|
|
||||||
|
private var eventSink: EventChannel.EventSink? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private var instance: AndroidPackageManagerPlugin? = null
|
||||||
|
|
||||||
|
/** Map sessionId to packageName for reverse lookup in broadcasts */
|
||||||
|
private val sessionToPackage = mutableMapOf<Int, String>()
|
||||||
|
|
||||||
|
/** Pending user action intents - stored for re-launch when app returns to foreground */
|
||||||
|
private val pendingUserActionIntents = mutableMapOf<String, Intent>()
|
||||||
|
|
||||||
|
/** Buffer last-known event per appId when Dart isn't listening yet */
|
||||||
|
private val bufferedEvents = mutableMapOf<String, Map<String, Any?>>()
|
||||||
|
|
||||||
|
/** App foreground state - auto-managed by ProcessLifecycleOwner */
|
||||||
|
private var isAppInForeground = true
|
||||||
|
|
||||||
|
private var appContext: Context? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by InstallResultReceiver when a broadcast arrives.
|
||||||
|
* Emits the status event to Dart via EventChannel.
|
||||||
|
*/
|
||||||
|
fun onInstallResult(
|
||||||
|
sessionId: Int,
|
||||||
|
status: String,
|
||||||
|
packageName: String,
|
||||||
|
message: String? = null,
|
||||||
|
isUpdate: Boolean = false,
|
||||||
|
confirmIntent: Intent? = null,
|
||||||
|
errorCode: String? = null
|
||||||
|
) {
|
||||||
|
val pkg = packageName.ifEmpty { sessionToPackage[sessionId] ?: "unknown" }
|
||||||
|
|
||||||
|
Log.d(TAG, "onInstallResult: pkg=$pkg, status=$status, msg=$message, errorCode=$errorCode, sessionId=$sessionId")
|
||||||
|
|
||||||
|
// Handle pending user action - store intent for potential re-launch
|
||||||
|
if (status == InstallStatus.PENDING_USER_ACTION && confirmIntent != null) {
|
||||||
|
pendingUserActionIntents[pkg] = confirmIntent
|
||||||
|
|
||||||
|
// Auto-launch dialog if app is in foreground
|
||||||
|
if (isAppInForeground) {
|
||||||
|
launchConfirmDialog(pkg, confirmIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up tracking on terminal states
|
||||||
|
if (status in listOf(InstallStatus.SUCCESS, InstallStatus.FAILED, InstallStatus.CANCELLED)) {
|
||||||
|
sessionToPackage.remove(sessionId)
|
||||||
|
pendingUserActionIntents.remove(pkg)
|
||||||
|
|
||||||
|
if (status != InstallStatus.SUCCESS) {
|
||||||
|
abandonSession(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val inst = instance
|
||||||
|
if (inst == null) {
|
||||||
|
Log.e(TAG, "CRITICAL: Cannot emit event - plugin instance is null! pkg=$pkg, status=$status")
|
||||||
|
bufferEvent(pkg, status, message, errorCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inst.emitInstallStatus(pkg, status, message, errorCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bufferEvent(
|
||||||
|
appId: String,
|
||||||
|
status: String,
|
||||||
|
message: String? = null,
|
||||||
|
errorCode: String? = null
|
||||||
|
) {
|
||||||
|
val event = mutableMapOf<String, Any?>(
|
||||||
|
"appId" to appId,
|
||||||
|
"status" to status
|
||||||
|
)
|
||||||
|
if (message != null) event["message"] = message
|
||||||
|
if (errorCode != null) event["errorCode"] = errorCode
|
||||||
|
bufferedEvents[appId] = event
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchConfirmDialog(packageName: String, intent: Intent) {
|
||||||
|
val ctx = appContext ?: return
|
||||||
|
try {
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
ctx.startActivity(intent)
|
||||||
|
Log.d(TAG, "Launched confirmation dialog for $packageName")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to launch confirmation dialog for $packageName", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun abandonSession(sessionId: Int) {
|
||||||
|
val ctx = appContext ?: return
|
||||||
|
try {
|
||||||
|
ctx.packageManager.packageInstaller.abandonSession(sessionId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d(TAG, "Could not abandon session $sessionId: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isAppInForeground(): Boolean = isAppInForeground
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// FLUTTER PLUGIN LIFECYCLE
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
context = binding.applicationContext
|
||||||
|
appContext = context
|
||||||
|
instance = this
|
||||||
|
|
||||||
|
methodChannel = MethodChannel(binding.binaryMessenger, "android_package_manager")
|
||||||
|
methodChannel.setMethodCallHandler(this)
|
||||||
|
|
||||||
|
eventChannel = EventChannel(binding.binaryMessenger, "android_package_manager/events")
|
||||||
|
eventChannel.setStreamHandler(this)
|
||||||
|
|
||||||
|
// Auto-detect foreground state via ProcessLifecycleOwner
|
||||||
|
mainHandler.post {
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupStaleSessions()
|
||||||
|
Log.d(TAG, "AndroidPackageManagerPlugin initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
mainHandler.post {
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
|
||||||
|
}
|
||||||
|
methodChannel.setMethodCallHandler(null)
|
||||||
|
eventChannel.setStreamHandler(null)
|
||||||
|
eventSink = null
|
||||||
|
instance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// LIFECYCLE OBSERVER (Auto foreground detection)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
|
// App came to foreground
|
||||||
|
val wasForeground = isAppInForeground
|
||||||
|
isAppInForeground = true
|
||||||
|
Log.d(TAG, "App foregrounded")
|
||||||
|
|
||||||
|
if (!wasForeground) {
|
||||||
|
// Re-launch any pending install dialogs
|
||||||
|
for ((packageName, intent) in pendingUserActionIntents.toMap()) {
|
||||||
|
Log.d(TAG, "Re-launching pending dialog for $packageName")
|
||||||
|
launchConfirmDialog(packageName, intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop(owner: LifecycleOwner) {
|
||||||
|
// App went to background
|
||||||
|
isAppInForeground = false
|
||||||
|
Log.d(TAG, "App backgrounded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// EVENT CHANNEL STREAM HANDLER
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||||
|
eventSink = events
|
||||||
|
// Flush buffered events (if any) so Dart can reconcile state.
|
||||||
|
val sink = eventSink ?: return
|
||||||
|
mainHandler.post {
|
||||||
|
if (bufferedEvents.isNotEmpty()) {
|
||||||
|
for (event in bufferedEvents.values) {
|
||||||
|
try {
|
||||||
|
sink.success(event)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to flush buffered event: $event", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bufferedEvents.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(arguments: Any?) {
|
||||||
|
eventSink = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitInstallStatus(
|
||||||
|
appId: String,
|
||||||
|
status: String,
|
||||||
|
message: String? = null,
|
||||||
|
errorCode: String? = null,
|
||||||
|
description: String? = null
|
||||||
|
) {
|
||||||
|
val emitNow = emit@{
|
||||||
|
// Update watchdog regardless of whether Dart is listening.
|
||||||
|
updateWatchdog(appId, status)
|
||||||
|
|
||||||
|
val sink = eventSink
|
||||||
|
if (sink == null) {
|
||||||
|
Log.e(TAG, "CRITICAL: Cannot emit event - eventSink is null! appId=$appId, status=$status, msg=$message")
|
||||||
|
bufferEvent(appId, status, message, errorCode)
|
||||||
|
return@emit
|
||||||
|
}
|
||||||
|
|
||||||
|
val event = mutableMapOf<String, Any?>(
|
||||||
|
"appId" to appId,
|
||||||
|
"status" to status
|
||||||
|
)
|
||||||
|
if (message != null) {
|
||||||
|
event["message"] = message
|
||||||
|
}
|
||||||
|
if (errorCode != null) {
|
||||||
|
event["errorCode"] = errorCode
|
||||||
|
}
|
||||||
|
if (description != null) {
|
||||||
|
event["description"] = description
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Emitting to Dart: $event")
|
||||||
|
sink.success(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're already on the main thread (common for MethodChannel calls),
|
||||||
|
// emit synchronously so the UI can transition immediately.
|
||||||
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
|
emitNow()
|
||||||
|
} else {
|
||||||
|
mainHandler.post { emitNow() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bumpWatchdogGen(appId: String): Int {
|
||||||
|
val next = (watchdogGen[appId] ?: 0) + 1
|
||||||
|
watchdogGen[appId] = next
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearWatchdog(appId: String) {
|
||||||
|
bumpWatchdogGen(appId)
|
||||||
|
watchdogDeadlineMs.remove(appId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleWatchdog(appId: String, initialDelayMs: Long) {
|
||||||
|
val gen = bumpWatchdogGen(appId)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val deadline = watchdogDeadlineMs[appId] ?: (now + MAX_INSTALL_WATCHDOG_MS).also {
|
||||||
|
watchdogDeadlineMs[appId] = it
|
||||||
|
}
|
||||||
|
|
||||||
|
mainHandler.postDelayed({
|
||||||
|
if (watchdogGen[appId] != gen) return@postDelayed
|
||||||
|
|
||||||
|
// "Ping" reality before timing out.
|
||||||
|
val hasSession = hasActiveSession(appId)
|
||||||
|
val hasPendingUi = pendingUserActionIntents.containsKey(appId)
|
||||||
|
val verifyThread = verificationThreads[appId]
|
||||||
|
val verifyingAlive = verifyThread?.isAlive == true
|
||||||
|
|
||||||
|
// If work is still happening, extend (bounded by deadline).
|
||||||
|
val now2 = System.currentTimeMillis()
|
||||||
|
if (now2 < deadline && (hasSession || hasPendingUi || verifyingAlive)) {
|
||||||
|
// Nudge Dart to show the most accurate state (esp. after reconnect).
|
||||||
|
if (hasPendingUi) {
|
||||||
|
emitInstallStatus(appId, InstallStatus.PENDING_USER_ACTION, "User confirmation pending")
|
||||||
|
} else if (hasSession) {
|
||||||
|
emitInstallStatus(appId, InstallStatus.STARTED)
|
||||||
|
} else if (verifyingAlive) {
|
||||||
|
emitInstallStatus(appId, InstallStatus.VERIFYING)
|
||||||
|
}
|
||||||
|
// Backoff: next check is another INSTALL_WATCHDOG_MS.
|
||||||
|
scheduleWatchdog(appId, INSTALL_WATCHDOG_MS)
|
||||||
|
return@postDelayed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deadline exceeded or no evidence of progress: fail fast and cleanup.
|
||||||
|
Log.w(TAG, "Watchdog timeout for $appId (hasSession=$hasSession, pendingUi=$hasPendingUi, verifyingAlive=$verifyingAlive)")
|
||||||
|
abandonExistingSession(appId)
|
||||||
|
emitInstallStatus(
|
||||||
|
appId,
|
||||||
|
InstallStatus.FAILED,
|
||||||
|
"Installation timed out. Please retry.",
|
||||||
|
ErrorCode.INSTALL_TIMEOUT
|
||||||
|
)
|
||||||
|
clearWatchdog(appId)
|
||||||
|
}, initialDelayMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateWatchdog(appId: String, status: String) {
|
||||||
|
when (status) {
|
||||||
|
InstallStatus.VERIFYING -> {
|
||||||
|
// Start watchdog for verify stage
|
||||||
|
scheduleWatchdog(appId, VERIFY_WATCHDOG_MS)
|
||||||
|
}
|
||||||
|
InstallStatus.STARTED, InstallStatus.PENDING_USER_ACTION -> {
|
||||||
|
// Start/refresh watchdog for install stage
|
||||||
|
scheduleWatchdog(appId, INSTALL_WATCHDOG_MS)
|
||||||
|
}
|
||||||
|
InstallStatus.SUCCESS, InstallStatus.FAILED, InstallStatus.CANCELLED -> {
|
||||||
|
clearWatchdog(appId)
|
||||||
|
verificationThreads.remove(appId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// METHOD CHANNEL HANDLER
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
override fun onMethodCall(call: MethodCall, result: Result) {
|
||||||
|
when (call.method) {
|
||||||
|
"install" -> {
|
||||||
|
val filePath = call.argument<String>("filePath")
|
||||||
|
val packageName = call.argument<String>("packageName")
|
||||||
|
val expectedHash = call.argument<String>("expectedHash")
|
||||||
|
val expectedSize = call.argument<Number>("expectedSize")?.toLong()
|
||||||
|
|
||||||
|
if (filePath == null || packageName == null) {
|
||||||
|
result.error("MISSING_ARGUMENT", "filePath and packageName required", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
installApk(filePath, packageName, expectedHash, expectedSize, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
"canInstallSilently" -> {
|
||||||
|
val packageName = call.argument<String>("packageName")
|
||||||
|
result.success(canInstallSilently(packageName))
|
||||||
|
}
|
||||||
|
|
||||||
|
"hasUnknownSourcesPermission" -> result.success(hasUnknownSourcesPermission())
|
||||||
|
|
||||||
|
"requestInstallPermission" -> requestInstallPermission(result)
|
||||||
|
|
||||||
|
"getInstalledApps" -> {
|
||||||
|
val includeSystem = call.argument<Boolean>("includeSystemApps") ?: false
|
||||||
|
result.success(getInstalledApps(includeSystem))
|
||||||
|
}
|
||||||
|
|
||||||
|
"uninstall" -> {
|
||||||
|
val packageName = call.argument<String>("packageName")
|
||||||
|
if (packageName == null) {
|
||||||
|
result.error("MISSING_ARGUMENT", "packageName required", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uninstallApp(packageName, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
"launchApp" -> {
|
||||||
|
val packageName = call.argument<String>("packageName")
|
||||||
|
if (packageName == null) {
|
||||||
|
result.error("MISSING_ARGUMENT", "packageName required", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
launchApp(packageName, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// INSTALLATION
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private fun installApk(
|
||||||
|
filePath: String,
|
||||||
|
packageName: String,
|
||||||
|
expectedHash: String?,
|
||||||
|
expectedSize: Long?,
|
||||||
|
result: Result
|
||||||
|
) {
|
||||||
|
val file = File(filePath)
|
||||||
|
if (!file.exists()) {
|
||||||
|
emitInstallStatus(packageName, InstallStatus.FAILED, "APK file not found", ErrorCode.DOWNLOAD_FAILED)
|
||||||
|
result.success(mapOf("started" to false, "error" to "APK file not found", "errorCode" to ErrorCode.DOWNLOAD_FAILED))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasActiveSession(packageName)) {
|
||||||
|
if (pendingUserActionIntents.containsKey(packageName)) {
|
||||||
|
// Real pending dialog - re-launch it and let user complete
|
||||||
|
pendingUserActionIntents[packageName]?.let { launchConfirmDialog(packageName, it) }
|
||||||
|
emitInstallStatus(packageName, InstallStatus.PENDING_USER_ACTION, "User confirmation pending")
|
||||||
|
result.success(mapOf("started" to false, "alreadyInProgress" to true))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// No pending dialog = stale session. Abandon and proceed with fresh install.
|
||||||
|
Log.d(TAG, "Abandoning stale session for $packageName")
|
||||||
|
abandonExistingSession(packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedHash != null && expectedSize != null) {
|
||||||
|
// Emit verifying status before starting hash check
|
||||||
|
emitInstallStatus(packageName, InstallStatus.VERIFYING)
|
||||||
|
result.success(mapOf("started" to true, "verifying" to true))
|
||||||
|
|
||||||
|
val t = Thread {
|
||||||
|
val verification = verifyApk(file, expectedHash, expectedSize)
|
||||||
|
mainHandler.post {
|
||||||
|
if (verification.isSuccess) {
|
||||||
|
startInstallSession(file, packageName, result = null) // Result already sent
|
||||||
|
} else {
|
||||||
|
emitInstallStatus(
|
||||||
|
packageName,
|
||||||
|
InstallStatus.FAILED,
|
||||||
|
verification.errorTitle,
|
||||||
|
verification.errorCode,
|
||||||
|
verification.errorDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
|
verificationThreads.remove(packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verificationThreads[packageName] = t
|
||||||
|
t.start()
|
||||||
|
} else {
|
||||||
|
startInstallSession(file, packageName, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startInstallSession(apkFile: File, packageName: String, result: Result?) {
|
||||||
|
try {
|
||||||
|
val userManager = context.getSystemService(UserManager::class.java)
|
||||||
|
val installBlocked = userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_APPS) ||
|
||||||
|
userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES) ||
|
||||||
|
userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY)
|
||||||
|
|
||||||
|
if (installBlocked) {
|
||||||
|
emitInstallStatus(packageName, InstallStatus.FAILED, "Installation blocked by device policy", ErrorCode.BLOCKED)
|
||||||
|
result?.success(mapOf("started" to false, "error" to "Blocked by policy", "errorCode" to ErrorCode.BLOCKED))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val packageInstaller = context.packageManager.packageInstaller
|
||||||
|
val pkgInfo = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0)
|
||||||
|
if (pkgInfo == null) {
|
||||||
|
emitInstallStatus(packageName, InstallStatus.FAILED, "Invalid APK file", ErrorCode.INVALID_FILE)
|
||||||
|
result?.success(mapOf("started" to false, "error" to "Invalid APK", "errorCode" to ErrorCode.INVALID_FILE))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Use the passed packageName for tracking, not the APK's internal package name
|
||||||
|
// This ensures Dart's operation key matches the events we emit
|
||||||
|
// The APK's packageName should match, but we log a warning if not
|
||||||
|
val trackingPackageName = packageName
|
||||||
|
if (pkgInfo.packageName != packageName) {
|
||||||
|
Log.w(TAG, "Package name mismatch: tracking='$packageName', APK contains='${pkgInfo.packageName}'. Using tracking name for events.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val isUpdate = try {
|
||||||
|
context.packageManager.getPackageInfo(pkgInfo.packageName, 0)
|
||||||
|
true
|
||||||
|
} catch (_: PackageManager.NameNotFoundException) { false }
|
||||||
|
|
||||||
|
abandonExistingSession(trackingPackageName)
|
||||||
|
|
||||||
|
val sessionParams = PackageInstaller.SessionParams(
|
||||||
|
PackageInstaller.SessionParams.MODE_FULL_INSTALL
|
||||||
|
).apply {
|
||||||
|
// Set app package name for proper installer tracking
|
||||||
|
setAppPackageName(pkgInfo.packageName)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||||
|
// Explicitly set ourselves as the installer for update ownership
|
||||||
|
setInstallerPackageName(context.packageName)
|
||||||
|
}
|
||||||
|
setInstallLocation(pkgInfo.installLocation)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
setPackageSource(PackageInstaller.PACKAGE_SOURCE_STORE)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
setRequestUpdateOwnership(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val sessionId = packageInstaller.createSession(sessionParams)
|
||||||
|
val session = packageInstaller.openSession(sessionId)
|
||||||
|
// Use tracking name so events match Dart's operation key
|
||||||
|
sessionToPackage[sessionId] = trackingPackageName
|
||||||
|
|
||||||
|
FileInputStream(apkFile).use { fis ->
|
||||||
|
val fileSize = Os.fstat(fis.fd).st_size
|
||||||
|
val sessionStream = session.openWrite(apkFile.name, 0, fileSize)
|
||||||
|
fis.copyTo(sessionStream)
|
||||||
|
fis.close()
|
||||||
|
session.fsync(sessionStream)
|
||||||
|
sessionStream.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(context.applicationContext, InstallResultReceiver::class.java).apply {
|
||||||
|
putExtra("sessionId", sessionId)
|
||||||
|
// Use tracking name so events match Dart's operation key
|
||||||
|
putExtra("packageName", trackingPackageName)
|
||||||
|
putExtra("isUpdate", isUpdate)
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context.applicationContext, sessionId, intent,
|
||||||
|
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
session.commit(pendingIntent.intentSender)
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
emitInstallStatus(trackingPackageName, InstallStatus.STARTED)
|
||||||
|
result?.success(mapOf("started" to true, "sessionId" to sessionId, "packageName" to trackingPackageName))
|
||||||
|
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
emitInstallStatus(packageName, InstallStatus.FAILED, "Permission denied: ${e.message}", ErrorCode.PERMISSION_DENIED)
|
||||||
|
result?.success(mapOf("started" to false, "error" to "Permission denied", "errorCode" to ErrorCode.PERMISSION_DENIED))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emitInstallStatus(packageName, InstallStatus.FAILED, e.message ?: "Installation failed", ErrorCode.INSTALL_FAILED)
|
||||||
|
result?.success(mapOf("started" to false, "error" to e.message, "errorCode" to ErrorCode.INSTALL_FAILED))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// SESSION MANAGEMENT
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private fun hasActiveSession(packageName: String): Boolean {
|
||||||
|
return sessionToPackage.containsValue(packageName) ||
|
||||||
|
findExistingSession(packageName) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findExistingSession(packageName: String): PackageInstaller.SessionInfo? {
|
||||||
|
return try {
|
||||||
|
context.packageManager.packageInstaller.mySessions
|
||||||
|
.find { it.appPackageName == packageName }
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun abandonExistingSession(packageName: String) {
|
||||||
|
val session = findExistingSession(packageName) ?: return
|
||||||
|
try {
|
||||||
|
context.packageManager.packageInstaller.abandonSession(session.sessionId)
|
||||||
|
sessionToPackage.remove(session.sessionId)
|
||||||
|
pendingUserActionIntents.remove(packageName)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanupStaleSessions() {
|
||||||
|
try {
|
||||||
|
val installer = context.packageManager.packageInstaller
|
||||||
|
installer.mySessions.filter { !it.isActive }.forEach { session ->
|
||||||
|
try { installer.abandonSession(session.sessionId) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
sessionToPackage.clear()
|
||||||
|
pendingUserActionIntents.clear()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// APK VERIFICATION
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private data class VerificationResult(
|
||||||
|
val isSuccess: Boolean,
|
||||||
|
val errorTitle: String = "",
|
||||||
|
val errorDescription: String? = null,
|
||||||
|
val errorCode: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun verifyApk(file: File, expectedHash: String, expectedSize: Long): VerificationResult {
|
||||||
|
if (!isValidApkFormat(file)) {
|
||||||
|
file.delete()
|
||||||
|
return VerificationResult(
|
||||||
|
false,
|
||||||
|
"Invalid APK file",
|
||||||
|
"The downloaded file is not a valid APK format.",
|
||||||
|
ErrorCode.INVALID_FILE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
FileInputStream(file).use { fis ->
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
var bytesRead: Int
|
||||||
|
while (fis.read(buffer).also { bytesRead = it } != -1) {
|
||||||
|
digest.update(buffer, 0, bytesRead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val actualHash = digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
if (actualHash.lowercase() != expectedHash.lowercase()) {
|
||||||
|
return VerificationResult(
|
||||||
|
false,
|
||||||
|
"Hash verification failed",
|
||||||
|
"The downloaded file hash does not match.\n\nExpected: $expectedHash\nActual: $actualHash",
|
||||||
|
ErrorCode.HASH_MISMATCH
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return VerificationResult(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isValidApkFormat(file: File): Boolean {
|
||||||
|
return try {
|
||||||
|
FileInputStream(file).use { fis ->
|
||||||
|
val magic = ByteArray(4)
|
||||||
|
if (fis.read(magic) < 4) return false
|
||||||
|
magic[0] == 0x50.toByte() && magic[1] == 0x4B.toByte() &&
|
||||||
|
magic[2] == 0x03.toByte() && magic[3] == 0x04.toByte()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// PERMISSIONS
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private fun canInstallSilently(packageName: String? = null): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||||
|
Log.d(TAG, "canInstallSilently($packageName): false - API < 31")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val canRequest = try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.packageManager.canRequestPackageInstalls()
|
||||||
|
} else true
|
||||||
|
} catch (_: Throwable) { false }
|
||||||
|
|
||||||
|
if (!canRequest) {
|
||||||
|
Log.d(TAG, "canInstallSilently($packageName): false - no install permission")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packageName != null) {
|
||||||
|
return try {
|
||||||
|
val installSource = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
context.packageManager.getInstallSourceInfo(packageName)
|
||||||
|
} else null
|
||||||
|
|
||||||
|
val installer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && installSource != null) {
|
||||||
|
installSource.installingPackageName
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
context.packageManager.getInstallerPackageName(packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
val ourPackage = context.packageName
|
||||||
|
val result = installer == ourPackage
|
||||||
|
|
||||||
|
Log.d(TAG, "canInstallSilently($packageName): installer=$installer, us=$ourPackage, result=$result")
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && installSource != null) {
|
||||||
|
Log.d(TAG, " initiatingPackage=${installSource.initiatingPackageName}")
|
||||||
|
Log.d(TAG, " originatingPackage=${installSource.originatingPackageName}")
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
} catch (_: PackageManager.NameNotFoundException) {
|
||||||
|
Log.d(TAG, "canInstallSilently($packageName): false - package not found")
|
||||||
|
false // Package not found = can't silently install it
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d(TAG, "canInstallSilently($packageName): false - error: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check general capability (no specific package)
|
||||||
|
val hasPermission = try {
|
||||||
|
context.checkSelfPermission("android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION") ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
} catch (_: Throwable) { false }
|
||||||
|
|
||||||
|
Log.d(TAG, "canInstallSilently(general): hasUpdatePermission=$hasPermission")
|
||||||
|
return hasPermission
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasUnknownSourcesPermission(): Boolean {
|
||||||
|
return try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.packageManager.canRequestPackageInstalls()
|
||||||
|
} else true
|
||||||
|
} catch (_: Throwable) { false }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestInstallPermission(result: Result) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||||
|
!context.packageManager.canRequestPackageInstalls()) {
|
||||||
|
try {
|
||||||
|
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||||
|
data = Uri.parse("package:${context.packageName}")
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
result.success(mapOf("success" to true))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.success(mapOf("success" to false, "message" to e.message))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.success(mapOf("success" to true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// UNINSTALL
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private fun uninstallApp(packageName: String, result: Result) {
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
context.packageManager.getPackageInfo(packageName, 0)
|
||||||
|
} catch (_: PackageManager.NameNotFoundException) {
|
||||||
|
result.success(mapOf("isSuccess" to false, "errorMessage" to "Package not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_DELETE).apply {
|
||||||
|
data = Uri.parse("package:$packageName")
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
|
||||||
|
// Poll for removal with 30 second timeout
|
||||||
|
Thread {
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
while (System.currentTimeMillis() - startTime < UNINSTALL_TIMEOUT_MS) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(UNINSTALL_POLL_INTERVAL_MS)
|
||||||
|
context.packageManager.getPackageInfo(packageName, 0)
|
||||||
|
} catch (_: PackageManager.NameNotFoundException) {
|
||||||
|
mainHandler.post {
|
||||||
|
result.success(mapOf("isSuccess" to true, "packageName" to packageName))
|
||||||
|
}
|
||||||
|
return@Thread
|
||||||
|
} catch (_: InterruptedException) { break }
|
||||||
|
}
|
||||||
|
|
||||||
|
val stillInstalled = try {
|
||||||
|
context.packageManager.getPackageInfo(packageName, 0)
|
||||||
|
true
|
||||||
|
} catch (_: PackageManager.NameNotFoundException) { false }
|
||||||
|
|
||||||
|
mainHandler.post {
|
||||||
|
result.success(mapOf(
|
||||||
|
"isSuccess" to !stillInstalled,
|
||||||
|
"packageName" to packageName,
|
||||||
|
"cancelled" to stillInstalled
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.success(mapOf("isSuccess" to false, "errorMessage" to e.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// APP LAUNCH
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private fun launchApp(packageName: String, result: Result) {
|
||||||
|
try {
|
||||||
|
val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName)
|
||||||
|
if (launchIntent == null) {
|
||||||
|
result.success(mapOf("isSuccess" to false, "errorMessage" to "No launch activity"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
result.success(mapOf("isSuccess" to true))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.success(mapOf("isSuccess" to false, "errorMessage" to e.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// INSTALLED APPS
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private fun getInstalledApps(includeSystemApps: Boolean): List<Map<String, Any?>> {
|
||||||
|
val pm = context.packageManager
|
||||||
|
return pm.getInstalledPackages(0).mapNotNull { pkg ->
|
||||||
|
val appInfo = pkg.applicationInfo ?: return@mapNotNull null
|
||||||
|
val isSystem = (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0
|
||||||
|
if (!includeSystemApps && isSystem) return@mapNotNull null
|
||||||
|
|
||||||
|
val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
pkg.longVersionCode
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
pkg.versionCode.toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
mapOf(
|
||||||
|
"name" to appInfo.loadLabel(pm)?.toString(),
|
||||||
|
"bundleId" to pkg.packageName,
|
||||||
|
"versionName" to pkg.versionName,
|
||||||
|
"versionCode" to versionCode,
|
||||||
|
"signatureHash" to getSignatureHash(pkg.packageName),
|
||||||
|
"canInstallSilently" to canInstallSilently(pkg.packageName)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSignatureHash(packageName: String): String {
|
||||||
|
return try {
|
||||||
|
val pm = context.packageManager
|
||||||
|
val pkgInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
pm.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(
|
||||||
|
PackageManager.GET_SIGNING_CERTIFICATES.toLong()))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES)
|
||||||
|
}
|
||||||
|
|
||||||
|
val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
pkgInfo.signingInfo?.let { si ->
|
||||||
|
if (si.hasMultipleSigners()) si.apkContentsSigners else si.signingCertificateHistory
|
||||||
|
} ?: emptyArray()
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
pkgInfo.signatures ?: emptyArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signatures.isEmpty()) return ""
|
||||||
|
|
||||||
|
MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(signatures[0].toByteArray())
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
} catch (_: Exception) { "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package dev.zapstore.alpha.plugins
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
class AppRestartPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||||
|
private lateinit var channel: MethodChannel
|
||||||
|
private lateinit var context: Context
|
||||||
|
|
||||||
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
context = binding.applicationContext
|
||||||
|
channel = MethodChannel(binding.binaryMessenger, "dev.zapstore/app_restart")
|
||||||
|
channel.setMethodCallHandler(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
channel.setMethodCallHandler(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
when (call.method) {
|
||||||
|
"restart" -> {
|
||||||
|
restartApp()
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restartApp() {
|
||||||
|
val packageManager = context.packageManager
|
||||||
|
val intent = packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package dev.zapstore.alpha.plugins
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
private const val TAG = "InstallResultReceiver"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BroadcastReceiver for APK installation results.
|
||||||
|
*
|
||||||
|
* Translates Android PackageInstaller status codes to simple status events
|
||||||
|
* that are emitted to Dart via EventChannel.
|
||||||
|
*
|
||||||
|
* This receiver is stateless - it simply maps broadcasts to events.
|
||||||
|
* All state management happens in AndroidPackageManagerPlugin.
|
||||||
|
*/
|
||||||
|
class InstallResultReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
|
||||||
|
val packageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME)
|
||||||
|
?: intent.getStringExtra("packageName")
|
||||||
|
?: ""
|
||||||
|
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||||
|
val sessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1)
|
||||||
|
val isUpdate = intent.getBooleanExtra("isUpdate", false)
|
||||||
|
|
||||||
|
Log.d(TAG, "Received: status=$status, package=$packageName, session=$sessionId, msg=$message")
|
||||||
|
|
||||||
|
when (status) {
|
||||||
|
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||||
|
// User confirmation required - extract the dialog intent
|
||||||
|
val confirmIntent = extractConfirmIntent(intent)
|
||||||
|
|
||||||
|
AndroidPackageManagerPlugin.onInstallResult(
|
||||||
|
sessionId = sessionId,
|
||||||
|
status = InstallStatus.PENDING_USER_ACTION,
|
||||||
|
packageName = packageName,
|
||||||
|
isUpdate = isUpdate,
|
||||||
|
confirmIntent = confirmIntent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PackageInstaller.STATUS_SUCCESS -> {
|
||||||
|
Log.d(TAG, "Installation successful: $packageName")
|
||||||
|
AndroidPackageManagerPlugin.onInstallResult(
|
||||||
|
sessionId = sessionId,
|
||||||
|
status = InstallStatus.SUCCESS,
|
||||||
|
packageName = packageName,
|
||||||
|
isUpdate = isUpdate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PackageInstaller.STATUS_FAILURE_ABORTED -> {
|
||||||
|
Log.d(TAG, "Installation cancelled: $packageName")
|
||||||
|
AndroidPackageManagerPlugin.onInstallResult(
|
||||||
|
sessionId = sessionId,
|
||||||
|
status = InstallStatus.CANCELLED,
|
||||||
|
packageName = packageName,
|
||||||
|
message = "Installation was cancelled"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PackageInstaller.STATUS_FAILURE -> {
|
||||||
|
Log.w(TAG, "Installation failed: $packageName - $message")
|
||||||
|
// Try to determine error code from message content
|
||||||
|
val errorCode = categorizeFailure(message)
|
||||||
|
AndroidPackageManagerPlugin.onInstallResult(
|
||||||
|
sessionId = sessionId,
|
||||||
|
status = InstallStatus.FAILED,
|
||||||
|
packageName = packageName,
|
||||||
|
message = message ?: "Installation failed",
|
||||||
|
errorCode = errorCode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PackageInstaller.STATUS_FAILURE_BLOCKED -> {
|
||||||
|
Log.w(TAG, "Installation blocked: $packageName")
|
||||||
|
AndroidPackageManagerPlugin.onInstallResult(
|
||||||
|
sessionId = sessionId,
|
||||||
|
status = InstallStatus.FAILED,
|
||||||
|
packageName = packageName,
|
||||||
|
message = message ?: "Installation blocked by device policy",
|
||||||
|
errorCode = ErrorCode.BLOCKED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PackageInstaller.STATUS_FAILURE_CONFLICT -> {
|
||||||
|
Log.w(TAG, "Installation conflict: $packageName")
|
||||||
|
// Conflict typically means signature mismatch
|
||||||
|
AndroidPackageManagerPlugin.onInstallResult(
|
||||||
|
sessionId = sessionId,
|
||||||
|
status = InstallStatus.FAILED,
|
||||||
|
packageName = packageName,
|
||||||
|
message = message ?: "Installation conflicts with existing package",
|
||||||
|
errorCode = ErrorCode.CERT_MISMATCH
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> {
|
||||||
|
Log.w(TAG, "Incompatible package: $packageName")
|
||||||
|
AndroidPackageManagerPlugin.onInstallResult(
|
||||||
|
sessionId = sessionId,
|
||||||
|
status = InstallStatus.FAILED,
|
||||||
|
packageName = packageName,
|
||||||
|
message = message ?: "Package is incompatible with this device",
|
||||||
|
errorCode = ErrorCode.INCOMPATIBLE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PackageInstaller.STATUS_FAILURE_INVALID -> {
|
||||||
|
Log.w(TAG, "Invalid package: $packageName")
|
||||||
|
AndroidPackageManagerPlugin.onInstallResult(
|
||||||
|
sessionId = sessionId,
|
||||||
|
status = InstallStatus.FAILED,
|
||||||
|
packageName = packageName,
|
||||||
|
message = message ?: "Invalid installation package",
|
||||||
|
errorCode = ErrorCode.INVALID_FILE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PackageInstaller.STATUS_FAILURE_STORAGE -> {
|
||||||
|
Log.w(TAG, "Storage failure: $packageName")
|
||||||
|
AndroidPackageManagerPlugin.onInstallResult(
|
||||||
|
sessionId = sessionId,
|
||||||
|
status = InstallStatus.FAILED,
|
||||||
|
packageName = packageName,
|
||||||
|
message = message ?: "Insufficient storage space",
|
||||||
|
errorCode = ErrorCode.INSUFFICIENT_STORAGE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Log.w(TAG, "Unknown status $status: $packageName")
|
||||||
|
AndroidPackageManagerPlugin.onInstallResult(
|
||||||
|
sessionId = sessionId,
|
||||||
|
status = InstallStatus.FAILED,
|
||||||
|
packageName = packageName,
|
||||||
|
message = message ?: "Installation failed (status: $status)",
|
||||||
|
errorCode = ErrorCode.INSTALL_FAILED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorize a generic failure message to determine the error code.
|
||||||
|
*/
|
||||||
|
private fun categorizeFailure(message: String?): String {
|
||||||
|
if (message == null) return ErrorCode.INSTALL_FAILED
|
||||||
|
val lower = message.lowercase()
|
||||||
|
return when {
|
||||||
|
lower.contains("signature") ||
|
||||||
|
lower.contains("certificate") ||
|
||||||
|
lower.contains("update_incompatible") -> ErrorCode.CERT_MISMATCH
|
||||||
|
lower.contains("storage") || lower.contains("space") -> ErrorCode.INSUFFICIENT_STORAGE
|
||||||
|
lower.contains("permission") || lower.contains("denied") -> ErrorCode.PERMISSION_DENIED
|
||||||
|
else -> ErrorCode.INSTALL_FAILED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the confirmation dialog intent from a PENDING_USER_ACTION broadcast.
|
||||||
|
*/
|
||||||
|
private fun extractConfirmIntent(intent: Intent): Intent? {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package dev.zapstore.app
|
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
|
||||||
|
|
||||||
class MainActivity: FlutterActivity()
|
|
||||||
|
After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 211 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 151 B |
@@ -3,10 +3,10 @@
|
|||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?android:colorBackground" />
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
<!-- You can insert your own image assets here -->
|
<!-- Zapstore logo for splash screen -->
|
||||||
<!-- <item>
|
<item>
|
||||||
<bitmap
|
<bitmap
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:src="@mipmap/launch_image" />
|
android:src="@drawable/logo" />
|
||||||
</item> -->
|
</item>
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 270 B |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 430 B |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 659 B |
@@ -3,10 +3,10 @@
|
|||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@android:color/white" />
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
<!-- You can insert your own image assets here -->
|
<!-- Zapstore logo for splash screen -->
|
||||||
<!-- <item>
|
<item>
|
||||||
<bitmap
|
<bitmap
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:src="@mipmap/launch_image" />
|
android:src="@drawable/logo" />
|
||||||
</item> -->
|
</item>
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 187 KiB |
@@ -1,6 +1,14 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
<foreground>
|
||||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
<inset
|
||||||
|
android:drawable="@drawable/ic_launcher_foreground"
|
||||||
|
android:inset="16%" />
|
||||||
|
</foreground>
|
||||||
|
<monochrome>
|
||||||
|
<inset
|
||||||
|
android:drawable="@drawable/ic_launcher_monochrome"
|
||||||
|
android:inset="16%" />
|
||||||
|
</monochrome>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<external-path name="external_files" path="."/>
|
|
||||||
</paths>
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
allprojects {
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
subprojects {
|
|
||||||
afterEvaluate { project ->
|
|
||||||
if (project.plugins.hasPlugin("com.android.application") ||
|
|
||||||
project.plugins.hasPlugin("com.android.library")) {
|
|
||||||
project.android {
|
|
||||||
compileSdkVersion 34
|
|
||||||
buildToolsVersion "34.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Used for plugins that do not specify their own namespace
|
|
||||||
// https://discuss.gradle.org/t/flutter-not-able-to-migrate-to-gradle-8/46937/7
|
|
||||||
subprojects {
|
|
||||||
afterEvaluate { project ->
|
|
||||||
if (project.hasProperty('android')) {
|
|
||||||
project.android {
|
|
||||||
if (namespace == null) {
|
|
||||||
namespace project.group
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rootProject.buildDir = '../build'
|
|
||||||
subprojects {
|
|
||||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
|
||||||
}
|
|
||||||
subprojects {
|
|
||||||
project.evaluationDependsOn(':app')
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.register("clean", Delete) {
|
|
||||||
delete rootProject.buildDir
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType(JavaCompile) {
|
|
||||||
options.compilerArgs << "-Xlint:-options"
|
|
||||||
}
|
|
||||||
|
|
||||||
ext {
|
|
||||||
compileSdkVersion = 34
|
|
||||||
targetSdkVersion = 34
|
|
||||||
appCompatVersion = "1.7.0"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
|
||||||
|
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||||
|
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||||
|
}
|
||||||
|
subprojects {
|
||||||
|
project.evaluationDependsOn(":app")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<Delete>("clean") {
|
||||||
|
delete(rootProject.layout.buildDirectory)
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
org.gradle.jvmargs=-Xmx4G
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
kotlin.jvm.target.validation.mode = IGNORE
|
|
||||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
pluginManagement {
|
|
||||||
def flutterSdkPath = {
|
|
||||||
def properties = new Properties()
|
|
||||||
file("local.properties").withInputStream { properties.load(it) }
|
|
||||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
|
||||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
|
||||||
return flutterSdkPath
|
|
||||||
}
|
|
||||||
settings.ext.flutterSdkPath = flutterSdkPath()
|
|
||||||
|
|
||||||
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
gradlePluginPortal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
|
||||||
id "com.android.application" version "8.3.2" apply false
|
|
||||||
id "org.jetbrains.kotlin.android" version "2.0.21" apply false
|
|
||||||
}
|
|
||||||
|
|
||||||
include ":app"
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
pluginManagement {
|
||||||
|
val flutterSdkPath = run {
|
||||||
|
val properties = java.util.Properties()
|
||||||
|
file("local.properties").inputStream().use { properties.load(it) }
|
||||||
|
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||||
|
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||||
|
flutterSdkPath
|
||||||
|
}
|
||||||
|
|
||||||
|
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
|
id("com.android.application") version "8.7.3" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
include(":app")
|
||||||
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 726 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 688 KiB |
@@ -0,0 +1,47 @@
|
|||||||
|
/// Application identifier for Zapstore itself
|
||||||
|
const kZapstoreAppIdentifier = 'dev.zapstore.alpha';
|
||||||
|
|
||||||
|
/// Zapstore's public key for relay-signed apps
|
||||||
|
const kZapstorePubkey =
|
||||||
|
'78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d';
|
||||||
|
|
||||||
|
/// Franzap's public key for curation sets
|
||||||
|
const kFranzapPubkey =
|
||||||
|
'726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11';
|
||||||
|
|
||||||
|
/// Identifier for storing user saved apps
|
||||||
|
const kAppBookmarksIdentifier = 'zapstore-bookmarks';
|
||||||
|
|
||||||
|
/// Event filter for app stacks - excludes saved apps and stacks with zero App references
|
||||||
|
bool appStackEventFilter(Map<String, dynamic> event) {
|
||||||
|
final tags = event['tags'] as List<dynamic>?;
|
||||||
|
if (tags == null) return false;
|
||||||
|
|
||||||
|
// Check for saved apps identifier in 'd' tag
|
||||||
|
for (final tag in tags) {
|
||||||
|
if (tag is List && tag.isNotEmpty && tag[0] == 'd') {
|
||||||
|
if (tag.length > 1 && tag[1] == kAppBookmarksIdentifier) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for at least one 'a' tag starting with '32267:' (App kind)
|
||||||
|
for (final tag in tags) {
|
||||||
|
if (tag is List && tag.isNotEmpty && tag[0] == 'a') {
|
||||||
|
if (tag.length > 1 && tag[1] is String && tag[1].startsWith('32267:')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No valid App references found
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Amber signer package ID
|
||||||
|
const kAmberPackageId = 'com.greenart7c3.nostrsigner';
|
||||||
|
|
||||||
|
/// Amber signer naddr
|
||||||
|
const kAmberNaddr =
|
||||||
|
'naddr1qqdkxmmd9enhyet9deshyaphvvejumn0wd68yumfvahx2uszyp6hjpmdntls5n8aa7n7ypzlyjrv0ewch33ml3452wtjx0smhl93jqcyqqq8uzcgpp6ky';
|
||||||
@@ -1,97 +1,320 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:io' show Platform;
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:app_links/app_links.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_data/flutter_data.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:flutter_phoenix/flutter_phoenix.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:models/models.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:zapstore/navigation/router.dart';
|
import 'package:purplebase/purplebase.dart';
|
||||||
import 'package:zapstore/utils/debounce.dart';
|
import 'package:amber_signer/amber_signer.dart';
|
||||||
import 'package:zapstore/utils/theme.dart';
|
import 'package:zapstore/services/app_restart_service.dart';
|
||||||
import 'package:zapstore/widgets/error_container.dart';
|
import 'package:zapstore/services/background_update_service.dart';
|
||||||
|
import 'package:zapstore/router.dart';
|
||||||
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
|
import 'package:zapstore/theme.dart';
|
||||||
|
import 'package:zapstore/services/package_manager/android_package_manager.dart';
|
||||||
|
import 'package:zapstore/services/package_manager/dummy_package_manager.dart';
|
||||||
|
import 'package:zapstore/services/market_intent_service.dart';
|
||||||
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
|
import 'package:zapstore/widgets/breathing_logo.dart';
|
||||||
|
|
||||||
const kDbVersion = 2;
|
/// Global provider container for error reporting (accessible outside widget tree)
|
||||||
final appLinks = AppLinks();
|
late final ProviderContainer _providerContainer;
|
||||||
|
|
||||||
/// Application entry point.
|
|
||||||
/// - Initializes Riverpod (and Flutter Data local storage)
|
|
||||||
/// - Handles application errors
|
|
||||||
/// - Triggers routing
|
|
||||||
void main() {
|
void main() {
|
||||||
|
// Create provider container with overrides
|
||||||
|
_providerContainer = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
storageNotifierProvider.overrideWith(PurplebaseStorageNotifier.new),
|
||||||
|
packageManagerProvider.overrideWith(
|
||||||
|
(ref) => Platform.isAndroid
|
||||||
|
? AndroidPackageManager(ref)
|
||||||
|
: DummyPackageManager(ref),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
runZonedGuarded(() {
|
runZonedGuarded(() {
|
||||||
runApp(
|
runApp(
|
||||||
Phoenix(
|
UncontrolledProviderScope(
|
||||||
child: ProviderScope(
|
container: _providerContainer,
|
||||||
overrides: [
|
child: const ZapstoreApp(),
|
||||||
localStorageProvider.overrideWithValue(
|
|
||||||
LocalStorage(
|
|
||||||
baseDirFn: () async {
|
|
||||||
final path = (await getApplicationSupportDirectory()).path;
|
|
||||||
print('Initializing local storage at $path');
|
|
||||||
return path;
|
|
||||||
},
|
|
||||||
clear: LocalStorageClearStrategy.whenError,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
child: const ZapstoreApp(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, errorHandler);
|
}, _errorHandler);
|
||||||
|
|
||||||
FlutterError.onError = (details) {
|
FlutterError.onError = (details) {
|
||||||
// Prevents debugger stopping multiple times
|
// Prevents debugger stopping multiple times
|
||||||
FlutterError.dumpErrorToConsole(details);
|
FlutterError.dumpErrorToConsole(details);
|
||||||
errorHandler(details.exception, details.stack);
|
_errorHandler(details.exception, details.stack);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class ZapstoreApp extends ConsumerWidget {
|
/// Global error handler that reports errors via NIP-44 encrypted DMs
|
||||||
|
void _errorHandler(Object exception, StackTrace? stack) {
|
||||||
|
// Report error asynchronously (fire and forget)
|
||||||
|
// TODO: Disabled until careful review
|
||||||
|
// unawaited(
|
||||||
|
// _providerContainer
|
||||||
|
// .read(errorReportingServiceProvider)
|
||||||
|
// .reportError(exception, stack),
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
|
||||||
|
class ZapstoreApp extends HookConsumerWidget {
|
||||||
const ZapstoreApp({super.key});
|
const ZapstoreApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final notifier =
|
||||||
|
ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier;
|
||||||
|
final title = 'Zapstore';
|
||||||
|
|
||||||
|
// Watch initialization state for error overlay display
|
||||||
|
final initState = ref.watch(appInitializationProvider);
|
||||||
|
|
||||||
|
// Listen to app lifecycle and check for updates when app regains focus
|
||||||
|
useEffect(() {
|
||||||
|
final observer = _AppLifecycleObserver(ref);
|
||||||
|
WidgetsBinding.instance.addObserver(observer);
|
||||||
|
return () => WidgetsBinding.instance.removeObserver(observer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Listen to connectivity changes and trigger ensureConnected when going online
|
||||||
|
useEffect(() {
|
||||||
|
final connectivity = Connectivity();
|
||||||
|
StreamSubscription<List<ConnectivityResult>>? subscription;
|
||||||
|
|
||||||
|
// Check initial connectivity state
|
||||||
|
connectivity.checkConnectivity().then((results) {
|
||||||
|
notifier.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen to connectivity changes
|
||||||
|
subscription = connectivity.onConnectivityChanged.listen((results) {
|
||||||
|
notifier.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => subscription?.cancel();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Always show the main app UI, even during initialization
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
builder: materialErrorBuilder,
|
title: title,
|
||||||
routerConfig: appRouter,
|
theme: darkTheme,
|
||||||
|
routerConfig: ref.watch(routerProvider),
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: theme,
|
builder: (context, child) {
|
||||||
|
// Limit text scale factor to prevent extreme sizes on different devices
|
||||||
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
final constrainedTextScale = mediaQuery.textScaler
|
||||||
|
.scale(1.0)
|
||||||
|
.clamp(1.0, 1.2);
|
||||||
|
final constrainedTextScaler = TextScaler.linear(constrainedTextScale);
|
||||||
|
|
||||||
|
// Show error overlay if initialization failed (do not block UI during loading)
|
||||||
|
if (initState is AsyncError) {
|
||||||
|
return MediaQuery(
|
||||||
|
data: mediaQuery.copyWith(textScaler: constrainedTextScaler),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||||
|
child: child!,
|
||||||
|
),
|
||||||
|
// Error overlay
|
||||||
|
Container(
|
||||||
|
color: Colors.black54,
|
||||||
|
child: Center(
|
||||||
|
child: Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Initialization Error',
|
||||||
|
style: context.textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
initState.error.toString(),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: context.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaQuery(
|
||||||
|
data: mediaQuery.copyWith(textScaler: constrainedTextScaler),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||||
|
child: child!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Directory? _dir;
|
class ZapstoreHome extends StatelessWidget {
|
||||||
|
const ZapstoreHome({super.key});
|
||||||
|
|
||||||
void errorHandler(Object exception, StackTrace? stack) {
|
@override
|
||||||
debounce((exception: exception, stack: stack), (records) async {
|
Widget build(BuildContext context) {
|
||||||
if (records.isEmpty) return;
|
return Scaffold(
|
||||||
_dir ??= await getApplicationDocumentsDirectory();
|
body: Center(
|
||||||
final file = File('${_dir!.path}/errors.json');
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
late final Map<String, dynamic> errorMap;
|
children: [
|
||||||
if (await file.exists()) {
|
const BreathingLogo(size: 120),
|
||||||
final contents = await file.readAsString();
|
const SizedBox(height: 24),
|
||||||
errorMap =
|
Text('Zapstore', style: context.textTheme.headlineMedium),
|
||||||
contents.isNotEmpty ? jsonDecode(await file.readAsString()) : {};
|
const SizedBox(height: 8),
|
||||||
} else {
|
Text(
|
||||||
errorMap = {};
|
'Permissionless app store for Nostr',
|
||||||
}
|
style: context.textTheme.bodyLarge,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
for (final record in records) {
|
),
|
||||||
final full =
|
],
|
||||||
'${record.exception}${record.stack?.toString() ?? ''}${DateTime.now().toIso8601String()}';
|
),
|
||||||
final key = full.split('\n').take(2).join();
|
),
|
||||||
// Only keep longest stack of similar errors, prevents duplicates
|
);
|
||||||
if (full.length > (errorMap[key]?.length ?? 0)) {
|
}
|
||||||
errorMap[key] = full;
|
}
|
||||||
}
|
|
||||||
}
|
final appInitializationProvider = FutureProvider<void>((ref) async {
|
||||||
await file.writeAsString(jsonEncode(errorMap));
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
});
|
final dbPath = path.join(dir.path, 'zapstore.db');
|
||||||
print(exception);
|
|
||||||
print(stack);
|
// Clear storage if requested from a clear all operation
|
||||||
|
await maybeClearStorage(dbPath);
|
||||||
|
|
||||||
|
// Initialize storage
|
||||||
|
await ref.read(
|
||||||
|
initializationProvider(
|
||||||
|
StorageConfiguration(
|
||||||
|
databasePath: dbPath,
|
||||||
|
defaultQuerySource: LocalAndRemoteSource(relays: 'AppCatalog'),
|
||||||
|
defaultRelays: {
|
||||||
|
'default': {'wss://relay.zapstore.dev'},
|
||||||
|
'bootstrap': {'wss://purplepag.es', 'wss://relay.zapstore.dev'},
|
||||||
|
'AppCatalog': {'wss://relay.zapstore.dev'},
|
||||||
|
'social': {
|
||||||
|
'wss://relay.damus.io',
|
||||||
|
'wss://relay.primal.net',
|
||||||
|
'wss://nos.lol',
|
||||||
|
},
|
||||||
|
'vertex': {'wss://relay.vertexlab.io'},
|
||||||
|
},
|
||||||
|
responseTimeout: Duration(seconds: 4),
|
||||||
|
),
|
||||||
|
).future,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize package manager
|
||||||
|
final packageManager = ref.read(packageManagerProvider.notifier);
|
||||||
|
await packageManager.syncInstalledPackages();
|
||||||
|
|
||||||
|
// Initialize background update service (WorkManager + notifications)
|
||||||
|
final backgroundService = ref.read(backgroundUpdateServiceProvider);
|
||||||
|
await backgroundService.initialize();
|
||||||
|
|
||||||
|
// Initialize market intent handling (for market:// URIs)
|
||||||
|
await ref.read(marketIntentServiceProvider).initialize();
|
||||||
|
|
||||||
|
await _attemptAutoSignIn(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
// AmberSigner provider for Nostr authentication
|
||||||
|
final amberSignerProvider = Provider<AmberSigner>(AmberSigner.new);
|
||||||
|
|
||||||
|
Future<void> _attemptAutoSignIn(Ref ref) async {
|
||||||
|
try {
|
||||||
|
await ref.read(amberSignerProvider).attemptAutoSignIn();
|
||||||
|
await onSignInSuccess(ref);
|
||||||
|
} catch (e) {
|
||||||
|
// Auto sign-in fails on first install — that's fine, just continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query AppCatalogRelayList after successful sign-in
|
||||||
|
Future<void> onSignInSuccess(Ref ref) async {
|
||||||
|
final pubkey = ref.read(Signer.activePubkeyProvider);
|
||||||
|
if (pubkey == null) return;
|
||||||
|
|
||||||
|
final storage =
|
||||||
|
ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier;
|
||||||
|
|
||||||
|
unawaited(
|
||||||
|
storage.query(
|
||||||
|
RequestFilter<AppCatalogRelayList>(authors: {pubkey}).toRequest(),
|
||||||
|
source: const RemoteSource(relays: 'bootstrap', stream: false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observes app lifecycle events and manages package/storage state
|
||||||
|
class _AppLifecycleObserver with WidgetsBindingObserver {
|
||||||
|
_AppLifecycleObserver(this._ref);
|
||||||
|
|
||||||
|
final WidgetRef _ref;
|
||||||
|
|
||||||
|
/// Handle permission grants that happened while app was backgrounded
|
||||||
|
Future<void> _checkPermissionGrants() async {
|
||||||
|
final packageManager = _ref.read(packageManagerProvider.notifier);
|
||||||
|
if (packageManager is! AndroidPackageManager) return;
|
||||||
|
|
||||||
|
final hasPermission = await packageManager.hasPermission();
|
||||||
|
if (!hasPermission) return;
|
||||||
|
|
||||||
|
// Advance any operations waiting for permission
|
||||||
|
final state = _ref.read(packageManagerProvider);
|
||||||
|
final awaitingPermission = state.operations.entries
|
||||||
|
.where((e) => e.value is AwaitingPermission)
|
||||||
|
.map((e) => e.key)
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
for (final appId in awaitingPermission) {
|
||||||
|
await packageManager.onPermissionGranted(appId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
final notifier =
|
||||||
|
_ref.read(storageNotifierProvider.notifier)
|
||||||
|
as PurplebaseStorageNotifier;
|
||||||
|
final packageManager = _ref.read(packageManagerProvider.notifier);
|
||||||
|
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
// Sync installed packages to detect installs that completed while backgrounded
|
||||||
|
unawaited(packageManager.syncInstalledPackages());
|
||||||
|
|
||||||
|
// Check for permission grants that happened in settings
|
||||||
|
unawaited(_checkPermissionGrants());
|
||||||
|
|
||||||
|
// Reconnect storage/relay connections
|
||||||
|
notifier.connect();
|
||||||
|
} else if (state == AppLifecycleState.paused) {
|
||||||
|
notifier.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
// ignore_for_file: directives_ordering, top_level_function_literal_block, depend_on_referenced_packages
|
|
||||||
|
|
||||||
import 'package:flutter_data/flutter_data.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
|
|
||||||
import 'package:zapstore/models/app_curation_set.dart';
|
|
||||||
import 'package:zapstore/models/app.dart';
|
|
||||||
import 'package:zapstore/models/file_metadata.dart';
|
|
||||||
import 'package:zapstore/models/local_app.dart';
|
|
||||||
import 'package:zapstore/models/release.dart';
|
|
||||||
import 'package:zapstore/models/settings.dart';
|
|
||||||
import 'package:zapstore/models/user.dart';
|
|
||||||
import 'package:zapstore/models/zap_receipt.dart';
|
|
||||||
|
|
||||||
final adapterProvidersMap = <String, Provider<Adapter<DataModelMixin>>>{
|
|
||||||
'appCurationSets': appCurationSetsAdapterProvider,
|
|
||||||
'apps': appsAdapterProvider,
|
|
||||||
'fileMetadata': fileMetadataAdapterProvider,
|
|
||||||
'localApps': localAppsAdapterProvider,
|
|
||||||
'releases': releasesAdapterProvider,
|
|
||||||
'settings': settingsAdapterProvider,
|
|
||||||
'users': usersAdapterProvider,
|
|
||||||
'zapReceipts': zapReceiptsAdapterProvider
|
|
||||||
};
|
|
||||||
|
|
||||||
extension AdapterWidgetRefX on WidgetRef {
|
|
||||||
Adapter<AppCurationSet> get appCurationSets =>
|
|
||||||
watch(appCurationSetsAdapterProvider)..internalWatch = watch;
|
|
||||||
Adapter<App> get apps => watch(appsAdapterProvider)..internalWatch = watch;
|
|
||||||
Adapter<FileMetadata> get fileMetadata =>
|
|
||||||
watch(fileMetadataAdapterProvider)..internalWatch = watch;
|
|
||||||
Adapter<LocalApp> get localApps =>
|
|
||||||
watch(localAppsAdapterProvider)..internalWatch = watch;
|
|
||||||
Adapter<Release> get releases =>
|
|
||||||
watch(releasesAdapterProvider)..internalWatch = watch;
|
|
||||||
Adapter<Settings> get settings =>
|
|
||||||
watch(settingsAdapterProvider)..internalWatch = watch;
|
|
||||||
Adapter<User> get users => watch(usersAdapterProvider)..internalWatch = watch;
|
|
||||||
Adapter<ZapReceipt> get zapReceipts =>
|
|
||||||
watch(zapReceiptsAdapterProvider)..internalWatch = watch;
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AdapterRefX on Ref {
|
|
||||||
Adapter<AppCurationSet> get appCurationSets =>
|
|
||||||
watch(appCurationSetsAdapterProvider)..internalWatch = watch;
|
|
||||||
Adapter<App> get apps => watch(appsAdapterProvider)..internalWatch = watch;
|
|
||||||
Adapter<FileMetadata> get fileMetadata =>
|
|
||||||
watch(fileMetadataAdapterProvider)..internalWatch = watch;
|
|
||||||
Adapter<LocalApp> get localApps =>
|
|
||||||
watch(localAppsAdapterProvider)..internalWatch = watch;
|
|
||||||
Adapter<Release> get releases =>
|
|
||||||
watch(releasesAdapterProvider)..internalWatch = watch;
|
|
||||||
Adapter<Settings> get settings =>
|
|
||||||
watch(settingsAdapterProvider)..internalWatch = watch;
|
|
||||||
Adapter<User> get users => watch(usersAdapterProvider)..internalWatch = watch;
|
|
||||||
Adapter<ZapReceipt> get zapReceipts =>
|
|
||||||
watch(zapReceiptsAdapterProvider)..internalWatch = watch;
|
|
||||||
}
|
|
||||||
@@ -1,462 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:io';
|
|
||||||
import 'dart:isolate';
|
|
||||||
|
|
||||||
import 'package:async/async.dart';
|
|
||||||
import 'package:background_downloader/background_downloader.dart';
|
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:convert/convert.dart';
|
|
||||||
import 'package:crypto/crypto.dart';
|
|
||||||
import 'package:flutter_data/flutter_data.dart';
|
|
||||||
import 'package:install_plugin/install_plugin.dart';
|
|
||||||
import 'package:mutex/mutex.dart';
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
|
||||||
import 'package:purplebase/purplebase.dart' as base;
|
|
||||||
import 'package:zapstore/main.data.dart';
|
|
||||||
import 'package:zapstore/models/file_metadata.dart';
|
|
||||||
import 'package:zapstore/models/local_app.dart';
|
|
||||||
import 'package:zapstore/models/nostr_adapter.dart';
|
|
||||||
import 'package:zapstore/models/release.dart';
|
|
||||||
import 'package:zapstore/models/user.dart';
|
|
||||||
import 'package:path/path.dart' as path;
|
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
|
||||||
import 'package:zapstore/utils/system_info.dart';
|
|
||||||
|
|
||||||
part 'app.g.dart';
|
|
||||||
|
|
||||||
final mutex = Mutex();
|
|
||||||
|
|
||||||
@DataAdapter([NostrAdapter, AppAdapter])
|
|
||||||
class App extends base.App with DataModelMixin<App> {
|
|
||||||
@override
|
|
||||||
Object? get id => event.id;
|
|
||||||
|
|
||||||
final HasMany<Release> releases;
|
|
||||||
final BelongsTo<User> signer;
|
|
||||||
final BelongsTo<LocalApp> localApp;
|
|
||||||
|
|
||||||
App.fromJson(super.map)
|
|
||||||
: releases = hasMany(map['releases']),
|
|
||||||
signer = belongsTo(map['signer']),
|
|
||||||
localApp = belongsTo(map['localApp']),
|
|
||||||
super.fromJson();
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => super.toMap();
|
|
||||||
|
|
||||||
Release? get latestRelease {
|
|
||||||
return releases.toList().sortedByLatest.firstOrNull;
|
|
||||||
}
|
|
||||||
|
|
||||||
FileMetadata? get latestMetadata {
|
|
||||||
return latestRelease?.artifacts
|
|
||||||
// All artifacts *should* be APKs for the
|
|
||||||
// current architecture, but double check
|
|
||||||
.where((a) =>
|
|
||||||
a.mimeType == kAndroidMimeType &&
|
|
||||||
a.platforms.contains('android-arm64-v8a'))
|
|
||||||
.firstOrNull;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get canInstall =>
|
|
||||||
localApp.value?.status == null &&
|
|
||||||
latestMetadata != null &&
|
|
||||||
signer.isPresent;
|
|
||||||
bool get canUpdate =>
|
|
||||||
localApp.value?.status == AppInstallStatus.updatable &&
|
|
||||||
latestMetadata != null &&
|
|
||||||
signer.isPresent;
|
|
||||||
bool get isInstalled => localApp.value?.status != null;
|
|
||||||
bool get isUpdated => localApp.value?.status == AppInstallStatus.updated;
|
|
||||||
bool get isDowngrade => localApp.value?.status == AppInstallStatus.downgrade;
|
|
||||||
bool get hasCertificateMismatch =>
|
|
||||||
localApp.value?.status == AppInstallStatus.certificateMismatch;
|
|
||||||
|
|
||||||
bool get isSelfSigned => !(signer.value!.pubkey == kZapstorePubkey &&
|
|
||||||
identifier != kZapstoreAppIdentifier);
|
|
||||||
|
|
||||||
Future<void> install({bool alwaysTrustSigner = false}) async {
|
|
||||||
if (!canInstall && !canUpdate || hasCertificateMismatch) {
|
|
||||||
throw Exception(
|
|
||||||
'Local status: ${localApp.value?.status}, signer: ${signer.value?.npub}, certificate mismatch: $hasCertificateMismatch');
|
|
||||||
}
|
|
||||||
|
|
||||||
final adapter = DataModel.adapterFor(this) as AppAdapter;
|
|
||||||
final notifier =
|
|
||||||
adapter.ref.read(installationProgressProvider(id!).notifier);
|
|
||||||
|
|
||||||
// Persist trusted preference
|
|
||||||
if (alwaysTrustSigner) {
|
|
||||||
final settings = adapter.ref.settings.findOneLocalById('_')!;
|
|
||||||
settings.trustedUsers.add(signer.value!);
|
|
||||||
settings.saveLocal();
|
|
||||||
}
|
|
||||||
|
|
||||||
final installPermission = await Permission.requestInstallPackages.status;
|
|
||||||
if (!installPermission.isGranted) {
|
|
||||||
final newStatus = await Permission.requestInstallPackages.request();
|
|
||||||
if (newStatus.isDenied) {
|
|
||||||
notifier.state = ErrorInstallProgress(Exception('Error'),
|
|
||||||
info: 'Installation permission denied by user');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final hash = latestMetadata!.hash!;
|
|
||||||
final dir = await getApplicationSupportDirectory();
|
|
||||||
final file = File(path.join(dir.path, hash));
|
|
||||||
|
|
||||||
installOnDevice() async {
|
|
||||||
notifier.state = VerifyingHashProgress();
|
|
||||||
|
|
||||||
if (await _isHashMismatch(file.path, hash)) {
|
|
||||||
const e = 'Hash mismatch';
|
|
||||||
await file.delete();
|
|
||||||
notifier.state = ErrorInstallProgress(Exception(e),
|
|
||||||
info: 'Possibly a malicious file. Aborting installation.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
notifier.state = RequestInstallProgress();
|
|
||||||
|
|
||||||
// NOTE: Using https://github.com/hui-z/flutter_install_plugin
|
|
||||||
// Tried https://github.com/zapstore/android_package_installer
|
|
||||||
// but it is giving users problems (and for me it's slower)
|
|
||||||
// Probably need to fork hui-z's with support for user-canceled action
|
|
||||||
// NOTE: Must use mutex: https://github.com/hui-z/flutter_install_plugin/issues/68
|
|
||||||
final result = await mutex.protect(() async {
|
|
||||||
return InstallPlugin.install(file.path);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result['isSuccess']) {
|
|
||||||
notifier.state = IdleInstallProgress(success: true);
|
|
||||||
await file.delete();
|
|
||||||
await adapter.ref.localApps.localAppAdapter
|
|
||||||
.refreshUpdateStatus(appId: identifier);
|
|
||||||
Future.microtask(() {
|
|
||||||
notifier.state = IdleInstallProgress();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const msg = 'App not installed';
|
|
||||||
notifier.state = ErrorInstallProgress(
|
|
||||||
Exception(msg),
|
|
||||||
info: 'User canceled or error occured: ${result['errorMessage']}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final fileExists = await file.exists();
|
|
||||||
if (fileExists) {
|
|
||||||
await installOnDevice();
|
|
||||||
} else {
|
|
||||||
if (fileExists) {
|
|
||||||
// If file exists download was probably partial, remove
|
|
||||||
await file.delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
final url = latestMetadata!.urls.first;
|
|
||||||
final (baseDirectory, directory, filename) =
|
|
||||||
await Task.split(filePath: file.path);
|
|
||||||
|
|
||||||
final task = DownloadTask(
|
|
||||||
url: url,
|
|
||||||
baseDirectory: baseDirectory,
|
|
||||||
directory: directory,
|
|
||||||
filename: filename,
|
|
||||||
updates: Updates.progress,
|
|
||||||
);
|
|
||||||
|
|
||||||
final result = await FileDownloader().download(
|
|
||||||
task,
|
|
||||||
onProgress: (progress) {
|
|
||||||
notifier.state = DownloadingInstallProgress(progress);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
switch (result.status) {
|
|
||||||
case TaskStatus.complete:
|
|
||||||
return await installOnDevice();
|
|
||||||
default:
|
|
||||||
notifier.state = ErrorInstallProgress(
|
|
||||||
Exception('App did not fully download.'),
|
|
||||||
info: 'Please try again.');
|
|
||||||
await file.delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mixin AppAdapter on Adapter<App> {
|
|
||||||
final queriedAtMap = <String, DateTime>{};
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<App>> findAll(
|
|
||||||
{bool? remote = true,
|
|
||||||
bool? background,
|
|
||||||
Map<String, dynamic>? params = const {},
|
|
||||||
Map<String, String>? headers,
|
|
||||||
bool? syncLocal,
|
|
||||||
OnSuccessAll<App>? onSuccess,
|
|
||||||
OnErrorAll<App>? onError,
|
|
||||||
DataRequestLabel? label}) async {
|
|
||||||
params ??= {};
|
|
||||||
if (params.remove('includes') ?? false) {
|
|
||||||
return await fetchAppModels(params);
|
|
||||||
}
|
|
||||||
return super.findAll(params: params);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> checkForUpdates() async {
|
|
||||||
final appIds = await _installedIdentifiers();
|
|
||||||
if (appIds.isNotEmpty) {
|
|
||||||
await fetchAppModels({'#d': appIds});
|
|
||||||
// Once apps are loaded, check for installed status
|
|
||||||
await ref.localApps.localAppAdapter.refreshUpdateStatus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<App>> fetchAppModels(Map<String, dynamic> params) async {
|
|
||||||
late final List<App> apps;
|
|
||||||
late final List<Release> releases;
|
|
||||||
|
|
||||||
// If looking up multiple apps via `#d`, use `queriedAtMap`
|
|
||||||
// to find the earliest queried at Date, to be used in the
|
|
||||||
// next request `since` field
|
|
||||||
if (params.containsKey('#d') && !(params['skipCache'] ?? false)) {
|
|
||||||
DateTime? earliestQueryAt;
|
|
||||||
final identifiers = <String>{...params['#d']};
|
|
||||||
|
|
||||||
final cachedIdentifiers =
|
|
||||||
identifiers.where((i) => queriedAtMap.containsKey(i));
|
|
||||||
final newIdentifiers =
|
|
||||||
identifiers.where((i) => !queriedAtMap.containsKey(i));
|
|
||||||
|
|
||||||
var cachedApps = <App>[];
|
|
||||||
var newApps = <App>[];
|
|
||||||
|
|
||||||
// For apps already in cache, find earliest date of the set
|
|
||||||
// (as we cannot have one `since` per app)
|
|
||||||
if (cachedIdentifiers.isNotEmpty) {
|
|
||||||
earliestQueryAt = cachedIdentifiers
|
|
||||||
.fold<DateTime>(queriedAtMap[cachedIdentifiers.first]!, (acc, e) {
|
|
||||||
return acc.isBefore(queriedAtMap[e]!) ? acc : queriedAtMap[e]!;
|
|
||||||
});
|
|
||||||
|
|
||||||
cachedApps = await super.findAll(
|
|
||||||
params: {
|
|
||||||
'#d': cachedIdentifiers,
|
|
||||||
// Query since latest + 1
|
|
||||||
'since': earliestQueryAt.add(Duration(seconds: 1)),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For apps not in cache, query without a since
|
|
||||||
if (newIdentifiers.isNotEmpty) {
|
|
||||||
newApps = await super.findAll(
|
|
||||||
params: {
|
|
||||||
'#d': newIdentifiers,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
apps = [...cachedApps, ...newApps];
|
|
||||||
|
|
||||||
// Find most recent `created_at` from returned apps
|
|
||||||
// and assign it to their queried at value for caching
|
|
||||||
final m = apps.isNotEmpty
|
|
||||||
? apps.fold(apps.first.event.createdAt, (acc, e) {
|
|
||||||
return acc.isAfter(e.event.createdAt) ? acc : e.event.createdAt;
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (m != null) {
|
|
||||||
for (final app in apps) {
|
|
||||||
queriedAtMap[app.identifier] = m;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Search (which uses no #d)
|
|
||||||
apps = await super.findAll(params: params);
|
|
||||||
}
|
|
||||||
|
|
||||||
final appsOldFormat =
|
|
||||||
apps.where((app) => app.event.linkedReplaceableEvents.isNotEmpty);
|
|
||||||
final appsNewFormat =
|
|
||||||
apps.where((app) => app.event.linkedReplaceableEvents.isEmpty);
|
|
||||||
|
|
||||||
if (appsOldFormat.isNotEmpty) {
|
|
||||||
// OLD FORMAT BRANCH
|
|
||||||
// Find all appid@version ($3) as we need to pick one tag to query on
|
|
||||||
// (filters by kind ($1) and pubkey ($2) done locally)
|
|
||||||
final latestReleaseIdentifiers = appsOldFormat
|
|
||||||
.map((app) => app.event.linkedReplaceableEvents.first.$3);
|
|
||||||
|
|
||||||
if (latestReleaseIdentifiers.isNotEmpty) {
|
|
||||||
releases = await ref.releases.findAll(
|
|
||||||
params: {'#d': latestReleaseIdentifiers},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
releases = [];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// NEW FORMAT BRANCH
|
|
||||||
final futures = appsNewFormat
|
|
||||||
.map((app) => ref.releases.findAll(
|
|
||||||
params: {
|
|
||||||
'#i': [app.identifier],
|
|
||||||
'authors': [app.event.pubkey],
|
|
||||||
'limit': 1
|
|
||||||
},
|
|
||||||
))
|
|
||||||
.toSet();
|
|
||||||
|
|
||||||
final lists = await Future.wait(futures);
|
|
||||||
releases = lists.expand((a) => a).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
await nostrAdapter.loadArtifactsAndUsers(releases);
|
|
||||||
await ref.localApps.localAppAdapter.refreshUpdateStatus();
|
|
||||||
|
|
||||||
return apps;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<App?> findOne(Object id,
|
|
||||||
{bool remote = true,
|
|
||||||
bool background = false,
|
|
||||||
Map<String, dynamic>? params = const {},
|
|
||||||
Map<String, String>? headers,
|
|
||||||
OnSuccessOne<App>? onSuccess,
|
|
||||||
OnErrorOne<App>? onError,
|
|
||||||
DataRequestLabel? label}) async {
|
|
||||||
final apps = await fetchAppModels({
|
|
||||||
...params ?? {},
|
|
||||||
'#d': [id]
|
|
||||||
});
|
|
||||||
// If ID not found in relay then clear from local storage
|
|
||||||
if (apps.isEmpty) {
|
|
||||||
deleteLocalById(id);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
await ref.localApps.localAppAdapter
|
|
||||||
.refreshUpdateStatus(appId: id.toString());
|
|
||||||
return apps.first;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<App> findWhereIdentifierInLocal(Iterable<String> appIds) {
|
|
||||||
const len = 5 + 64 + 3; // kind + pubkey + separators length
|
|
||||||
final result = db.select(
|
|
||||||
'SELECT key, data, substr(json_extract(data, \'\$.id\'), $len) AS id from apps where id in (${appIds.map((_) => '?').join(', ')})',
|
|
||||||
appIds.toList());
|
|
||||||
return deserializeFromResult(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Set<String>> _installedIdentifiers() async {
|
|
||||||
if (!Platform.isAndroid) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
final infos = await packageManager.getInstalledPackages();
|
|
||||||
return {
|
|
||||||
for (final i in infos!)
|
|
||||||
if (i.packageName != null &&
|
|
||||||
!kExcludedAppIdNamespaces.any((e) => i.packageName!.startsWith(e)))
|
|
||||||
i.packageName!,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
DeserializedData<App> deserialize(Object? data, {String? key}) {
|
|
||||||
final list = data is Iterable ? data : [data as Map];
|
|
||||||
|
|
||||||
for (final Map<String, dynamic> map in list) {
|
|
||||||
final tags = map['tags'];
|
|
||||||
map['localApp'] = base.BaseUtil.getTag(tags, 'd')!;
|
|
||||||
map['signer'] = map['pubkey'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.deserialize(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> _isHashMismatch(String path, String hash) async {
|
|
||||||
return await Isolate.run(() async {
|
|
||||||
final file = File(path);
|
|
||||||
final fileStream = file.openRead();
|
|
||||||
final reader = ChunkedStreamReader(fileStream);
|
|
||||||
final digestOutputSink = AccumulatorSink<Digest>();
|
|
||||||
final digestInputSink = sha256.startChunkedConversion(digestOutputSink);
|
|
||||||
String? digest;
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
// Chunk size determined from rough CPU/memory profiling
|
|
||||||
final chunk = await reader.readChunk(3072);
|
|
||||||
if (chunk.isEmpty) {
|
|
||||||
break; // EOF
|
|
||||||
}
|
|
||||||
digestInputSink.add(chunk);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
digestInputSink.close();
|
|
||||||
digest = digestOutputSink.events.single.toString();
|
|
||||||
digestOutputSink.close();
|
|
||||||
reader.cancel();
|
|
||||||
}
|
|
||||||
return digest != hash;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppExt on Iterable<App> {
|
|
||||||
List<App> get sortedByLatest =>
|
|
||||||
sorted((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
|
|
||||||
}
|
|
||||||
|
|
||||||
// class
|
|
||||||
|
|
||||||
sealed class AppInstallProgress {}
|
|
||||||
|
|
||||||
class IdleInstallProgress extends AppInstallProgress {
|
|
||||||
final bool? success;
|
|
||||||
IdleInstallProgress({this.success});
|
|
||||||
}
|
|
||||||
|
|
||||||
class DownloadingInstallProgress extends AppInstallProgress {
|
|
||||||
final double progress;
|
|
||||||
DownloadingInstallProgress(this.progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
class VerifyingHashProgress extends AppInstallProgress {}
|
|
||||||
|
|
||||||
class RequestInstallProgress extends AppInstallProgress {}
|
|
||||||
|
|
||||||
class ErrorInstallProgress extends AppInstallProgress {
|
|
||||||
final Exception e;
|
|
||||||
final String? info;
|
|
||||||
final List<(String, Future<void> Function())> actions;
|
|
||||||
ErrorInstallProgress(this.e, {this.info, this.actions = const []});
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ExceptionExt on Exception {
|
|
||||||
String get message => (this as dynamic).message ?? toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
final installationProgressProvider =
|
|
||||||
StateProvider.family<AppInstallProgress, Object>(
|
|
||||||
(_, arg) => IdleInstallProgress());
|
|
||||||
|
|
||||||
final appsToUpdateProvider = StateProvider((_) => 0);
|
|
||||||
|
|
||||||
// Constants
|
|
||||||
|
|
||||||
const kAndroidMimeType = 'application/vnd.android.package-archive';
|
|
||||||
|
|
||||||
const kExcludedAppIdNamespaces = [
|
|
||||||
'android',
|
|
||||||
'com.android',
|
|
||||||
'com.google',
|
|
||||||
'com.samsung',
|
|
||||||
'com.sec.android',
|
|
||||||
'org.chromium.webview_shell',
|
|
||||||
'app.grapheneos',
|
|
||||||
'app.vanadium',
|
|
||||||
];
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'app.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// AdapterGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
|
|
||||||
|
|
||||||
mixin _$AppAdapter on Adapter<App> {
|
|
||||||
static final Map<String, RelationshipMeta> _kAppRelationshipMetas = {
|
|
||||||
'releases': RelationshipMeta<Release>(
|
|
||||||
name: 'releases',
|
|
||||||
inverseName: 'app',
|
|
||||||
type: 'releases',
|
|
||||||
kind: 'HasMany',
|
|
||||||
instance: (_) => (_ as App).releases,
|
|
||||||
),
|
|
||||||
'signer': RelationshipMeta<User>(
|
|
||||||
name: 'signer',
|
|
||||||
inverseName: 'apps',
|
|
||||||
type: 'users',
|
|
||||||
kind: 'BelongsTo',
|
|
||||||
instance: (_) => (_ as App).signer,
|
|
||||||
),
|
|
||||||
'localApp': RelationshipMeta<LocalApp>(
|
|
||||||
name: 'localApp',
|
|
||||||
type: 'localApps',
|
|
||||||
kind: 'BelongsTo',
|
|
||||||
instance: (_) => (_ as App).localApp,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
@override
|
|
||||||
Map<String, RelationshipMeta> get relationshipMetas => _kAppRelationshipMetas;
|
|
||||||
|
|
||||||
@override
|
|
||||||
App deserializeLocal(map, {String? key}) {
|
|
||||||
map = transformDeserialize(map);
|
|
||||||
return internalWrapStopInit(() => App.fromJson(map), key: key);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
|
|
||||||
final map = model.toJson();
|
|
||||||
return transformSerialize(map, withRelationships: withRelationships);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final _appsFinders = <String, dynamic>{};
|
|
||||||
|
|
||||||
class $AppAdapter = Adapter<App>
|
|
||||||
with _$AppAdapter, NostrAdapter<App>, AppAdapter;
|
|
||||||
|
|
||||||
final appsAdapterProvider = Provider<Adapter<App>>(
|
|
||||||
(ref) => $AppAdapter(ref, InternalHolder(_appsFinders)));
|
|
||||||
|
|
||||||
extension AppAdapterX on Adapter<App> {
|
|
||||||
NostrAdapter<App> get nostrAdapter => this as NostrAdapter<App>;
|
|
||||||
AppAdapter get appAdapter => this as AppAdapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppRelationshipGraphNodeX on RelationshipGraphNode<App> {
|
|
||||||
RelationshipGraphNode<Release> get releases {
|
|
||||||
final meta = _$AppAdapter._kAppRelationshipMetas['releases']
|
|
||||||
as RelationshipMeta<Release>;
|
|
||||||
return meta.clone(
|
|
||||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
RelationshipGraphNode<User> get signer {
|
|
||||||
final meta =
|
|
||||||
_$AppAdapter._kAppRelationshipMetas['signer'] as RelationshipMeta<User>;
|
|
||||||
return meta.clone(
|
|
||||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
RelationshipGraphNode<LocalApp> get localApp {
|
|
||||||
final meta = _$AppAdapter._kAppRelationshipMetas['localApp']
|
|
||||||
as RelationshipMeta<LocalApp>;
|
|
||||||
return meta.clone(
|
|
||||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import 'package:flutter_data/flutter_data.dart';
|
|
||||||
import 'package:purplebase/purplebase.dart' as base;
|
|
||||||
import 'package:zapstore/main.data.dart';
|
|
||||||
import 'package:zapstore/models/app.dart';
|
|
||||||
import 'package:zapstore/models/nostr_adapter.dart';
|
|
||||||
import 'package:zapstore/models/user.dart';
|
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
|
||||||
|
|
||||||
part 'app_curation_set.g.dart';
|
|
||||||
|
|
||||||
@DataAdapter([NostrAdapter, AppCurationSetAdapter])
|
|
||||||
class AppCurationSet extends base.AppCurationSet
|
|
||||||
with DataModelMixin<AppCurationSet> {
|
|
||||||
@override
|
|
||||||
Object get id => event.id;
|
|
||||||
|
|
||||||
final HasMany<App> apps;
|
|
||||||
final BelongsTo<User> signer;
|
|
||||||
|
|
||||||
AppCurationSet.fromJson(super.map)
|
|
||||||
: apps = hasMany(map['apps']),
|
|
||||||
signer = belongsTo(map['signer']),
|
|
||||||
super.fromJson();
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => super.toMap();
|
|
||||||
|
|
||||||
String get name => event.content.isNotEmpty ? event.content : identifier;
|
|
||||||
}
|
|
||||||
|
|
||||||
mixin AppCurationSetAdapter on Adapter<AppCurationSet> {
|
|
||||||
@override
|
|
||||||
Future<List<AppCurationSet>> findAll(
|
|
||||||
{bool? remote = true,
|
|
||||||
bool? background = false,
|
|
||||||
Map<String, dynamic>? params,
|
|
||||||
Map<String, String>? headers,
|
|
||||||
bool? syncLocal,
|
|
||||||
OnSuccessAll<AppCurationSet>? onSuccess,
|
|
||||||
OnErrorAll<AppCurationSet>? onError,
|
|
||||||
DataRequestLabel? label}) async {
|
|
||||||
final sets = await super.findAll(remote: remote!, params: params);
|
|
||||||
final userIds = {for (final set in sets) set.signer.id}.nonNulls;
|
|
||||||
await ref.users.findAll(params: {'authors': userIds, 'ignoreReturn': true});
|
|
||||||
return sets;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
DeserializedData<AppCurationSet> deserialize(Object? data, {String? key}) {
|
|
||||||
final list = data is Iterable ? data : [data as Map];
|
|
||||||
for (final Map<String, dynamic> map in list) {
|
|
||||||
final tags = map['tags'];
|
|
||||||
map['apps'] = base.BaseUtil.getTagSet(tags, 'a');
|
|
||||||
}
|
|
||||||
return super.deserialize(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'app_curation_set.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// AdapterGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
|
|
||||||
|
|
||||||
mixin _$AppCurationSetAdapter on Adapter<AppCurationSet> {
|
|
||||||
static final Map<String, RelationshipMeta> _kAppCurationSetRelationshipMetas =
|
|
||||||
{
|
|
||||||
'apps': RelationshipMeta<App>(
|
|
||||||
name: 'apps',
|
|
||||||
type: 'apps',
|
|
||||||
kind: 'HasMany',
|
|
||||||
instance: (_) => (_ as AppCurationSet).apps,
|
|
||||||
),
|
|
||||||
'signer': RelationshipMeta<User>(
|
|
||||||
name: 'signer',
|
|
||||||
type: 'users',
|
|
||||||
kind: 'BelongsTo',
|
|
||||||
instance: (_) => (_ as AppCurationSet).signer,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
@override
|
|
||||||
Map<String, RelationshipMeta> get relationshipMetas =>
|
|
||||||
_kAppCurationSetRelationshipMetas;
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppCurationSet deserializeLocal(map, {String? key}) {
|
|
||||||
map = transformDeserialize(map);
|
|
||||||
return internalWrapStopInit(() => AppCurationSet.fromJson(map), key: key);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
|
|
||||||
final map = model.toJson();
|
|
||||||
return transformSerialize(map, withRelationships: withRelationships);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final _appCurationSetsFinders = <String, dynamic>{};
|
|
||||||
|
|
||||||
class $AppCurationSetAdapter = Adapter<AppCurationSet>
|
|
||||||
with
|
|
||||||
_$AppCurationSetAdapter,
|
|
||||||
NostrAdapter<AppCurationSet>,
|
|
||||||
AppCurationSetAdapter;
|
|
||||||
|
|
||||||
final appCurationSetsAdapterProvider = Provider<Adapter<AppCurationSet>>(
|
|
||||||
(ref) =>
|
|
||||||
$AppCurationSetAdapter(ref, InternalHolder(_appCurationSetsFinders)));
|
|
||||||
|
|
||||||
extension AppCurationSetAdapterX on Adapter<AppCurationSet> {
|
|
||||||
NostrAdapter<AppCurationSet> get nostrAdapter =>
|
|
||||||
this as NostrAdapter<AppCurationSet>;
|
|
||||||
AppCurationSetAdapter get appCurationSetAdapter =>
|
|
||||||
this as AppCurationSetAdapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppCurationSetRelationshipGraphNodeX
|
|
||||||
on RelationshipGraphNode<AppCurationSet> {
|
|
||||||
RelationshipGraphNode<App> get apps {
|
|
||||||
final meta = _$AppCurationSetAdapter
|
|
||||||
._kAppCurationSetRelationshipMetas['apps'] as RelationshipMeta<App>;
|
|
||||||
return meta.clone(
|
|
||||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
RelationshipGraphNode<User> get signer {
|
|
||||||
final meta = _$AppCurationSetAdapter
|
|
||||||
._kAppCurationSetRelationshipMetas['signer'] as RelationshipMeta<User>;
|
|
||||||
return meta.clone(
|
|
||||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import 'package:purplebase/purplebase.dart';
|
|
||||||
|
|
||||||
class AppFeedback extends RegularEvent<AppFeedback> {
|
|
||||||
AppFeedback.fromJson(super.map) : super.fromJson();
|
|
||||||
}
|
|
||||||
|
|
||||||
class PartialAppFeedback extends RegularPartialEvent<AppFeedback> {
|
|
||||||
PartialAppFeedback({
|
|
||||||
required String content,
|
|
||||||
}) {
|
|
||||||
event.content = content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import 'package:flutter_data/flutter_data.dart';
|
|
||||||
import 'package:purplebase/purplebase.dart' as base;
|
|
||||||
import 'package:zapstore/models/nostr_adapter.dart';
|
|
||||||
import 'package:zapstore/models/release.dart';
|
|
||||||
import 'package:zapstore/models/user.dart';
|
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
|
||||||
|
|
||||||
part 'file_metadata.g.dart';
|
|
||||||
|
|
||||||
@DataAdapter([NostrAdapter, FileMetadataAdapter])
|
|
||||||
class FileMetadata extends base.FileMetadata with DataModelMixin<FileMetadata> {
|
|
||||||
final BelongsTo<Release> release;
|
|
||||||
final BelongsTo<User> signer;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Object? get id => event.id;
|
|
||||||
|
|
||||||
FileMetadata.fromJson(super.map)
|
|
||||||
: release = belongsTo(map['release']),
|
|
||||||
signer = belongsTo(map['signer']),
|
|
||||||
super.fromJson();
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => super.toMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
mixin FileMetadataAdapter on Adapter<FileMetadata> {
|
|
||||||
@override
|
|
||||||
DeserializedData<FileMetadata> deserialize(Object? data, {String? key}) {
|
|
||||||
final list = data is Iterable ? data : [data as Map];
|
|
||||||
for (final e in list) {
|
|
||||||
final map = e as Map<String, dynamic>;
|
|
||||||
final isNewFormat = map['kind'] == 3063;
|
|
||||||
if (isNewFormat) {
|
|
||||||
// Map to old 1063
|
|
||||||
map['kind'] = 1063;
|
|
||||||
map['tags'].add([
|
|
||||||
'min_sdk_version',
|
|
||||||
base.BaseUtil.getTag(map['tags'], 'min_os_version')
|
|
||||||
]);
|
|
||||||
map['tags'].add([
|
|
||||||
'target_sdk_version',
|
|
||||||
base.BaseUtil.getTag(map['tags'], 'target_os_version')
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return super.deserialize(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'file_metadata.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// AdapterGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
|
|
||||||
|
|
||||||
mixin _$FileMetadataAdapter on Adapter<FileMetadata> {
|
|
||||||
static final Map<String, RelationshipMeta> _kFileMetadataRelationshipMetas = {
|
|
||||||
'release': RelationshipMeta<Release>(
|
|
||||||
name: 'release',
|
|
||||||
inverseName: 'artifacts',
|
|
||||||
type: 'releases',
|
|
||||||
kind: 'BelongsTo',
|
|
||||||
instance: (_) => (_ as FileMetadata).release,
|
|
||||||
),
|
|
||||||
'signer': RelationshipMeta<User>(
|
|
||||||
name: 'signer',
|
|
||||||
type: 'users',
|
|
||||||
kind: 'BelongsTo',
|
|
||||||
instance: (_) => (_ as FileMetadata).signer,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
@override
|
|
||||||
Map<String, RelationshipMeta> get relationshipMetas =>
|
|
||||||
_kFileMetadataRelationshipMetas;
|
|
||||||
|
|
||||||
@override
|
|
||||||
FileMetadata deserializeLocal(map, {String? key}) {
|
|
||||||
map = transformDeserialize(map);
|
|
||||||
return internalWrapStopInit(() => FileMetadata.fromJson(map), key: key);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
|
|
||||||
final map = model.toJson();
|
|
||||||
return transformSerialize(map, withRelationships: withRelationships);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final _fileMetadataFinders = <String, dynamic>{};
|
|
||||||
|
|
||||||
class $FileMetadataAdapter = Adapter<FileMetadata>
|
|
||||||
with _$FileMetadataAdapter, NostrAdapter<FileMetadata>, FileMetadataAdapter;
|
|
||||||
|
|
||||||
final fileMetadataAdapterProvider = Provider<Adapter<FileMetadata>>(
|
|
||||||
(ref) => $FileMetadataAdapter(ref, InternalHolder(_fileMetadataFinders)));
|
|
||||||
|
|
||||||
extension FileMetadataAdapterX on Adapter<FileMetadata> {
|
|
||||||
NostrAdapter<FileMetadata> get nostrAdapter =>
|
|
||||||
this as NostrAdapter<FileMetadata>;
|
|
||||||
FileMetadataAdapter get fileMetadataAdapter => this as FileMetadataAdapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
extension FileMetadataRelationshipGraphNodeX
|
|
||||||
on RelationshipGraphNode<FileMetadata> {
|
|
||||||
RelationshipGraphNode<Release> get release {
|
|
||||||
final meta =
|
|
||||||
_$FileMetadataAdapter._kFileMetadataRelationshipMetas['release']
|
|
||||||
as RelationshipMeta<Release>;
|
|
||||||
return meta.clone(
|
|
||||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
RelationshipGraphNode<User> get signer {
|
|
||||||
final meta = _$FileMetadataAdapter._kFileMetadataRelationshipMetas['signer']
|
|
||||||
as RelationshipMeta<User>;
|
|
||||||
return meta.clone(
|
|
||||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:android_package_manager/android_package_manager.dart';
|
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:crypto/crypto.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter_data/flutter_data.dart';
|
|
||||||
import 'package:json_annotation/json_annotation.dart';
|
|
||||||
import 'package:zapstore/main.data.dart';
|
|
||||||
import 'package:zapstore/models/app.dart';
|
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
|
||||||
import 'package:zapstore/utils/system_info.dart';
|
|
||||||
import 'package:zapstore/utils/version.dart';
|
|
||||||
|
|
||||||
part 'local_app.g.dart';
|
|
||||||
|
|
||||||
@DataAdapter([LocalAppAdapter])
|
|
||||||
@JsonSerializable()
|
|
||||||
class LocalApp extends DataModel<LocalApp> {
|
|
||||||
@override
|
|
||||||
final Object? id;
|
|
||||||
final String? installedVersion;
|
|
||||||
final int? installedVersionCode;
|
|
||||||
final AppInstallStatus? status;
|
|
||||||
|
|
||||||
LocalApp(
|
|
||||||
{required this.id,
|
|
||||||
this.installedVersion,
|
|
||||||
this.installedVersionCode,
|
|
||||||
this.status});
|
|
||||||
|
|
||||||
LocalApp copyWith(
|
|
||||||
{String? installedVersion,
|
|
||||||
int? installedVersionCode,
|
|
||||||
AppInstallStatus? status}) {
|
|
||||||
return LocalApp(
|
|
||||||
id: id,
|
|
||||||
installedVersion: installedVersion ?? this.installedVersion,
|
|
||||||
installedVersionCode: installedVersionCode ?? this.installedVersionCode,
|
|
||||||
status: status ?? this.status,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mixin LocalAppAdapter on Adapter<LocalApp> {
|
|
||||||
Future<void> refreshUpdateStatus({String? appId}) async {
|
|
||||||
if (!Platform.isAndroid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// NOTE: Using packageManager.getPackageInfo(packageName: appId)
|
|
||||||
// throws an uncatchable error every time it queries a non-installed package
|
|
||||||
final infos = await packageManager.getInstalledPackages(
|
|
||||||
flags: PackageInfoFlags(
|
|
||||||
{PMFlag.getPermissions, PMFlag.getSigningCertificates},
|
|
||||||
));
|
|
||||||
|
|
||||||
final installedPackageInfos = infos!.where((i) =>
|
|
||||||
!kExcludedAppIdNamespaces.any((e) => i.packageName!.startsWith(e)));
|
|
||||||
|
|
||||||
final ids = installedPackageInfos.map((i) => i.packageName).nonNulls;
|
|
||||||
|
|
||||||
// appId == null is the case of all apps
|
|
||||||
if (appId == null) {
|
|
||||||
final localApps = findAllLocal();
|
|
||||||
final apps = ref.apps.appAdapter.findWhereIdentifierInLocal(ids);
|
|
||||||
|
|
||||||
final localAppsToSave = <LocalApp>[];
|
|
||||||
|
|
||||||
for (final i in installedPackageInfos) {
|
|
||||||
final appId = i.packageName!;
|
|
||||||
final app = apps.firstWhereOrNull((a) => a.identifier == appId);
|
|
||||||
|
|
||||||
final installedVersion = i.versionName;
|
|
||||||
final installedVersionCode = i.versionCode;
|
|
||||||
final signingCertificateBytes =
|
|
||||||
i.signingInfo?.signingCertificateHistory?.firstOrNull;
|
|
||||||
|
|
||||||
final status = determineUpdateStatus(app, installedVersion,
|
|
||||||
installedVersionCode, signingCertificateBytes);
|
|
||||||
|
|
||||||
if (status == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final localApp = localApps.firstWhereOrNull((app) => appId == app.id) ??
|
|
||||||
LocalApp(id: i.packageName!);
|
|
||||||
localAppsToSave.add(
|
|
||||||
localApp.copyWith(
|
|
||||||
installedVersion: installedVersion,
|
|
||||||
installedVersionCode: installedVersionCode,
|
|
||||||
status: status,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save local apps
|
|
||||||
await saveManyLocal(localAppsToSave, async: true);
|
|
||||||
|
|
||||||
// Remove uninstalled apps
|
|
||||||
final uninstalledApps = localApps.where((a) => !ids.contains(a.id));
|
|
||||||
if (uninstalledApps.isNotEmpty) {
|
|
||||||
deleteLocalByKeys(uninstalledApps.map((a) => DataModel.keyFor(a)));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
final app =
|
|
||||||
ref.apps.appAdapter.findWhereIdentifierInLocal({appId}).firstOrNull;
|
|
||||||
final i =
|
|
||||||
installedPackageInfos.firstWhereOrNull((i) => i.packageName == appId);
|
|
||||||
|
|
||||||
final installedVersion = i?.versionName;
|
|
||||||
final installedVersionCode = i?.versionCode;
|
|
||||||
final signingCertificateBytes =
|
|
||||||
i?.signingInfo?.signingCertificateHistory?.firstOrNull;
|
|
||||||
|
|
||||||
final status = determineUpdateStatus(
|
|
||||||
app, installedVersion, installedVersionCode, signingCertificateBytes);
|
|
||||||
|
|
||||||
if (status != null) {
|
|
||||||
final localApp = app!.localApp.value ?? LocalApp(id: i!.packageName!);
|
|
||||||
|
|
||||||
localApp
|
|
||||||
.copyWith(
|
|
||||||
installedVersion: installedVersion,
|
|
||||||
installedVersionCode: installedVersionCode,
|
|
||||||
status: status,
|
|
||||||
)
|
|
||||||
.saveLocal();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateNumberOfApps();
|
|
||||||
}
|
|
||||||
|
|
||||||
void updateNumberOfApps() {
|
|
||||||
final rs = db.select(
|
|
||||||
'SELECT count(*) as c FROM localApps WHERE json_extract(data, \'\$.status\') is \'updatable\'');
|
|
||||||
ref.read(appsToUpdateProvider.notifier).state = rs.first['c'];
|
|
||||||
}
|
|
||||||
|
|
||||||
AppInstallStatus? determineUpdateStatus(App? app, String? installedVersion,
|
|
||||||
int? installedVersionCode, List<int>? signingCertificateBytes) {
|
|
||||||
if (app == null || app.releases.isEmpty || app.latestMetadata == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (signingCertificateBytes != null) {
|
|
||||||
final installedApkSigHash =
|
|
||||||
sha256.convert(signingCertificateBytes).toString().toLowerCase();
|
|
||||||
final metadataSigHashes =
|
|
||||||
app.latestMetadata?.event.getTagSet('apk_signature_hash') ?? {};
|
|
||||||
final matches = metadataSigHashes
|
|
||||||
.any((msh) => msh.toLowerCase() == installedApkSigHash);
|
|
||||||
if (!matches) {
|
|
||||||
return AppInstallStatus.certificateMismatch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (installedVersion == null || installedVersionCode == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var comp = 0;
|
|
||||||
if (app.latestMetadata!.versionCode != null) {
|
|
||||||
// NOTE: Zapstore development versions always carry a lower version code
|
|
||||||
// (e.g. 12) than published ones (e.g. 2012)
|
|
||||||
final code = app.identifier == kZapstoreAppIdentifier && !kReleaseMode
|
|
||||||
? installedVersionCode + 2000
|
|
||||||
: installedVersionCode;
|
|
||||||
comp = app.latestMetadata!.versionCode!.compareTo(code);
|
|
||||||
} else {
|
|
||||||
// If versionCode was not available, compare string version
|
|
||||||
try {
|
|
||||||
comp = compareVersions(installedVersion, app.latestMetadata!.version!);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (comp == 1) return AppInstallStatus.updatable;
|
|
||||||
if (comp == 0) return AppInstallStatus.updated;
|
|
||||||
// else it's a downgrade, which is not installable
|
|
||||||
return AppInstallStatus.downgrade;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum AppInstallStatus {
|
|
||||||
updated,
|
|
||||||
updatable,
|
|
||||||
downgrade,
|
|
||||||
certificateMismatch,
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'local_app.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// AdapterGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
|
|
||||||
|
|
||||||
mixin _$LocalAppAdapter on Adapter<LocalApp> {
|
|
||||||
static final Map<String, RelationshipMeta> _kLocalAppRelationshipMetas = {};
|
|
||||||
|
|
||||||
@override
|
|
||||||
Map<String, RelationshipMeta> get relationshipMetas =>
|
|
||||||
_kLocalAppRelationshipMetas;
|
|
||||||
|
|
||||||
@override
|
|
||||||
LocalApp deserializeLocal(map, {String? key}) {
|
|
||||||
map = transformDeserialize(map);
|
|
||||||
return internalWrapStopInit(() => _$LocalAppFromJson(map), key: key);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
|
|
||||||
final map = _$LocalAppToJson(model);
|
|
||||||
return transformSerialize(map, withRelationships: withRelationships);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final _localAppsFinders = <String, dynamic>{};
|
|
||||||
|
|
||||||
class $LocalAppAdapter = Adapter<LocalApp>
|
|
||||||
with _$LocalAppAdapter, LocalAppAdapter;
|
|
||||||
|
|
||||||
final localAppsAdapterProvider = Provider<Adapter<LocalApp>>(
|
|
||||||
(ref) => $LocalAppAdapter(ref, InternalHolder(_localAppsFinders)));
|
|
||||||
|
|
||||||
extension LocalAppAdapterX on Adapter<LocalApp> {
|
|
||||||
LocalAppAdapter get localAppAdapter => this as LocalAppAdapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
extension LocalAppRelationshipGraphNodeX on RelationshipGraphNode<LocalApp> {}
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// JsonSerializableGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
LocalApp _$LocalAppFromJson(Map<String, dynamic> json) => LocalApp(
|
|
||||||
id: json['id'],
|
|
||||||
installedVersion: json['installedVersion'] as String?,
|
|
||||||
installedVersionCode: (json['installedVersionCode'] as num?)?.toInt(),
|
|
||||||
status: $enumDecodeNullable(_$AppInstallStatusEnumMap, json['status']),
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, dynamic> _$LocalAppToJson(LocalApp instance) => <String, dynamic>{
|
|
||||||
'id': instance.id,
|
|
||||||
'installedVersion': instance.installedVersion,
|
|
||||||
'installedVersionCode': instance.installedVersionCode,
|
|
||||||
'status': _$AppInstallStatusEnumMap[instance.status],
|
|
||||||
};
|
|
||||||
|
|
||||||
const _$AppInstallStatusEnumMap = {
|
|
||||||
AppInstallStatus.updated: 'updated',
|
|
||||||
AppInstallStatus.updatable: 'updatable',
|
|
||||||
AppInstallStatus.downgrade: 'downgrade',
|
|
||||||
AppInstallStatus.certificateMismatch: 'certificateMismatch',
|
|
||||||
};
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:flutter_data/flutter_data.dart';
|
|
||||||
import 'package:purplebase/purplebase.dart' hide Release;
|
|
||||||
import 'package:zapstore/main.data.dart';
|
|
||||||
import 'package:zapstore/models/app.dart';
|
|
||||||
import 'package:zapstore/models/release.dart';
|
|
||||||
import 'package:zapstore/utils/system_info.dart';
|
|
||||||
import 'package:zapstore/widgets/latest_releases_container.dart';
|
|
||||||
|
|
||||||
// NOTE: Very important to use const in relay args to preserve equality in Riverpod families
|
|
||||||
// const kAppRelays = {'ws://10.0.2.2:3000'};
|
|
||||||
const kAppRelays = {'wss://relay.zapstore.dev'};
|
|
||||||
const kSocialRelays = {
|
|
||||||
'wss://relay.damus.io',
|
|
||||||
'wss://relay.primal.net',
|
|
||||||
'wss://nos.lol',
|
|
||||||
'ndk'
|
|
||||||
};
|
|
||||||
const kVertexRelay = {'wss://relay.vertexlab.io'};
|
|
||||||
|
|
||||||
mixin NostrAdapter<T extends DataModelMixin<T>> on Adapter<T> {
|
|
||||||
RelayMessageNotifier get relay =>
|
|
||||||
ref.read(relayProviderFamily(kAppRelays).notifier);
|
|
||||||
|
|
||||||
RelayMessageNotifier get socialRelays =>
|
|
||||||
ref.read(relayProviderFamily(kSocialRelays).notifier);
|
|
||||||
|
|
||||||
RelayMessageNotifier get vertexRelay =>
|
|
||||||
ref.read(relayProviderFamily(kVertexRelay).notifier);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> onInitialized() async {
|
|
||||||
await super.onInitialized();
|
|
||||||
|
|
||||||
// DO NOT run on isolates, the mere fact of calling the
|
|
||||||
// relay getter will trigger its initialization
|
|
||||||
// as providers are obviously not cached across isolates
|
|
||||||
if (!inIsolate) {
|
|
||||||
// Upon adapter initialization, configure relays
|
|
||||||
// with the event verification caching function
|
|
||||||
relay.configure(
|
|
||||||
isEventVerified: (Map<String, dynamic> map) {
|
|
||||||
// If replaceable, we check for that ID
|
|
||||||
final identifier = (map['tags'] as Iterable)
|
|
||||||
.firstWhereOrNull((t) => t[0] == 'd')?[1]
|
|
||||||
?.toString();
|
|
||||||
final id = identifier != null
|
|
||||||
? (map['kind'] as int, map['pubkey'].toString(), identifier)
|
|
||||||
.formatted
|
|
||||||
: map['id'];
|
|
||||||
return ref.apps.nostrAdapter.existsId(id);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
socialRelays.configure(
|
|
||||||
isEventVerified: (map) => ref.apps.nostrAdapter.existsId(map['pubkey']),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int get kind {
|
|
||||||
var pbType = internalType.singularize().capitalize();
|
|
||||||
// handle FileMetadata edge case
|
|
||||||
pbType = pbType.replaceFirst('datum', 'data');
|
|
||||||
return Event.types[pbType]!.kind;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
DeserializedData<T> deserialize(Object? data, {String? key}) {
|
|
||||||
final list = data is Iterable ? data : [data as Map];
|
|
||||||
final models = <T>[];
|
|
||||||
final included = <DataModelMixin>[];
|
|
||||||
|
|
||||||
for (final e in list) {
|
|
||||||
final map = e as Map<String, dynamic>;
|
|
||||||
map['signer'] = map['pubkey'];
|
|
||||||
|
|
||||||
// ID should be the replaceable link/reference so as to make it replaceable in local db too
|
|
||||||
final tags = map['tags'];
|
|
||||||
final isReplaceable = BaseUtil.containsTag(tags, 'd');
|
|
||||||
if (isReplaceable) {
|
|
||||||
map['id'] = (
|
|
||||||
map['kind'] as int,
|
|
||||||
map['pubkey'].toString(),
|
|
||||||
BaseUtil.getTag(tags, 'd')!,
|
|
||||||
).formatted;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove signature as it's already been verified
|
|
||||||
map.remove('sig');
|
|
||||||
|
|
||||||
// Collect models for current kind and included for others
|
|
||||||
final eventType = Event.types.entries
|
|
||||||
.firstWhere((e) => e.value.kind == kind)
|
|
||||||
.key
|
|
||||||
.pluralize()
|
|
||||||
.decapitalize();
|
|
||||||
if (eventType == internalType) {
|
|
||||||
final newData = super.deserialize(map);
|
|
||||||
models.addAll(newData.models as Iterable<T>);
|
|
||||||
} else {
|
|
||||||
final newData = adapters[eventType]!.deserialize(map);
|
|
||||||
included.addAll(newData.models as Iterable<DataModelMixin>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return DeserializedData<T>(models, included: included);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool existsId(Object id) {
|
|
||||||
final r = db.select('SELECT 1 FROM _keys WHERE id = ?', [id]);
|
|
||||||
return r.isNotEmpty;
|
|
||||||
}
|
|
||||||
|
|
||||||
Iterable<String> existingIds(Iterable<Object> ids) {
|
|
||||||
final r = db.select(
|
|
||||||
'SELECT id FROM _keys WHERE id in (${ids.map((e) => '?').join(',')})',
|
|
||||||
ids.toList());
|
|
||||||
return r.map((e) => e['id']);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<T>> findAll(
|
|
||||||
{bool? remote,
|
|
||||||
bool? background,
|
|
||||||
Map<String, dynamic>? params,
|
|
||||||
Map<String, String>? headers,
|
|
||||||
bool? syncLocal,
|
|
||||||
OnSuccessAll<T>? onSuccess,
|
|
||||||
OnErrorAll<T>? onError,
|
|
||||||
DataRequestLabel? label}) async {
|
|
||||||
params ??= {};
|
|
||||||
if (remote == false) {
|
|
||||||
return findAllLocal();
|
|
||||||
}
|
|
||||||
|
|
||||||
final additionalKinds = params.remove('kinds');
|
|
||||||
final limit = params.remove('limit');
|
|
||||||
final since = params.remove('since');
|
|
||||||
final until = params.remove('until');
|
|
||||||
final ignoreReturn = params.remove('ignoreReturn');
|
|
||||||
|
|
||||||
if (['apps', 'fileMetadatas'].contains(internalType)) {
|
|
||||||
if (Platform.isAndroid) {
|
|
||||||
final info =
|
|
||||||
await ref.read(systemInfoNotifierProvider.notifier).fetch();
|
|
||||||
params['#f'] = info.androidInfo.supportedAbis.map((a) => 'android-$a');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final req = RelayRequest(
|
|
||||||
kinds: {kind, ...?additionalKinds},
|
|
||||||
tags: params,
|
|
||||||
limit: limit,
|
|
||||||
until: until,
|
|
||||||
// Disable since when until is present
|
|
||||||
since: until != null ? null : since,
|
|
||||||
);
|
|
||||||
|
|
||||||
final result = await relay.queryRaw(req);
|
|
||||||
final deserialized = await deserializeAsync(result,
|
|
||||||
save: true, ignoreReturn: ignoreReturn == true);
|
|
||||||
return deserialized.models;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> loadArtifactsAndUsers(Iterable<Release> releases) async {
|
|
||||||
final metadataIds =
|
|
||||||
releases.map((r) => r.event.linkedEvents).nonNulls.expand((_) => _);
|
|
||||||
final apps = releases.map((r) => r.app.value).nonNulls;
|
|
||||||
final userIds = apps.map((a) => a.signer.id).nonNulls;
|
|
||||||
|
|
||||||
// Metadata and users probably go to separate relays
|
|
||||||
// so query in parallel
|
|
||||||
// Can use ignoreReturn on both as we only care about saved models
|
|
||||||
await Future.wait([
|
|
||||||
if (metadataIds.isNotEmpty)
|
|
||||||
ref.fileMetadata.findAll(
|
|
||||||
params: {
|
|
||||||
'kinds': {1063, 3063},
|
|
||||||
'ids': metadataIds,
|
|
||||||
'#m': [kAndroidMimeType],
|
|
||||||
'ignoreReturn': true,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (userIds.isNotEmpty)
|
|
||||||
ref.users.findAll(
|
|
||||||
params: {
|
|
||||||
'authors': userIds,
|
|
||||||
'ignoreReturn': true,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RelayListenerNotifier extends Notifier<void> {
|
|
||||||
@override
|
|
||||||
void build() {
|
|
||||||
final timer = Timer.periodic(Duration(minutes: 30), (_) => fetch());
|
|
||||||
|
|
||||||
// This will get disposed when clearing and restarting the app
|
|
||||||
ref.onDispose(() {
|
|
||||||
timer.cancel();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetch() async {
|
|
||||||
await Future.microtask(() async {
|
|
||||||
await ref.read(latestReleasesAppProvider.notifier).fetchRemote();
|
|
||||||
await ref.apps.appAdapter.checkForUpdates();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final relayListenerProvider =
|
|
||||||
NotifierProvider<RelayListenerNotifier, void>(RelayListenerNotifier.new);
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:flutter_data/flutter_data.dart';
|
|
||||||
import 'package:purplebase/purplebase.dart' as base;
|
|
||||||
import 'package:zapstore/models/app.dart';
|
|
||||||
import 'package:zapstore/models/file_metadata.dart';
|
|
||||||
import 'package:zapstore/models/nostr_adapter.dart';
|
|
||||||
import 'package:zapstore/models/user.dart';
|
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
|
||||||
|
|
||||||
part 'release.g.dart';
|
|
||||||
|
|
||||||
@DataAdapter([NostrAdapter, ReleaseAdapter])
|
|
||||||
class Release extends base.Release with DataModelMixin<Release> {
|
|
||||||
@override
|
|
||||||
Object? get id => event.id;
|
|
||||||
|
|
||||||
final HasMany<FileMetadata> artifacts;
|
|
||||||
final BelongsTo<App> app;
|
|
||||||
final BelongsTo<User> signer;
|
|
||||||
|
|
||||||
// Release(
|
|
||||||
// {super.createdAt,
|
|
||||||
// super.content,
|
|
||||||
// super.tags,
|
|
||||||
// required this.artifacts,
|
|
||||||
// required this.app,
|
|
||||||
// required this.signer});
|
|
||||||
|
|
||||||
Release.fromJson(super.map)
|
|
||||||
: app = belongsTo(map['app']),
|
|
||||||
artifacts = hasMany(map['artifacts']),
|
|
||||||
signer = belongsTo(map['signer']),
|
|
||||||
super.fromJson();
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => super.toMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
mixin ReleaseAdapter on Adapter<Release> {
|
|
||||||
@override
|
|
||||||
DeserializedData<Release> deserialize(Object? data, {String? key}) {
|
|
||||||
final list = data is Iterable ? data : [data as Map];
|
|
||||||
|
|
||||||
for (final Map<String, dynamic> map in list) {
|
|
||||||
final tags = map['tags'];
|
|
||||||
map['app'] = base.BaseUtil.getTag(tags, 'a') ??
|
|
||||||
'32267:${map['pubkey']}:${base.BaseUtil.getTag(tags, 'i')}';
|
|
||||||
map['artifacts'] = base.BaseUtil.getTagSet(tags, 'e').toList();
|
|
||||||
}
|
|
||||||
return super.deserialize(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ReleaseExt on Iterable<Release> {
|
|
||||||
List<Release> get sortedByLatest =>
|
|
||||||
sorted((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'release.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// AdapterGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
|
|
||||||
|
|
||||||
mixin _$ReleaseAdapter on Adapter<Release> {
|
|
||||||
static final Map<String, RelationshipMeta> _kReleaseRelationshipMetas = {
|
|
||||||
'artifacts': RelationshipMeta<FileMetadata>(
|
|
||||||
name: 'artifacts',
|
|
||||||
inverseName: 'release',
|
|
||||||
type: 'fileMetadata',
|
|
||||||
kind: 'HasMany',
|
|
||||||
instance: (_) => (_ as Release).artifacts,
|
|
||||||
),
|
|
||||||
'app': RelationshipMeta<App>(
|
|
||||||
name: 'app',
|
|
||||||
inverseName: 'releases',
|
|
||||||
type: 'apps',
|
|
||||||
kind: 'BelongsTo',
|
|
||||||
instance: (_) => (_ as Release).app,
|
|
||||||
),
|
|
||||||
'signer': RelationshipMeta<User>(
|
|
||||||
name: 'signer',
|
|
||||||
type: 'users',
|
|
||||||
kind: 'BelongsTo',
|
|
||||||
instance: (_) => (_ as Release).signer,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
@override
|
|
||||||
Map<String, RelationshipMeta> get relationshipMetas =>
|
|
||||||
_kReleaseRelationshipMetas;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Release deserializeLocal(map, {String? key}) {
|
|
||||||
map = transformDeserialize(map);
|
|
||||||
return internalWrapStopInit(() => Release.fromJson(map), key: key);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
|
|
||||||
final map = model.toJson();
|
|
||||||
return transformSerialize(map, withRelationships: withRelationships);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final _releasesFinders = <String, dynamic>{};
|
|
||||||
|
|
||||||
class $ReleaseAdapter = Adapter<Release>
|
|
||||||
with _$ReleaseAdapter, NostrAdapter<Release>, ReleaseAdapter;
|
|
||||||
|
|
||||||
final releasesAdapterProvider = Provider<Adapter<Release>>(
|
|
||||||
(ref) => $ReleaseAdapter(ref, InternalHolder(_releasesFinders)));
|
|
||||||
|
|
||||||
extension ReleaseAdapterX on Adapter<Release> {
|
|
||||||
NostrAdapter<Release> get nostrAdapter => this as NostrAdapter<Release>;
|
|
||||||
ReleaseAdapter get releaseAdapter => this as ReleaseAdapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ReleaseRelationshipGraphNodeX on RelationshipGraphNode<Release> {
|
|
||||||
RelationshipGraphNode<FileMetadata> get artifacts {
|
|
||||||
final meta = _$ReleaseAdapter._kReleaseRelationshipMetas['artifacts']
|
|
||||||
as RelationshipMeta<FileMetadata>;
|
|
||||||
return meta.clone(
|
|
||||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
RelationshipGraphNode<App> get app {
|
|
||||||
final meta = _$ReleaseAdapter._kReleaseRelationshipMetas['app']
|
|
||||||
as RelationshipMeta<App>;
|
|
||||||
return meta.clone(
|
|
||||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
RelationshipGraphNode<User> get signer {
|
|
||||||
final meta = _$ReleaseAdapter._kReleaseRelationshipMetas['signer']
|
|
||||||
as RelationshipMeta<User>;
|
|
||||||
return meta.clone(
|
|
||||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import 'package:flutter_data/flutter_data.dart';
|
|
||||||
import 'package:json_annotation/json_annotation.dart';
|
|
||||||
import 'package:zapstore/models/user.dart';
|
|
||||||
|
|
||||||
part 'settings.g.dart';
|
|
||||||
|
|
||||||
@JsonSerializable()
|
|
||||||
@DataAdapter([SettingsAdapter])
|
|
||||||
class Settings extends DataModel<Settings> {
|
|
||||||
@override
|
|
||||||
String get id => '_';
|
|
||||||
final BelongsTo<User> user = BelongsTo();
|
|
||||||
final HasMany<User> trustedUsers = HasMany();
|
|
||||||
SignInMethod? signInMethod;
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SignInMethod {
|
|
||||||
pubkey,
|
|
||||||
nip55,
|
|
||||||
}
|
|
||||||
|
|
||||||
mixin SettingsAdapter on Adapter<Settings> {
|
|
||||||
@override
|
|
||||||
Future<void> onInitialized() async {
|
|
||||||
await super.onInitialized();
|
|
||||||
if (inIsolate) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// ensure it's always present
|
|
||||||
if (!existsId('_')) {
|
|
||||||
Settings().saveLocal();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'settings.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// AdapterGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
|
|
||||||
|
|
||||||
mixin _$SettingsAdapter on Adapter<Settings> {
|
|
||||||
static final Map<String, RelationshipMeta> _kSettingsRelationshipMetas = {
|
|
||||||
'user': RelationshipMeta<User>(
|
|
||||||
name: 'user',
|
|
||||||
inverseName: 'settings',
|
|
||||||
type: 'users',
|
|
||||||
kind: 'BelongsTo',
|
|
||||||
instance: (_) => (_ as Settings).user,
|
|
||||||
),
|
|
||||||
'trustedUsers': RelationshipMeta<User>(
|
|
||||||
name: 'trustedUsers',
|
|
||||||
inverseName: 'settings',
|
|
||||||
type: 'users',
|
|
||||||
kind: 'HasMany',
|
|
||||||
instance: (_) => (_ as Settings).trustedUsers,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
@override
|
|
||||||
Map<String, RelationshipMeta> get relationshipMetas =>
|
|
||||||
_kSettingsRelationshipMetas;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Settings deserializeLocal(map, {String? key}) {
|
|
||||||
map = transformDeserialize(map);
|
|
||||||
return internalWrapStopInit(() => _$SettingsFromJson(map), key: key);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
|
|
||||||
final map = _$SettingsToJson(model);
|
|
||||||
return transformSerialize(map, withRelationships: withRelationships);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final _settingsFinders = <String, dynamic>{};
|
|
||||||
|
|
||||||
class $SettingsAdapter = Adapter<Settings>
|
|
||||||
with _$SettingsAdapter, SettingsAdapter;
|
|
||||||
|
|
||||||
final settingsAdapterProvider = Provider<Adapter<Settings>>(
|
|
||||||
(ref) => $SettingsAdapter(ref, InternalHolder(_settingsFinders)));
|
|
||||||
|
|
||||||
extension SettingsAdapterX on Adapter<Settings> {
|
|
||||||
SettingsAdapter get settingsAdapter => this as SettingsAdapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
extension SettingsRelationshipGraphNodeX on RelationshipGraphNode<Settings> {
|
|
||||||
RelationshipGraphNode<User> get user {
|
|
||||||
final meta = _$SettingsAdapter._kSettingsRelationshipMetas['user']
|
|
||||||
as RelationshipMeta<User>;
|
|
||||||
return meta.clone(
|
|
||||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
RelationshipGraphNode<User> get trustedUsers {
|
|
||||||
final meta = _$SettingsAdapter._kSettingsRelationshipMetas['trustedUsers']
|
|
||||||
as RelationshipMeta<User>;
|
|
||||||
return meta.clone(
|
|
||||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// JsonSerializableGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
Settings _$SettingsFromJson(Map<String, dynamic> json) => Settings()
|
|
||||||
..signInMethod =
|
|
||||||
$enumDecodeNullable(_$SignInMethodEnumMap, json['signInMethod']);
|
|
||||||
|
|
||||||
Map<String, dynamic> _$SettingsToJson(Settings instance) => <String, dynamic>{
|
|
||||||
'signInMethod': _$SignInMethodEnumMap[instance.signInMethod],
|
|
||||||
};
|
|
||||||
|
|
||||||
const _$SignInMethodEnumMap = {
|
|
||||||
SignInMethod.pubkey: 'pubkey',
|
|
||||||
SignInMethod.nip55: 'nip55',
|
|
||||||
};
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:flutter_data/flutter_data.dart';
|
|
||||||
import 'package:purplebase/purplebase.dart' as base;
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:zapstore/main.data.dart';
|
|
||||||
import 'package:zapstore/models/app.dart';
|
|
||||||
import 'package:zapstore/models/nostr_adapter.dart';
|
|
||||||
import 'package:zapstore/models/settings.dart';
|
|
||||||
import 'package:zapstore/models/verify_reputation_dvm.dart';
|
|
||||||
import 'package:zapstore/navigation/app_initializer.dart';
|
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
|
||||||
import 'package:zapstore/utils/nwc.dart';
|
|
||||||
|
|
||||||
part 'user.g.dart';
|
|
||||||
|
|
||||||
@DataAdapter([NostrAdapter, UserAdapter])
|
|
||||||
class User extends base.User with DataModelMixin<User> {
|
|
||||||
@override
|
|
||||||
Object? get id => event.id;
|
|
||||||
|
|
||||||
User.fromJson(super.map)
|
|
||||||
: followers = hasMany(map['followers']),
|
|
||||||
following = hasMany(map['following']),
|
|
||||||
settings = belongsTo(map['settings']),
|
|
||||||
apps = hasMany(map['apps']),
|
|
||||||
super.fromJson();
|
|
||||||
|
|
||||||
User.fromPubkey(String pubkey)
|
|
||||||
: this.fromJson({
|
|
||||||
'id': pubkey,
|
|
||||||
'kind': 0,
|
|
||||||
'pubkey': pubkey.hexKey,
|
|
||||||
'created_at': DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
||||||
'content': '{}',
|
|
||||||
'tags': [],
|
|
||||||
});
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => super.toMap();
|
|
||||||
|
|
||||||
@DataRelationship(inverse: 'followers')
|
|
||||||
final HasMany<User> following;
|
|
||||||
@DataRelationship(inverse: 'following')
|
|
||||||
final HasMany<User> followers;
|
|
||||||
@DataRelationship(inverse: 'user')
|
|
||||||
final BelongsTo<Settings> settings;
|
|
||||||
|
|
||||||
@DataRelationship(inverse: 'signer')
|
|
||||||
final HasMany<App> apps;
|
|
||||||
|
|
||||||
String get nameOrNpub => name ?? npub.shorten;
|
|
||||||
|
|
||||||
// Zaps
|
|
||||||
|
|
||||||
Future<void> zap(int amountInSats,
|
|
||||||
{required base.Event event, String? comment, base.Signer? signer}) async {
|
|
||||||
final adapter = DataModel.adapterFor(this) as NostrAdapter;
|
|
||||||
|
|
||||||
// First ensure connection is available
|
|
||||||
final nwcConnection = adapter.ref.read(nwcConnectionProvider).asData?.value;
|
|
||||||
if (nwcConnection == null) {
|
|
||||||
throw 'No NWC connection';
|
|
||||||
}
|
|
||||||
|
|
||||||
final authorPubkey = event.event.pubkey;
|
|
||||||
final author = adapter.ref.users.findOneLocalById(authorPubkey);
|
|
||||||
|
|
||||||
if (author?.lud16 == null) {
|
|
||||||
throw 'Recipient has no Lightning address';
|
|
||||||
}
|
|
||||||
|
|
||||||
final amountInMillisats = amountInSats * 1000;
|
|
||||||
final partialZapRequest = base.PartialZapRequest();
|
|
||||||
partialZapRequest
|
|
||||||
..addLinkedEvent(event)
|
|
||||||
..addLinkedUser(author!)
|
|
||||||
..relays = kSocialRelays.where((r) => r != 'ndk')
|
|
||||||
..amount = amountInMillisats
|
|
||||||
..comment = comment;
|
|
||||||
|
|
||||||
final zapRequest = await partialZapRequest.signWith(signer ?? amberSigner,
|
|
||||||
withPubkey: pubkey);
|
|
||||||
|
|
||||||
// Now we fetch the invoice
|
|
||||||
final lnResponse = await author.fetchLightningAddress();
|
|
||||||
|
|
||||||
final recipientAllowsNostr = lnResponse['allowsNostr'];
|
|
||||||
final recipientPubkey = lnResponse['nostrPubkey'];
|
|
||||||
// All amounts are in millisats
|
|
||||||
final recipientMin = lnResponse['minSendable'];
|
|
||||||
final recipientMax = lnResponse['maxSendable'];
|
|
||||||
|
|
||||||
if (recipientAllowsNostr != true && recipientPubkey != null) {
|
|
||||||
throw 'Recipient does not allow nostr';
|
|
||||||
}
|
|
||||||
if ((recipientMin != null && amountInMillisats < recipientMin) ||
|
|
||||||
(recipientMax != null && amountInMillisats > recipientMax)) {
|
|
||||||
throw 'Amount not between min and max sendable';
|
|
||||||
}
|
|
||||||
|
|
||||||
final commentLength = lnResponse['commentAllowed'];
|
|
||||||
comment =
|
|
||||||
commentLength != null ? comment?.safeSubstring(commentLength) : comment;
|
|
||||||
|
|
||||||
var callbackUri = Uri.parse(lnResponse['callback']!);
|
|
||||||
callbackUri = callbackUri.replace(
|
|
||||||
queryParameters: {
|
|
||||||
...callbackUri.queryParameters,
|
|
||||||
'amount': amountInMillisats.toString(),
|
|
||||||
if (comment != null) 'comment': comment,
|
|
||||||
'nostr': jsonEncode(zapRequest.toMap()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
final invoiceResponse = await http.get(callbackUri);
|
|
||||||
final invoiceMap = jsonDecode(invoiceResponse.body);
|
|
||||||
|
|
||||||
final invoice = invoiceMap['pr'];
|
|
||||||
if (invoice == null) {
|
|
||||||
throw 'No invoice';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pay the invoice via NWC (errors are thrown)
|
|
||||||
await adapter.socialRelays.ndk!.nwc.payInvoice(nwcConnection,
|
|
||||||
invoice: invoice, timeout: Duration(seconds: 20));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<String, dynamic>> fetchLightningAddress() async {
|
|
||||||
final [userName, domainName] = lud16!.split('@');
|
|
||||||
final lnurl = 'https://$domainName/.well-known/lnurlp/$userName';
|
|
||||||
final response = await http.get(Uri.parse(lnurl));
|
|
||||||
final map = jsonDecode(response.body);
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mixin UserAdapter on NostrAdapter<User> {
|
|
||||||
@override
|
|
||||||
Future<List<User>> findAll(
|
|
||||||
{bool? remote,
|
|
||||||
bool? background,
|
|
||||||
Map<String, dynamic>? params,
|
|
||||||
Map<String, String>? headers,
|
|
||||||
bool? syncLocal,
|
|
||||||
OnSuccessAll<User>? onSuccess,
|
|
||||||
OnErrorAll<User>? onError,
|
|
||||||
DataRequestLabel? label}) async {
|
|
||||||
final authors = params!['authors'] as Iterable;
|
|
||||||
if (authors.isEmpty) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
final request = base.RelayRequest(
|
|
||||||
kinds: {0},
|
|
||||||
authors: {...authors},
|
|
||||||
);
|
|
||||||
|
|
||||||
final result = await socialRelays.queryRaw(request);
|
|
||||||
|
|
||||||
if (onSuccess != null) {
|
|
||||||
return await onSuccess.call(DataResponse(statusCode: 200, body: result),
|
|
||||||
label ?? DataRequestLabel('findAll', type: type), this);
|
|
||||||
}
|
|
||||||
final data = await deserializeAsync(result, save: true);
|
|
||||||
return data.models;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool isOfflineError(Object? error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<User?> findOne(Object id,
|
|
||||||
{bool remote = true,
|
|
||||||
bool background = false,
|
|
||||||
Map<String, dynamic>? params,
|
|
||||||
Map<String, String>? headers,
|
|
||||||
OnSuccessOne<User>? onSuccess,
|
|
||||||
OnErrorOne<User>? onError,
|
|
||||||
DataRequestLabel? label}) async {
|
|
||||||
if (id.toString().isEmpty) return null;
|
|
||||||
|
|
||||||
var publicKey = id.toString();
|
|
||||||
|
|
||||||
if (publicKey.startsWith('npub')) {
|
|
||||||
publicKey = publicKey.hexKey;
|
|
||||||
} else if (publicKey.contains('@')) {
|
|
||||||
// If it's not an npub and it has a @ instead, we treat the string as NIP-05
|
|
||||||
final [username, domain] = publicKey.split('@');
|
|
||||||
publicKey = await sendRequest<dynamic>(
|
|
||||||
Uri.parse('https://$domain/.well-known/nostr.json?name=$username'),
|
|
||||||
onSuccess: (response, label) {
|
|
||||||
var body = response.body;
|
|
||||||
if (body is String) {
|
|
||||||
body = jsonDecode(body);
|
|
||||||
}
|
|
||||||
return (body as Map)['names']?[username];
|
|
||||||
},
|
|
||||||
onError: (e, _) {
|
|
||||||
throw e;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
params ??= {};
|
|
||||||
|
|
||||||
final result = await socialRelays.queryRaw(base.RelayRequest(
|
|
||||||
kinds: {0},
|
|
||||||
tags: params,
|
|
||||||
authors: {publicKey},
|
|
||||||
));
|
|
||||||
|
|
||||||
final data = await deserializeAsync(result, save: true);
|
|
||||||
return data.models.firstWhereOrNull((e) {
|
|
||||||
return e.id == publicKey;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
DeserializedData<User> deserialize(Object? data, {String? key}) {
|
|
||||||
final list = data is Iterable ? data : [data as Map];
|
|
||||||
|
|
||||||
for (final Map<String, dynamic> map in list) {
|
|
||||||
// if (map['kind'] == 3) {
|
|
||||||
// map['following'] = {
|
|
||||||
// for (final [name, value] in map['tags'] as Iterable)
|
|
||||||
// if (name == 'p') value
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
map['id'] = map['pubkey'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.deserialize(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<User>> getRelevantWhoFollow(
|
|
||||||
String fromNpub, String toNpub) async {
|
|
||||||
final dvmReq = await PartialVerifyReputationRequest(
|
|
||||||
source: fromNpub.hexKey, target: toNpub.hexKey)
|
|
||||||
.signWith(amberSigner, withPubkey: fromNpub.hexKey);
|
|
||||||
await ref.users.nostrAdapter.vertexRelay.publish(dvmReq);
|
|
||||||
|
|
||||||
final req = base.RelayRequest(kinds: {
|
|
||||||
6312,
|
|
||||||
7000
|
|
||||||
}, tags: {
|
|
||||||
'e': [dvmReq.event.id]
|
|
||||||
});
|
|
||||||
final response = await ref.users.nostrAdapter.vertexRelay.queryRaw(req);
|
|
||||||
|
|
||||||
if (response.isEmpty) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (response.first['kind'] == 7000) {
|
|
||||||
final error = (response.first['tags'] as Iterable?)
|
|
||||||
?.firstWhereOrNull((t) => t[0] == 'status')?[2];
|
|
||||||
if (error.toString().contains('credits')) {
|
|
||||||
throw 'Unable to check followers';
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
final result = response.first['content'];
|
|
||||||
final pubkeyList = (jsonDecode(result) as List).map((e) => e['pubkey']);
|
|
||||||
final users = await findAll(params: {'authors': pubkeyList});
|
|
||||||
return users;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final signedInUserProvider = Provider<User?>((ref) {
|
|
||||||
return ref.settings
|
|
||||||
.watchOne('_', alsoWatch: (_) => {_.user})
|
|
||||||
.model
|
|
||||||
?.user
|
|
||||||
.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
const kZapstorePubkey =
|
|
||||||
'78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d';
|
|
||||||
|
|
||||||
const kFranzapPubkey =
|
|
||||||
'726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11';
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'user.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// AdapterGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
|
|
||||||
|
|
||||||
mixin _$UserAdapter on Adapter<User> {
|
|
||||||
static final Map<String, RelationshipMeta> _kUserRelationshipMetas = {
|
|
||||||
'following': RelationshipMeta<User>(
|
|
||||||
name: 'following',
|
|
||||||
inverseName: 'followers',
|
|
||||||
type: 'users',
|
|
||||||
kind: 'HasMany',
|
|
||||||
instance: (_) => (_ as User).following,
|
|
||||||
),
|
|
||||||
'followers': RelationshipMeta<User>(
|
|
||||||
name: 'followers',
|
|
||||||
inverseName: 'following',
|
|
||||||
type: 'users',
|
|
||||||
kind: 'HasMany',
|
|
||||||
instance: (_) => (_ as User).followers,
|
|
||||||
),
|
|
||||||
'settings': RelationshipMeta<Settings>(
|
|
||||||
name: 'settings',
|
|
||||||
inverseName: 'user',
|
|
||||||
type: 'settings',
|
|
||||||
kind: 'BelongsTo',
|
|
||||||
instance: (_) => (_ as User).settings,
|
|
||||||
),
|
|
||||||
'apps': RelationshipMeta<App>(
|
|
||||||
name: 'apps',
|
|
||||||
inverseName: 'signer',
|
|
||||||
type: 'apps',
|
|
||||||
kind: 'HasMany',
|
|
||||||
instance: (_) => (_ as User).apps,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
@override
|
|
||||||
Map<String, RelationshipMeta> get relationshipMetas =>
|
|
||||||
_kUserRelationshipMetas;
|
|
||||||
|
|
||||||
@override
|
|
||||||
User deserializeLocal(map, {String? key}) {
|
|
||||||
map = transformDeserialize(map);
|
|
||||||
return internalWrapStopInit(() => User.fromJson(map), key: key);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
|
|
||||||
final map = model.toJson();
|
|
||||||
return transformSerialize(map, withRelationships: withRelationships);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final _usersFinders = <String, dynamic>{};
|
|
||||||
|
|
||||||
class $UserAdapter = Adapter<User>
|
|
||||||
with _$UserAdapter, NostrAdapter<User>, UserAdapter;
|
|
||||||
|
|
||||||
final usersAdapterProvider = Provider<Adapter<User>>(
|
|
||||||
(ref) => $UserAdapter(ref, InternalHolder(_usersFinders)));
|
|
||||||
|
|
||||||
extension UserAdapterX on Adapter<User> {
|
|
||||||
NostrAdapter<User> get nostrAdapter => this as NostrAdapter<User>;
|
|
||||||
UserAdapter get userAdapter => this as UserAdapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UserRelationshipGraphNodeX on RelationshipGraphNode<User> {
|
|
||||||
RelationshipGraphNode<User> get following {
|
|
||||||
final meta = _$UserAdapter._kUserRelationshipMetas['following']
|
|
||||||
as RelationshipMeta<User>;
|
|
||||||
return meta.clone(
|
|
||||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
RelationshipGraphNode<User> get followers {
|
|
||||||
final meta = _$UserAdapter._kUserRelationshipMetas['followers']
|
|
||||||
as RelationshipMeta<User>;
|
|
||||||
return meta.clone(
|
|
||||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
RelationshipGraphNode<Settings> get settings {
|
|
||||||
final meta = _$UserAdapter._kUserRelationshipMetas['settings']
|
|
||||||
as RelationshipMeta<Settings>;
|
|
||||||
return meta.clone(
|
|
||||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
RelationshipGraphNode<App> get apps {
|
|
||||||
final meta =
|
|
||||||
_$UserAdapter._kUserRelationshipMetas['apps'] as RelationshipMeta<App>;
|
|
||||||
return meta.clone(
|
|
||||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import 'package:purplebase/purplebase.dart';
|
|
||||||
|
|
||||||
class VerifyReputationRequest extends RegularEvent<VerifyReputationRequest> {
|
|
||||||
VerifyReputationRequest.fromJson(super.map) : super.fromJson();
|
|
||||||
}
|
|
||||||
|
|
||||||
class PartialVerifyReputationRequest
|
|
||||||
extends RegularPartialEvent<VerifyReputationRequest> {
|
|
||||||
PartialVerifyReputationRequest({
|
|
||||||
required String source,
|
|
||||||
required String target,
|
|
||||||
}) {
|
|
||||||
event.setTag('param', ['source', source]);
|
|
||||||
event.setTag('param', ['target', target]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import 'package:flutter_data/flutter_data.dart';
|
|
||||||
import 'package:purplebase/purplebase.dart' as base;
|
|
||||||
import 'package:zapstore/models/nostr_adapter.dart';
|
|
||||||
import 'package:zapstore/models/user.dart';
|
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
|
||||||
|
|
||||||
part 'zap_receipt.g.dart';
|
|
||||||
|
|
||||||
@DataAdapter([NostrAdapter, ZapReceiptAdapter])
|
|
||||||
class ZapReceipt extends base.ZapReceipt with DataModelMixin<ZapReceipt> {
|
|
||||||
final BelongsTo<User> recipient;
|
|
||||||
final BelongsTo<User> signer;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Object? get id => event.id;
|
|
||||||
|
|
||||||
ZapReceipt.fromJson(super.map)
|
|
||||||
: recipient = belongsTo(map['recipient']),
|
|
||||||
signer = belongsTo(map['signer']),
|
|
||||||
super.fromJson();
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => super.toMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
mixin ZapReceiptAdapter on Adapter<ZapReceipt> {
|
|
||||||
@override
|
|
||||||
DeserializedData<ZapReceipt> deserialize(Object? data, {String? key}) {
|
|
||||||
final list = data is Iterable ? data : [data as Map];
|
|
||||||
for (final e in list) {
|
|
||||||
final map = e as Map<String, dynamic>;
|
|
||||||
map['recipient'] = base.BaseUtil.getTagSet(map['tags'], 'p');
|
|
||||||
}
|
|
||||||
return super.deserialize(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<ZapReceipt> findByEventId(String eventId) {
|
|
||||||
final result = db.select(
|
|
||||||
"SELECT z.key, z.data from zapReceipts z, json_each(json_extract(data, '\$.tags')) WHERE json_extract(value, '\$[0]') = 'e' AND json_extract(value, '\$[1]') = ?",
|
|
||||||
[eventId]);
|
|
||||||
return deserializeFromResult(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'zap_receipt.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// AdapterGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
|
|
||||||
|
|
||||||
mixin _$ZapReceiptAdapter on Adapter<ZapReceipt> {
|
|
||||||
static final Map<String, RelationshipMeta> _kZapReceiptRelationshipMetas = {
|
|
||||||
'recipient': RelationshipMeta<User>(
|
|
||||||
name: 'recipient',
|
|
||||||
type: 'users',
|
|
||||||
kind: 'BelongsTo',
|
|
||||||
instance: (_) => (_ as ZapReceipt).recipient,
|
|
||||||
),
|
|
||||||
'signer': RelationshipMeta<User>(
|
|
||||||
name: 'signer',
|
|
||||||
type: 'users',
|
|
||||||
kind: 'BelongsTo',
|
|
||||||
instance: (_) => (_ as ZapReceipt).signer,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
@override
|
|
||||||
Map<String, RelationshipMeta> get relationshipMetas =>
|
|
||||||
_kZapReceiptRelationshipMetas;
|
|
||||||
|
|
||||||
@override
|
|
||||||
ZapReceipt deserializeLocal(map, {String? key}) {
|
|
||||||
map = transformDeserialize(map);
|
|
||||||
return internalWrapStopInit(() => ZapReceipt.fromJson(map), key: key);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
|
|
||||||
final map = model.toJson();
|
|
||||||
return transformSerialize(map, withRelationships: withRelationships);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final _zapReceiptsFinders = <String, dynamic>{};
|
|
||||||
|
|
||||||
class $ZapReceiptAdapter = Adapter<ZapReceipt>
|
|
||||||
with _$ZapReceiptAdapter, NostrAdapter<ZapReceipt>, ZapReceiptAdapter;
|
|
||||||
|
|
||||||
final zapReceiptsAdapterProvider = Provider<Adapter<ZapReceipt>>(
|
|
||||||
(ref) => $ZapReceiptAdapter(ref, InternalHolder(_zapReceiptsFinders)));
|
|
||||||
|
|
||||||
extension ZapReceiptAdapterX on Adapter<ZapReceipt> {
|
|
||||||
NostrAdapter<ZapReceipt> get nostrAdapter => this as NostrAdapter<ZapReceipt>;
|
|
||||||
ZapReceiptAdapter get zapReceiptAdapter => this as ZapReceiptAdapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ZapReceiptRelationshipGraphNodeX
|
|
||||||
on RelationshipGraphNode<ZapReceipt> {
|
|
||||||
RelationshipGraphNode<User> get recipient {
|
|
||||||
final meta = _$ZapReceiptAdapter._kZapReceiptRelationshipMetas['recipient']
|
|
||||||
as RelationshipMeta<User>;
|
|
||||||
return meta.clone(
|
|
||||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
RelationshipGraphNode<User> get signer {
|
|
||||||
final meta = _$ZapReceiptAdapter._kZapReceiptRelationshipMetas['signer']
|
|
||||||
as RelationshipMeta<User>;
|
|
||||||
return meta.clone(
|
|
||||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:flutter_data/flutter_data.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'package:zapstore/main.dart';
|
|
||||||
import 'package:zapstore/main.data.dart';
|
|
||||||
import 'package:zapstore/models/app.dart';
|
|
||||||
import 'package:zapstore/models/local_app.dart';
|
|
||||||
import 'package:zapstore/models/user.dart';
|
|
||||||
import 'package:zapstore/models/verify_reputation_dvm.dart';
|
|
||||||
import 'package:zapstore/navigation/router.dart';
|
|
||||||
import 'package:zapstore/utils/signers.dart';
|
|
||||||
import 'package:zapstore/widgets/app_curation_container.dart';
|
|
||||||
import 'package:purplebase/purplebase.dart' as base;
|
|
||||||
|
|
||||||
AppLifecycleListener? _lifecycleListener;
|
|
||||||
SharedPreferences? sharedPreferences;
|
|
||||||
User? anonUser;
|
|
||||||
final amberSigner = AmberSigner();
|
|
||||||
|
|
||||||
final appInitializer = FutureProvider<void>((ref) async {
|
|
||||||
sharedPreferences = await SharedPreferences.getInstance();
|
|
||||||
final dbVersion = sharedPreferences!.getInt('dbVersion');
|
|
||||||
|
|
||||||
// Perform migration
|
|
||||||
if (dbVersion == null || dbVersion < kDbVersion) {
|
|
||||||
final storage = ref.read(localStorageProvider);
|
|
||||||
await storage.initialize();
|
|
||||||
await storage.destroy();
|
|
||||||
await sharedPreferences!.setInt('dbVersion', kDbVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize Flutter Data
|
|
||||||
await ref.read(initializeFlutterData(adapterProvidersMap).future);
|
|
||||||
|
|
||||||
// NOTE: Do not use ignoreReturn here
|
|
||||||
if (ref.appCurationSets.countLocal == 0) {
|
|
||||||
// If we are here, local storage is empty
|
|
||||||
// Preload curation sets
|
|
||||||
await ref.appCurationSets.findAll(syncLocal: true);
|
|
||||||
// Preload updates in the background (no await)
|
|
||||||
ref.apps.appAdapter.checkForUpdates();
|
|
||||||
} else {
|
|
||||||
// Preload curation sets in the background (no await)
|
|
||||||
ref.appCurationSets.findAll(syncLocal: true, background: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preload zapstore's nostr curation set
|
|
||||||
ref.read(appCurationSetProvider(kNostrCurationSetLink).notifier).fetch();
|
|
||||||
|
|
||||||
// Initialize signer
|
|
||||||
await amberSigner.initialize();
|
|
||||||
|
|
||||||
// Set up anon user (pubkey derives from pkSigner secret key)
|
|
||||||
anonUser ??= User.fromPubkey(
|
|
||||||
'c86eda2daae768374526bc54903f388d9a866c00740ec8db418d7ef2dca77b5b')
|
|
||||||
.init()
|
|
||||||
.saveLocal();
|
|
||||||
|
|
||||||
// Register event types
|
|
||||||
base.Event.types['VerifyReputationRequest'] =
|
|
||||||
(kind: 5312, constructor: VerifyReputationRequest.fromJson);
|
|
||||||
|
|
||||||
// App-wide listeners
|
|
||||||
|
|
||||||
// Register app lifecycle listener
|
|
||||||
_lifecycleListener = AppLifecycleListener(
|
|
||||||
onStateChange: (state) {
|
|
||||||
if (state == AppLifecycleState.resumed) {
|
|
||||||
ref.localApps.localAppAdapter.refreshUpdateStatus();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle deep links
|
|
||||||
final appLinksSub = appLinks.uriLinkStream.listen((uri) async {
|
|
||||||
String? appId;
|
|
||||||
if (uri.scheme == 'https' && uri.hasFragment) {
|
|
||||||
appId = uri.fragment;
|
|
||||||
} else if (uri.scheme == 'zapstore') {
|
|
||||||
// zapstore://com.app.id
|
|
||||||
// zapstore://com.app.id?signer=npub1xyz
|
|
||||||
appId = uri.host;
|
|
||||||
}
|
|
||||||
if (appId != null) {
|
|
||||||
final adapter = ref.apps.appAdapter;
|
|
||||||
final apps = adapter.findWhereIdentifierInLocal({appId});
|
|
||||||
// Filter by signer npub, if present, otherwise pick first
|
|
||||||
final appSignerNpub = uri.queryParameters['signer'];
|
|
||||||
var goToApp =
|
|
||||||
apps.firstWhereOrNull((a) => a.signer.value?.npub == appSignerNpub);
|
|
||||||
if (appSignerNpub == null) {
|
|
||||||
goToApp = apps.first;
|
|
||||||
}
|
|
||||||
if (goToApp != null) {
|
|
||||||
appRouter.go('/details', extra: goToApp);
|
|
||||||
} else {
|
|
||||||
appRouter.go('/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ref.onDispose(() {
|
|
||||||
_lifecycleListener?.dispose();
|
|
||||||
appLinksSub.cancel();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class DesktopScaffold extends StatelessWidget {
|
|
||||||
const DesktopScaffold({
|
|
||||||
super.key,
|
|
||||||
required this.body,
|
|
||||||
required this.selectedIndex,
|
|
||||||
required this.onDestinationSelected,
|
|
||||||
});
|
|
||||||
final Widget body;
|
|
||||||
final int selectedIndex;
|
|
||||||
final ValueChanged<int> onDestinationSelected;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
body: Row(
|
|
||||||
children: [
|
|
||||||
NavigationRail(
|
|
||||||
minWidth: 120,
|
|
||||||
selectedIndex: selectedIndex,
|
|
||||||
onDestinationSelected: onDestinationSelected,
|
|
||||||
labelType: NavigationRailLabelType.all,
|
|
||||||
destinations: [
|
|
||||||
const NavigationRailDestination(
|
|
||||||
label: Text('Home'),
|
|
||||||
icon: Icon(Icons.home_outlined),
|
|
||||||
selectedIcon: Icon(Icons.home_filled),
|
|
||||||
),
|
|
||||||
const NavigationRailDestination(
|
|
||||||
label: Text('Updates'),
|
|
||||||
icon: Icon(Icons.download_for_offline_outlined),
|
|
||||||
selectedIcon: Icon(Icons.download_for_offline),
|
|
||||||
),
|
|
||||||
const NavigationRailDestination(
|
|
||||||
label: Text('Settings'),
|
|
||||||
icon: Icon(Icons.settings_outlined),
|
|
||||||
selectedIcon: Icon(Icons.settings),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Flexible(
|
|
||||||
child: Container(
|
|
||||||
constraints: BoxConstraints(
|
|
||||||
maxWidth: 768,
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
|
|
||||||
child: body,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:zapstore/models/app.dart';
|
|
||||||
import 'package:zapstore/utils/theme.dart';
|
|
||||||
import 'package:zapstore/widgets/drawer_container.dart';
|
|
||||||
|
|
||||||
class MobileScaffold extends HookConsumerWidget {
|
|
||||||
const MobileScaffold({
|
|
||||||
super.key,
|
|
||||||
required this.body,
|
|
||||||
required this.selectedIndex,
|
|
||||||
required this.onDestinationSelected,
|
|
||||||
});
|
|
||||||
final Widget body;
|
|
||||||
final int selectedIndex;
|
|
||||||
final ValueChanged<int> onDestinationSelected;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final appsToUpdate = ref.watch(appsToUpdateProvider);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
key: scaffoldKey,
|
|
||||||
body: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
|
|
||||||
child: body,
|
|
||||||
),
|
|
||||||
bottomNavigationBar: NavigationBar(
|
|
||||||
height: 60,
|
|
||||||
backgroundColor: kBackgroundColor,
|
|
||||||
indicatorColor: Colors.transparent,
|
|
||||||
selectedIndex: selectedIndex,
|
|
||||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
|
|
||||||
destinations: [
|
|
||||||
NavigationDestination(
|
|
||||||
label: 'Home',
|
|
||||||
icon: Icon(Icons.home_outlined),
|
|
||||||
selectedIcon: Icon(Icons.home_filled),
|
|
||||||
),
|
|
||||||
NavigationDestination(
|
|
||||||
label: 'Updates',
|
|
||||||
icon: appsToUpdate > 0
|
|
||||||
? Badge(
|
|
||||||
label: Text(appsToUpdate.toString()),
|
|
||||||
child: Icon(Icons.update_outlined),
|
|
||||||
)
|
|
||||||
: Icon(Icons.update_outlined),
|
|
||||||
selectedIcon: appsToUpdate > 0
|
|
||||||
? Badge(
|
|
||||||
label: Text(appsToUpdate.toString()),
|
|
||||||
child: Icon(Icons.update),
|
|
||||||
)
|
|
||||||
: Icon(Icons.update),
|
|
||||||
),
|
|
||||||
NavigationDestination(
|
|
||||||
label: 'Settings',
|
|
||||||
icon: Icon(Icons.settings_outlined),
|
|
||||||
selectedIcon: Icon(Icons.settings),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onDestinationSelected: onDestinationSelected,
|
|
||||||
),
|
|
||||||
drawer: DrawerContainer(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final scaffoldKey = GlobalKey<ScaffoldState>();
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:zapstore/main.dart';
|
|
||||||
import 'package:zapstore/models/app.dart';
|
|
||||||
import 'package:zapstore/models/nostr_adapter.dart';
|
|
||||||
import 'package:zapstore/models/user.dart';
|
|
||||||
import 'package:zapstore/navigation/app_initializer.dart';
|
|
||||||
import 'package:zapstore/navigation/desktop_scaffold.dart';
|
|
||||||
import 'package:zapstore/navigation/mobile_scaffold.dart';
|
|
||||||
import 'package:zapstore/screens/app_detail_screen.dart';
|
|
||||||
import 'package:zapstore/screens/developer_screen.dart';
|
|
||||||
import 'package:zapstore/screens/search_screen.dart';
|
|
||||||
import 'package:zapstore/screens/settings_screen.dart';
|
|
||||||
import 'package:zapstore/screens/updates_screen.dart';
|
|
||||||
import 'package:zapstore/widgets/error_container.dart';
|
|
||||||
import 'package:zapstore/widgets/spinning_logo.dart';
|
|
||||||
|
|
||||||
/// Application navigation.
|
|
||||||
/// - Initializes Go Router, dispatches routes
|
|
||||||
/// - Initializes Flutter Data (database) and Purplebase (nostr library)
|
|
||||||
/// - Builds media query dependent scaffold (mobile, desktop)
|
|
||||||
final appRouter = GoRouter(
|
|
||||||
initialLocation: '/',
|
|
||||||
// * Passing a navigatorKey causes an issue on hot reload:
|
|
||||||
// * https://github.com/flutter/flutter/issues/113757#issuecomment-1518421380
|
|
||||||
// * However it's still necessary otherwise the navigator pops back to
|
|
||||||
// * root on hot reload
|
|
||||||
navigatorKey: _rootNavigatorKey,
|
|
||||||
routes: [
|
|
||||||
StatefulShellRoute.indexedStack(
|
|
||||||
builder: (context, state, navigationShell) {
|
|
||||||
return ScaffoldWithNestedNavigation(navigationShell: navigationShell);
|
|
||||||
},
|
|
||||||
branches: [
|
|
||||||
StatefulShellBranch(
|
|
||||||
navigatorKey: _searchNavigatorKey,
|
|
||||||
routes: [
|
|
||||||
GoRoute(
|
|
||||||
path: '/',
|
|
||||||
builder: (context, state) => SearchScreen(),
|
|
||||||
routes: [appDetailsRoute, developerRoute],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
StatefulShellBranch(
|
|
||||||
navigatorKey: _updatesNavigatorKey,
|
|
||||||
routes: [
|
|
||||||
GoRoute(
|
|
||||||
path: '/updates',
|
|
||||||
builder: (context, state) => UpdatesScreen(),
|
|
||||||
routes: [appDetailsRoute, developerRoute],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
StatefulShellBranch(
|
|
||||||
navigatorKey: _settingsNavigatorKey,
|
|
||||||
routes: [
|
|
||||||
GoRoute(
|
|
||||||
path: '/settings',
|
|
||||||
pageBuilder: (context, state) => NoTransitionPage(
|
|
||||||
child: SettingsScreen(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
final appDetailsRoute = GoRoute(
|
|
||||||
path: 'details',
|
|
||||||
builder: (context, state) => AppDetailScreen(model: state.extra as App),
|
|
||||||
);
|
|
||||||
|
|
||||||
final developerRoute = GoRoute(
|
|
||||||
path: 'developer',
|
|
||||||
builder: (context, state) => DeveloperScreen(model: state.extra as User),
|
|
||||||
);
|
|
||||||
|
|
||||||
class ScaffoldWithNestedNavigation extends HookConsumerWidget {
|
|
||||||
const ScaffoldWithNestedNavigation({
|
|
||||||
Key? key,
|
|
||||||
required this.navigationShell,
|
|
||||||
}) : super(key: key ?? const ValueKey('ScaffoldWithNestedNavigation'));
|
|
||||||
final StatefulNavigationShell navigationShell;
|
|
||||||
|
|
||||||
void onDestinationSelected(int index) {
|
|
||||||
navigationShell.goBranch(
|
|
||||||
index,
|
|
||||||
// A common pattern when using bottom navigation bars is to support
|
|
||||||
// navigating to the initial location when tapping the item that is
|
|
||||||
// already active. This example demonstrates how to support this behavior,
|
|
||||||
// using the initialLocation parameter of goBranch.
|
|
||||||
initialLocation: index == navigationShell.currentIndex,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final initializer = ref.watch(appInitializer);
|
|
||||||
return SafeArea(
|
|
||||||
child: initializer.when(
|
|
||||||
data: (_) => LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
// At this point initialization is done
|
|
||||||
// listen to relay
|
|
||||||
ref.watch(relayListenerProvider);
|
|
||||||
|
|
||||||
if (constraints.maxWidth < 550) {
|
|
||||||
return MobileScaffold(
|
|
||||||
body: navigationShell,
|
|
||||||
selectedIndex: navigationShell.currentIndex,
|
|
||||||
onDestinationSelected: onDestinationSelected,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return DesktopScaffold(
|
|
||||||
body: navigationShell,
|
|
||||||
selectedIndex: navigationShell.currentIndex,
|
|
||||||
onDestinationSelected: onDestinationSelected,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
error: (e, stack) {
|
|
||||||
errorHandler(e, stack);
|
|
||||||
return ErrorContainer(exception: e, stack: stack);
|
|
||||||
},
|
|
||||||
loading: () => Center(child: SpinningLogo()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keys
|
|
||||||
|
|
||||||
final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
|
||||||
final _searchNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'search');
|
|
||||||
final _updatesNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'updates');
|
|
||||||
final _settingsNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'settings');
|
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:models/models.dart';
|
||||||
|
import 'package:zapstore/screens/main_scaffold.dart';
|
||||||
|
import 'package:zapstore/screens/app_detail_screen.dart';
|
||||||
|
import 'package:zapstore/screens/app_stack_screen.dart';
|
||||||
|
import 'package:zapstore/screens/user_screen.dart';
|
||||||
|
import 'package:zapstore/screens/search_screen.dart';
|
||||||
|
import 'package:zapstore/screens/updates_screen.dart';
|
||||||
|
import 'package:zapstore/screens/profile_screen.dart';
|
||||||
|
|
||||||
|
/// Root paths for each navigation branch (used for back navigation handling)
|
||||||
|
const kBranchRoots = ['/search', '/updates', '/profile'];
|
||||||
|
|
||||||
|
typedef _ResolvedRoute = ({String identifier, String? author});
|
||||||
|
|
||||||
|
_ResolvedRoute _resolveNaddrRouteId(String rawId) {
|
||||||
|
if (rawId.startsWith('naddr1')) {
|
||||||
|
try {
|
||||||
|
final decoded = Utils.decodeShareableIdentifier(rawId);
|
||||||
|
if (decoded is AddressData) {
|
||||||
|
return (identifier: decoded.identifier, author: decoded.author);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Fall back to treating it as a plain identifier.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (identifier: rawId, author: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to build app detail route
|
||||||
|
GoRoute _appDetailRoute() {
|
||||||
|
return GoRoute(
|
||||||
|
path: 'app/:id',
|
||||||
|
builder: (context, state) {
|
||||||
|
final rawId = state.pathParameters['id']!;
|
||||||
|
final resolved = _resolveNaddrRouteId(rawId);
|
||||||
|
return AppDetailScreen(
|
||||||
|
appId: resolved.identifier,
|
||||||
|
authorPubkey: resolved.author,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to build stack detail route
|
||||||
|
GoRoute _stackDetailRoute() {
|
||||||
|
return GoRoute(
|
||||||
|
path: 'stack/:id',
|
||||||
|
builder: (context, state) {
|
||||||
|
final rawId = state.pathParameters['id']!;
|
||||||
|
final resolved = _resolveNaddrRouteId(rawId);
|
||||||
|
return AppStackScreen(
|
||||||
|
stackId: resolved.identifier,
|
||||||
|
authorPubkey: resolved.author,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to build user route
|
||||||
|
GoRoute _userRoute() {
|
||||||
|
return GoRoute(
|
||||||
|
path: 'user/:pubkey',
|
||||||
|
builder: (context, state) {
|
||||||
|
final pubkey = state.pathParameters['pubkey']!;
|
||||||
|
return UserScreen(pubkey: pubkey);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
|
return GoRouter(
|
||||||
|
initialLocation: '/search',
|
||||||
|
routes: [
|
||||||
|
// Top-level route for market:// intents
|
||||||
|
// Redirect to search branch to show with nav bar
|
||||||
|
GoRoute(
|
||||||
|
path: '/market/:packageId',
|
||||||
|
redirect: (context, state) {
|
||||||
|
final packageId = state.pathParameters['packageId'];
|
||||||
|
if (packageId == null || packageId.isEmpty) {
|
||||||
|
return '/search';
|
||||||
|
}
|
||||||
|
return '/search/app/$packageId';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// Single stateful shell route that handles everything
|
||||||
|
StatefulShellRoute.indexedStack(
|
||||||
|
builder: (context, state, navigationShell) =>
|
||||||
|
MainScaffold(navigationShell: navigationShell),
|
||||||
|
branches: [
|
||||||
|
// Search tab branch with nested detail routes
|
||||||
|
StatefulShellBranch(
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/search',
|
||||||
|
builder: (context, state) => const SearchScreen(),
|
||||||
|
routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Updates tab branch with nested detail routes
|
||||||
|
StatefulShellBranch(
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/updates',
|
||||||
|
builder: (context, state) => const UpdatesScreen(),
|
||||||
|
routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Profile tab branch with nested detail routes
|
||||||
|
StatefulShellBranch(
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/profile',
|
||||||
|
builder: (context, state) => const ProfileScreen(),
|
||||||
|
routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||