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
|
||||
.fvm/
|
||||
|
||||
android/app/.cxx/
|
||||
|
||||
.env
|
||||
@@ -4,7 +4,7 @@
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "bae5e49bc2a867403c43b2aae2de8f8c33b037e4"
|
||||
revision: "fcf2c11572af6f390246c056bc905eca609533a0"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
@@ -13,26 +13,26 @@ project_type: app
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
- platform: android
|
||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
- platform: ios
|
||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
- platform: linux
|
||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
- platform: macos
|
||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
- platform: web
|
||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
- platform: windows
|
||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
|
||||
# User provided section
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
../.cursor/mcp.json
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Zapstore
|
||||
Copyright (c) 2025 Zapstore
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
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
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
analyzer:
|
||||
errors:
|
||||
no_leading_underscores_for_local_identifiers: ignore
|
||||
must_be_immutable: ignore
|
||||
dangling_library_doc_comments: ignore
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
@@ -27,12 +26,5 @@ linter:
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` 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
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
@@ -5,9 +5,10 @@ gradle-wrapper.jar
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# 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
|
||||
**/*.keystore
|
||||
**/*.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
|
||||
-dontwarn com.google.errorprone.annotations.CheckReturnValue
|
||||
-dontwarn com.google.errorprone.annotations.Immutable
|
||||
-dontwarn com.google.errorprone.annotations.RestrictedApi
|
||||
-dontwarn javax.annotation.Nullable
|
||||
-dontwarn javax.annotation.concurrent.GuardedBy
|
||||
# Flutter wrapper
|
||||
-keep class io.flutter.app.** { *; }
|
||||
-keep class io.flutter.plugin.** { *; }
|
||||
-keep class io.flutter.util.** { *; }
|
||||
-keep class io.flutter.view.** { *; }
|
||||
-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"
|
||||
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
|
||||
android:label="Zapstore"
|
||||
android:label="Zapstore Alpha"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="false"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
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
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
@@ -27,61 +37,52 @@
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />
|
||||
<intent-filter>
|
||||
<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"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action
|
||||
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>
|
||||
|
||||
<!-- App Links for https://zapstore.dev/apps/... -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="zapstore.dev" />
|
||||
<data android:pathPrefix="/download"/>
|
||||
<data
|
||||
android:scheme="https"
|
||||
android:host="zapstore.dev"
|
||||
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>
|
||||
</activity>
|
||||
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"/>
|
||||
</provider>
|
||||
|
||||
<receiver
|
||||
android:name="dev.zapstore.alpha.plugins.InstallResultReceiver"
|
||||
android:exported="false" />
|
||||
</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:
|
||||
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.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<!-- <queries>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries> -->
|
||||
</queries>
|
||||
</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">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<!-- Zapstore logo for splash screen -->
|
||||
<item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
android:src="@drawable/logo" />
|
||||
</item>
|
||||
</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">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<!-- Zapstore logo for splash screen -->
|
||||
<item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
android:src="@drawable/logo" />
|
||||
</item>
|
||||
</layer-list>
|
||||
|
||||
|
After Width: | Height: | Size: 187 KiB |
@@ -1,6 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground>
|
||||
<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>
|
||||
|
||||
|
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.enableJetifier=true
|
||||
kotlin.jvm.target.validation.mode = IGNORE
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
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:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_data/flutter_data.dart';
|
||||
import 'package:flutter_phoenix/flutter_phoenix.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.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:zapstore/navigation/router.dart';
|
||||
import 'package:zapstore/utils/debounce.dart';
|
||||
import 'package:zapstore/utils/theme.dart';
|
||||
import 'package:zapstore/widgets/error_container.dart';
|
||||
import 'package:purplebase/purplebase.dart';
|
||||
import 'package:amber_signer/amber_signer.dart';
|
||||
import 'package:zapstore/services/app_restart_service.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;
|
||||
final appLinks = AppLinks();
|
||||
/// Global provider container for error reporting (accessible outside widget tree)
|
||||
late final ProviderContainer _providerContainer;
|
||||
|
||||
/// Application entry point.
|
||||
/// - Initializes Riverpod (and Flutter Data local storage)
|
||||
/// - Handles application errors
|
||||
/// - Triggers routing
|
||||
void main() {
|
||||
// Create provider container with overrides
|
||||
_providerContainer = ProviderContainer(
|
||||
overrides: [
|
||||
storageNotifierProvider.overrideWith(PurplebaseStorageNotifier.new),
|
||||
packageManagerProvider.overrideWith(
|
||||
(ref) => Platform.isAndroid
|
||||
? AndroidPackageManager(ref)
|
||||
: DummyPackageManager(ref),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
runZonedGuarded(() {
|
||||
runApp(
|
||||
Phoenix(
|
||||
child: ProviderScope(
|
||||
overrides: [
|
||||
localStorageProvider.overrideWithValue(
|
||||
LocalStorage(
|
||||
baseDirFn: () async {
|
||||
final path = (await getApplicationSupportDirectory()).path;
|
||||
print('Initializing local storage at $path');
|
||||
return path;
|
||||
},
|
||||
clear: LocalStorageClearStrategy.whenError,
|
||||
),
|
||||
)
|
||||
],
|
||||
UncontrolledProviderScope(
|
||||
container: _providerContainer,
|
||||
child: const ZapstoreApp(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}, errorHandler);
|
||||
}, _errorHandler);
|
||||
|
||||
FlutterError.onError = (details) {
|
||||
// Prevents debugger stopping multiple times
|
||||
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});
|
||||
|
||||
@override
|
||||
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(
|
||||
builder: materialErrorBuilder,
|
||||
routerConfig: appRouter,
|
||||
title: title,
|
||||
theme: darkTheme,
|
||||
routerConfig: ref.watch(routerProvider),
|
||||
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) {
|
||||
debounce((exception: exception, stack: stack), (records) async {
|
||||
if (records.isEmpty) return;
|
||||
_dir ??= await getApplicationDocumentsDirectory();
|
||||
final file = File('${_dir!.path}/errors.json');
|
||||
|
||||
late final Map<String, dynamic> errorMap;
|
||||
if (await file.exists()) {
|
||||
final contents = await file.readAsString();
|
||||
errorMap =
|
||||
contents.isNotEmpty ? jsonDecode(await file.readAsString()) : {};
|
||||
} else {
|
||||
errorMap = {};
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const BreathingLogo(size: 120),
|
||||
const SizedBox(height: 24),
|
||||
Text('Zapstore', style: context.textTheme.headlineMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Permissionless app store for Nostr',
|
||||
style: context.textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final appInitializationProvider = FutureProvider<void>((ref) async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dbPath = path.join(dir.path, 'zapstore.db');
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
await file.writeAsString(jsonEncode(errorMap));
|
||||
});
|
||||
print(exception);
|
||||
print(stack);
|
||||
}
|
||||
|
||||
@@ -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()],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||