Merge new app

This commit is contained in:
franzap
2025-12-25 20:52:16 -03:00
215 changed files with 22179 additions and 7113 deletions
+1
View File
@@ -0,0 +1 @@
../tools/content/agent.json
+1
View File
@@ -0,0 +1 @@
tools/content/CONTEXT.md
-2
View File
@@ -49,6 +49,4 @@ app.*.map.json
/flutter_data
.fvm/
android/app/.cxx/
.env
+15 -15
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
../.cursor/mcp.json
+1 -1
View File
@@ -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
+5 -1
View File
@@ -1,4 +1,8 @@
# zapstore
# Zapstore
The open app store powered by your social network
[https://zapstore.dev](https://zapstore.dev)
## Build from source
+1 -9
View File
@@ -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
+2 -1
View File
@@ -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
-81
View File
@@ -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 {}
+102
View File
@@ -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")
}
+36 -6
View File
@@ -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
+41 -40
View File
@@ -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>
Binary file not shown.

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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

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>
Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

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>
-55
View File
@@ -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"
}
+21
View File
@@ -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 -2
View File
@@ -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
+1 -1
View File
@@ -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
-26
View File
@@ -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"
+25
View File
@@ -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")
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 726 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 688 KiB

View File
+47
View File
@@ -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';
+287 -64
View File
@@ -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);
}
-59
View File
@@ -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;
}
-462
View File
@@ -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',
];
-85
View File
@@ -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);
}
}
-56
View File
@@ -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);
}
}
-79
View File
@@ -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);
}
}
-13
View File
@@ -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;
}
}
-48
View File
@@ -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);
}
}
-75
View File
@@ -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);
}
}
-187
View File
@@ -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,
}
-68
View File
@@ -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',
};
-218
View File
@@ -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);
-56
View File
@@ -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));
}
-86
View File
@@ -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);
}
}
-34
View File
@@ -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();
}
}
}
-89
View File
@@ -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',
};
-285
View File
@@ -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';
-101
View File
@@ -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);
}
}
-16
View File
@@ -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]);
}
}
-42
View File
@@ -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);
}
}
-72
View File
@@ -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);
}
}
-107
View File
@@ -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();
});
});
-55
View File
@@ -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,
),
),
],
),
);
}
}
-68
View File
@@ -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>();
-141
View File
@@ -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');
+127
View File
@@ -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()],
),
],
),
],
),
],
);
});

Some files were not shown because too many files have changed in this diff Show More