New app
@@ -0,0 +1 @@
|
||||
../tools/content/agent.json
|
||||
@@ -0,0 +1 @@
|
||||
tools/content/CONTEXT.md
|
||||
@@ -49,6 +49,4 @@ app.*.map.json
|
||||
/flutter_data
|
||||
.fvm/
|
||||
|
||||
android/app/.cxx/
|
||||
|
||||
.env
|
||||
@@ -4,7 +4,7 @@
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "bae5e49bc2a867403c43b2aae2de8f8c33b037e4"
|
||||
revision: "fcf2c11572af6f390246c056bc905eca609533a0"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
@@ -13,26 +13,26 @@ project_type: app
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
- platform: android
|
||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
- platform: ios
|
||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
- platform: linux
|
||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
- platform: macos
|
||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
- platform: web
|
||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
- platform: windows
|
||||
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
|
||||
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
|
||||
|
||||
# User provided section
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
../.cursor/mcp.json
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Zapstore
|
||||
Copyright (c) 2025 Zapstore
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
[](https://deepwiki.com/zapstore/zapstore)
|
||||
# Zapstore
|
||||
|
||||
# zapstore
|
||||
The open app store powered by your social network
|
||||
|
||||
[https://zapstore.dev](https://zapstore.dev)
|
||||
|
||||
## Build from source
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
analyzer:
|
||||
errors:
|
||||
no_leading_underscores_for_local_identifiers: ignore
|
||||
must_be_immutable: ignore
|
||||
dangling_library_doc_comments: ignore
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
@@ -27,12 +26,5 @@ linter:
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
prefer_const_constructors: false
|
||||
prefer_const_constructors_in_immutables: false
|
||||
avoid_print: false
|
||||
prefer_const_literals_to_create_immutables: false
|
||||
no_wildcard_variable_uses: false
|
||||
omit_local_variable_types: true
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
@@ -5,9 +5,10 @@ gradle-wrapper.jar
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
def keystoreProperties = new Properties()
|
||||
def keystorePropertiesFile = rootProject.file('key.properties')
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "dev.zapstore.app"
|
||||
compileSdk flutter.compileSdkVersion
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "dev.zapstore.app"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion flutter.targetSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
keyPassword keystoreProperties['keyPassword']
|
||||
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
|
||||
storePassword keystoreProperties['storePassword']
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.debug
|
||||
signingConfig signingConfigs.release
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {}
|
||||
@@ -0,0 +1,96 @@
|
||||
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
|
||||
}
|
||||
|
||||
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 = "../.."
|
||||
}
|
||||
@@ -1,6 +1,36 @@
|
||||
-dontwarn com.google.errorprone.annotations.CanIgnoreReturnValue
|
||||
-dontwarn com.google.errorprone.annotations.CheckReturnValue
|
||||
-dontwarn com.google.errorprone.annotations.Immutable
|
||||
-dontwarn com.google.errorprone.annotations.RestrictedApi
|
||||
-dontwarn javax.annotation.Nullable
|
||||
-dontwarn javax.annotation.concurrent.GuardedBy
|
||||
# Flutter wrapper
|
||||
-keep class io.flutter.app.** { *; }
|
||||
-keep class io.flutter.plugin.** { *; }
|
||||
-keep class io.flutter.util.** { *; }
|
||||
-keep class io.flutter.view.** { *; }
|
||||
-keep class io.flutter.** { *; }
|
||||
-keep class io.flutter.plugins.** { *; }
|
||||
|
||||
# Keep native methods
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# Keep Kotlin metadata
|
||||
-keep class kotlin.Metadata { *; }
|
||||
-dontwarn kotlin.**
|
||||
|
||||
# Keep SQLite3 native libraries
|
||||
-keep class org.sqlite.** { *; }
|
||||
|
||||
# Keep package manager plugin classes
|
||||
-keep class dev.zapstore.alpha.plugins.** { *; }
|
||||
|
||||
# Ignore missing Google Play Core classes (optional dependency for dynamic features)
|
||||
-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication
|
||||
-dontwarn com.google.android.play.core.splitinstall.SplitInstallException
|
||||
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManager
|
||||
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManagerFactory
|
||||
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest$Builder
|
||||
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest
|
||||
-dontwarn com.google.android.play.core.splitinstall.SplitInstallSessionState
|
||||
-dontwarn com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener
|
||||
-dontwarn com.google.android.play.core.tasks.OnFailureListener
|
||||
-dontwarn com.google.android.play.core.tasks.OnSuccessListener
|
||||
-dontwarn com.google.android.play.core.tasks.Task
|
||||
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
<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.REQUEST_DELETE_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||
|
||||
<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 +35,44 @@
|
||||
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>
|
||||
</activity>
|
||||
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"/>
|
||||
</provider>
|
||||
|
||||
<receiver
|
||||
android:name="dev.zapstore.alpha.plugins.InstallResultReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility?hl=en and
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<!-- <queries>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries> -->
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
|
Before Width: | Height: | Size: 174 KiB |
@@ -0,0 +1,14 @@
|
||||
package dev.zapstore.alpha
|
||||
|
||||
import dev.zapstore.alpha.plugins.AndroidPackageManagerPlugin
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
class MainActivity: FlutterActivity() {
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
// Register the unified package manager plugin
|
||||
flutterEngine.plugins.add(AndroidPackageManagerPlugin())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
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.app.role.RoleManager
|
||||
import android.app.admin.DevicePolicyManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.UserManager
|
||||
import android.provider.Settings
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
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.io.IOException
|
||||
import java.security.MessageDigest
|
||||
|
||||
private const val TAG = "AndroidPackageManager"
|
||||
|
||||
/**
|
||||
* AndroidPackageManagerPlugin following Accrescent's proven silent installation approach.
|
||||
*
|
||||
* Silent Installation Behavior:
|
||||
* - Apps installed BY Zapstore: Updates are completely silent (no user prompt)
|
||||
* - Apps installed by OTHER sources: First update requires user confirmation to transfer
|
||||
* installer ownership. After accepting once, all subsequent updates are silent.
|
||||
*
|
||||
* This is Android's security model to prevent malicious "store hijacking" - even with
|
||||
* APP_MARKET category and UPDATE_PACKAGES_WITHOUT_USER_ACTION permission.
|
||||
*
|
||||
* Requirements:
|
||||
* - Android 12+ (API 31+) for USER_ACTION_NOT_REQUIRED
|
||||
* - APP_MARKET category in AndroidManifest.xml
|
||||
* - UPDATE_PACKAGES_WITHOUT_USER_ACTION permission
|
||||
* - ENFORCE_UPDATE_OWNERSHIP permission (Android 14+)
|
||||
*/
|
||||
class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler {
|
||||
private lateinit var channel: MethodChannel
|
||||
private lateinit var context: Context
|
||||
|
||||
companion object {
|
||||
private var staticChannel: MethodChannel? = null
|
||||
|
||||
fun notifyInstallResult(result: Map<String, Any?>) {
|
||||
staticChannel?.invokeMethod("onInstallResult", result)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "android_package_manager")
|
||||
channel.setMethodCallHandler(this)
|
||||
context = flutterPluginBinding.applicationContext
|
||||
staticChannel = channel
|
||||
|
||||
Log.d(TAG, "AndroidPackageManagerPlugin initialized")
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channel.setMethodCallHandler(null)
|
||||
staticChannel = null
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: Result) {
|
||||
when (call.method) {
|
||||
"install" -> {
|
||||
val filePath = call.argument<String>("filePath")
|
||||
if (filePath == null) {
|
||||
result.error("MISSING_ARGUMENT", "File path is required", null)
|
||||
return
|
||||
}
|
||||
val expectedHash = call.argument<String>("expectedHash")
|
||||
// Dart integers come as Integer, not Long, so we need to safely convert
|
||||
val expectedSize = call.argument<Number>("expectedSize")?.toLong()
|
||||
val skipVerification = call.argument<Boolean>("skipVerification") ?: false
|
||||
installApk(filePath, expectedHash, expectedSize, skipVerification, result)
|
||||
}
|
||||
"canInstallSilently" -> {
|
||||
val packageName = call.argument<String>("packageName")
|
||||
result.success(canInstallSilently(packageName))
|
||||
}
|
||||
"hasUnknownSourcesPermission" -> {
|
||||
result.success(hasUnknownSourcesPermission())
|
||||
}
|
||||
"getInstalledApps" -> {
|
||||
val includeSystem = call.argument<Boolean>("includeSystemApps") ?: false
|
||||
result.success(getInstalledApps(includeSystem))
|
||||
}
|
||||
"requestInstallPermission" -> {
|
||||
requestInstallPermission(result)
|
||||
}
|
||||
"uninstall" -> {
|
||||
val packageName = call.argument<String>("packageName")
|
||||
if (packageName == null) {
|
||||
result.error("MISSING_ARGUMENT", "Package name is required", null)
|
||||
return
|
||||
}
|
||||
uninstallApp(packageName, result)
|
||||
}
|
||||
"launchApp" -> {
|
||||
val packageName = call.argument<String>("packageName")
|
||||
if (packageName == null) {
|
||||
result.error("MISSING_ARGUMENT", "Package name is required", null)
|
||||
return
|
||||
}
|
||||
launchApp(packageName, result)
|
||||
}
|
||||
else -> {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun canInstallSilently(packageName: String? = null): Boolean {
|
||||
try {
|
||||
// Android 12+ (API 31+) is required for USER_ACTION_NOT_REQUIRED to work
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 1) UPDATE_PACKAGES_WITHOUT_USER_ACTION permission (the main one we rely on)
|
||||
val hasUpdateWithoutUserActionPerm = try {
|
||||
context.checkSelfPermission("android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION") == PackageManager.PERMISSION_GRANTED
|
||||
} catch (_: Throwable) { false }
|
||||
|
||||
// 2) Device owner (enterprise) can generally install without user action
|
||||
val isDeviceOwner = try {
|
||||
val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
|
||||
dpm.isDeviceOwnerApp(context.packageName)
|
||||
} catch (_: Throwable) { false }
|
||||
|
||||
// 3) Default package installer role (rare; typically system store)
|
||||
val hasInstallerRole = try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val roleManager = context.getSystemService(Context.ROLE_SERVICE) as RoleManager
|
||||
val installerRole = "android.app.role.PACKAGE_INSTALLER"
|
||||
roleManager.isRoleAvailable(installerRole) &&
|
||||
roleManager.isRoleHeld(installerRole)
|
||||
} else false
|
||||
} catch (_: Throwable) { false }
|
||||
|
||||
// 4) Check if we can request package installs (basic requirement)
|
||||
val canRequestInstalls = try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.packageManager.canRequestPackageInstalls()
|
||||
} else true
|
||||
} catch (_: Throwable) { false }
|
||||
|
||||
// Additional check for installer ownership if package name provided
|
||||
val weAreInstaller = if (packageName != null) {
|
||||
try {
|
||||
val installerPackageName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
context.packageManager.getInstallSourceInfo(packageName).installingPackageName
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
context.packageManager.getInstallerPackageName(packageName)
|
||||
}
|
||||
val isInstaller = installerPackageName == context.packageName
|
||||
isInstaller
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
// Package not installed yet, so we can install it silently (first install)
|
||||
Log.d(TAG, "Package $packageName not installed yet (NameNotFoundException)")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
// If we can't verify we're the installer, assume we're not
|
||||
Log.w(TAG, "Failed to check installer for $packageName", e)
|
||||
false
|
||||
}
|
||||
} else {
|
||||
true // No specific package check
|
||||
}
|
||||
|
||||
// Can install silently if we have the permission OR (we're the installer/device owner/have role)
|
||||
val hasSpecialPrivilege = hasUpdateWithoutUserActionPerm || isDeviceOwner || hasInstallerRole
|
||||
|
||||
// CRITICAL: Even with UPDATE_PACKAGES_WITHOUT_USER_ACTION permission,
|
||||
// Android requires one-time user confirmation to transfer installer ownership.
|
||||
// For UI categorization, we must check if we're already the installer.
|
||||
// If a specific package is being checked, installer ownership is REQUIRED for truly silent updates.
|
||||
val canSilently = if (packageName != null) {
|
||||
// For specific package checks: must be installer OR have special privileges AND be installer
|
||||
// Actually, even with privileges, first update of non-owned app requires user action
|
||||
canRequestInstalls && weAreInstaller
|
||||
} else {
|
||||
// General capability check (no specific package)
|
||||
canRequestInstalls && hasSpecialPrivilege
|
||||
}
|
||||
|
||||
return canSilently
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "canInstallSilently check failed", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!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,
|
||||
"message" to "Permission request launched"
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
result.success(mapOf(
|
||||
"success" to false,
|
||||
"message" to "Failed to launch permission request: ${e.message}"
|
||||
))
|
||||
}
|
||||
} else {
|
||||
result.success(mapOf(
|
||||
"success" to true,
|
||||
"message" to "Permission already granted"
|
||||
))
|
||||
}
|
||||
} else {
|
||||
result.success(mapOf(
|
||||
"success" to true,
|
||||
"message" to "Permission not required on this Android version"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private fun isValidApk(apkFile: File): Boolean {
|
||||
// Check if file is a valid ZIP/APK by verifying ZIP magic bytes
|
||||
// APK files are ZIP archives, so they should start with PK\x03\x04 (0x504B0304)
|
||||
try {
|
||||
FileInputStream(apkFile).use { fis ->
|
||||
val magicBytes = ByteArray(4)
|
||||
val bytesRead = fis.read(magicBytes)
|
||||
if (bytesRead < 4) {
|
||||
Log.w(TAG, "File too small to be a valid APK: ${apkFile.length()} bytes")
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for ZIP magic number: 0x504B0304 (PK\x03\x04)
|
||||
val isZip = magicBytes[0] == 0x50.toByte() &&
|
||||
magicBytes[1] == 0x4B.toByte() &&
|
||||
magicBytes[2] == 0x03.toByte() &&
|
||||
magicBytes[3] == 0x04.toByte()
|
||||
|
||||
if (!isZip) {
|
||||
Log.w(TAG, "File does not have valid ZIP/APK magic bytes")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error checking APK validity", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun verifyApk(
|
||||
apkFile: File,
|
||||
expectedHash: String,
|
||||
expectedSize: Long,
|
||||
result: Result
|
||||
): Boolean {
|
||||
val actualSize = apkFile.length()
|
||||
|
||||
// First, verify this is a valid APK/ZIP file
|
||||
if (!isValidApk(apkFile)) {
|
||||
val errorMsg = """
|
||||
Invalid APK file. The downloaded file is not a valid Android package.
|
||||
This may indicate:
|
||||
• Incomplete download
|
||||
• Corrupted file
|
||||
• Wrong file type
|
||||
|
||||
File size: ${String.format("%.2f", actualSize / 1024.0 / 1024.0)} MB ($actualSize bytes)
|
||||
Please try downloading again.
|
||||
""".trimIndent()
|
||||
|
||||
result.success(mapOf(
|
||||
"isSuccess" to false,
|
||||
"errorMessage" to errorMsg
|
||||
))
|
||||
apkFile.delete()
|
||||
return false
|
||||
}
|
||||
|
||||
Log.d(TAG, "APK file format validation passed")
|
||||
|
||||
// Calculate SHA-256 hash for verification
|
||||
// This is the primary integrity check - if hash matches, file is correct
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val buffer = ByteArray(8192)
|
||||
FileInputStream(apkFile).use { fis ->
|
||||
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()) {
|
||||
// Hash mismatch - include size information in error
|
||||
val expectedSizeMb = String.format("%.2f", expectedSize / 1024.0 / 1024.0)
|
||||
val actualSizeMb = String.format("%.2f", actualSize / 1024.0 / 1024.0)
|
||||
val sizeDiff = actualSize - expectedSize
|
||||
val sizeDiffMb = String.format("%.2f", kotlin.math.abs(sizeDiff) / 1024.0 / 1024.0)
|
||||
val sizeDiffSign = if (sizeDiff > 0) "+" else ""
|
||||
|
||||
val errorMsg = """
|
||||
Hash verification failed. File may be corrupted or tampered.
|
||||
Expected hash: $expectedHash
|
||||
Actual hash: $actualHash
|
||||
Expected size: $expectedSizeMb MB ($expectedSize bytes)
|
||||
Actual size: $actualSizeMb MB ($actualSize bytes)
|
||||
Difference: $sizeDiffSign$sizeDiffMb MB ($sizeDiffSign$sizeDiff bytes)
|
||||
""".trimIndent()
|
||||
|
||||
result.success(mapOf(
|
||||
"isSuccess" to false,
|
||||
"errorMessage" to errorMsg
|
||||
))
|
||||
// Don't delete the file - user might want to proceed anyway
|
||||
return false
|
||||
}
|
||||
|
||||
Log.d(TAG, "APK verification successful: size=$actualSize bytes (expected $expectedSize), hash=$actualHash")
|
||||
return true
|
||||
}
|
||||
|
||||
private fun installApk(
|
||||
filePath: String,
|
||||
expectedHash: String?,
|
||||
expectedSize: Long?,
|
||||
skipVerification: Boolean,
|
||||
result: Result
|
||||
) {
|
||||
val file = File(filePath)
|
||||
if (!file.exists()) {
|
||||
result.success(mapOf(
|
||||
"isSuccess" to false,
|
||||
"errorMessage" to "The APK file was not found"
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// Verify APK if hash and size are provided, unless skipping verification
|
||||
if (skipVerification) {
|
||||
Log.w(TAG, "⚠️ RECKLESS MODE: APK verification SKIPPED by user request ⚠️")
|
||||
Log.w(TAG, "Installing APK without hash verification: $filePath")
|
||||
} else if (expectedHash != null && expectedSize != null) {
|
||||
if (!verifyApk(file, expectedHash, expectedSize, result)) {
|
||||
return // Verification failed, error already sent
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "APK verification skipped - hash or size not provided")
|
||||
}
|
||||
|
||||
try {
|
||||
installApp(file, result)
|
||||
} catch (e: SecurityException) {
|
||||
// Fallback to system installer
|
||||
launchSystemInstaller(filePath, result)
|
||||
} catch (e: Exception) {
|
||||
result.success(mapOf(
|
||||
"isSuccess" to false,
|
||||
"errorMessage" to "Installation failed: ${e.message}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private fun installApp(apkFile: File, result: Result) {
|
||||
// Check UserManager restrictions (like Accrescent does)
|
||||
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) {
|
||||
Log.w(TAG, "Installation blocked by UserManager policy")
|
||||
result.success(mapOf(
|
||||
"isSuccess" to false,
|
||||
"errorMessage" to "Installation blocked by device policy"
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
val packageInstaller = context.packageManager.packageInstaller
|
||||
|
||||
// Get package info from the APK
|
||||
val pkgInfo = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0)
|
||||
if (pkgInfo == null) {
|
||||
throw IOException("Invalid APK file")
|
||||
}
|
||||
|
||||
val packageName = pkgInfo.packageName
|
||||
val isUpdate = try {
|
||||
context.packageManager.getPackageInfo(packageName, 0)
|
||||
true
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
false
|
||||
}
|
||||
|
||||
Log.d(TAG, "Installing $packageName (update=$isUpdate)")
|
||||
|
||||
// Match Accrescent's session setup exactly
|
||||
// The APP_MARKET category in the manifest grants silent install privileges
|
||||
val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
sessionParams.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
sessionParams.setInstallLocation(pkgInfo.installLocation)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
sessionParams.setPackageSource(PackageInstaller.PACKAGE_SOURCE_STORE)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
sessionParams.setRequestUpdateOwnership(true)
|
||||
}
|
||||
|
||||
val sessionId = packageInstaller.createSession(sessionParams)
|
||||
val session = packageInstaller.openSession(sessionId)
|
||||
|
||||
// Transfer the APK file - match Accrescent's approach exactly
|
||||
FileInputStream(apkFile).use { fileInputStream ->
|
||||
// Use Os.fstat for accurate file size (like Accrescent)
|
||||
val fileDescriptor = fileInputStream.fd
|
||||
val fileSize = Os.fstat(fileDescriptor).st_size
|
||||
|
||||
// Use APK file name instead of generic "package"
|
||||
val sessionStream = session.openWrite(apkFile.name, 0, fileSize)
|
||||
|
||||
fileInputStream.copyTo(sessionStream)
|
||||
|
||||
// Close in Accrescent's order: file first, then session
|
||||
fileInputStream.close()
|
||||
session.fsync(sessionStream)
|
||||
sessionStream.close()
|
||||
}
|
||||
|
||||
// Create pending intent for installation result
|
||||
val intent = Intent(context.applicationContext, InstallResultReceiver::class.java)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context.applicationContext,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_MUTABLE
|
||||
)
|
||||
|
||||
// Commit the session
|
||||
session.commit(pendingIntent.intentSender)
|
||||
session.close()
|
||||
|
||||
// Return success - actual result will come via broadcast
|
||||
result.success(mapOf(
|
||||
"isSuccess" to true,
|
||||
"errorMessage" to "",
|
||||
"isUpdate" to isUpdate,
|
||||
"packageName" to packageName,
|
||||
"sessionId" to sessionId
|
||||
))
|
||||
}
|
||||
|
||||
private fun launchSystemInstaller(filePath: String, result: Result) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(Uri.parse("file://$filePath"), "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
context.startActivity(intent)
|
||||
|
||||
result.success(mapOf(
|
||||
"isSuccess" to true,
|
||||
"errorMessage" to "",
|
||||
"fallbackMethod" to "system_installer"
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
result.success(mapOf(
|
||||
"isSuccess" to false,
|
||||
"errorMessage" to "Failed to launch installer: ${e.message}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private fun uninstallApp(packageName: String, result: Result) {
|
||||
try {
|
||||
// Check if the package is installed
|
||||
val pm = context.packageManager
|
||||
try {
|
||||
pm.getPackageInfo(packageName, 0)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
result.success(mapOf(
|
||||
"isSuccess" to false,
|
||||
"errorMessage" to "Package not found: $packageName"
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// Launch system uninstaller
|
||||
val intent = Intent(Intent.ACTION_DELETE).apply {
|
||||
data = Uri.parse("package:$packageName")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
context.startActivity(intent)
|
||||
|
||||
result.success(mapOf(
|
||||
"isSuccess" to true,
|
||||
"errorMessage" to "",
|
||||
"packageName" to packageName
|
||||
))
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Uninstall failed for $packageName", e)
|
||||
result.success(mapOf(
|
||||
"isSuccess" to false,
|
||||
"errorMessage" to "Uninstall failed: ${e.message}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchApp(packageName: String, result: Result) {
|
||||
try {
|
||||
val pm = context.packageManager
|
||||
|
||||
// Check if the package is installed
|
||||
try {
|
||||
pm.getPackageInfo(packageName, 0)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
result.success(mapOf(
|
||||
"isSuccess" to false,
|
||||
"errorMessage" to "Package not found: $packageName"
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// Get the launch intent for the package
|
||||
val launchIntent = pm.getLaunchIntentForPackage(packageName)
|
||||
if (launchIntent == null) {
|
||||
result.success(mapOf(
|
||||
"isSuccess" to false,
|
||||
"errorMessage" to "No launch activity found for package: $packageName"
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// Add flags to ensure the app launches properly
|
||||
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
launchIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
|
||||
// Launch the app
|
||||
context.startActivity(launchIntent)
|
||||
|
||||
result.success(mapOf(
|
||||
"isSuccess" to true,
|
||||
"errorMessage" to "",
|
||||
"packageName" to packageName
|
||||
))
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Launch failed for $packageName", e)
|
||||
result.success(mapOf(
|
||||
"isSuccess" to false,
|
||||
"errorMessage" to "Launch failed: ${e.message}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private fun getInstalledApps(includeSystemApps: Boolean): List<Map<String, Any?>> {
|
||||
val pm = context.packageManager
|
||||
val packages = pm.getInstalledPackages(0)
|
||||
val out = mutableListOf<Map<String, Any?>>()
|
||||
|
||||
for (pkg in packages) {
|
||||
val appInfo = pkg.applicationInfo ?: continue
|
||||
val isSystem = (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0
|
||||
if (!includeSystemApps && isSystem) continue
|
||||
|
||||
val name = appInfo.loadLabel(pm)?.toString()
|
||||
val bundleId = pkg.packageName
|
||||
val versionName = pkg.versionName
|
||||
val versionCode: Long = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
pkg.longVersionCode
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
pkg.versionCode.toLong()
|
||||
}
|
||||
|
||||
out.add(mapOf(
|
||||
"name" to name,
|
||||
"bundleId" to bundleId,
|
||||
"versionName" to versionName,
|
||||
"versionCode" to versionCode,
|
||||
))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package dev.zapstore.alpha.plugins
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
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
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.getSystemService
|
||||
|
||||
private const val TAG = "InstallResultReceiver"
|
||||
|
||||
/**
|
||||
* BroadcastReceiver to handle APK installation results
|
||||
* Enhanced with Accrescent-inspired silent install handling
|
||||
*/
|
||||
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) ?: "unknown"
|
||||
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
val sessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1)
|
||||
|
||||
Log.d(TAG, "Install result: status=$status, package=$packageName, message=$message")
|
||||
|
||||
when (status) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
Log.d(TAG, "User confirmation required for $packageName (installer takeover)")
|
||||
|
||||
// Launch confirmation dialog
|
||||
val confirmIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
|
||||
}
|
||||
if (confirmIntent != null) {
|
||||
confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
try {
|
||||
context.startActivity(confirmIntent)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to launch confirmation dialog", e)
|
||||
showUserActionNotification(context, sessionId, confirmIntent, packageName)
|
||||
}
|
||||
}
|
||||
|
||||
AndroidPackageManagerPlugin.notifyInstallResult(mapOf(
|
||||
"success" to false,
|
||||
"packageName" to packageName,
|
||||
"message" to "User action required",
|
||||
"status" to status,
|
||||
"requiresUserAction" to true
|
||||
))
|
||||
}
|
||||
|
||||
PackageInstaller.STATUS_SUCCESS -> {
|
||||
Log.d(TAG, "Installation successful: $packageName")
|
||||
AndroidPackageManagerPlugin.notifyInstallResult(mapOf(
|
||||
"success" to true,
|
||||
"packageName" to packageName,
|
||||
"message" to (message ?: "Installation completed successfully")
|
||||
))
|
||||
}
|
||||
|
||||
PackageInstaller.STATUS_FAILURE -> {
|
||||
Log.w(TAG, "Installation failed: $packageName - $message")
|
||||
AndroidPackageManagerPlugin.notifyInstallResult(mapOf(
|
||||
"success" to false,
|
||||
"packageName" to packageName,
|
||||
"message" to (message ?: "Installation failed"),
|
||||
"status" to status
|
||||
))
|
||||
}
|
||||
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> {
|
||||
Log.d(TAG, "Installation cancelled: $packageName")
|
||||
AndroidPackageManagerPlugin.notifyInstallResult(mapOf(
|
||||
"success" to false,
|
||||
"packageName" to packageName,
|
||||
"message" to "Installation was cancelled",
|
||||
"status" to status,
|
||||
"aborted" to true
|
||||
))
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown installation status $status: $packageName")
|
||||
AndroidPackageManagerPlugin.notifyInstallResult(mapOf(
|
||||
"success" to false,
|
||||
"packageName" to packageName,
|
||||
"message" to (message ?: "Installation failed with unknown status"),
|
||||
"status" to status
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUserActionNotification(
|
||||
context: Context,
|
||||
sessionId: Int,
|
||||
confirmIntent: Intent,
|
||||
packageName: String
|
||||
) {
|
||||
// Check notification permission on Android 13+
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val notificationManager = context.getSystemService<NotificationManager>()
|
||||
if (notificationManager?.areNotificationsEnabled() == true) {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
sessionId,
|
||||
confirmIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, "install_channel")
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle("Installation requires confirmation")
|
||||
.setContentText("Tap to continue installing $packageName")
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
try {
|
||||
notificationManager.notify(sessionId, notification)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to show notification", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package dev.zapstore.app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity: FlutterActivity()
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 211 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 151 B |
@@ -3,10 +3,10 @@
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<!-- Zapstore logo for splash screen -->
|
||||
<item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
android:src="@drawable/logo" />
|
||||
</item>
|
||||
</layer-list>
|
||||
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 270 B |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 430 B |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 659 B |
@@ -3,10 +3,10 @@
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<!-- Zapstore logo for splash screen -->
|
||||
<item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
android:src="@drawable/logo" />
|
||||
</item>
|
||||
</layer-list>
|
||||
|
||||
|
After Width: | Height: | Size: 187 KiB |
@@ -1,6 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
<monochrome>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_monochrome"
|
||||
android:inset="16%" />
|
||||
</monochrome>
|
||||
</adaptive-icon>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="external_files" path="."/>
|
||||
</paths>
|
||||
@@ -1,55 +0,0 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
subprojects {
|
||||
afterEvaluate { project ->
|
||||
if (project.plugins.hasPlugin("com.android.application") ||
|
||||
project.plugins.hasPlugin("com.android.library")) {
|
||||
project.android {
|
||||
compileSdkVersion 34
|
||||
buildToolsVersion "34.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Used for plugins that do not specify their own namespace
|
||||
// https://discuss.gradle.org/t/flutter-not-able-to-migrate-to-gradle-8/46937/7
|
||||
subprojects {
|
||||
afterEvaluate { project ->
|
||||
if (project.hasProperty('android')) {
|
||||
project.android {
|
||||
if (namespace == null) {
|
||||
namespace project.group
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.buildDir = '../build'
|
||||
subprojects {
|
||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile) {
|
||||
options.compilerArgs << "-Xlint:-options"
|
||||
}
|
||||
|
||||
ext {
|
||||
compileSdkVersion = 34
|
||||
targetSdkVersion = 34
|
||||
appCompatVersion = "1.7.0"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx4G
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
kotlin.jvm.target.validation.mode = IGNORE
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
pluginManagement {
|
||||
def flutterSdkPath = {
|
||||
def properties = new Properties()
|
||||
file("local.properties").withInputStream { properties.load(it) }
|
||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||
return flutterSdkPath
|
||||
}
|
||||
settings.ext.flutterSdkPath = flutterSdkPath()
|
||||
|
||||
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.3.2" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.0.21" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
@@ -0,0 +1,25 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath = run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 726 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 688 KiB |
@@ -0,0 +1,22 @@
|
||||
/// 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';
|
||||
|
||||
/// Shareable identifier for the Nostr curation set
|
||||
const kNostrCurationSetShareableId = '30267:$kFranzapPubkey:nostr';
|
||||
|
||||
/// Anonymous private key for error reporting and anonymous operations
|
||||
/// This key is used when the user is not signed in
|
||||
const kAnonymousPrivateKey =
|
||||
'c86eda2daae768374526bc54903f388d9a866c00740ec8db418d7ef2dca77b5b';
|
||||
|
||||
/// Identifier for storing user bookmarks
|
||||
const kAppBookmarksIdentifier = 'zapstore-bookmarks';
|
||||
|
||||
@@ -1,97 +1,298 @@
|
||||
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:flutter_phoenix/flutter_phoenix.dart';
|
||||
import 'package:zapstore/router.dart';
|
||||
import 'package:zapstore/services/error_reporting_service.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
import 'package:zapstore/services/updates_service.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/utils/extensions.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,
|
||||
),
|
||||
)
|
||||
],
|
||||
child: 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)
|
||||
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';
|
||||
final theme = ref.watch(themeProvider);
|
||||
|
||||
// Watch initialization state for error overlay display
|
||||
final initState = ref.watch(appInitializationProvider);
|
||||
|
||||
// Keep categorizedAppsProvider alive for badge count updates
|
||||
// Using listen instead of watch since we don't use the value directly
|
||||
ref.listen(categorizedAppsProvider, (_, __) {});
|
||||
|
||||
// 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.ensureConnected();
|
||||
});
|
||||
|
||||
// Listen to connectivity changes
|
||||
subscription = connectivity.onConnectivityChanged.listen((results) {
|
||||
notifier.ensureConnected();
|
||||
});
|
||||
|
||||
return () => subscription?.cancel();
|
||||
}, []);
|
||||
|
||||
// Always show the main app UI, even during initialization
|
||||
return MaterialApp.router(
|
||||
builder: materialErrorBuilder,
|
||||
routerConfig: appRouter,
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: title,
|
||||
theme: theme,
|
||||
routerConfig: ref.watch(routerProvider),
|
||||
debugShowCheckedModeBanner: false,
|
||||
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.3);
|
||||
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 = {};
|
||||
}
|
||||
|
||||
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);
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.rocket_launch,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
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();
|
||||
|
||||
// Initialize storage first
|
||||
await ref.read(
|
||||
initializationProvider(
|
||||
StorageConfiguration(
|
||||
databasePath: path.join(dir.path, 'zapstore.db'),
|
||||
defaultRelays: {
|
||||
'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'},
|
||||
},
|
||||
),
|
||||
).future,
|
||||
);
|
||||
|
||||
// Initialize package manager
|
||||
final packageManager = ref.read(packageManagerProvider.notifier);
|
||||
await packageManager.syncInstalledPackages();
|
||||
|
||||
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
|
||||
/// TODO: Fix ref type
|
||||
Future<void> onSignInSuccess(dynamic ref) async {
|
||||
final pubkey = ref.read(Signer.activePubkeyProvider);
|
||||
if (pubkey == null) return;
|
||||
|
||||
final storage =
|
||||
ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier;
|
||||
|
||||
await storage.query(
|
||||
RequestFilter<AppCatalogRelayList>(authors: {pubkey}).toRequest(),
|
||||
source: const RemoteSource(
|
||||
relays: 'bootstrap',
|
||||
background: false,
|
||||
stream: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Observes app lifecycle events and re-syncs package state when app regains focus
|
||||
class _AppLifecycleObserver with WidgetsBindingObserver {
|
||||
_AppLifecycleObserver(this._ref);
|
||||
|
||||
final WidgetRef _ref;
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
// App regained focus - re-sync installed packages from Android system
|
||||
// This will detect any apps installed/uninstalled outside of Zapstore
|
||||
unawaited(
|
||||
_ref.read(packageManagerProvider.notifier).syncInstalledPackages(),
|
||||
);
|
||||
// Note: UpdateNotifier automatically triggers checkForUpdates() when
|
||||
// packageManagerProvider state changes, so we don't need to call it explicitly
|
||||
|
||||
// Force immediate health check on storage/relay connections
|
||||
// This detects stale connections after system sleep and triggers reconnection
|
||||
|
||||
final notifier =
|
||||
_ref.read(storageNotifierProvider.notifier)
|
||||
as PurplebaseStorageNotifier;
|
||||
notifier.ensureConnected();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: directives_ordering, top_level_function_literal_block, depend_on_referenced_packages
|
||||
|
||||
import 'package:flutter_data/flutter_data.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:zapstore/models/app_curation_set.dart';
|
||||
import 'package:zapstore/models/app.dart';
|
||||
import 'package:zapstore/models/file_metadata.dart';
|
||||
import 'package:zapstore/models/local_app.dart';
|
||||
import 'package:zapstore/models/release.dart';
|
||||
import 'package:zapstore/models/settings.dart';
|
||||
import 'package:zapstore/models/user.dart';
|
||||
import 'package:zapstore/models/zap_receipt.dart';
|
||||
|
||||
final adapterProvidersMap = <String, Provider<Adapter<DataModelMixin>>>{
|
||||
'appCurationSets': appCurationSetsAdapterProvider,
|
||||
'apps': appsAdapterProvider,
|
||||
'fileMetadata': fileMetadataAdapterProvider,
|
||||
'localApps': localAppsAdapterProvider,
|
||||
'releases': releasesAdapterProvider,
|
||||
'settings': settingsAdapterProvider,
|
||||
'users': usersAdapterProvider,
|
||||
'zapReceipts': zapReceiptsAdapterProvider
|
||||
};
|
||||
|
||||
extension AdapterWidgetRefX on WidgetRef {
|
||||
Adapter<AppCurationSet> get appCurationSets =>
|
||||
watch(appCurationSetsAdapterProvider)..internalWatch = watch;
|
||||
Adapter<App> get apps => watch(appsAdapterProvider)..internalWatch = watch;
|
||||
Adapter<FileMetadata> get fileMetadata =>
|
||||
watch(fileMetadataAdapterProvider)..internalWatch = watch;
|
||||
Adapter<LocalApp> get localApps =>
|
||||
watch(localAppsAdapterProvider)..internalWatch = watch;
|
||||
Adapter<Release> get releases =>
|
||||
watch(releasesAdapterProvider)..internalWatch = watch;
|
||||
Adapter<Settings> get settings =>
|
||||
watch(settingsAdapterProvider)..internalWatch = watch;
|
||||
Adapter<User> get users => watch(usersAdapterProvider)..internalWatch = watch;
|
||||
Adapter<ZapReceipt> get zapReceipts =>
|
||||
watch(zapReceiptsAdapterProvider)..internalWatch = watch;
|
||||
}
|
||||
|
||||
extension AdapterRefX on Ref {
|
||||
Adapter<AppCurationSet> get appCurationSets =>
|
||||
watch(appCurationSetsAdapterProvider)..internalWatch = watch;
|
||||
Adapter<App> get apps => watch(appsAdapterProvider)..internalWatch = watch;
|
||||
Adapter<FileMetadata> get fileMetadata =>
|
||||
watch(fileMetadataAdapterProvider)..internalWatch = watch;
|
||||
Adapter<LocalApp> get localApps =>
|
||||
watch(localAppsAdapterProvider)..internalWatch = watch;
|
||||
Adapter<Release> get releases =>
|
||||
watch(releasesAdapterProvider)..internalWatch = watch;
|
||||
Adapter<Settings> get settings =>
|
||||
watch(settingsAdapterProvider)..internalWatch = watch;
|
||||
Adapter<User> get users => watch(usersAdapterProvider)..internalWatch = watch;
|
||||
Adapter<ZapReceipt> get zapReceipts =>
|
||||
watch(zapReceiptsAdapterProvider)..internalWatch = watch;
|
||||
}
|
||||
@@ -1,462 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:convert/convert.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_data/flutter_data.dart';
|
||||
import 'package:install_plugin/install_plugin.dart';
|
||||
import 'package:mutex/mutex.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:purplebase/purplebase.dart' as base;
|
||||
import 'package:zapstore/main.data.dart';
|
||||
import 'package:zapstore/models/file_metadata.dart';
|
||||
import 'package:zapstore/models/local_app.dart';
|
||||
import 'package:zapstore/models/nostr_adapter.dart';
|
||||
import 'package:zapstore/models/release.dart';
|
||||
import 'package:zapstore/models/user.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/utils/system_info.dart';
|
||||
|
||||
part 'app.g.dart';
|
||||
|
||||
final mutex = Mutex();
|
||||
|
||||
@DataAdapter([NostrAdapter, AppAdapter])
|
||||
class App extends base.App with DataModelMixin<App> {
|
||||
@override
|
||||
Object? get id => event.id;
|
||||
|
||||
final HasMany<Release> releases;
|
||||
final BelongsTo<User> signer;
|
||||
final BelongsTo<LocalApp> localApp;
|
||||
|
||||
App.fromJson(super.map)
|
||||
: releases = hasMany(map['releases']),
|
||||
signer = belongsTo(map['signer']),
|
||||
localApp = belongsTo(map['localApp']),
|
||||
super.fromJson();
|
||||
|
||||
Map<String, dynamic> toJson() => super.toMap();
|
||||
|
||||
Release? get latestRelease {
|
||||
return releases.toList().sortedByLatest.firstOrNull;
|
||||
}
|
||||
|
||||
FileMetadata? get latestMetadata {
|
||||
return latestRelease?.artifacts
|
||||
// All artifacts *should* be APKs for the
|
||||
// current architecture, but double check
|
||||
.where((a) =>
|
||||
a.mimeType == kAndroidMimeType &&
|
||||
a.platforms.contains('android-arm64-v8a'))
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
bool get canInstall =>
|
||||
localApp.value?.status == null &&
|
||||
latestMetadata != null &&
|
||||
signer.isPresent;
|
||||
bool get canUpdate =>
|
||||
localApp.value?.status == AppInstallStatus.updatable &&
|
||||
latestMetadata != null &&
|
||||
signer.isPresent;
|
||||
bool get isInstalled => localApp.value?.status != null;
|
||||
bool get isUpdated => localApp.value?.status == AppInstallStatus.updated;
|
||||
bool get isDowngrade => localApp.value?.status == AppInstallStatus.downgrade;
|
||||
bool get hasCertificateMismatch =>
|
||||
localApp.value?.status == AppInstallStatus.certificateMismatch;
|
||||
|
||||
bool get isSelfSigned => !(signer.value!.pubkey == kZapstorePubkey &&
|
||||
identifier != kZapstoreAppIdentifier);
|
||||
|
||||
Future<void> install({bool alwaysTrustSigner = false}) async {
|
||||
if (!canInstall && !canUpdate || hasCertificateMismatch) {
|
||||
throw Exception(
|
||||
'Local status: ${localApp.value?.status}, signer: ${signer.value?.npub}, certificate mismatch: $hasCertificateMismatch');
|
||||
}
|
||||
|
||||
final adapter = DataModel.adapterFor(this) as AppAdapter;
|
||||
final notifier =
|
||||
adapter.ref.read(installationProgressProvider(id!).notifier);
|
||||
|
||||
// Persist trusted preference
|
||||
if (alwaysTrustSigner) {
|
||||
final settings = adapter.ref.settings.findOneLocalById('_')!;
|
||||
settings.trustedUsers.add(signer.value!);
|
||||
settings.saveLocal();
|
||||
}
|
||||
|
||||
final installPermission = await Permission.requestInstallPackages.status;
|
||||
if (!installPermission.isGranted) {
|
||||
final newStatus = await Permission.requestInstallPackages.request();
|
||||
if (newStatus.isDenied) {
|
||||
notifier.state = ErrorInstallProgress(Exception('Error'),
|
||||
info: 'Installation permission denied by user');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final hash = latestMetadata!.hash!;
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final file = File(path.join(dir.path, hash));
|
||||
|
||||
installOnDevice() async {
|
||||
notifier.state = VerifyingHashProgress();
|
||||
|
||||
if (await _isHashMismatch(file.path, hash)) {
|
||||
const e = 'Hash mismatch';
|
||||
await file.delete();
|
||||
notifier.state = ErrorInstallProgress(Exception(e),
|
||||
info: 'Possibly a malicious file. Aborting installation.');
|
||||
return;
|
||||
}
|
||||
|
||||
notifier.state = RequestInstallProgress();
|
||||
|
||||
// NOTE: Using https://github.com/hui-z/flutter_install_plugin
|
||||
// Tried https://github.com/zapstore/android_package_installer
|
||||
// but it is giving users problems (and for me it's slower)
|
||||
// Probably need to fork hui-z's with support for user-canceled action
|
||||
// NOTE: Must use mutex: https://github.com/hui-z/flutter_install_plugin/issues/68
|
||||
final result = await mutex.protect(() async {
|
||||
return InstallPlugin.install(file.path);
|
||||
});
|
||||
|
||||
if (result['isSuccess']) {
|
||||
notifier.state = IdleInstallProgress(success: true);
|
||||
await file.delete();
|
||||
await adapter.ref.localApps.localAppAdapter
|
||||
.refreshUpdateStatus(appId: identifier);
|
||||
Future.microtask(() {
|
||||
notifier.state = IdleInstallProgress();
|
||||
});
|
||||
} else {
|
||||
const msg = 'App not installed';
|
||||
notifier.state = ErrorInstallProgress(
|
||||
Exception(msg),
|
||||
info: 'User canceled or error occured: ${result['errorMessage']}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final fileExists = await file.exists();
|
||||
if (fileExists) {
|
||||
await installOnDevice();
|
||||
} else {
|
||||
if (fileExists) {
|
||||
// If file exists download was probably partial, remove
|
||||
await file.delete();
|
||||
}
|
||||
|
||||
final url = latestMetadata!.urls.first;
|
||||
final (baseDirectory, directory, filename) =
|
||||
await Task.split(filePath: file.path);
|
||||
|
||||
final task = DownloadTask(
|
||||
url: url,
|
||||
baseDirectory: baseDirectory,
|
||||
directory: directory,
|
||||
filename: filename,
|
||||
updates: Updates.progress,
|
||||
);
|
||||
|
||||
final result = await FileDownloader().download(
|
||||
task,
|
||||
onProgress: (progress) {
|
||||
notifier.state = DownloadingInstallProgress(progress);
|
||||
},
|
||||
);
|
||||
|
||||
switch (result.status) {
|
||||
case TaskStatus.complete:
|
||||
return await installOnDevice();
|
||||
default:
|
||||
notifier.state = ErrorInstallProgress(
|
||||
Exception('App did not fully download.'),
|
||||
info: 'Please try again.');
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mixin AppAdapter on Adapter<App> {
|
||||
final queriedAtMap = <String, DateTime>{};
|
||||
|
||||
@override
|
||||
Future<List<App>> findAll(
|
||||
{bool? remote = true,
|
||||
bool? background,
|
||||
Map<String, dynamic>? params = const {},
|
||||
Map<String, String>? headers,
|
||||
bool? syncLocal,
|
||||
OnSuccessAll<App>? onSuccess,
|
||||
OnErrorAll<App>? onError,
|
||||
DataRequestLabel? label}) async {
|
||||
params ??= {};
|
||||
if (params.remove('includes') ?? false) {
|
||||
return await fetchAppModels(params);
|
||||
}
|
||||
return super.findAll(params: params);
|
||||
}
|
||||
|
||||
Future<void> checkForUpdates() async {
|
||||
final appIds = await _installedIdentifiers();
|
||||
if (appIds.isNotEmpty) {
|
||||
await fetchAppModels({'#d': appIds});
|
||||
// Once apps are loaded, check for installed status
|
||||
await ref.localApps.localAppAdapter.refreshUpdateStatus();
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<App>> fetchAppModels(Map<String, dynamic> params) async {
|
||||
late final List<App> apps;
|
||||
late final List<Release> releases;
|
||||
|
||||
// If looking up multiple apps via `#d`, use `queriedAtMap`
|
||||
// to find the earliest queried at Date, to be used in the
|
||||
// next request `since` field
|
||||
if (params.containsKey('#d') && !(params['skipCache'] ?? false)) {
|
||||
DateTime? earliestQueryAt;
|
||||
final identifiers = <String>{...params['#d']};
|
||||
|
||||
final cachedIdentifiers =
|
||||
identifiers.where((i) => queriedAtMap.containsKey(i));
|
||||
final newIdentifiers =
|
||||
identifiers.where((i) => !queriedAtMap.containsKey(i));
|
||||
|
||||
var cachedApps = <App>[];
|
||||
var newApps = <App>[];
|
||||
|
||||
// For apps already in cache, find earliest date of the set
|
||||
// (as we cannot have one `since` per app)
|
||||
if (cachedIdentifiers.isNotEmpty) {
|
||||
earliestQueryAt = cachedIdentifiers
|
||||
.fold<DateTime>(queriedAtMap[cachedIdentifiers.first]!, (acc, e) {
|
||||
return acc.isBefore(queriedAtMap[e]!) ? acc : queriedAtMap[e]!;
|
||||
});
|
||||
|
||||
cachedApps = await super.findAll(
|
||||
params: {
|
||||
'#d': cachedIdentifiers,
|
||||
// Query since latest + 1
|
||||
'since': earliestQueryAt.add(Duration(seconds: 1)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// For apps not in cache, query without a since
|
||||
if (newIdentifiers.isNotEmpty) {
|
||||
newApps = await super.findAll(
|
||||
params: {
|
||||
'#d': newIdentifiers,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
apps = [...cachedApps, ...newApps];
|
||||
|
||||
// Find most recent `created_at` from returned apps
|
||||
// and assign it to their queried at value for caching
|
||||
final m = apps.isNotEmpty
|
||||
? apps.fold(apps.first.event.createdAt, (acc, e) {
|
||||
return acc.isAfter(e.event.createdAt) ? acc : e.event.createdAt;
|
||||
})
|
||||
: null;
|
||||
|
||||
if (m != null) {
|
||||
for (final app in apps) {
|
||||
queriedAtMap[app.identifier] = m;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Search (which uses no #d)
|
||||
apps = await super.findAll(params: params);
|
||||
}
|
||||
|
||||
final appsOldFormat =
|
||||
apps.where((app) => app.event.linkedReplaceableEvents.isNotEmpty);
|
||||
final appsNewFormat =
|
||||
apps.where((app) => app.event.linkedReplaceableEvents.isEmpty);
|
||||
|
||||
if (appsOldFormat.isNotEmpty) {
|
||||
// OLD FORMAT BRANCH
|
||||
// Find all appid@version ($3) as we need to pick one tag to query on
|
||||
// (filters by kind ($1) and pubkey ($2) done locally)
|
||||
final latestReleaseIdentifiers = appsOldFormat
|
||||
.map((app) => app.event.linkedReplaceableEvents.first.$3);
|
||||
|
||||
if (latestReleaseIdentifiers.isNotEmpty) {
|
||||
releases = await ref.releases.findAll(
|
||||
params: {'#d': latestReleaseIdentifiers},
|
||||
);
|
||||
} else {
|
||||
releases = [];
|
||||
}
|
||||
} else {
|
||||
// NEW FORMAT BRANCH
|
||||
final futures = appsNewFormat
|
||||
.map((app) => ref.releases.findAll(
|
||||
params: {
|
||||
'#i': [app.identifier],
|
||||
'authors': [app.event.pubkey],
|
||||
'limit': 1
|
||||
},
|
||||
))
|
||||
.toSet();
|
||||
|
||||
final lists = await Future.wait(futures);
|
||||
releases = lists.expand((a) => a).toList();
|
||||
}
|
||||
|
||||
await nostrAdapter.loadArtifactsAndUsers(releases);
|
||||
await ref.localApps.localAppAdapter.refreshUpdateStatus();
|
||||
|
||||
return apps;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<App?> findOne(Object id,
|
||||
{bool remote = true,
|
||||
bool background = false,
|
||||
Map<String, dynamic>? params = const {},
|
||||
Map<String, String>? headers,
|
||||
OnSuccessOne<App>? onSuccess,
|
||||
OnErrorOne<App>? onError,
|
||||
DataRequestLabel? label}) async {
|
||||
final apps = await fetchAppModels({
|
||||
...params ?? {},
|
||||
'#d': [id]
|
||||
});
|
||||
// If ID not found in relay then clear from local storage
|
||||
if (apps.isEmpty) {
|
||||
deleteLocalById(id);
|
||||
return null;
|
||||
}
|
||||
await ref.localApps.localAppAdapter
|
||||
.refreshUpdateStatus(appId: id.toString());
|
||||
return apps.first;
|
||||
}
|
||||
|
||||
List<App> findWhereIdentifierInLocal(Iterable<String> appIds) {
|
||||
const len = 5 + 64 + 3; // kind + pubkey + separators length
|
||||
final result = db.select(
|
||||
'SELECT key, data, substr(json_extract(data, \'\$.id\'), $len) AS id from apps where id in (${appIds.map((_) => '?').join(', ')})',
|
||||
appIds.toList());
|
||||
return deserializeFromResult(result);
|
||||
}
|
||||
|
||||
Future<Set<String>> _installedIdentifiers() async {
|
||||
if (!Platform.isAndroid) {
|
||||
return {};
|
||||
}
|
||||
final infos = await packageManager.getInstalledPackages();
|
||||
return {
|
||||
for (final i in infos!)
|
||||
if (i.packageName != null &&
|
||||
!kExcludedAppIdNamespaces.any((e) => i.packageName!.startsWith(e)))
|
||||
i.packageName!,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
DeserializedData<App> deserialize(Object? data, {String? key}) {
|
||||
final list = data is Iterable ? data : [data as Map];
|
||||
|
||||
for (final Map<String, dynamic> map in list) {
|
||||
final tags = map['tags'];
|
||||
map['localApp'] = base.BaseUtil.getTag(tags, 'd')!;
|
||||
map['signer'] = map['pubkey'];
|
||||
}
|
||||
|
||||
return super.deserialize(data);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _isHashMismatch(String path, String hash) async {
|
||||
return await Isolate.run(() async {
|
||||
final file = File(path);
|
||||
final fileStream = file.openRead();
|
||||
final reader = ChunkedStreamReader(fileStream);
|
||||
final digestOutputSink = AccumulatorSink<Digest>();
|
||||
final digestInputSink = sha256.startChunkedConversion(digestOutputSink);
|
||||
String? digest;
|
||||
try {
|
||||
while (true) {
|
||||
// Chunk size determined from rough CPU/memory profiling
|
||||
final chunk = await reader.readChunk(3072);
|
||||
if (chunk.isEmpty) {
|
||||
break; // EOF
|
||||
}
|
||||
digestInputSink.add(chunk);
|
||||
}
|
||||
} finally {
|
||||
digestInputSink.close();
|
||||
digest = digestOutputSink.events.single.toString();
|
||||
digestOutputSink.close();
|
||||
reader.cancel();
|
||||
}
|
||||
return digest != hash;
|
||||
});
|
||||
}
|
||||
|
||||
extension AppExt on Iterable<App> {
|
||||
List<App> get sortedByLatest =>
|
||||
sorted((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
|
||||
}
|
||||
|
||||
// class
|
||||
|
||||
sealed class AppInstallProgress {}
|
||||
|
||||
class IdleInstallProgress extends AppInstallProgress {
|
||||
final bool? success;
|
||||
IdleInstallProgress({this.success});
|
||||
}
|
||||
|
||||
class DownloadingInstallProgress extends AppInstallProgress {
|
||||
final double progress;
|
||||
DownloadingInstallProgress(this.progress);
|
||||
}
|
||||
|
||||
class VerifyingHashProgress extends AppInstallProgress {}
|
||||
|
||||
class RequestInstallProgress extends AppInstallProgress {}
|
||||
|
||||
class ErrorInstallProgress extends AppInstallProgress {
|
||||
final Exception e;
|
||||
final String? info;
|
||||
final List<(String, Future<void> Function())> actions;
|
||||
ErrorInstallProgress(this.e, {this.info, this.actions = const []});
|
||||
}
|
||||
|
||||
extension ExceptionExt on Exception {
|
||||
String get message => (this as dynamic).message ?? toString();
|
||||
}
|
||||
|
||||
final installationProgressProvider =
|
||||
StateProvider.family<AppInstallProgress, Object>(
|
||||
(_, arg) => IdleInstallProgress());
|
||||
|
||||
final appsToUpdateProvider = StateProvider((_) => 0);
|
||||
|
||||
// Constants
|
||||
|
||||
const kAndroidMimeType = 'application/vnd.android.package-archive';
|
||||
|
||||
const kExcludedAppIdNamespaces = [
|
||||
'android',
|
||||
'com.android',
|
||||
'com.google',
|
||||
'com.samsung',
|
||||
'com.sec.android',
|
||||
'org.chromium.webview_shell',
|
||||
'app.grapheneos',
|
||||
'app.vanadium',
|
||||
];
|
||||
@@ -1,85 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'app.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// AdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
|
||||
|
||||
mixin _$AppAdapter on Adapter<App> {
|
||||
static final Map<String, RelationshipMeta> _kAppRelationshipMetas = {
|
||||
'releases': RelationshipMeta<Release>(
|
||||
name: 'releases',
|
||||
inverseName: 'app',
|
||||
type: 'releases',
|
||||
kind: 'HasMany',
|
||||
instance: (_) => (_ as App).releases,
|
||||
),
|
||||
'signer': RelationshipMeta<User>(
|
||||
name: 'signer',
|
||||
inverseName: 'apps',
|
||||
type: 'users',
|
||||
kind: 'BelongsTo',
|
||||
instance: (_) => (_ as App).signer,
|
||||
),
|
||||
'localApp': RelationshipMeta<LocalApp>(
|
||||
name: 'localApp',
|
||||
type: 'localApps',
|
||||
kind: 'BelongsTo',
|
||||
instance: (_) => (_ as App).localApp,
|
||||
)
|
||||
};
|
||||
|
||||
@override
|
||||
Map<String, RelationshipMeta> get relationshipMetas => _kAppRelationshipMetas;
|
||||
|
||||
@override
|
||||
App deserializeLocal(map, {String? key}) {
|
||||
map = transformDeserialize(map);
|
||||
return internalWrapStopInit(() => App.fromJson(map), key: key);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
|
||||
final map = model.toJson();
|
||||
return transformSerialize(map, withRelationships: withRelationships);
|
||||
}
|
||||
}
|
||||
|
||||
final _appsFinders = <String, dynamic>{};
|
||||
|
||||
class $AppAdapter = Adapter<App>
|
||||
with _$AppAdapter, NostrAdapter<App>, AppAdapter;
|
||||
|
||||
final appsAdapterProvider = Provider<Adapter<App>>(
|
||||
(ref) => $AppAdapter(ref, InternalHolder(_appsFinders)));
|
||||
|
||||
extension AppAdapterX on Adapter<App> {
|
||||
NostrAdapter<App> get nostrAdapter => this as NostrAdapter<App>;
|
||||
AppAdapter get appAdapter => this as AppAdapter;
|
||||
}
|
||||
|
||||
extension AppRelationshipGraphNodeX on RelationshipGraphNode<App> {
|
||||
RelationshipGraphNode<Release> get releases {
|
||||
final meta = _$AppAdapter._kAppRelationshipMetas['releases']
|
||||
as RelationshipMeta<Release>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
|
||||
RelationshipGraphNode<User> get signer {
|
||||
final meta =
|
||||
_$AppAdapter._kAppRelationshipMetas['signer'] as RelationshipMeta<User>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
|
||||
RelationshipGraphNode<LocalApp> get localApp {
|
||||
final meta = _$AppAdapter._kAppRelationshipMetas['localApp']
|
||||
as RelationshipMeta<LocalApp>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import 'package:flutter_data/flutter_data.dart';
|
||||
import 'package:purplebase/purplebase.dart' as base;
|
||||
import 'package:zapstore/main.data.dart';
|
||||
import 'package:zapstore/models/app.dart';
|
||||
import 'package:zapstore/models/nostr_adapter.dart';
|
||||
import 'package:zapstore/models/user.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
|
||||
part 'app_curation_set.g.dart';
|
||||
|
||||
@DataAdapter([NostrAdapter, AppCurationSetAdapter])
|
||||
class AppCurationSet extends base.AppCurationSet
|
||||
with DataModelMixin<AppCurationSet> {
|
||||
@override
|
||||
Object get id => event.id;
|
||||
|
||||
final HasMany<App> apps;
|
||||
final BelongsTo<User> signer;
|
||||
|
||||
AppCurationSet.fromJson(super.map)
|
||||
: apps = hasMany(map['apps']),
|
||||
signer = belongsTo(map['signer']),
|
||||
super.fromJson();
|
||||
|
||||
Map<String, dynamic> toJson() => super.toMap();
|
||||
|
||||
String get name => event.content.isNotEmpty ? event.content : identifier;
|
||||
}
|
||||
|
||||
mixin AppCurationSetAdapter on Adapter<AppCurationSet> {
|
||||
@override
|
||||
Future<List<AppCurationSet>> findAll(
|
||||
{bool? remote = true,
|
||||
bool? background = false,
|
||||
Map<String, dynamic>? params,
|
||||
Map<String, String>? headers,
|
||||
bool? syncLocal,
|
||||
OnSuccessAll<AppCurationSet>? onSuccess,
|
||||
OnErrorAll<AppCurationSet>? onError,
|
||||
DataRequestLabel? label}) async {
|
||||
final sets = await super.findAll(remote: remote!, params: params);
|
||||
final userIds = {for (final set in sets) set.signer.id}.nonNulls;
|
||||
await ref.users.findAll(params: {'authors': userIds, 'ignoreReturn': true});
|
||||
return sets;
|
||||
}
|
||||
|
||||
@override
|
||||
DeserializedData<AppCurationSet> deserialize(Object? data, {String? key}) {
|
||||
final list = data is Iterable ? data : [data as Map];
|
||||
for (final Map<String, dynamic> map in list) {
|
||||
final tags = map['tags'];
|
||||
map['apps'] = base.BaseUtil.getTagSet(tags, 'a');
|
||||
}
|
||||
return super.deserialize(data);
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'app_curation_set.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// AdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
|
||||
|
||||
mixin _$AppCurationSetAdapter on Adapter<AppCurationSet> {
|
||||
static final Map<String, RelationshipMeta> _kAppCurationSetRelationshipMetas =
|
||||
{
|
||||
'apps': RelationshipMeta<App>(
|
||||
name: 'apps',
|
||||
type: 'apps',
|
||||
kind: 'HasMany',
|
||||
instance: (_) => (_ as AppCurationSet).apps,
|
||||
),
|
||||
'signer': RelationshipMeta<User>(
|
||||
name: 'signer',
|
||||
type: 'users',
|
||||
kind: 'BelongsTo',
|
||||
instance: (_) => (_ as AppCurationSet).signer,
|
||||
)
|
||||
};
|
||||
|
||||
@override
|
||||
Map<String, RelationshipMeta> get relationshipMetas =>
|
||||
_kAppCurationSetRelationshipMetas;
|
||||
|
||||
@override
|
||||
AppCurationSet deserializeLocal(map, {String? key}) {
|
||||
map = transformDeserialize(map);
|
||||
return internalWrapStopInit(() => AppCurationSet.fromJson(map), key: key);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
|
||||
final map = model.toJson();
|
||||
return transformSerialize(map, withRelationships: withRelationships);
|
||||
}
|
||||
}
|
||||
|
||||
final _appCurationSetsFinders = <String, dynamic>{};
|
||||
|
||||
class $AppCurationSetAdapter = Adapter<AppCurationSet>
|
||||
with
|
||||
_$AppCurationSetAdapter,
|
||||
NostrAdapter<AppCurationSet>,
|
||||
AppCurationSetAdapter;
|
||||
|
||||
final appCurationSetsAdapterProvider = Provider<Adapter<AppCurationSet>>(
|
||||
(ref) =>
|
||||
$AppCurationSetAdapter(ref, InternalHolder(_appCurationSetsFinders)));
|
||||
|
||||
extension AppCurationSetAdapterX on Adapter<AppCurationSet> {
|
||||
NostrAdapter<AppCurationSet> get nostrAdapter =>
|
||||
this as NostrAdapter<AppCurationSet>;
|
||||
AppCurationSetAdapter get appCurationSetAdapter =>
|
||||
this as AppCurationSetAdapter;
|
||||
}
|
||||
|
||||
extension AppCurationSetRelationshipGraphNodeX
|
||||
on RelationshipGraphNode<AppCurationSet> {
|
||||
RelationshipGraphNode<App> get apps {
|
||||
final meta = _$AppCurationSetAdapter
|
||||
._kAppCurationSetRelationshipMetas['apps'] as RelationshipMeta<App>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
|
||||
RelationshipGraphNode<User> get signer {
|
||||
final meta = _$AppCurationSetAdapter
|
||||
._kAppCurationSetRelationshipMetas['signer'] as RelationshipMeta<User>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import 'package:purplebase/purplebase.dart';
|
||||
|
||||
class AppFeedback extends RegularEvent<AppFeedback> {
|
||||
AppFeedback.fromJson(super.map) : super.fromJson();
|
||||
}
|
||||
|
||||
class PartialAppFeedback extends RegularPartialEvent<AppFeedback> {
|
||||
PartialAppFeedback({
|
||||
required String content,
|
||||
}) {
|
||||
event.content = content;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import 'package:flutter_data/flutter_data.dart';
|
||||
import 'package:purplebase/purplebase.dart' as base;
|
||||
import 'package:zapstore/models/nostr_adapter.dart';
|
||||
import 'package:zapstore/models/release.dart';
|
||||
import 'package:zapstore/models/user.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
|
||||
part 'file_metadata.g.dart';
|
||||
|
||||
@DataAdapter([NostrAdapter, FileMetadataAdapter])
|
||||
class FileMetadata extends base.FileMetadata with DataModelMixin<FileMetadata> {
|
||||
final BelongsTo<Release> release;
|
||||
final BelongsTo<User> signer;
|
||||
|
||||
@override
|
||||
Object? get id => event.id;
|
||||
|
||||
FileMetadata.fromJson(super.map)
|
||||
: release = belongsTo(map['release']),
|
||||
signer = belongsTo(map['signer']),
|
||||
super.fromJson();
|
||||
|
||||
Map<String, dynamic> toJson() => super.toMap();
|
||||
}
|
||||
|
||||
mixin FileMetadataAdapter on Adapter<FileMetadata> {
|
||||
@override
|
||||
DeserializedData<FileMetadata> deserialize(Object? data, {String? key}) {
|
||||
final list = data is Iterable ? data : [data as Map];
|
||||
for (final e in list) {
|
||||
final map = e as Map<String, dynamic>;
|
||||
final isNewFormat = map['kind'] == 3063;
|
||||
if (isNewFormat) {
|
||||
// Map to old 1063
|
||||
map['kind'] = 1063;
|
||||
map['tags'].add([
|
||||
'min_sdk_version',
|
||||
base.BaseUtil.getTag(map['tags'], 'min_os_version')
|
||||
]);
|
||||
map['tags'].add([
|
||||
'target_sdk_version',
|
||||
base.BaseUtil.getTag(map['tags'], 'target_os_version')
|
||||
]);
|
||||
}
|
||||
}
|
||||
return super.deserialize(data);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'file_metadata.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// AdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
|
||||
|
||||
mixin _$FileMetadataAdapter on Adapter<FileMetadata> {
|
||||
static final Map<String, RelationshipMeta> _kFileMetadataRelationshipMetas = {
|
||||
'release': RelationshipMeta<Release>(
|
||||
name: 'release',
|
||||
inverseName: 'artifacts',
|
||||
type: 'releases',
|
||||
kind: 'BelongsTo',
|
||||
instance: (_) => (_ as FileMetadata).release,
|
||||
),
|
||||
'signer': RelationshipMeta<User>(
|
||||
name: 'signer',
|
||||
type: 'users',
|
||||
kind: 'BelongsTo',
|
||||
instance: (_) => (_ as FileMetadata).signer,
|
||||
)
|
||||
};
|
||||
|
||||
@override
|
||||
Map<String, RelationshipMeta> get relationshipMetas =>
|
||||
_kFileMetadataRelationshipMetas;
|
||||
|
||||
@override
|
||||
FileMetadata deserializeLocal(map, {String? key}) {
|
||||
map = transformDeserialize(map);
|
||||
return internalWrapStopInit(() => FileMetadata.fromJson(map), key: key);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
|
||||
final map = model.toJson();
|
||||
return transformSerialize(map, withRelationships: withRelationships);
|
||||
}
|
||||
}
|
||||
|
||||
final _fileMetadataFinders = <String, dynamic>{};
|
||||
|
||||
class $FileMetadataAdapter = Adapter<FileMetadata>
|
||||
with _$FileMetadataAdapter, NostrAdapter<FileMetadata>, FileMetadataAdapter;
|
||||
|
||||
final fileMetadataAdapterProvider = Provider<Adapter<FileMetadata>>(
|
||||
(ref) => $FileMetadataAdapter(ref, InternalHolder(_fileMetadataFinders)));
|
||||
|
||||
extension FileMetadataAdapterX on Adapter<FileMetadata> {
|
||||
NostrAdapter<FileMetadata> get nostrAdapter =>
|
||||
this as NostrAdapter<FileMetadata>;
|
||||
FileMetadataAdapter get fileMetadataAdapter => this as FileMetadataAdapter;
|
||||
}
|
||||
|
||||
extension FileMetadataRelationshipGraphNodeX
|
||||
on RelationshipGraphNode<FileMetadata> {
|
||||
RelationshipGraphNode<Release> get release {
|
||||
final meta =
|
||||
_$FileMetadataAdapter._kFileMetadataRelationshipMetas['release']
|
||||
as RelationshipMeta<Release>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
|
||||
RelationshipGraphNode<User> get signer {
|
||||
final meta = _$FileMetadataAdapter._kFileMetadataRelationshipMetas['signer']
|
||||
as RelationshipMeta<User>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:android_package_manager/android_package_manager.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_data/flutter_data.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:zapstore/main.data.dart';
|
||||
import 'package:zapstore/models/app.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/utils/system_info.dart';
|
||||
import 'package:zapstore/utils/version.dart';
|
||||
|
||||
part 'local_app.g.dart';
|
||||
|
||||
@DataAdapter([LocalAppAdapter])
|
||||
@JsonSerializable()
|
||||
class LocalApp extends DataModel<LocalApp> {
|
||||
@override
|
||||
final Object? id;
|
||||
final String? installedVersion;
|
||||
final int? installedVersionCode;
|
||||
final AppInstallStatus? status;
|
||||
|
||||
LocalApp(
|
||||
{required this.id,
|
||||
this.installedVersion,
|
||||
this.installedVersionCode,
|
||||
this.status});
|
||||
|
||||
LocalApp copyWith(
|
||||
{String? installedVersion,
|
||||
int? installedVersionCode,
|
||||
AppInstallStatus? status}) {
|
||||
return LocalApp(
|
||||
id: id,
|
||||
installedVersion: installedVersion ?? this.installedVersion,
|
||||
installedVersionCode: installedVersionCode ?? this.installedVersionCode,
|
||||
status: status ?? this.status,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mixin LocalAppAdapter on Adapter<LocalApp> {
|
||||
Future<void> refreshUpdateStatus({String? appId}) async {
|
||||
if (!Platform.isAndroid) {
|
||||
return;
|
||||
}
|
||||
// NOTE: Using packageManager.getPackageInfo(packageName: appId)
|
||||
// throws an uncatchable error every time it queries a non-installed package
|
||||
final infos = await packageManager.getInstalledPackages(
|
||||
flags: PackageInfoFlags(
|
||||
{PMFlag.getPermissions, PMFlag.getSigningCertificates},
|
||||
));
|
||||
|
||||
final installedPackageInfos = infos!.where((i) =>
|
||||
!kExcludedAppIdNamespaces.any((e) => i.packageName!.startsWith(e)));
|
||||
|
||||
final ids = installedPackageInfos.map((i) => i.packageName).nonNulls;
|
||||
|
||||
// appId == null is the case of all apps
|
||||
if (appId == null) {
|
||||
final localApps = findAllLocal();
|
||||
final apps = ref.apps.appAdapter.findWhereIdentifierInLocal(ids);
|
||||
|
||||
final localAppsToSave = <LocalApp>[];
|
||||
|
||||
for (final i in installedPackageInfos) {
|
||||
final appId = i.packageName!;
|
||||
final app = apps.firstWhereOrNull((a) => a.identifier == appId);
|
||||
|
||||
final installedVersion = i.versionName;
|
||||
final installedVersionCode = i.versionCode;
|
||||
final signingCertificateBytes =
|
||||
i.signingInfo?.signingCertificateHistory?.firstOrNull;
|
||||
|
||||
final status = determineUpdateStatus(app, installedVersion,
|
||||
installedVersionCode, signingCertificateBytes);
|
||||
|
||||
if (status == null) {
|
||||
continue;
|
||||
}
|
||||
final localApp = localApps.firstWhereOrNull((app) => appId == app.id) ??
|
||||
LocalApp(id: i.packageName!);
|
||||
localAppsToSave.add(
|
||||
localApp.copyWith(
|
||||
installedVersion: installedVersion,
|
||||
installedVersionCode: installedVersionCode,
|
||||
status: status,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Save local apps
|
||||
await saveManyLocal(localAppsToSave, async: true);
|
||||
|
||||
// Remove uninstalled apps
|
||||
final uninstalledApps = localApps.where((a) => !ids.contains(a.id));
|
||||
if (uninstalledApps.isNotEmpty) {
|
||||
deleteLocalByKeys(uninstalledApps.map((a) => DataModel.keyFor(a)));
|
||||
}
|
||||
} else {
|
||||
final app =
|
||||
ref.apps.appAdapter.findWhereIdentifierInLocal({appId}).firstOrNull;
|
||||
final i =
|
||||
installedPackageInfos.firstWhereOrNull((i) => i.packageName == appId);
|
||||
|
||||
final installedVersion = i?.versionName;
|
||||
final installedVersionCode = i?.versionCode;
|
||||
final signingCertificateBytes =
|
||||
i?.signingInfo?.signingCertificateHistory?.firstOrNull;
|
||||
|
||||
final status = determineUpdateStatus(
|
||||
app, installedVersion, installedVersionCode, signingCertificateBytes);
|
||||
|
||||
if (status != null) {
|
||||
final localApp = app!.localApp.value ?? LocalApp(id: i!.packageName!);
|
||||
|
||||
localApp
|
||||
.copyWith(
|
||||
installedVersion: installedVersion,
|
||||
installedVersionCode: installedVersionCode,
|
||||
status: status,
|
||||
)
|
||||
.saveLocal();
|
||||
}
|
||||
}
|
||||
|
||||
updateNumberOfApps();
|
||||
}
|
||||
|
||||
void updateNumberOfApps() {
|
||||
final rs = db.select(
|
||||
'SELECT count(*) as c FROM localApps WHERE json_extract(data, \'\$.status\') is \'updatable\'');
|
||||
ref.read(appsToUpdateProvider.notifier).state = rs.first['c'];
|
||||
}
|
||||
|
||||
AppInstallStatus? determineUpdateStatus(App? app, String? installedVersion,
|
||||
int? installedVersionCode, List<int>? signingCertificateBytes) {
|
||||
if (app == null || app.releases.isEmpty || app.latestMetadata == null) {
|
||||
return null;
|
||||
}
|
||||
if (signingCertificateBytes != null) {
|
||||
final installedApkSigHash =
|
||||
sha256.convert(signingCertificateBytes).toString().toLowerCase();
|
||||
final metadataSigHashes =
|
||||
app.latestMetadata?.event.getTagSet('apk_signature_hash') ?? {};
|
||||
final matches = metadataSigHashes
|
||||
.any((msh) => msh.toLowerCase() == installedApkSigHash);
|
||||
if (!matches) {
|
||||
return AppInstallStatus.certificateMismatch;
|
||||
}
|
||||
}
|
||||
|
||||
if (installedVersion == null || installedVersionCode == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var comp = 0;
|
||||
if (app.latestMetadata!.versionCode != null) {
|
||||
// NOTE: Zapstore development versions always carry a lower version code
|
||||
// (e.g. 12) than published ones (e.g. 2012)
|
||||
final code = app.identifier == kZapstoreAppIdentifier && !kReleaseMode
|
||||
? installedVersionCode + 2000
|
||||
: installedVersionCode;
|
||||
comp = app.latestMetadata!.versionCode!.compareTo(code);
|
||||
} else {
|
||||
// If versionCode was not available, compare string version
|
||||
try {
|
||||
comp = compareVersions(installedVersion, app.latestMetadata!.version!);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (comp == 1) return AppInstallStatus.updatable;
|
||||
if (comp == 0) return AppInstallStatus.updated;
|
||||
// else it's a downgrade, which is not installable
|
||||
return AppInstallStatus.downgrade;
|
||||
}
|
||||
}
|
||||
|
||||
enum AppInstallStatus {
|
||||
updated,
|
||||
updatable,
|
||||
downgrade,
|
||||
certificateMismatch,
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'local_app.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// AdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
|
||||
|
||||
mixin _$LocalAppAdapter on Adapter<LocalApp> {
|
||||
static final Map<String, RelationshipMeta> _kLocalAppRelationshipMetas = {};
|
||||
|
||||
@override
|
||||
Map<String, RelationshipMeta> get relationshipMetas =>
|
||||
_kLocalAppRelationshipMetas;
|
||||
|
||||
@override
|
||||
LocalApp deserializeLocal(map, {String? key}) {
|
||||
map = transformDeserialize(map);
|
||||
return internalWrapStopInit(() => _$LocalAppFromJson(map), key: key);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
|
||||
final map = _$LocalAppToJson(model);
|
||||
return transformSerialize(map, withRelationships: withRelationships);
|
||||
}
|
||||
}
|
||||
|
||||
final _localAppsFinders = <String, dynamic>{};
|
||||
|
||||
class $LocalAppAdapter = Adapter<LocalApp>
|
||||
with _$LocalAppAdapter, LocalAppAdapter;
|
||||
|
||||
final localAppsAdapterProvider = Provider<Adapter<LocalApp>>(
|
||||
(ref) => $LocalAppAdapter(ref, InternalHolder(_localAppsFinders)));
|
||||
|
||||
extension LocalAppAdapterX on Adapter<LocalApp> {
|
||||
LocalAppAdapter get localAppAdapter => this as LocalAppAdapter;
|
||||
}
|
||||
|
||||
extension LocalAppRelationshipGraphNodeX on RelationshipGraphNode<LocalApp> {}
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
LocalApp _$LocalAppFromJson(Map<String, dynamic> json) => LocalApp(
|
||||
id: json['id'],
|
||||
installedVersion: json['installedVersion'] as String?,
|
||||
installedVersionCode: (json['installedVersionCode'] as num?)?.toInt(),
|
||||
status: $enumDecodeNullable(_$AppInstallStatusEnumMap, json['status']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$LocalAppToJson(LocalApp instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'installedVersion': instance.installedVersion,
|
||||
'installedVersionCode': instance.installedVersionCode,
|
||||
'status': _$AppInstallStatusEnumMap[instance.status],
|
||||
};
|
||||
|
||||
const _$AppInstallStatusEnumMap = {
|
||||
AppInstallStatus.updated: 'updated',
|
||||
AppInstallStatus.updatable: 'updatable',
|
||||
AppInstallStatus.downgrade: 'downgrade',
|
||||
AppInstallStatus.certificateMismatch: 'certificateMismatch',
|
||||
};
|
||||
@@ -1,218 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_data/flutter_data.dart';
|
||||
import 'package:purplebase/purplebase.dart' hide Release;
|
||||
import 'package:zapstore/main.data.dart';
|
||||
import 'package:zapstore/models/app.dart';
|
||||
import 'package:zapstore/models/release.dart';
|
||||
import 'package:zapstore/utils/system_info.dart';
|
||||
import 'package:zapstore/widgets/latest_releases_container.dart';
|
||||
|
||||
// NOTE: Very important to use const in relay args to preserve equality in Riverpod families
|
||||
// const kAppRelays = {'ws://10.0.2.2:3000'};
|
||||
const kAppRelays = {'wss://relay.zapstore.dev'};
|
||||
const kSocialRelays = {
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.primal.net',
|
||||
'wss://nos.lol',
|
||||
'ndk'
|
||||
};
|
||||
const kVertexRelay = {'wss://relay.vertexlab.io'};
|
||||
|
||||
mixin NostrAdapter<T extends DataModelMixin<T>> on Adapter<T> {
|
||||
RelayMessageNotifier get relay =>
|
||||
ref.read(relayProviderFamily(kAppRelays).notifier);
|
||||
|
||||
RelayMessageNotifier get socialRelays =>
|
||||
ref.read(relayProviderFamily(kSocialRelays).notifier);
|
||||
|
||||
RelayMessageNotifier get vertexRelay =>
|
||||
ref.read(relayProviderFamily(kVertexRelay).notifier);
|
||||
|
||||
@override
|
||||
Future<void> onInitialized() async {
|
||||
await super.onInitialized();
|
||||
|
||||
// DO NOT run on isolates, the mere fact of calling the
|
||||
// relay getter will trigger its initialization
|
||||
// as providers are obviously not cached across isolates
|
||||
if (!inIsolate) {
|
||||
// Upon adapter initialization, configure relays
|
||||
// with the event verification caching function
|
||||
relay.configure(
|
||||
isEventVerified: (Map<String, dynamic> map) {
|
||||
// If replaceable, we check for that ID
|
||||
final identifier = (map['tags'] as Iterable)
|
||||
.firstWhereOrNull((t) => t[0] == 'd')?[1]
|
||||
?.toString();
|
||||
final id = identifier != null
|
||||
? (map['kind'] as int, map['pubkey'].toString(), identifier)
|
||||
.formatted
|
||||
: map['id'];
|
||||
return ref.apps.nostrAdapter.existsId(id);
|
||||
},
|
||||
);
|
||||
socialRelays.configure(
|
||||
isEventVerified: (map) => ref.apps.nostrAdapter.existsId(map['pubkey']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int get kind {
|
||||
var pbType = internalType.singularize().capitalize();
|
||||
// handle FileMetadata edge case
|
||||
pbType = pbType.replaceFirst('datum', 'data');
|
||||
return Event.types[pbType]!.kind;
|
||||
}
|
||||
|
||||
@override
|
||||
DeserializedData<T> deserialize(Object? data, {String? key}) {
|
||||
final list = data is Iterable ? data : [data as Map];
|
||||
final models = <T>[];
|
||||
final included = <DataModelMixin>[];
|
||||
|
||||
for (final e in list) {
|
||||
final map = e as Map<String, dynamic>;
|
||||
map['signer'] = map['pubkey'];
|
||||
|
||||
// ID should be the replaceable link/reference so as to make it replaceable in local db too
|
||||
final tags = map['tags'];
|
||||
final isReplaceable = BaseUtil.containsTag(tags, 'd');
|
||||
if (isReplaceable) {
|
||||
map['id'] = (
|
||||
map['kind'] as int,
|
||||
map['pubkey'].toString(),
|
||||
BaseUtil.getTag(tags, 'd')!,
|
||||
).formatted;
|
||||
}
|
||||
|
||||
// Remove signature as it's already been verified
|
||||
map.remove('sig');
|
||||
|
||||
// Collect models for current kind and included for others
|
||||
final eventType = Event.types.entries
|
||||
.firstWhere((e) => e.value.kind == kind)
|
||||
.key
|
||||
.pluralize()
|
||||
.decapitalize();
|
||||
if (eventType == internalType) {
|
||||
final newData = super.deserialize(map);
|
||||
models.addAll(newData.models as Iterable<T>);
|
||||
} else {
|
||||
final newData = adapters[eventType]!.deserialize(map);
|
||||
included.addAll(newData.models as Iterable<DataModelMixin>);
|
||||
}
|
||||
}
|
||||
return DeserializedData<T>(models, included: included);
|
||||
}
|
||||
|
||||
@override
|
||||
bool existsId(Object id) {
|
||||
final r = db.select('SELECT 1 FROM _keys WHERE id = ?', [id]);
|
||||
return r.isNotEmpty;
|
||||
}
|
||||
|
||||
Iterable<String> existingIds(Iterable<Object> ids) {
|
||||
final r = db.select(
|
||||
'SELECT id FROM _keys WHERE id in (${ids.map((e) => '?').join(',')})',
|
||||
ids.toList());
|
||||
return r.map((e) => e['id']);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<T>> findAll(
|
||||
{bool? remote,
|
||||
bool? background,
|
||||
Map<String, dynamic>? params,
|
||||
Map<String, String>? headers,
|
||||
bool? syncLocal,
|
||||
OnSuccessAll<T>? onSuccess,
|
||||
OnErrorAll<T>? onError,
|
||||
DataRequestLabel? label}) async {
|
||||
params ??= {};
|
||||
if (remote == false) {
|
||||
return findAllLocal();
|
||||
}
|
||||
|
||||
final additionalKinds = params.remove('kinds');
|
||||
final limit = params.remove('limit');
|
||||
final since = params.remove('since');
|
||||
final until = params.remove('until');
|
||||
final ignoreReturn = params.remove('ignoreReturn');
|
||||
|
||||
if (['apps', 'fileMetadatas'].contains(internalType)) {
|
||||
if (Platform.isAndroid) {
|
||||
final info =
|
||||
await ref.read(systemInfoNotifierProvider.notifier).fetch();
|
||||
params['#f'] = info.androidInfo.supportedAbis.map((a) => 'android-$a');
|
||||
}
|
||||
}
|
||||
|
||||
final req = RelayRequest(
|
||||
kinds: {kind, ...?additionalKinds},
|
||||
tags: params,
|
||||
limit: limit,
|
||||
until: until,
|
||||
// Disable since when until is present
|
||||
since: until != null ? null : since,
|
||||
);
|
||||
|
||||
final result = await relay.queryRaw(req);
|
||||
final deserialized = await deserializeAsync(result,
|
||||
save: true, ignoreReturn: ignoreReturn == true);
|
||||
return deserialized.models;
|
||||
}
|
||||
|
||||
Future<void> loadArtifactsAndUsers(Iterable<Release> releases) async {
|
||||
final metadataIds =
|
||||
releases.map((r) => r.event.linkedEvents).nonNulls.expand((_) => _);
|
||||
final apps = releases.map((r) => r.app.value).nonNulls;
|
||||
final userIds = apps.map((a) => a.signer.id).nonNulls;
|
||||
|
||||
// Metadata and users probably go to separate relays
|
||||
// so query in parallel
|
||||
// Can use ignoreReturn on both as we only care about saved models
|
||||
await Future.wait([
|
||||
if (metadataIds.isNotEmpty)
|
||||
ref.fileMetadata.findAll(
|
||||
params: {
|
||||
'kinds': {1063, 3063},
|
||||
'ids': metadataIds,
|
||||
'#m': [kAndroidMimeType],
|
||||
'ignoreReturn': true,
|
||||
},
|
||||
),
|
||||
if (userIds.isNotEmpty)
|
||||
ref.users.findAll(
|
||||
params: {
|
||||
'authors': userIds,
|
||||
'ignoreReturn': true,
|
||||
},
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class RelayListenerNotifier extends Notifier<void> {
|
||||
@override
|
||||
void build() {
|
||||
final timer = Timer.periodic(Duration(minutes: 30), (_) => fetch());
|
||||
|
||||
// This will get disposed when clearing and restarting the app
|
||||
ref.onDispose(() {
|
||||
timer.cancel();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> fetch() async {
|
||||
await Future.microtask(() async {
|
||||
await ref.read(latestReleasesAppProvider.notifier).fetchRemote();
|
||||
await ref.apps.appAdapter.checkForUpdates();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final relayListenerProvider =
|
||||
NotifierProvider<RelayListenerNotifier, void>(RelayListenerNotifier.new);
|
||||
@@ -1,56 +0,0 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_data/flutter_data.dart';
|
||||
import 'package:purplebase/purplebase.dart' as base;
|
||||
import 'package:zapstore/models/app.dart';
|
||||
import 'package:zapstore/models/file_metadata.dart';
|
||||
import 'package:zapstore/models/nostr_adapter.dart';
|
||||
import 'package:zapstore/models/user.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
|
||||
part 'release.g.dart';
|
||||
|
||||
@DataAdapter([NostrAdapter, ReleaseAdapter])
|
||||
class Release extends base.Release with DataModelMixin<Release> {
|
||||
@override
|
||||
Object? get id => event.id;
|
||||
|
||||
final HasMany<FileMetadata> artifacts;
|
||||
final BelongsTo<App> app;
|
||||
final BelongsTo<User> signer;
|
||||
|
||||
// Release(
|
||||
// {super.createdAt,
|
||||
// super.content,
|
||||
// super.tags,
|
||||
// required this.artifacts,
|
||||
// required this.app,
|
||||
// required this.signer});
|
||||
|
||||
Release.fromJson(super.map)
|
||||
: app = belongsTo(map['app']),
|
||||
artifacts = hasMany(map['artifacts']),
|
||||
signer = belongsTo(map['signer']),
|
||||
super.fromJson();
|
||||
|
||||
Map<String, dynamic> toJson() => super.toMap();
|
||||
}
|
||||
|
||||
mixin ReleaseAdapter on Adapter<Release> {
|
||||
@override
|
||||
DeserializedData<Release> deserialize(Object? data, {String? key}) {
|
||||
final list = data is Iterable ? data : [data as Map];
|
||||
|
||||
for (final Map<String, dynamic> map in list) {
|
||||
final tags = map['tags'];
|
||||
map['app'] = base.BaseUtil.getTag(tags, 'a') ??
|
||||
'32267:${map['pubkey']}:${base.BaseUtil.getTag(tags, 'i')}';
|
||||
map['artifacts'] = base.BaseUtil.getTagSet(tags, 'e').toList();
|
||||
}
|
||||
return super.deserialize(data);
|
||||
}
|
||||
}
|
||||
|
||||
extension ReleaseExt on Iterable<Release> {
|
||||
List<Release> get sortedByLatest =>
|
||||
sorted((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'release.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// AdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
|
||||
|
||||
mixin _$ReleaseAdapter on Adapter<Release> {
|
||||
static final Map<String, RelationshipMeta> _kReleaseRelationshipMetas = {
|
||||
'artifacts': RelationshipMeta<FileMetadata>(
|
||||
name: 'artifacts',
|
||||
inverseName: 'release',
|
||||
type: 'fileMetadata',
|
||||
kind: 'HasMany',
|
||||
instance: (_) => (_ as Release).artifacts,
|
||||
),
|
||||
'app': RelationshipMeta<App>(
|
||||
name: 'app',
|
||||
inverseName: 'releases',
|
||||
type: 'apps',
|
||||
kind: 'BelongsTo',
|
||||
instance: (_) => (_ as Release).app,
|
||||
),
|
||||
'signer': RelationshipMeta<User>(
|
||||
name: 'signer',
|
||||
type: 'users',
|
||||
kind: 'BelongsTo',
|
||||
instance: (_) => (_ as Release).signer,
|
||||
)
|
||||
};
|
||||
|
||||
@override
|
||||
Map<String, RelationshipMeta> get relationshipMetas =>
|
||||
_kReleaseRelationshipMetas;
|
||||
|
||||
@override
|
||||
Release deserializeLocal(map, {String? key}) {
|
||||
map = transformDeserialize(map);
|
||||
return internalWrapStopInit(() => Release.fromJson(map), key: key);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
|
||||
final map = model.toJson();
|
||||
return transformSerialize(map, withRelationships: withRelationships);
|
||||
}
|
||||
}
|
||||
|
||||
final _releasesFinders = <String, dynamic>{};
|
||||
|
||||
class $ReleaseAdapter = Adapter<Release>
|
||||
with _$ReleaseAdapter, NostrAdapter<Release>, ReleaseAdapter;
|
||||
|
||||
final releasesAdapterProvider = Provider<Adapter<Release>>(
|
||||
(ref) => $ReleaseAdapter(ref, InternalHolder(_releasesFinders)));
|
||||
|
||||
extension ReleaseAdapterX on Adapter<Release> {
|
||||
NostrAdapter<Release> get nostrAdapter => this as NostrAdapter<Release>;
|
||||
ReleaseAdapter get releaseAdapter => this as ReleaseAdapter;
|
||||
}
|
||||
|
||||
extension ReleaseRelationshipGraphNodeX on RelationshipGraphNode<Release> {
|
||||
RelationshipGraphNode<FileMetadata> get artifacts {
|
||||
final meta = _$ReleaseAdapter._kReleaseRelationshipMetas['artifacts']
|
||||
as RelationshipMeta<FileMetadata>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
|
||||
RelationshipGraphNode<App> get app {
|
||||
final meta = _$ReleaseAdapter._kReleaseRelationshipMetas['app']
|
||||
as RelationshipMeta<App>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
|
||||
RelationshipGraphNode<User> get signer {
|
||||
final meta = _$ReleaseAdapter._kReleaseRelationshipMetas['signer']
|
||||
as RelationshipMeta<User>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import 'package:flutter_data/flutter_data.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:zapstore/models/user.dart';
|
||||
|
||||
part 'settings.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
@DataAdapter([SettingsAdapter])
|
||||
class Settings extends DataModel<Settings> {
|
||||
@override
|
||||
String get id => '_';
|
||||
final BelongsTo<User> user = BelongsTo();
|
||||
final HasMany<User> trustedUsers = HasMany();
|
||||
SignInMethod? signInMethod;
|
||||
}
|
||||
|
||||
enum SignInMethod {
|
||||
pubkey,
|
||||
nip55,
|
||||
}
|
||||
|
||||
mixin SettingsAdapter on Adapter<Settings> {
|
||||
@override
|
||||
Future<void> onInitialized() async {
|
||||
await super.onInitialized();
|
||||
if (inIsolate) {
|
||||
return;
|
||||
}
|
||||
// ensure it's always present
|
||||
if (!existsId('_')) {
|
||||
Settings().saveLocal();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'settings.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// AdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
|
||||
|
||||
mixin _$SettingsAdapter on Adapter<Settings> {
|
||||
static final Map<String, RelationshipMeta> _kSettingsRelationshipMetas = {
|
||||
'user': RelationshipMeta<User>(
|
||||
name: 'user',
|
||||
inverseName: 'settings',
|
||||
type: 'users',
|
||||
kind: 'BelongsTo',
|
||||
instance: (_) => (_ as Settings).user,
|
||||
),
|
||||
'trustedUsers': RelationshipMeta<User>(
|
||||
name: 'trustedUsers',
|
||||
inverseName: 'settings',
|
||||
type: 'users',
|
||||
kind: 'HasMany',
|
||||
instance: (_) => (_ as Settings).trustedUsers,
|
||||
)
|
||||
};
|
||||
|
||||
@override
|
||||
Map<String, RelationshipMeta> get relationshipMetas =>
|
||||
_kSettingsRelationshipMetas;
|
||||
|
||||
@override
|
||||
Settings deserializeLocal(map, {String? key}) {
|
||||
map = transformDeserialize(map);
|
||||
return internalWrapStopInit(() => _$SettingsFromJson(map), key: key);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
|
||||
final map = _$SettingsToJson(model);
|
||||
return transformSerialize(map, withRelationships: withRelationships);
|
||||
}
|
||||
}
|
||||
|
||||
final _settingsFinders = <String, dynamic>{};
|
||||
|
||||
class $SettingsAdapter = Adapter<Settings>
|
||||
with _$SettingsAdapter, SettingsAdapter;
|
||||
|
||||
final settingsAdapterProvider = Provider<Adapter<Settings>>(
|
||||
(ref) => $SettingsAdapter(ref, InternalHolder(_settingsFinders)));
|
||||
|
||||
extension SettingsAdapterX on Adapter<Settings> {
|
||||
SettingsAdapter get settingsAdapter => this as SettingsAdapter;
|
||||
}
|
||||
|
||||
extension SettingsRelationshipGraphNodeX on RelationshipGraphNode<Settings> {
|
||||
RelationshipGraphNode<User> get user {
|
||||
final meta = _$SettingsAdapter._kSettingsRelationshipMetas['user']
|
||||
as RelationshipMeta<User>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
|
||||
RelationshipGraphNode<User> get trustedUsers {
|
||||
final meta = _$SettingsAdapter._kSettingsRelationshipMetas['trustedUsers']
|
||||
as RelationshipMeta<User>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
Settings _$SettingsFromJson(Map<String, dynamic> json) => Settings()
|
||||
..signInMethod =
|
||||
$enumDecodeNullable(_$SignInMethodEnumMap, json['signInMethod']);
|
||||
|
||||
Map<String, dynamic> _$SettingsToJson(Settings instance) => <String, dynamic>{
|
||||
'signInMethod': _$SignInMethodEnumMap[instance.signInMethod],
|
||||
};
|
||||
|
||||
const _$SignInMethodEnumMap = {
|
||||
SignInMethod.pubkey: 'pubkey',
|
||||
SignInMethod.nip55: 'nip55',
|
||||
};
|
||||
@@ -1,285 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_data/flutter_data.dart';
|
||||
import 'package:purplebase/purplebase.dart' as base;
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:zapstore/main.data.dart';
|
||||
import 'package:zapstore/models/app.dart';
|
||||
import 'package:zapstore/models/nostr_adapter.dart';
|
||||
import 'package:zapstore/models/settings.dart';
|
||||
import 'package:zapstore/models/verify_reputation_dvm.dart';
|
||||
import 'package:zapstore/navigation/app_initializer.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/utils/nwc.dart';
|
||||
|
||||
part 'user.g.dart';
|
||||
|
||||
@DataAdapter([NostrAdapter, UserAdapter])
|
||||
class User extends base.User with DataModelMixin<User> {
|
||||
@override
|
||||
Object? get id => event.id;
|
||||
|
||||
User.fromJson(super.map)
|
||||
: followers = hasMany(map['followers']),
|
||||
following = hasMany(map['following']),
|
||||
settings = belongsTo(map['settings']),
|
||||
apps = hasMany(map['apps']),
|
||||
super.fromJson();
|
||||
|
||||
User.fromPubkey(String pubkey)
|
||||
: this.fromJson({
|
||||
'id': pubkey,
|
||||
'kind': 0,
|
||||
'pubkey': pubkey.hexKey,
|
||||
'created_at': DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
'content': '{}',
|
||||
'tags': [],
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => super.toMap();
|
||||
|
||||
@DataRelationship(inverse: 'followers')
|
||||
final HasMany<User> following;
|
||||
@DataRelationship(inverse: 'following')
|
||||
final HasMany<User> followers;
|
||||
@DataRelationship(inverse: 'user')
|
||||
final BelongsTo<Settings> settings;
|
||||
|
||||
@DataRelationship(inverse: 'signer')
|
||||
final HasMany<App> apps;
|
||||
|
||||
String get nameOrNpub => name ?? npub.shorten;
|
||||
|
||||
// Zaps
|
||||
|
||||
Future<void> zap(int amountInSats,
|
||||
{required base.Event event, String? comment, base.Signer? signer}) async {
|
||||
final adapter = DataModel.adapterFor(this) as NostrAdapter;
|
||||
|
||||
// First ensure connection is available
|
||||
final nwcConnection = adapter.ref.read(nwcConnectionProvider).asData?.value;
|
||||
if (nwcConnection == null) {
|
||||
throw 'No NWC connection';
|
||||
}
|
||||
|
||||
final authorPubkey = event.event.pubkey;
|
||||
final author = adapter.ref.users.findOneLocalById(authorPubkey);
|
||||
|
||||
if (author?.lud16 == null) {
|
||||
throw 'Recipient has no Lightning address';
|
||||
}
|
||||
|
||||
final amountInMillisats = amountInSats * 1000;
|
||||
final partialZapRequest = base.PartialZapRequest();
|
||||
partialZapRequest
|
||||
..addLinkedEvent(event)
|
||||
..addLinkedUser(author!)
|
||||
..relays = kSocialRelays.where((r) => r != 'ndk')
|
||||
..amount = amountInMillisats
|
||||
..comment = comment;
|
||||
|
||||
final zapRequest = await partialZapRequest.signWith(signer ?? amberSigner,
|
||||
withPubkey: pubkey);
|
||||
|
||||
// Now we fetch the invoice
|
||||
final lnResponse = await author.fetchLightningAddress();
|
||||
|
||||
final recipientAllowsNostr = lnResponse['allowsNostr'];
|
||||
final recipientPubkey = lnResponse['nostrPubkey'];
|
||||
// All amounts are in millisats
|
||||
final recipientMin = lnResponse['minSendable'];
|
||||
final recipientMax = lnResponse['maxSendable'];
|
||||
|
||||
if (recipientAllowsNostr != true && recipientPubkey != null) {
|
||||
throw 'Recipient does not allow nostr';
|
||||
}
|
||||
if ((recipientMin != null && amountInMillisats < recipientMin) ||
|
||||
(recipientMax != null && amountInMillisats > recipientMax)) {
|
||||
throw 'Amount not between min and max sendable';
|
||||
}
|
||||
|
||||
final commentLength = lnResponse['commentAllowed'];
|
||||
comment =
|
||||
commentLength != null ? comment?.safeSubstring(commentLength) : comment;
|
||||
|
||||
var callbackUri = Uri.parse(lnResponse['callback']!);
|
||||
callbackUri = callbackUri.replace(
|
||||
queryParameters: {
|
||||
...callbackUri.queryParameters,
|
||||
'amount': amountInMillisats.toString(),
|
||||
if (comment != null) 'comment': comment,
|
||||
'nostr': jsonEncode(zapRequest.toMap()),
|
||||
},
|
||||
);
|
||||
|
||||
final invoiceResponse = await http.get(callbackUri);
|
||||
final invoiceMap = jsonDecode(invoiceResponse.body);
|
||||
|
||||
final invoice = invoiceMap['pr'];
|
||||
if (invoice == null) {
|
||||
throw 'No invoice';
|
||||
}
|
||||
|
||||
// Pay the invoice via NWC (errors are thrown)
|
||||
await adapter.socialRelays.ndk!.nwc.payInvoice(nwcConnection,
|
||||
invoice: invoice, timeout: Duration(seconds: 20));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fetchLightningAddress() async {
|
||||
final [userName, domainName] = lud16!.split('@');
|
||||
final lnurl = 'https://$domainName/.well-known/lnurlp/$userName';
|
||||
final response = await http.get(Uri.parse(lnurl));
|
||||
final map = jsonDecode(response.body);
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
mixin UserAdapter on NostrAdapter<User> {
|
||||
@override
|
||||
Future<List<User>> findAll(
|
||||
{bool? remote,
|
||||
bool? background,
|
||||
Map<String, dynamic>? params,
|
||||
Map<String, String>? headers,
|
||||
bool? syncLocal,
|
||||
OnSuccessAll<User>? onSuccess,
|
||||
OnErrorAll<User>? onError,
|
||||
DataRequestLabel? label}) async {
|
||||
final authors = params!['authors'] as Iterable;
|
||||
if (authors.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final request = base.RelayRequest(
|
||||
kinds: {0},
|
||||
authors: {...authors},
|
||||
);
|
||||
|
||||
final result = await socialRelays.queryRaw(request);
|
||||
|
||||
if (onSuccess != null) {
|
||||
return await onSuccess.call(DataResponse(statusCode: 200, body: result),
|
||||
label ?? DataRequestLabel('findAll', type: type), this);
|
||||
}
|
||||
final data = await deserializeAsync(result, save: true);
|
||||
return data.models;
|
||||
}
|
||||
|
||||
@override
|
||||
bool isOfflineError(Object? error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<User?> findOne(Object id,
|
||||
{bool remote = true,
|
||||
bool background = false,
|
||||
Map<String, dynamic>? params,
|
||||
Map<String, String>? headers,
|
||||
OnSuccessOne<User>? onSuccess,
|
||||
OnErrorOne<User>? onError,
|
||||
DataRequestLabel? label}) async {
|
||||
if (id.toString().isEmpty) return null;
|
||||
|
||||
var publicKey = id.toString();
|
||||
|
||||
if (publicKey.startsWith('npub')) {
|
||||
publicKey = publicKey.hexKey;
|
||||
} else if (publicKey.contains('@')) {
|
||||
// If it's not an npub and it has a @ instead, we treat the string as NIP-05
|
||||
final [username, domain] = publicKey.split('@');
|
||||
publicKey = await sendRequest<dynamic>(
|
||||
Uri.parse('https://$domain/.well-known/nostr.json?name=$username'),
|
||||
onSuccess: (response, label) {
|
||||
var body = response.body;
|
||||
if (body is String) {
|
||||
body = jsonDecode(body);
|
||||
}
|
||||
return (body as Map)['names']?[username];
|
||||
},
|
||||
onError: (e, _) {
|
||||
throw e;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
params ??= {};
|
||||
|
||||
final result = await socialRelays.queryRaw(base.RelayRequest(
|
||||
kinds: {0},
|
||||
tags: params,
|
||||
authors: {publicKey},
|
||||
));
|
||||
|
||||
final data = await deserializeAsync(result, save: true);
|
||||
return data.models.firstWhereOrNull((e) {
|
||||
return e.id == publicKey;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
DeserializedData<User> deserialize(Object? data, {String? key}) {
|
||||
final list = data is Iterable ? data : [data as Map];
|
||||
|
||||
for (final Map<String, dynamic> map in list) {
|
||||
// if (map['kind'] == 3) {
|
||||
// map['following'] = {
|
||||
// for (final [name, value] in map['tags'] as Iterable)
|
||||
// if (name == 'p') value
|
||||
// };
|
||||
// }
|
||||
map['id'] = map['pubkey'];
|
||||
}
|
||||
|
||||
return super.deserialize(data);
|
||||
}
|
||||
|
||||
Future<List<User>> getRelevantWhoFollow(
|
||||
String fromNpub, String toNpub) async {
|
||||
final dvmReq = await PartialVerifyReputationRequest(
|
||||
source: fromNpub.hexKey, target: toNpub.hexKey)
|
||||
.signWith(amberSigner, withPubkey: fromNpub.hexKey);
|
||||
await ref.users.nostrAdapter.vertexRelay.publish(dvmReq);
|
||||
|
||||
final req = base.RelayRequest(kinds: {
|
||||
6312,
|
||||
7000
|
||||
}, tags: {
|
||||
'e': [dvmReq.event.id]
|
||||
});
|
||||
final response = await ref.users.nostrAdapter.vertexRelay.queryRaw(req);
|
||||
|
||||
if (response.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
if (response.first['kind'] == 7000) {
|
||||
final error = (response.first['tags'] as Iterable?)
|
||||
?.firstWhereOrNull((t) => t[0] == 'status')?[2];
|
||||
if (error.toString().contains('credits')) {
|
||||
throw 'Unable to check followers';
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
final result = response.first['content'];
|
||||
final pubkeyList = (jsonDecode(result) as List).map((e) => e['pubkey']);
|
||||
final users = await findAll(params: {'authors': pubkeyList});
|
||||
return users;
|
||||
}
|
||||
}
|
||||
|
||||
final signedInUserProvider = Provider<User?>((ref) {
|
||||
return ref.settings
|
||||
.watchOne('_', alsoWatch: (_) => {_.user})
|
||||
.model
|
||||
?.user
|
||||
.value;
|
||||
});
|
||||
|
||||
const kZapstorePubkey =
|
||||
'78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d';
|
||||
|
||||
const kFranzapPubkey =
|
||||
'726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11';
|
||||
@@ -1,101 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'user.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// AdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
|
||||
|
||||
mixin _$UserAdapter on Adapter<User> {
|
||||
static final Map<String, RelationshipMeta> _kUserRelationshipMetas = {
|
||||
'following': RelationshipMeta<User>(
|
||||
name: 'following',
|
||||
inverseName: 'followers',
|
||||
type: 'users',
|
||||
kind: 'HasMany',
|
||||
instance: (_) => (_ as User).following,
|
||||
),
|
||||
'followers': RelationshipMeta<User>(
|
||||
name: 'followers',
|
||||
inverseName: 'following',
|
||||
type: 'users',
|
||||
kind: 'HasMany',
|
||||
instance: (_) => (_ as User).followers,
|
||||
),
|
||||
'settings': RelationshipMeta<Settings>(
|
||||
name: 'settings',
|
||||
inverseName: 'user',
|
||||
type: 'settings',
|
||||
kind: 'BelongsTo',
|
||||
instance: (_) => (_ as User).settings,
|
||||
),
|
||||
'apps': RelationshipMeta<App>(
|
||||
name: 'apps',
|
||||
inverseName: 'signer',
|
||||
type: 'apps',
|
||||
kind: 'HasMany',
|
||||
instance: (_) => (_ as User).apps,
|
||||
)
|
||||
};
|
||||
|
||||
@override
|
||||
Map<String, RelationshipMeta> get relationshipMetas =>
|
||||
_kUserRelationshipMetas;
|
||||
|
||||
@override
|
||||
User deserializeLocal(map, {String? key}) {
|
||||
map = transformDeserialize(map);
|
||||
return internalWrapStopInit(() => User.fromJson(map), key: key);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
|
||||
final map = model.toJson();
|
||||
return transformSerialize(map, withRelationships: withRelationships);
|
||||
}
|
||||
}
|
||||
|
||||
final _usersFinders = <String, dynamic>{};
|
||||
|
||||
class $UserAdapter = Adapter<User>
|
||||
with _$UserAdapter, NostrAdapter<User>, UserAdapter;
|
||||
|
||||
final usersAdapterProvider = Provider<Adapter<User>>(
|
||||
(ref) => $UserAdapter(ref, InternalHolder(_usersFinders)));
|
||||
|
||||
extension UserAdapterX on Adapter<User> {
|
||||
NostrAdapter<User> get nostrAdapter => this as NostrAdapter<User>;
|
||||
UserAdapter get userAdapter => this as UserAdapter;
|
||||
}
|
||||
|
||||
extension UserRelationshipGraphNodeX on RelationshipGraphNode<User> {
|
||||
RelationshipGraphNode<User> get following {
|
||||
final meta = _$UserAdapter._kUserRelationshipMetas['following']
|
||||
as RelationshipMeta<User>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
|
||||
RelationshipGraphNode<User> get followers {
|
||||
final meta = _$UserAdapter._kUserRelationshipMetas['followers']
|
||||
as RelationshipMeta<User>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
|
||||
RelationshipGraphNode<Settings> get settings {
|
||||
final meta = _$UserAdapter._kUserRelationshipMetas['settings']
|
||||
as RelationshipMeta<Settings>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
|
||||
RelationshipGraphNode<App> get apps {
|
||||
final meta =
|
||||
_$UserAdapter._kUserRelationshipMetas['apps'] as RelationshipMeta<App>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import 'package:purplebase/purplebase.dart';
|
||||
|
||||
class VerifyReputationRequest extends RegularEvent<VerifyReputationRequest> {
|
||||
VerifyReputationRequest.fromJson(super.map) : super.fromJson();
|
||||
}
|
||||
|
||||
class PartialVerifyReputationRequest
|
||||
extends RegularPartialEvent<VerifyReputationRequest> {
|
||||
PartialVerifyReputationRequest({
|
||||
required String source,
|
||||
required String target,
|
||||
}) {
|
||||
event.setTag('param', ['source', source]);
|
||||
event.setTag('param', ['target', target]);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import 'package:flutter_data/flutter_data.dart';
|
||||
import 'package:purplebase/purplebase.dart' as base;
|
||||
import 'package:zapstore/models/nostr_adapter.dart';
|
||||
import 'package:zapstore/models/user.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
|
||||
part 'zap_receipt.g.dart';
|
||||
|
||||
@DataAdapter([NostrAdapter, ZapReceiptAdapter])
|
||||
class ZapReceipt extends base.ZapReceipt with DataModelMixin<ZapReceipt> {
|
||||
final BelongsTo<User> recipient;
|
||||
final BelongsTo<User> signer;
|
||||
|
||||
@override
|
||||
Object? get id => event.id;
|
||||
|
||||
ZapReceipt.fromJson(super.map)
|
||||
: recipient = belongsTo(map['recipient']),
|
||||
signer = belongsTo(map['signer']),
|
||||
super.fromJson();
|
||||
|
||||
Map<String, dynamic> toJson() => super.toMap();
|
||||
}
|
||||
|
||||
mixin ZapReceiptAdapter on Adapter<ZapReceipt> {
|
||||
@override
|
||||
DeserializedData<ZapReceipt> deserialize(Object? data, {String? key}) {
|
||||
final list = data is Iterable ? data : [data as Map];
|
||||
for (final e in list) {
|
||||
final map = e as Map<String, dynamic>;
|
||||
map['recipient'] = base.BaseUtil.getTagSet(map['tags'], 'p');
|
||||
}
|
||||
return super.deserialize(data);
|
||||
}
|
||||
|
||||
List<ZapReceipt> findByEventId(String eventId) {
|
||||
final result = db.select(
|
||||
"SELECT z.key, z.data from zapReceipts z, json_each(json_extract(data, '\$.tags')) WHERE json_extract(value, '\$[0]') = 'e' AND json_extract(value, '\$[1]') = ?",
|
||||
[eventId]);
|
||||
return deserializeFromResult(result);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'zap_receipt.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// AdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
|
||||
|
||||
mixin _$ZapReceiptAdapter on Adapter<ZapReceipt> {
|
||||
static final Map<String, RelationshipMeta> _kZapReceiptRelationshipMetas = {
|
||||
'recipient': RelationshipMeta<User>(
|
||||
name: 'recipient',
|
||||
type: 'users',
|
||||
kind: 'BelongsTo',
|
||||
instance: (_) => (_ as ZapReceipt).recipient,
|
||||
),
|
||||
'signer': RelationshipMeta<User>(
|
||||
name: 'signer',
|
||||
type: 'users',
|
||||
kind: 'BelongsTo',
|
||||
instance: (_) => (_ as ZapReceipt).signer,
|
||||
)
|
||||
};
|
||||
|
||||
@override
|
||||
Map<String, RelationshipMeta> get relationshipMetas =>
|
||||
_kZapReceiptRelationshipMetas;
|
||||
|
||||
@override
|
||||
ZapReceipt deserializeLocal(map, {String? key}) {
|
||||
map = transformDeserialize(map);
|
||||
return internalWrapStopInit(() => ZapReceipt.fromJson(map), key: key);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
|
||||
final map = model.toJson();
|
||||
return transformSerialize(map, withRelationships: withRelationships);
|
||||
}
|
||||
}
|
||||
|
||||
final _zapReceiptsFinders = <String, dynamic>{};
|
||||
|
||||
class $ZapReceiptAdapter = Adapter<ZapReceipt>
|
||||
with _$ZapReceiptAdapter, NostrAdapter<ZapReceipt>, ZapReceiptAdapter;
|
||||
|
||||
final zapReceiptsAdapterProvider = Provider<Adapter<ZapReceipt>>(
|
||||
(ref) => $ZapReceiptAdapter(ref, InternalHolder(_zapReceiptsFinders)));
|
||||
|
||||
extension ZapReceiptAdapterX on Adapter<ZapReceipt> {
|
||||
NostrAdapter<ZapReceipt> get nostrAdapter => this as NostrAdapter<ZapReceipt>;
|
||||
ZapReceiptAdapter get zapReceiptAdapter => this as ZapReceiptAdapter;
|
||||
}
|
||||
|
||||
extension ZapReceiptRelationshipGraphNodeX
|
||||
on RelationshipGraphNode<ZapReceipt> {
|
||||
RelationshipGraphNode<User> get recipient {
|
||||
final meta = _$ZapReceiptAdapter._kZapReceiptRelationshipMetas['recipient']
|
||||
as RelationshipMeta<User>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
|
||||
RelationshipGraphNode<User> get signer {
|
||||
final meta = _$ZapReceiptAdapter._kZapReceiptRelationshipMetas['signer']
|
||||
as RelationshipMeta<User>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_data/flutter_data.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:zapstore/main.dart';
|
||||
import 'package:zapstore/main.data.dart';
|
||||
import 'package:zapstore/models/app.dart';
|
||||
import 'package:zapstore/models/local_app.dart';
|
||||
import 'package:zapstore/models/user.dart';
|
||||
import 'package:zapstore/models/verify_reputation_dvm.dart';
|
||||
import 'package:zapstore/navigation/router.dart';
|
||||
import 'package:zapstore/utils/signers.dart';
|
||||
import 'package:zapstore/widgets/app_curation_container.dart';
|
||||
import 'package:purplebase/purplebase.dart' as base;
|
||||
|
||||
AppLifecycleListener? _lifecycleListener;
|
||||
SharedPreferences? sharedPreferences;
|
||||
User? anonUser;
|
||||
final amberSigner = AmberSigner();
|
||||
|
||||
final appInitializer = FutureProvider<void>((ref) async {
|
||||
sharedPreferences = await SharedPreferences.getInstance();
|
||||
final dbVersion = sharedPreferences!.getInt('dbVersion');
|
||||
|
||||
// Perform migration
|
||||
if (dbVersion == null || dbVersion < kDbVersion) {
|
||||
final storage = ref.read(localStorageProvider);
|
||||
await storage.initialize();
|
||||
await storage.destroy();
|
||||
await sharedPreferences!.setInt('dbVersion', kDbVersion);
|
||||
}
|
||||
|
||||
// Initialize Flutter Data
|
||||
await ref.read(initializeFlutterData(adapterProvidersMap).future);
|
||||
|
||||
// NOTE: Do not use ignoreReturn here
|
||||
if (ref.appCurationSets.countLocal == 0) {
|
||||
// If we are here, local storage is empty
|
||||
// Preload curation sets
|
||||
await ref.appCurationSets.findAll(syncLocal: true);
|
||||
// Preload updates in the background (no await)
|
||||
ref.apps.appAdapter.checkForUpdates();
|
||||
} else {
|
||||
// Preload curation sets in the background (no await)
|
||||
ref.appCurationSets.findAll(syncLocal: true, background: true);
|
||||
}
|
||||
|
||||
// Preload zapstore's nostr curation set
|
||||
ref.read(appCurationSetProvider(kNostrCurationSetLink).notifier).fetch();
|
||||
|
||||
// Initialize signer
|
||||
await amberSigner.initialize();
|
||||
|
||||
// Set up anon user (pubkey derives from pkSigner secret key)
|
||||
anonUser ??= User.fromPubkey(
|
||||
'c86eda2daae768374526bc54903f388d9a866c00740ec8db418d7ef2dca77b5b')
|
||||
.init()
|
||||
.saveLocal();
|
||||
|
||||
// Register event types
|
||||
base.Event.types['VerifyReputationRequest'] =
|
||||
(kind: 5312, constructor: VerifyReputationRequest.fromJson);
|
||||
|
||||
// App-wide listeners
|
||||
|
||||
// Register app lifecycle listener
|
||||
_lifecycleListener = AppLifecycleListener(
|
||||
onStateChange: (state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
ref.localApps.localAppAdapter.refreshUpdateStatus();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Handle deep links
|
||||
final appLinksSub = appLinks.uriLinkStream.listen((uri) async {
|
||||
String? appId;
|
||||
if (uri.scheme == 'https' && uri.hasFragment) {
|
||||
appId = uri.fragment;
|
||||
} else if (uri.scheme == 'zapstore') {
|
||||
// zapstore://com.app.id
|
||||
// zapstore://com.app.id?signer=npub1xyz
|
||||
appId = uri.host;
|
||||
}
|
||||
if (appId != null) {
|
||||
final adapter = ref.apps.appAdapter;
|
||||
final apps = adapter.findWhereIdentifierInLocal({appId});
|
||||
// Filter by signer npub, if present, otherwise pick first
|
||||
final appSignerNpub = uri.queryParameters['signer'];
|
||||
var goToApp =
|
||||
apps.firstWhereOrNull((a) => a.signer.value?.npub == appSignerNpub);
|
||||
if (appSignerNpub == null) {
|
||||
goToApp = apps.first;
|
||||
}
|
||||
if (goToApp != null) {
|
||||
appRouter.go('/details', extra: goToApp);
|
||||
} else {
|
||||
appRouter.go('/');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ref.onDispose(() {
|
||||
_lifecycleListener?.dispose();
|
||||
appLinksSub.cancel();
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DesktopScaffold extends StatelessWidget {
|
||||
const DesktopScaffold({
|
||||
super.key,
|
||||
required this.body,
|
||||
required this.selectedIndex,
|
||||
required this.onDestinationSelected,
|
||||
});
|
||||
final Widget body;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
minWidth: 120,
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
labelType: NavigationRailLabelType.all,
|
||||
destinations: [
|
||||
const NavigationRailDestination(
|
||||
label: Text('Home'),
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home_filled),
|
||||
),
|
||||
const NavigationRailDestination(
|
||||
label: Text('Updates'),
|
||||
icon: Icon(Icons.download_for_offline_outlined),
|
||||
selectedIcon: Icon(Icons.download_for_offline),
|
||||
),
|
||||
const NavigationRailDestination(
|
||||
label: Text('Settings'),
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
Flexible(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 768,
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:zapstore/models/app.dart';
|
||||
import 'package:zapstore/utils/theme.dart';
|
||||
import 'package:zapstore/widgets/drawer_container.dart';
|
||||
|
||||
class MobileScaffold extends HookConsumerWidget {
|
||||
const MobileScaffold({
|
||||
super.key,
|
||||
required this.body,
|
||||
required this.selectedIndex,
|
||||
required this.onDestinationSelected,
|
||||
});
|
||||
final Widget body;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appsToUpdate = ref.watch(appsToUpdateProvider);
|
||||
|
||||
return Scaffold(
|
||||
key: scaffoldKey,
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
|
||||
child: body,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
height: 60,
|
||||
backgroundColor: kBackgroundColor,
|
||||
indicatorColor: Colors.transparent,
|
||||
selectedIndex: selectedIndex,
|
||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
|
||||
destinations: [
|
||||
NavigationDestination(
|
||||
label: 'Home',
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home_filled),
|
||||
),
|
||||
NavigationDestination(
|
||||
label: 'Updates',
|
||||
icon: appsToUpdate > 0
|
||||
? Badge(
|
||||
label: Text(appsToUpdate.toString()),
|
||||
child: Icon(Icons.update_outlined),
|
||||
)
|
||||
: Icon(Icons.update_outlined),
|
||||
selectedIcon: appsToUpdate > 0
|
||||
? Badge(
|
||||
label: Text(appsToUpdate.toString()),
|
||||
child: Icon(Icons.update),
|
||||
)
|
||||
: Icon(Icons.update),
|
||||
),
|
||||
NavigationDestination(
|
||||
label: 'Settings',
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
),
|
||||
],
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
),
|
||||
drawer: DrawerContainer(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
@@ -1,141 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:zapstore/main.dart';
|
||||
import 'package:zapstore/models/app.dart';
|
||||
import 'package:zapstore/models/nostr_adapter.dart';
|
||||
import 'package:zapstore/models/user.dart';
|
||||
import 'package:zapstore/navigation/app_initializer.dart';
|
||||
import 'package:zapstore/navigation/desktop_scaffold.dart';
|
||||
import 'package:zapstore/navigation/mobile_scaffold.dart';
|
||||
import 'package:zapstore/screens/app_detail_screen.dart';
|
||||
import 'package:zapstore/screens/developer_screen.dart';
|
||||
import 'package:zapstore/screens/search_screen.dart';
|
||||
import 'package:zapstore/screens/settings_screen.dart';
|
||||
import 'package:zapstore/screens/updates_screen.dart';
|
||||
import 'package:zapstore/widgets/error_container.dart';
|
||||
import 'package:zapstore/widgets/spinning_logo.dart';
|
||||
|
||||
/// Application navigation.
|
||||
/// - Initializes Go Router, dispatches routes
|
||||
/// - Initializes Flutter Data (database) and Purplebase (nostr library)
|
||||
/// - Builds media query dependent scaffold (mobile, desktop)
|
||||
final appRouter = GoRouter(
|
||||
initialLocation: '/',
|
||||
// * Passing a navigatorKey causes an issue on hot reload:
|
||||
// * https://github.com/flutter/flutter/issues/113757#issuecomment-1518421380
|
||||
// * However it's still necessary otherwise the navigator pops back to
|
||||
// * root on hot reload
|
||||
navigatorKey: _rootNavigatorKey,
|
||||
routes: [
|
||||
StatefulShellRoute.indexedStack(
|
||||
builder: (context, state, navigationShell) {
|
||||
return ScaffoldWithNestedNavigation(navigationShell: navigationShell);
|
||||
},
|
||||
branches: [
|
||||
StatefulShellBranch(
|
||||
navigatorKey: _searchNavigatorKey,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => SearchScreen(),
|
||||
routes: [appDetailsRoute, developerRoute],
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
navigatorKey: _updatesNavigatorKey,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/updates',
|
||||
builder: (context, state) => UpdatesScreen(),
|
||||
routes: [appDetailsRoute, developerRoute],
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
navigatorKey: _settingsNavigatorKey,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
child: SettingsScreen(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final appDetailsRoute = GoRoute(
|
||||
path: 'details',
|
||||
builder: (context, state) => AppDetailScreen(model: state.extra as App),
|
||||
);
|
||||
|
||||
final developerRoute = GoRoute(
|
||||
path: 'developer',
|
||||
builder: (context, state) => DeveloperScreen(model: state.extra as User),
|
||||
);
|
||||
|
||||
class ScaffoldWithNestedNavigation extends HookConsumerWidget {
|
||||
const ScaffoldWithNestedNavigation({
|
||||
Key? key,
|
||||
required this.navigationShell,
|
||||
}) : super(key: key ?? const ValueKey('ScaffoldWithNestedNavigation'));
|
||||
final StatefulNavigationShell navigationShell;
|
||||
|
||||
void onDestinationSelected(int index) {
|
||||
navigationShell.goBranch(
|
||||
index,
|
||||
// A common pattern when using bottom navigation bars is to support
|
||||
// navigating to the initial location when tapping the item that is
|
||||
// already active. This example demonstrates how to support this behavior,
|
||||
// using the initialLocation parameter of goBranch.
|
||||
initialLocation: index == navigationShell.currentIndex,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final initializer = ref.watch(appInitializer);
|
||||
return SafeArea(
|
||||
child: initializer.when(
|
||||
data: (_) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// At this point initialization is done
|
||||
// listen to relay
|
||||
ref.watch(relayListenerProvider);
|
||||
|
||||
if (constraints.maxWidth < 550) {
|
||||
return MobileScaffold(
|
||||
body: navigationShell,
|
||||
selectedIndex: navigationShell.currentIndex,
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
);
|
||||
} else {
|
||||
return DesktopScaffold(
|
||||
body: navigationShell,
|
||||
selectedIndex: navigationShell.currentIndex,
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
error: (e, stack) {
|
||||
errorHandler(e, stack);
|
||||
return ErrorContainer(exception: e, stack: stack);
|
||||
},
|
||||
loading: () => Center(child: SpinningLogo()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Keys
|
||||
|
||||
final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
final _searchNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'search');
|
||||
final _updatesNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'updates');
|
||||
final _settingsNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'settings');
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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/developer_screen.dart';
|
||||
import 'package:zapstore/screens/search_screen.dart';
|
||||
import 'package:zapstore/screens/updates_screen.dart';
|
||||
import 'package:zapstore/screens/profile_screen.dart';
|
||||
import 'package:zapstore/screens/app_from_naddr_screen.dart';
|
||||
|
||||
/// Fallback screen shown when app data is not available
|
||||
class _AppNotFoundScreen extends StatelessWidget {
|
||||
const _AppNotFoundScreen();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('App Not Found')),
|
||||
body: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 64),
|
||||
SizedBox(height: 16),
|
||||
Text('App data not available'),
|
||||
SizedBox(height: 8),
|
||||
Text('Please try navigating from the app list'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to build app detail route with fallback
|
||||
GoRoute _appDetailRoute() {
|
||||
return GoRoute(
|
||||
path: 'app/:id',
|
||||
builder: (context, state) {
|
||||
final app = state.extra as App?;
|
||||
if (app != null) {
|
||||
return AppDetailScreen(app: app);
|
||||
}
|
||||
return const _AppNotFoundScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper to build developer route
|
||||
GoRoute _developerRoute() {
|
||||
return GoRoute(
|
||||
path: 'developer/:pubkey',
|
||||
builder: (context, state) {
|
||||
final pubkey = state.pathParameters['pubkey']!;
|
||||
return DeveloperScreen(pubkey: pubkey);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
return GoRouter(
|
||||
initialLocation: '/search',
|
||||
routes: [
|
||||
// Top-level deep link route: /apps/:naddr
|
||||
// Redirect to profile branch to show with nav bar
|
||||
GoRoute(
|
||||
path: '/apps/:naddr',
|
||||
redirect: (context, state) {
|
||||
final naddr = state.pathParameters['naddr'];
|
||||
if (naddr == null || naddr.isEmpty) {
|
||||
return '/search';
|
||||
}
|
||||
return '/profile/apps/$naddr';
|
||||
},
|
||||
),
|
||||
// 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(),
|
||||
_developerRoute(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// Updates tab branch with nested detail routes
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/updates',
|
||||
builder: (context, state) => const UpdatesScreen(),
|
||||
routes: [
|
||||
_appDetailRoute(),
|
||||
_developerRoute(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// Profile tab branch with nested detail routes
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/profile',
|
||||
builder: (context, state) => const ProfileScreen(),
|
||||
routes: [
|
||||
_appDetailRoute(),
|
||||
GoRoute(
|
||||
path: 'apps/:naddr',
|
||||
builder: (context, state) {
|
||||
final naddr = state.pathParameters['naddr']!;
|
||||
return AppFromNaddrScreen(naddr: naddr);
|
||||
},
|
||||
),
|
||||
_developerRoute(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -1,356 +1,256 @@
|
||||
import 'package:auto_size_text/auto_size_text.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:easy_image_viewer/easy_image_viewer.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:zapstore/main.data.dart';
|
||||
import 'package:zapstore/models/app.dart';
|
||||
import 'package:zapstore/models/release.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:zapstore/utils/debug_utils.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/services/profile_service.dart';
|
||||
import 'package:zapstore/widgets/app_detail_widgets.dart';
|
||||
import 'package:zapstore/widgets/app_header.dart';
|
||||
import 'package:zapstore/widgets/app_info_table.dart';
|
||||
import 'package:zapstore/widgets/author_container.dart';
|
||||
import 'package:zapstore/widgets/comments_section.dart';
|
||||
import 'package:zapstore/widgets/download_text_container.dart';
|
||||
import 'package:zapstore/widgets/expandable_markdown.dart';
|
||||
import 'package:zapstore/widgets/install_button.dart';
|
||||
import 'package:zapstore/widgets/release_card.dart';
|
||||
import 'package:zapstore/widgets/signer_container.dart';
|
||||
import 'package:zapstore/widgets/users_rich_text.dart';
|
||||
import 'package:zapstore/widgets/versioned_app_header.dart';
|
||||
import 'package:zapstore/widgets/zap_button.dart';
|
||||
import 'package:zapstore/widgets/zap_receipts.dart';
|
||||
import 'package:zapstore/widgets/screenshots_gallery.dart';
|
||||
import 'package:zapstore/widgets/version_pill_widget.dart';
|
||||
|
||||
class AppDetailScreen extends HookConsumerWidget {
|
||||
final App model;
|
||||
AppDetailScreen({
|
||||
required this.model,
|
||||
super.key,
|
||||
});
|
||||
const AppDetailScreen({super.key, required this.app});
|
||||
|
||||
final App app;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scrollController = ScrollController();
|
||||
// Check if debug mode should be enabled
|
||||
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
|
||||
final showDebugSections = isDebugMode(signedInPubkey);
|
||||
|
||||
final snapshot = useFuture(useMemoized(() async {
|
||||
// Skip cache to actually hit the remote to refresh
|
||||
return await ref.apps
|
||||
.findOne(model.identifier, remote: true, params: {'skipCache': true});
|
||||
}));
|
||||
// Watch the app for real-time updates using model provider
|
||||
final appState = ref.watch(
|
||||
model<App>(
|
||||
app,
|
||||
and: (a) => {
|
||||
a.latestRelease,
|
||||
if (a.latestRelease.value != null)
|
||||
a.latestRelease.value!.latestMetadata,
|
||||
},
|
||||
source: LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||
),
|
||||
);
|
||||
|
||||
final state = ref.apps.watchOne(model.id!,
|
||||
alsoWatch: (_) => {
|
||||
_.localApp,
|
||||
_.releases,
|
||||
_.releases.artifacts,
|
||||
_.signer,
|
||||
});
|
||||
// Query author profile from social relays
|
||||
final authorAsync = ref.watch(profileProvider(app.pubkey));
|
||||
final author = authorAsync.value;
|
||||
|
||||
final app = state.model ?? model;
|
||||
// Use loaded version from state
|
||||
final currentApp = appState.models.firstOrNull ?? app;
|
||||
final latestRelease = currentApp.latestRelease.value;
|
||||
final latestMetadata = currentApp.latestFileMetadata;
|
||||
|
||||
final curatedBy = ref.appCurationSets
|
||||
.findAllLocal()
|
||||
.where((s) => s.appIds.contains(app.identifier))
|
||||
.map((s) => s.signer.value)
|
||||
.nonNulls
|
||||
.toSet();
|
||||
|
||||
// If request returned and result was null, go back
|
||||
if (snapshot.connectionState == ConnectionState.done &&
|
||||
snapshot.data == null) {
|
||||
return Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
'This app is no longer available.\nPress to go back.',
|
||||
// Show skeleton while loading essential relationships
|
||||
if (latestRelease == null || latestMetadata == null) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
RefreshIndicator(
|
||||
onRefresh: () => [app].loadMetadata(),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 80),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppDetailSkeleton(),
|
||||
),
|
||||
),
|
||||
),
|
||||
InstallButton(app: currentApp, release: latestRelease),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.apps.findOne(model.identifier, remote: true),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate(
|
||||
[
|
||||
VersionedAppHeader(app: app),
|
||||
Gap(16),
|
||||
if (app.hasCertificateMismatch)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange[900],
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Text(
|
||||
'${app.name} ${app.latestMetadata!.version} has a different Android certificate than version ${app.localApp.value!.installedVersion} installed on your device.\n\nHave you used another app store to install ${app.name}? If so, you can choose to remove it and re-install coming back to this screen.\n\nIt otherwise could be a malicious update, contact the developer for details.\n\nNew certificate hash: ${app.latestMetadata!.apkSignatureHash}',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (app.images.isNotEmpty)
|
||||
Scrollbar(
|
||||
controller: scrollController,
|
||||
interactive: true,
|
||||
trackVisibility: true,
|
||||
child: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SizedBox(
|
||||
height: 320,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final i in app.images)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
final imageProvider =
|
||||
CachedNetworkImageProvider(i,
|
||||
scale: 0.6);
|
||||
showImageViewer(
|
||||
context,
|
||||
imageProvider,
|
||||
doubleTapZoomable: true,
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(right: 12),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: i,
|
||||
errorWidget: (_, __, ___) =>
|
||||
Container(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Divider(height: 24),
|
||||
if (curatedBy.isNotEmpty)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[900],
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: UsersRichText(
|
||||
trailingText: ' picked this app',
|
||||
users: curatedBy.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (curatedBy.isNotEmpty) Gap(20),
|
||||
MarkdownBody(
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
h1: TextStyle(fontWeight: FontWeight.bold),
|
||||
h2: TextStyle(fontWeight: FontWeight.bold),
|
||||
p: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
selectable: false,
|
||||
data: app.event.content.parseEmojis(),
|
||||
),
|
||||
Gap(10),
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Main scrollable content
|
||||
RefreshIndicator(
|
||||
onRefresh: () => [app].loadMetadata(),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 16,
|
||||
bottom: 80,
|
||||
), // Horizontal padding applied per-section to allow screenshots to be full-bleed
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// App header with icon, name, version, and author (always show app info)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppHeader(app: currentApp),
|
||||
),
|
||||
|
||||
// Published by / Released at section
|
||||
if (app.isRelaySigned)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 14),
|
||||
child: SignerContainer(app: app),
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 8,
|
||||
),
|
||||
child: DownloadTextContainer(
|
||||
url: latestMetadata.urls.first,
|
||||
size: 14,
|
||||
),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 8,
|
||||
),
|
||||
child: author != null
|
||||
? AuthorContainer(
|
||||
profile: author,
|
||||
beforeText: 'Published by',
|
||||
oneLine: true,
|
||||
size: 14,
|
||||
app: app,
|
||||
onTap: () {
|
||||
final segments = GoRouterState.of(
|
||||
context,
|
||||
).uri.pathSegments;
|
||||
final first = segments.isNotEmpty
|
||||
? segments.first
|
||||
: 'search';
|
||||
context.push(
|
||||
'/$first/developer/${author.pubkey}',
|
||||
);
|
||||
},
|
||||
)
|
||||
: const AuthorSkeleton(),
|
||||
),
|
||||
if (app.latestMetadata != null &&
|
||||
app.signer.isPresent &&
|
||||
app.isSelfSigned)
|
||||
Column(
|
||||
|
||||
// Screenshots gallery
|
||||
if (currentApp.images.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: ScreenshotsGallery(app: currentApp),
|
||||
),
|
||||
|
||||
// App description (always available from app)
|
||||
if (app.description.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 8,
|
||||
),
|
||||
child: ExpandableMarkdown(
|
||||
data: app.description,
|
||||
styleSheet:
|
||||
MarkdownStyleSheet.fromTheme(
|
||||
Theme.of(context),
|
||||
).copyWith(
|
||||
p: context.textTheme.bodyLarge?.copyWith(
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Social action buttons (Zap + Share + Save)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
SocialActionsRow(app: currentApp, author: author),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (!currentApp.isInstalled || currentApp.hasUpdate)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Gap(10),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ZapButton(app: app)),
|
||||
Gap(10),
|
||||
ZapReceipts(app: app),
|
||||
],
|
||||
),
|
||||
Gap(20),
|
||||
Container(
|
||||
padding: EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[900],
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Source'),
|
||||
Gap(10),
|
||||
Flexible(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (app.repository != null) {
|
||||
launchUrl(Uri.parse(app.repository!));
|
||||
}
|
||||
},
|
||||
child: app.repository != null
|
||||
? AutoSizeText(
|
||||
app.repository!,
|
||||
minFontSize: 12,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: Text('Source code not available',
|
||||
style: TextStyle(
|
||||
color: Colors.red[300],
|
||||
fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Latest release title
|
||||
Text(
|
||||
'Latest release',
|
||||
style: context.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Latest version info
|
||||
Row(
|
||||
children: [
|
||||
VersionPillWidget(
|
||||
app: currentApp,
|
||||
forceVersion: latestMetadata.version,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
formatDate(latestMetadata.createdAt),
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('License'),
|
||||
Text((app.license == null ||
|
||||
app.license == 'NOASSERTION')
|
||||
? 'Unknown'
|
||||
: app.license!)
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('App ID'),
|
||||
Gap(10),
|
||||
Flexible(
|
||||
child: AutoSizeText(
|
||||
app.identifier,
|
||||
minFontSize: 12,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
if (app.latestMetadata != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('APK package SHA-256'),
|
||||
Flexible(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(
|
||||
text: app.latestMetadata!.hash!));
|
||||
context.showInfo(
|
||||
'Copied APK package SHA-256 to the clipboard');
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
app.latestMetadata!.hash!.shorten,
|
||||
maxLines: 1,
|
||||
),
|
||||
Gap(6),
|
||||
Icon(Icons.copy_rounded, size: 18)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (app.latestMetadata?.apkSignatureHash != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('APK certificate SHA-256'),
|
||||
Flexible(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(
|
||||
text: app.latestMetadata!
|
||||
.apkSignatureHash!));
|
||||
context.showInfo(
|
||||
'Copied APK certificate SHA-256 to the clipboard');
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
app.latestMetadata!
|
||||
.apkSignatureHash!.shorten,
|
||||
maxLines: 1,
|
||||
),
|
||||
Gap(6),
|
||||
Icon(Icons.copy_rounded, size: 18)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: ReleaseNotes(release: latestRelease),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(height: 60),
|
||||
Text(
|
||||
'Latest release'.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
letterSpacing: 3,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppInfoTable(
|
||||
app: currentApp,
|
||||
fileMetadata: latestMetadata,
|
||||
),
|
||||
Gap(10),
|
||||
if (app.releases.isEmpty) Text('No available releases'),
|
||||
if (app.releases.isNotEmpty)
|
||||
ReleaseCard(
|
||||
release:
|
||||
app.releases.toList().sortedByLatest.first),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
CommentsSection(fileMetadata: latestMetadata),
|
||||
|
||||
// Debug section - only visible for specific pubkey
|
||||
if (showDebugSections)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: DebugVersionsSection(app: currentApp),
|
||||
),
|
||||
|
||||
const SizedBox(height: 100), // Space for install button
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: Center(
|
||||
child: InstallButton(app: app),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Sticky install/uninstall row (show even if release is loading)
|
||||
InstallButton(app: currentApp, release: latestRelease),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/widgets/app_detail_widgets.dart';
|
||||
|
||||
import 'package:zapstore/screens/app_detail_screen.dart';
|
||||
|
||||
class AppFromNaddrScreen extends ConsumerWidget {
|
||||
final String naddr;
|
||||
|
||||
const AppFromNaddrScreen({super.key, required this.naddr});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Decode naddr
|
||||
AddressData? address;
|
||||
try {
|
||||
final decoded = Utils.decodeShareableIdentifier(naddr);
|
||||
if (decoded is AddressData) {
|
||||
address = decoded;
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore - handled below
|
||||
}
|
||||
|
||||
if (address == null || address.identifier.isEmpty) {
|
||||
return _ErrorScaffold(message: 'Invalid link. Not a valid naddr.');
|
||||
}
|
||||
|
||||
// Query the App by its identifier (d-tag)
|
||||
final appsState = ref.watch(
|
||||
query<App>(
|
||||
tags: {
|
||||
'#d': {address.identifier},
|
||||
},
|
||||
limit: 1,
|
||||
source: LocalAndRemoteSource(stream: false, background: true),
|
||||
subscriptionPrefix: 'naddr-app',
|
||||
),
|
||||
);
|
||||
|
||||
final app = appsState.models.firstOrNull;
|
||||
|
||||
if (app != null) {
|
||||
return AppDetailScreen(app: app);
|
||||
}
|
||||
|
||||
// Handle error state
|
||||
switch (appsState) {
|
||||
case StorageError(:final exception):
|
||||
return _ErrorScaffold(message: exception.toString());
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Loading state
|
||||
return const Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(padding: EdgeInsets.all(16), child: AppDetailSkeleton()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorScaffold extends StatelessWidget {
|
||||
final String message;
|
||||
const _ErrorScaffold({required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Open App')),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 64),
|
||||
const SizedBox(height: 12),
|
||||
Text(message, textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,87 +1,493 @@
|
||||
import 'package:auto_size_text/auto_size_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:zapstore/main.data.dart';
|
||||
import 'package:zapstore/models/user.dart';
|
||||
import 'package:zapstore/widgets/app_card.dart';
|
||||
import 'package:zapstore/widgets/relevant_who_follow_container.dart';
|
||||
import 'package:zapstore/widgets/rounded_image.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import '../theme.dart';
|
||||
|
||||
import '../widgets/common/note_parser.dart';
|
||||
import '../widgets/app_card.dart';
|
||||
import '../widgets/profile_header.dart';
|
||||
import '../widgets/zap_widgets.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../services/profile_service.dart';
|
||||
|
||||
/// Developer profile and apps screen
|
||||
class DeveloperScreen extends HookConsumerWidget {
|
||||
final User model;
|
||||
const DeveloperScreen({super.key, required this.pubkey});
|
||||
|
||||
DeveloperScreen({
|
||||
required this.model,
|
||||
super.key,
|
||||
});
|
||||
final String pubkey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
useFuture(useMemoized(() async {
|
||||
if (model.pubkey != kZapstorePubkey) {
|
||||
await ref.apps.findAll(
|
||||
params: {
|
||||
'authors': [model.pubkey],
|
||||
'ignoreReturn': false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}));
|
||||
final scrollController = useScrollController();
|
||||
|
||||
final state = ref.users.watchOne(model.id!, alsoWatch: (_) => {_.apps});
|
||||
// Load developer profile
|
||||
final profileAsync = ref.watch(profileProvider(pubkey));
|
||||
final profile = profileAsync.value;
|
||||
|
||||
return SingleChildScrollView(
|
||||
physics: AlwaysScrollableScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
RoundedImage(url: model.avatarUrl, size: 80),
|
||||
Gap(10),
|
||||
Expanded(
|
||||
child: AutoSizeText(
|
||||
model.nameOrNpub,
|
||||
style: TextStyle(fontSize: 34, fontWeight: FontWeight.bold),
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Developer header - always show, with skeleton if loading
|
||||
_DeveloperHeader(
|
||||
profile: profile,
|
||||
isLoading: profileAsync.isLoading,
|
||||
hasError: profileAsync.hasError,
|
||||
),
|
||||
|
||||
// Zaps list for all developer's apps
|
||||
_DeveloperZapsList(pubkey: pubkey),
|
||||
|
||||
// Tabs for apps and info
|
||||
Expanded(
|
||||
child: DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
children: [
|
||||
const TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'Apps'),
|
||||
Tab(text: 'About'),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
_DeveloperAppsTab(
|
||||
pubkey: pubkey,
|
||||
scrollController: scrollController,
|
||||
),
|
||||
_DeveloperAboutTab(
|
||||
profile: profile,
|
||||
isLoading: profileAsync.isLoading,
|
||||
hasError: profileAsync.hasError,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Gap(20),
|
||||
// if (model.apps.first.latestMetadata != null)
|
||||
// ZapReceipts(developer: model),
|
||||
RelevantWhoFollowContainer(
|
||||
toNpub: model.npub,
|
||||
loadingText: 'Loading connections...',
|
||||
),
|
||||
Gap(10),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final url = Uri.parse('https://npub.world/${model.npub}');
|
||||
launchUrl(url);
|
||||
},
|
||||
child: Text(
|
||||
'View ${model.nameOrNpub} on nostr',
|
||||
),
|
||||
),
|
||||
Gap(20),
|
||||
Text('Signed apps',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
||||
Gap(10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (state.hasModel)
|
||||
for (final app in model.apps.where((a) => a.isSelfSigned))
|
||||
AppCard(model: app, showUpdate: true),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeveloperHeader extends HookConsumerWidget {
|
||||
const _DeveloperHeader({
|
||||
required this.profile,
|
||||
this.isLoading = false,
|
||||
this.hasError = false,
|
||||
});
|
||||
|
||||
final Profile? profile;
|
||||
final bool isLoading;
|
||||
final bool hasError;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Show skeleton when loading or error
|
||||
if (isLoading || hasError || profile == null) {
|
||||
return ProfileHeader(isLoading: true, radius: 40);
|
||||
}
|
||||
|
||||
return ProfileHeader(profile: profile, radius: 40);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeveloperAppsTab extends HookConsumerWidget {
|
||||
const _DeveloperAppsTab({
|
||||
required this.pubkey,
|
||||
required this.scrollController,
|
||||
});
|
||||
|
||||
final String pubkey;
|
||||
final ScrollController scrollController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Query apps by this specific developer
|
||||
final appsState = ref.watch(
|
||||
query<App>(
|
||||
authors: {pubkey}, // Filter by developer pubkey
|
||||
limit: 100,
|
||||
tags: {
|
||||
'#f': {'android-arm64-v8a'},
|
||||
},
|
||||
and: (app) => {app.latestRelease},
|
||||
subscriptionPrefix: 'developer-apps',
|
||||
),
|
||||
);
|
||||
|
||||
// Query author profile from 'social' relay group
|
||||
final authorAsync = ref.watch(profileProvider(pubkey));
|
||||
final author = authorAsync.value;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Apps List
|
||||
Expanded(
|
||||
child: switch (appsState) {
|
||||
StorageLoading() => ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: 5,
|
||||
itemBuilder: (context, index) => AppCard(isLoading: true),
|
||||
),
|
||||
StorageError() => _buildErrorState(
|
||||
context,
|
||||
appsState.toString(),
|
||||
),
|
||||
StorageData(:final models) when models.isEmpty => _buildEmptyState(context),
|
||||
StorageData(:final models) => _buildAppsList(
|
||||
context,
|
||||
models,
|
||||
author,
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(BuildContext context, String error) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 48, color: Colors.red[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text('Error loading apps', style: context.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error,
|
||||
style: context.textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.apps_outlined, size: 48, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text('No apps published', style: context.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'This developer hasn\'t published any apps yet',
|
||||
style: context.textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppsList(
|
||||
BuildContext context,
|
||||
List<App> apps,
|
||||
Profile? author,
|
||||
) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: apps.length,
|
||||
itemBuilder: (context, index) {
|
||||
final app = apps[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: AppCard(app: app, author: author, showSignedBy: false),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeveloperZapsList extends HookConsumerWidget {
|
||||
const _DeveloperZapsList({required this.pubkey});
|
||||
|
||||
final String pubkey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Query all apps by this developer with their latest metadata
|
||||
final appsState = ref.watch(
|
||||
query<App>(
|
||||
authors: {pubkey},
|
||||
tags: {
|
||||
'#f': {'android-arm64-v8a'},
|
||||
},
|
||||
limit: 100,
|
||||
and: (app) => {app.latestRelease},
|
||||
source: LocalAndRemoteSource(stream: false, background: true),
|
||||
subscriptionPrefix: 'developer-all-apps',
|
||||
),
|
||||
);
|
||||
|
||||
// Get all apps
|
||||
final apps = appsState is StorageData ? appsState.models : <App>[];
|
||||
|
||||
if (apps.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Create combined tag maps for:
|
||||
// 1. App addressable IDs (for zaps on apps)
|
||||
// 2. FileMetadata event IDs (for zaps on metadata)
|
||||
final allAppTags = <String, Set<String>>{};
|
||||
final metadataIds = <String>{};
|
||||
|
||||
for (final app in apps) {
|
||||
// Add app addressable ID tags
|
||||
final appTags = app.event.addressableIdTagMap;
|
||||
for (final entry in appTags.entries) {
|
||||
allAppTags[entry.key] = {...?allAppTags[entry.key], ...entry.value};
|
||||
}
|
||||
|
||||
// Add metadata event IDs
|
||||
final metadata = app.latestFileMetadata;
|
||||
if (metadata != null) {
|
||||
metadataIds.add(metadata.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Query zaps on app addressable IDs
|
||||
final zapsOnAppsState = ref.watch(
|
||||
query<Zap>(
|
||||
tags: allAppTags,
|
||||
source: LocalAndRemoteSource(relays: 'social'),
|
||||
and: (zap) => {zap.author, zap.zapRequest},
|
||||
andSource: LocalAndRemoteSource(relays: 'social', stream: false),
|
||||
subscriptionPrefix: 'developer-zaps-apps',
|
||||
),
|
||||
);
|
||||
|
||||
// Query zaps on file metadata
|
||||
final zapsOnMetadataState = metadataIds.isNotEmpty
|
||||
? ref.watch(
|
||||
query<Zap>(
|
||||
tags: {'#e': metadataIds},
|
||||
source: LocalAndRemoteSource(relays: 'social'),
|
||||
and: (zap) => {zap.author, zap.zapRequest},
|
||||
andSource: LocalAndRemoteSource(relays: 'social', stream: false),
|
||||
subscriptionPrefix: 'developer-zaps-metadata',
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
// Combine all zaps
|
||||
final allZaps = <Zap>{
|
||||
if (zapsOnAppsState is StorageData) ...zapsOnAppsState.models,
|
||||
if (zapsOnMetadataState != null && zapsOnMetadataState is StorageData)
|
||||
...zapsOnMetadataState.models,
|
||||
};
|
||||
|
||||
if (allZaps.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: ZappersHorizontalList(zaps: allZaps.toList()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeveloperAboutTab extends StatelessWidget {
|
||||
const _DeveloperAboutTab({
|
||||
required this.profile,
|
||||
this.isLoading = false,
|
||||
this.hasError = false,
|
||||
});
|
||||
|
||||
final Profile? profile;
|
||||
final bool isLoading;
|
||||
final bool hasError;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Show skeleton when loading or profile is null
|
||||
if (isLoading || hasError || profile == null) {
|
||||
return SkeletonizerConfig(
|
||||
data: AppColors.getSkeletonizerConfig(Theme.of(context).brightness),
|
||||
child: Skeletonizer(
|
||||
enabled: true,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Bio section skeleton
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 20,
|
||||
width: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.darkSkeletonBase,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
height: 16,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.darkSkeletonBase,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Contact section skeleton
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 20,
|
||||
width: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.darkSkeletonBase,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const ListTile(
|
||||
leading: Icon(Icons.verified),
|
||||
title: Text('Loading...'),
|
||||
subtitle: Text('---'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Bio section
|
||||
if (profile!.about != null && profile!.about!.isNotEmpty) ...[
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: NoteParser.parse(
|
||||
context,
|
||||
profile!.about!,
|
||||
textStyle: context.textTheme.bodyMedium,
|
||||
onNostrEntity: (entity) => NostrEntityWidget(
|
||||
entity: entity,
|
||||
colorPair: [
|
||||
Theme.of(context).colorScheme.primary,
|
||||
Theme.of(context).colorScheme.secondary,
|
||||
],
|
||||
onProfileTap: (pubkey) {
|
||||
final segments = GoRouterState.of(context).uri.pathSegments;
|
||||
final first = segments.isNotEmpty
|
||||
? segments.first
|
||||
: 'search';
|
||||
context.push('/$first/developer/$pubkey');
|
||||
},
|
||||
onHashtagTap: (hashtag) => context.push('/search?q=$hashtag'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Contact info
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// npub
|
||||
ListTile(
|
||||
leading: const Icon(Icons.share),
|
||||
title: const Text('npub'),
|
||||
subtitle: Text(
|
||||
'${Utils.encodeShareableFromString(profile!.pubkey, type: 'npub').substring(0, 16)}...',
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: () {
|
||||
final npub = Utils.encodeShareableFromString(
|
||||
profile!.pubkey,
|
||||
type: 'npub',
|
||||
);
|
||||
Clipboard.setData(ClipboardData(text: npub));
|
||||
context.showInfo('npub copied to clipboard');
|
||||
},
|
||||
),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
|
||||
// NIP-05 identifier
|
||||
if (profile!.nip05 != null) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.verified),
|
||||
title: const Text('NIP-05 Verified'),
|
||||
subtitle: Text(profile!.nip05!),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: profile!.nip05!));
|
||||
context.showInfo('NIP-05 copied to clipboard');
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
// Lightning address
|
||||
if (profile!.lud16?.trim().isNotEmpty ?? false) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.bolt),
|
||||
title: const Text('Lightning Address'),
|
||||
subtitle: Text(profile!.lud16!),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: profile!.lud16!));
|
||||
context.showInfo('Lightning address copied to clipboard');
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||