This commit is contained in:
franzap
2025-12-11 17:49:55 -03:00
parent 3931bc0aa4
commit dce9ad6fca
210 changed files with 19168 additions and 7131 deletions
+1
View File
@@ -0,0 +1 @@
../tools/content/agent.json
+1
View File
@@ -0,0 +1 @@
tools/content/CONTEXT.md
-2
View File
@@ -49,6 +49,4 @@ app.*.map.json
/flutter_data
.fvm/
android/app/.cxx/
.env
+15 -15
View File
@@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited.
version:
revision: "bae5e49bc2a867403c43b2aae2de8f8c33b037e4"
revision: "fcf2c11572af6f390246c056bc905eca609533a0"
channel: "stable"
project_type: app
@@ -13,26 +13,26 @@ project_type: app
migration:
platforms:
- platform: root
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
- platform: android
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
- platform: ios
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
- platform: linux
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
- platform: macos
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
- platform: web
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
- platform: windows
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
# User provided section
+1
View File
@@ -0,0 +1 @@
../.cursor/mcp.json
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2024 Zapstore
Copyright (c) 2025 Zapstore
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+4 -2
View File
@@ -1,6 +1,8 @@
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](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
+1 -9
View File
@@ -9,8 +9,7 @@
# packages, and plugins designed to encourage good coding practices.
analyzer:
errors:
no_leading_underscores_for_local_identifiers: ignore
must_be_immutable: ignore
dangling_library_doc_comments: ignore
include: package:flutter_lints/flutter.yaml
linter:
@@ -27,12 +26,5 @@ linter:
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
prefer_const_constructors: false
prefer_const_constructors_in_immutables: false
avoid_print: false
prefer_const_literals_to_create_immutables: false
no_wildcard_variable_uses: false
omit_local_variable_types: true
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+2 -1
View File
@@ -5,9 +5,10 @@ gradle-wrapper.jar
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
-81
View File
@@ -1,81 +0,0 @@
plugins {
id "com.android.application"
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
namespace "dev.zapstore.app"
compileSdk flutter.compileSdkVersion
ndkVersion flutter.ndkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
applicationId "dev.zapstore.app"
minSdkVersion 21
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.debug
signingConfig signingConfigs.release
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
flutter {
source '../..'
}
dependencies {}
+96
View File
@@ -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 = "../.."
}
+36 -6
View File
@@ -1,6 +1,36 @@
-dontwarn com.google.errorprone.annotations.CanIgnoreReturnValue
-dontwarn com.google.errorprone.annotations.CheckReturnValue
-dontwarn com.google.errorprone.annotations.Immutable
-dontwarn com.google.errorprone.annotations.RestrictedApi
-dontwarn javax.annotation.Nullable
-dontwarn javax.annotation.concurrent.GuardedBy
# Flutter wrapper
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.** { *; }
-keep class io.flutter.util.** { *; }
-keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
# Keep native methods
-keepclasseswithmembernames class * {
native <methods>;
}
# Keep Kotlin metadata
-keep class kotlin.Metadata { *; }
-dontwarn kotlin.**
# Keep SQLite3 native libraries
-keep class org.sqlite.** { *; }
# Keep package manager plugin classes
-keep class dev.zapstore.alpha.plugins.** { *; }
# Ignore missing Google Play Core classes (optional dependency for dynamic features)
-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication
-dontwarn com.google.android.play.core.splitinstall.SplitInstallException
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManager
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManagerFactory
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest$Builder
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest
-dontwarn com.google.android.play.core.splitinstall.SplitInstallSessionState
-dontwarn com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener
-dontwarn com.google.android.play.core.tasks.OnFailureListener
-dontwarn com.google.android.play.core.tasks.OnSuccessListener
-dontwarn com.google.android.play.core.tasks.Task
+31 -40
View File
@@ -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>
Binary file not shown.

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

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 B

@@ -3,10 +3,10 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<!-- Zapstore logo for splash screen -->
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
android:src="@drawable/logo" />
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 659 B

@@ -3,10 +3,10 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<!-- Zapstore logo for splash screen -->
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
android:src="@drawable/logo" />
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

@@ -1,6 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
<monochrome>
<inset
android:drawable="@drawable/ic_launcher_monochrome"
android:inset="16%" />
</monochrome>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="external_files" path="."/>
</paths>
-55
View File
@@ -1,55 +0,0 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
subprojects {
afterEvaluate { project ->
if (project.plugins.hasPlugin("com.android.application") ||
project.plugins.hasPlugin("com.android.library")) {
project.android {
compileSdkVersion 34
buildToolsVersion "34.0.0"
}
}
}
}
// Used for plugins that do not specify their own namespace
// https://discuss.gradle.org/t/flutter-not-able-to-migrate-to-gradle-8/46937/7
subprojects {
afterEvaluate { project ->
if (project.hasProperty('android')) {
project.android {
if (namespace == null) {
namespace project.group
}
}
}
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(':app')
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}
tasks.withType(JavaCompile) {
options.compilerArgs << "-Xlint:-options"
}
ext {
compileSdkVersion = 34
targetSdkVersion = 34
appCompatVersion = "1.7.0"
}
+21
View File
@@ -0,0 +1,21 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+1 -2
View File
@@ -1,4 +1,3 @@
org.gradle.jvmargs=-Xmx4G
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
kotlin.jvm.target.validation.mode = IGNORE
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
-26
View File
@@ -1,26 +0,0 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}
settings.ext.flutterSdkPath = flutterSdkPath()
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.3.2" apply false
id "org.jetbrains.kotlin.android" version "2.0.21" apply false
}
include ":app"
+25
View File
@@ -0,0 +1,25 @@
pluginManagement {
val flutterSdkPath = run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
include(":app")
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 726 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 688 KiB

View File
+22
View File
@@ -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';
+264 -63
View File
@@ -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();
}
}
}
-59
View File
@@ -1,59 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: directives_ordering, top_level_function_literal_block, depend_on_referenced_packages
import 'package:flutter_data/flutter_data.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:zapstore/models/app_curation_set.dart';
import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/file_metadata.dart';
import 'package:zapstore/models/local_app.dart';
import 'package:zapstore/models/release.dart';
import 'package:zapstore/models/settings.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/models/zap_receipt.dart';
final adapterProvidersMap = <String, Provider<Adapter<DataModelMixin>>>{
'appCurationSets': appCurationSetsAdapterProvider,
'apps': appsAdapterProvider,
'fileMetadata': fileMetadataAdapterProvider,
'localApps': localAppsAdapterProvider,
'releases': releasesAdapterProvider,
'settings': settingsAdapterProvider,
'users': usersAdapterProvider,
'zapReceipts': zapReceiptsAdapterProvider
};
extension AdapterWidgetRefX on WidgetRef {
Adapter<AppCurationSet> get appCurationSets =>
watch(appCurationSetsAdapterProvider)..internalWatch = watch;
Adapter<App> get apps => watch(appsAdapterProvider)..internalWatch = watch;
Adapter<FileMetadata> get fileMetadata =>
watch(fileMetadataAdapterProvider)..internalWatch = watch;
Adapter<LocalApp> get localApps =>
watch(localAppsAdapterProvider)..internalWatch = watch;
Adapter<Release> get releases =>
watch(releasesAdapterProvider)..internalWatch = watch;
Adapter<Settings> get settings =>
watch(settingsAdapterProvider)..internalWatch = watch;
Adapter<User> get users => watch(usersAdapterProvider)..internalWatch = watch;
Adapter<ZapReceipt> get zapReceipts =>
watch(zapReceiptsAdapterProvider)..internalWatch = watch;
}
extension AdapterRefX on Ref {
Adapter<AppCurationSet> get appCurationSets =>
watch(appCurationSetsAdapterProvider)..internalWatch = watch;
Adapter<App> get apps => watch(appsAdapterProvider)..internalWatch = watch;
Adapter<FileMetadata> get fileMetadata =>
watch(fileMetadataAdapterProvider)..internalWatch = watch;
Adapter<LocalApp> get localApps =>
watch(localAppsAdapterProvider)..internalWatch = watch;
Adapter<Release> get releases =>
watch(releasesAdapterProvider)..internalWatch = watch;
Adapter<Settings> get settings =>
watch(settingsAdapterProvider)..internalWatch = watch;
Adapter<User> get users => watch(usersAdapterProvider)..internalWatch = watch;
Adapter<ZapReceipt> get zapReceipts =>
watch(zapReceiptsAdapterProvider)..internalWatch = watch;
}
-462
View File
@@ -1,462 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'dart:isolate';
import 'package:async/async.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart';
import 'package:convert/convert.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter_data/flutter_data.dart';
import 'package:install_plugin/install_plugin.dart';
import 'package:mutex/mutex.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:purplebase/purplebase.dart' as base;
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/file_metadata.dart';
import 'package:zapstore/models/local_app.dart';
import 'package:zapstore/models/nostr_adapter.dart';
import 'package:zapstore/models/release.dart';
import 'package:zapstore/models/user.dart';
import 'package:path/path.dart' as path;
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/system_info.dart';
part 'app.g.dart';
final mutex = Mutex();
@DataAdapter([NostrAdapter, AppAdapter])
class App extends base.App with DataModelMixin<App> {
@override
Object? get id => event.id;
final HasMany<Release> releases;
final BelongsTo<User> signer;
final BelongsTo<LocalApp> localApp;
App.fromJson(super.map)
: releases = hasMany(map['releases']),
signer = belongsTo(map['signer']),
localApp = belongsTo(map['localApp']),
super.fromJson();
Map<String, dynamic> toJson() => super.toMap();
Release? get latestRelease {
return releases.toList().sortedByLatest.firstOrNull;
}
FileMetadata? get latestMetadata {
return latestRelease?.artifacts
// All artifacts *should* be APKs for the
// current architecture, but double check
.where((a) =>
a.mimeType == kAndroidMimeType &&
a.platforms.contains('android-arm64-v8a'))
.firstOrNull;
}
bool get canInstall =>
localApp.value?.status == null &&
latestMetadata != null &&
signer.isPresent;
bool get canUpdate =>
localApp.value?.status == AppInstallStatus.updatable &&
latestMetadata != null &&
signer.isPresent;
bool get isInstalled => localApp.value?.status != null;
bool get isUpdated => localApp.value?.status == AppInstallStatus.updated;
bool get isDowngrade => localApp.value?.status == AppInstallStatus.downgrade;
bool get hasCertificateMismatch =>
localApp.value?.status == AppInstallStatus.certificateMismatch;
bool get isSelfSigned => !(signer.value!.pubkey == kZapstorePubkey &&
identifier != kZapstoreAppIdentifier);
Future<void> install({bool alwaysTrustSigner = false}) async {
if (!canInstall && !canUpdate || hasCertificateMismatch) {
throw Exception(
'Local status: ${localApp.value?.status}, signer: ${signer.value?.npub}, certificate mismatch: $hasCertificateMismatch');
}
final adapter = DataModel.adapterFor(this) as AppAdapter;
final notifier =
adapter.ref.read(installationProgressProvider(id!).notifier);
// Persist trusted preference
if (alwaysTrustSigner) {
final settings = adapter.ref.settings.findOneLocalById('_')!;
settings.trustedUsers.add(signer.value!);
settings.saveLocal();
}
final installPermission = await Permission.requestInstallPackages.status;
if (!installPermission.isGranted) {
final newStatus = await Permission.requestInstallPackages.request();
if (newStatus.isDenied) {
notifier.state = ErrorInstallProgress(Exception('Error'),
info: 'Installation permission denied by user');
return;
}
}
final hash = latestMetadata!.hash!;
final dir = await getApplicationSupportDirectory();
final file = File(path.join(dir.path, hash));
installOnDevice() async {
notifier.state = VerifyingHashProgress();
if (await _isHashMismatch(file.path, hash)) {
const e = 'Hash mismatch';
await file.delete();
notifier.state = ErrorInstallProgress(Exception(e),
info: 'Possibly a malicious file. Aborting installation.');
return;
}
notifier.state = RequestInstallProgress();
// NOTE: Using https://github.com/hui-z/flutter_install_plugin
// Tried https://github.com/zapstore/android_package_installer
// but it is giving users problems (and for me it's slower)
// Probably need to fork hui-z's with support for user-canceled action
// NOTE: Must use mutex: https://github.com/hui-z/flutter_install_plugin/issues/68
final result = await mutex.protect(() async {
return InstallPlugin.install(file.path);
});
if (result['isSuccess']) {
notifier.state = IdleInstallProgress(success: true);
await file.delete();
await adapter.ref.localApps.localAppAdapter
.refreshUpdateStatus(appId: identifier);
Future.microtask(() {
notifier.state = IdleInstallProgress();
});
} else {
const msg = 'App not installed';
notifier.state = ErrorInstallProgress(
Exception(msg),
info: 'User canceled or error occured: ${result['errorMessage']}',
);
}
}
final fileExists = await file.exists();
if (fileExists) {
await installOnDevice();
} else {
if (fileExists) {
// If file exists download was probably partial, remove
await file.delete();
}
final url = latestMetadata!.urls.first;
final (baseDirectory, directory, filename) =
await Task.split(filePath: file.path);
final task = DownloadTask(
url: url,
baseDirectory: baseDirectory,
directory: directory,
filename: filename,
updates: Updates.progress,
);
final result = await FileDownloader().download(
task,
onProgress: (progress) {
notifier.state = DownloadingInstallProgress(progress);
},
);
switch (result.status) {
case TaskStatus.complete:
return await installOnDevice();
default:
notifier.state = ErrorInstallProgress(
Exception('App did not fully download.'),
info: 'Please try again.');
await file.delete();
}
}
}
}
mixin AppAdapter on Adapter<App> {
final queriedAtMap = <String, DateTime>{};
@override
Future<List<App>> findAll(
{bool? remote = true,
bool? background,
Map<String, dynamic>? params = const {},
Map<String, String>? headers,
bool? syncLocal,
OnSuccessAll<App>? onSuccess,
OnErrorAll<App>? onError,
DataRequestLabel? label}) async {
params ??= {};
if (params.remove('includes') ?? false) {
return await fetchAppModels(params);
}
return super.findAll(params: params);
}
Future<void> checkForUpdates() async {
final appIds = await _installedIdentifiers();
if (appIds.isNotEmpty) {
await fetchAppModels({'#d': appIds});
// Once apps are loaded, check for installed status
await ref.localApps.localAppAdapter.refreshUpdateStatus();
}
}
Future<List<App>> fetchAppModels(Map<String, dynamic> params) async {
late final List<App> apps;
late final List<Release> releases;
// If looking up multiple apps via `#d`, use `queriedAtMap`
// to find the earliest queried at Date, to be used in the
// next request `since` field
if (params.containsKey('#d') && !(params['skipCache'] ?? false)) {
DateTime? earliestQueryAt;
final identifiers = <String>{...params['#d']};
final cachedIdentifiers =
identifiers.where((i) => queriedAtMap.containsKey(i));
final newIdentifiers =
identifiers.where((i) => !queriedAtMap.containsKey(i));
var cachedApps = <App>[];
var newApps = <App>[];
// For apps already in cache, find earliest date of the set
// (as we cannot have one `since` per app)
if (cachedIdentifiers.isNotEmpty) {
earliestQueryAt = cachedIdentifiers
.fold<DateTime>(queriedAtMap[cachedIdentifiers.first]!, (acc, e) {
return acc.isBefore(queriedAtMap[e]!) ? acc : queriedAtMap[e]!;
});
cachedApps = await super.findAll(
params: {
'#d': cachedIdentifiers,
// Query since latest + 1
'since': earliestQueryAt.add(Duration(seconds: 1)),
},
);
}
// For apps not in cache, query without a since
if (newIdentifiers.isNotEmpty) {
newApps = await super.findAll(
params: {
'#d': newIdentifiers,
},
);
}
apps = [...cachedApps, ...newApps];
// Find most recent `created_at` from returned apps
// and assign it to their queried at value for caching
final m = apps.isNotEmpty
? apps.fold(apps.first.event.createdAt, (acc, e) {
return acc.isAfter(e.event.createdAt) ? acc : e.event.createdAt;
})
: null;
if (m != null) {
for (final app in apps) {
queriedAtMap[app.identifier] = m;
}
}
} else {
// Search (which uses no #d)
apps = await super.findAll(params: params);
}
final appsOldFormat =
apps.where((app) => app.event.linkedReplaceableEvents.isNotEmpty);
final appsNewFormat =
apps.where((app) => app.event.linkedReplaceableEvents.isEmpty);
if (appsOldFormat.isNotEmpty) {
// OLD FORMAT BRANCH
// Find all appid@version ($3) as we need to pick one tag to query on
// (filters by kind ($1) and pubkey ($2) done locally)
final latestReleaseIdentifiers = appsOldFormat
.map((app) => app.event.linkedReplaceableEvents.first.$3);
if (latestReleaseIdentifiers.isNotEmpty) {
releases = await ref.releases.findAll(
params: {'#d': latestReleaseIdentifiers},
);
} else {
releases = [];
}
} else {
// NEW FORMAT BRANCH
final futures = appsNewFormat
.map((app) => ref.releases.findAll(
params: {
'#i': [app.identifier],
'authors': [app.event.pubkey],
'limit': 1
},
))
.toSet();
final lists = await Future.wait(futures);
releases = lists.expand((a) => a).toList();
}
await nostrAdapter.loadArtifactsAndUsers(releases);
await ref.localApps.localAppAdapter.refreshUpdateStatus();
return apps;
}
@override
Future<App?> findOne(Object id,
{bool remote = true,
bool background = false,
Map<String, dynamic>? params = const {},
Map<String, String>? headers,
OnSuccessOne<App>? onSuccess,
OnErrorOne<App>? onError,
DataRequestLabel? label}) async {
final apps = await fetchAppModels({
...params ?? {},
'#d': [id]
});
// If ID not found in relay then clear from local storage
if (apps.isEmpty) {
deleteLocalById(id);
return null;
}
await ref.localApps.localAppAdapter
.refreshUpdateStatus(appId: id.toString());
return apps.first;
}
List<App> findWhereIdentifierInLocal(Iterable<String> appIds) {
const len = 5 + 64 + 3; // kind + pubkey + separators length
final result = db.select(
'SELECT key, data, substr(json_extract(data, \'\$.id\'), $len) AS id from apps where id in (${appIds.map((_) => '?').join(', ')})',
appIds.toList());
return deserializeFromResult(result);
}
Future<Set<String>> _installedIdentifiers() async {
if (!Platform.isAndroid) {
return {};
}
final infos = await packageManager.getInstalledPackages();
return {
for (final i in infos!)
if (i.packageName != null &&
!kExcludedAppIdNamespaces.any((e) => i.packageName!.startsWith(e)))
i.packageName!,
};
}
@override
DeserializedData<App> deserialize(Object? data, {String? key}) {
final list = data is Iterable ? data : [data as Map];
for (final Map<String, dynamic> map in list) {
final tags = map['tags'];
map['localApp'] = base.BaseUtil.getTag(tags, 'd')!;
map['signer'] = map['pubkey'];
}
return super.deserialize(data);
}
}
Future<bool> _isHashMismatch(String path, String hash) async {
return await Isolate.run(() async {
final file = File(path);
final fileStream = file.openRead();
final reader = ChunkedStreamReader(fileStream);
final digestOutputSink = AccumulatorSink<Digest>();
final digestInputSink = sha256.startChunkedConversion(digestOutputSink);
String? digest;
try {
while (true) {
// Chunk size determined from rough CPU/memory profiling
final chunk = await reader.readChunk(3072);
if (chunk.isEmpty) {
break; // EOF
}
digestInputSink.add(chunk);
}
} finally {
digestInputSink.close();
digest = digestOutputSink.events.single.toString();
digestOutputSink.close();
reader.cancel();
}
return digest != hash;
});
}
extension AppExt on Iterable<App> {
List<App> get sortedByLatest =>
sorted((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
}
// class
sealed class AppInstallProgress {}
class IdleInstallProgress extends AppInstallProgress {
final bool? success;
IdleInstallProgress({this.success});
}
class DownloadingInstallProgress extends AppInstallProgress {
final double progress;
DownloadingInstallProgress(this.progress);
}
class VerifyingHashProgress extends AppInstallProgress {}
class RequestInstallProgress extends AppInstallProgress {}
class ErrorInstallProgress extends AppInstallProgress {
final Exception e;
final String? info;
final List<(String, Future<void> Function())> actions;
ErrorInstallProgress(this.e, {this.info, this.actions = const []});
}
extension ExceptionExt on Exception {
String get message => (this as dynamic).message ?? toString();
}
final installationProgressProvider =
StateProvider.family<AppInstallProgress, Object>(
(_, arg) => IdleInstallProgress());
final appsToUpdateProvider = StateProvider((_) => 0);
// Constants
const kAndroidMimeType = 'application/vnd.android.package-archive';
const kExcludedAppIdNamespaces = [
'android',
'com.android',
'com.google',
'com.samsung',
'com.sec.android',
'org.chromium.webview_shell',
'app.grapheneos',
'app.vanadium',
];
-85
View File
@@ -1,85 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app.dart';
// **************************************************************************
// AdapterGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
mixin _$AppAdapter on Adapter<App> {
static final Map<String, RelationshipMeta> _kAppRelationshipMetas = {
'releases': RelationshipMeta<Release>(
name: 'releases',
inverseName: 'app',
type: 'releases',
kind: 'HasMany',
instance: (_) => (_ as App).releases,
),
'signer': RelationshipMeta<User>(
name: 'signer',
inverseName: 'apps',
type: 'users',
kind: 'BelongsTo',
instance: (_) => (_ as App).signer,
),
'localApp': RelationshipMeta<LocalApp>(
name: 'localApp',
type: 'localApps',
kind: 'BelongsTo',
instance: (_) => (_ as App).localApp,
)
};
@override
Map<String, RelationshipMeta> get relationshipMetas => _kAppRelationshipMetas;
@override
App deserializeLocal(map, {String? key}) {
map = transformDeserialize(map);
return internalWrapStopInit(() => App.fromJson(map), key: key);
}
@override
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
final map = model.toJson();
return transformSerialize(map, withRelationships: withRelationships);
}
}
final _appsFinders = <String, dynamic>{};
class $AppAdapter = Adapter<App>
with _$AppAdapter, NostrAdapter<App>, AppAdapter;
final appsAdapterProvider = Provider<Adapter<App>>(
(ref) => $AppAdapter(ref, InternalHolder(_appsFinders)));
extension AppAdapterX on Adapter<App> {
NostrAdapter<App> get nostrAdapter => this as NostrAdapter<App>;
AppAdapter get appAdapter => this as AppAdapter;
}
extension AppRelationshipGraphNodeX on RelationshipGraphNode<App> {
RelationshipGraphNode<Release> get releases {
final meta = _$AppAdapter._kAppRelationshipMetas['releases']
as RelationshipMeta<Release>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
RelationshipGraphNode<User> get signer {
final meta =
_$AppAdapter._kAppRelationshipMetas['signer'] as RelationshipMeta<User>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
RelationshipGraphNode<LocalApp> get localApp {
final meta = _$AppAdapter._kAppRelationshipMetas['localApp']
as RelationshipMeta<LocalApp>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
}
-56
View File
@@ -1,56 +0,0 @@
import 'package:flutter_data/flutter_data.dart';
import 'package:purplebase/purplebase.dart' as base;
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/nostr_adapter.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/utils/extensions.dart';
part 'app_curation_set.g.dart';
@DataAdapter([NostrAdapter, AppCurationSetAdapter])
class AppCurationSet extends base.AppCurationSet
with DataModelMixin<AppCurationSet> {
@override
Object get id => event.id;
final HasMany<App> apps;
final BelongsTo<User> signer;
AppCurationSet.fromJson(super.map)
: apps = hasMany(map['apps']),
signer = belongsTo(map['signer']),
super.fromJson();
Map<String, dynamic> toJson() => super.toMap();
String get name => event.content.isNotEmpty ? event.content : identifier;
}
mixin AppCurationSetAdapter on Adapter<AppCurationSet> {
@override
Future<List<AppCurationSet>> findAll(
{bool? remote = true,
bool? background = false,
Map<String, dynamic>? params,
Map<String, String>? headers,
bool? syncLocal,
OnSuccessAll<AppCurationSet>? onSuccess,
OnErrorAll<AppCurationSet>? onError,
DataRequestLabel? label}) async {
final sets = await super.findAll(remote: remote!, params: params);
final userIds = {for (final set in sets) set.signer.id}.nonNulls;
await ref.users.findAll(params: {'authors': userIds, 'ignoreReturn': true});
return sets;
}
@override
DeserializedData<AppCurationSet> deserialize(Object? data, {String? key}) {
final list = data is Iterable ? data : [data as Map];
for (final Map<String, dynamic> map in list) {
final tags = map['tags'];
map['apps'] = base.BaseUtil.getTagSet(tags, 'a');
}
return super.deserialize(data);
}
}
-79
View File
@@ -1,79 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_curation_set.dart';
// **************************************************************************
// AdapterGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
mixin _$AppCurationSetAdapter on Adapter<AppCurationSet> {
static final Map<String, RelationshipMeta> _kAppCurationSetRelationshipMetas =
{
'apps': RelationshipMeta<App>(
name: 'apps',
type: 'apps',
kind: 'HasMany',
instance: (_) => (_ as AppCurationSet).apps,
),
'signer': RelationshipMeta<User>(
name: 'signer',
type: 'users',
kind: 'BelongsTo',
instance: (_) => (_ as AppCurationSet).signer,
)
};
@override
Map<String, RelationshipMeta> get relationshipMetas =>
_kAppCurationSetRelationshipMetas;
@override
AppCurationSet deserializeLocal(map, {String? key}) {
map = transformDeserialize(map);
return internalWrapStopInit(() => AppCurationSet.fromJson(map), key: key);
}
@override
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
final map = model.toJson();
return transformSerialize(map, withRelationships: withRelationships);
}
}
final _appCurationSetsFinders = <String, dynamic>{};
class $AppCurationSetAdapter = Adapter<AppCurationSet>
with
_$AppCurationSetAdapter,
NostrAdapter<AppCurationSet>,
AppCurationSetAdapter;
final appCurationSetsAdapterProvider = Provider<Adapter<AppCurationSet>>(
(ref) =>
$AppCurationSetAdapter(ref, InternalHolder(_appCurationSetsFinders)));
extension AppCurationSetAdapterX on Adapter<AppCurationSet> {
NostrAdapter<AppCurationSet> get nostrAdapter =>
this as NostrAdapter<AppCurationSet>;
AppCurationSetAdapter get appCurationSetAdapter =>
this as AppCurationSetAdapter;
}
extension AppCurationSetRelationshipGraphNodeX
on RelationshipGraphNode<AppCurationSet> {
RelationshipGraphNode<App> get apps {
final meta = _$AppCurationSetAdapter
._kAppCurationSetRelationshipMetas['apps'] as RelationshipMeta<App>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
RelationshipGraphNode<User> get signer {
final meta = _$AppCurationSetAdapter
._kAppCurationSetRelationshipMetas['signer'] as RelationshipMeta<User>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
}
-13
View File
@@ -1,13 +0,0 @@
import 'package:purplebase/purplebase.dart';
class AppFeedback extends RegularEvent<AppFeedback> {
AppFeedback.fromJson(super.map) : super.fromJson();
}
class PartialAppFeedback extends RegularPartialEvent<AppFeedback> {
PartialAppFeedback({
required String content,
}) {
event.content = content;
}
}
-48
View File
@@ -1,48 +0,0 @@
import 'package:flutter_data/flutter_data.dart';
import 'package:purplebase/purplebase.dart' as base;
import 'package:zapstore/models/nostr_adapter.dart';
import 'package:zapstore/models/release.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/utils/extensions.dart';
part 'file_metadata.g.dart';
@DataAdapter([NostrAdapter, FileMetadataAdapter])
class FileMetadata extends base.FileMetadata with DataModelMixin<FileMetadata> {
final BelongsTo<Release> release;
final BelongsTo<User> signer;
@override
Object? get id => event.id;
FileMetadata.fromJson(super.map)
: release = belongsTo(map['release']),
signer = belongsTo(map['signer']),
super.fromJson();
Map<String, dynamic> toJson() => super.toMap();
}
mixin FileMetadataAdapter on Adapter<FileMetadata> {
@override
DeserializedData<FileMetadata> deserialize(Object? data, {String? key}) {
final list = data is Iterable ? data : [data as Map];
for (final e in list) {
final map = e as Map<String, dynamic>;
final isNewFormat = map['kind'] == 3063;
if (isNewFormat) {
// Map to old 1063
map['kind'] = 1063;
map['tags'].add([
'min_sdk_version',
base.BaseUtil.getTag(map['tags'], 'min_os_version')
]);
map['tags'].add([
'target_sdk_version',
base.BaseUtil.getTag(map['tags'], 'target_os_version')
]);
}
}
return super.deserialize(data);
}
}
-75
View File
@@ -1,75 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'file_metadata.dart';
// **************************************************************************
// AdapterGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
mixin _$FileMetadataAdapter on Adapter<FileMetadata> {
static final Map<String, RelationshipMeta> _kFileMetadataRelationshipMetas = {
'release': RelationshipMeta<Release>(
name: 'release',
inverseName: 'artifacts',
type: 'releases',
kind: 'BelongsTo',
instance: (_) => (_ as FileMetadata).release,
),
'signer': RelationshipMeta<User>(
name: 'signer',
type: 'users',
kind: 'BelongsTo',
instance: (_) => (_ as FileMetadata).signer,
)
};
@override
Map<String, RelationshipMeta> get relationshipMetas =>
_kFileMetadataRelationshipMetas;
@override
FileMetadata deserializeLocal(map, {String? key}) {
map = transformDeserialize(map);
return internalWrapStopInit(() => FileMetadata.fromJson(map), key: key);
}
@override
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
final map = model.toJson();
return transformSerialize(map, withRelationships: withRelationships);
}
}
final _fileMetadataFinders = <String, dynamic>{};
class $FileMetadataAdapter = Adapter<FileMetadata>
with _$FileMetadataAdapter, NostrAdapter<FileMetadata>, FileMetadataAdapter;
final fileMetadataAdapterProvider = Provider<Adapter<FileMetadata>>(
(ref) => $FileMetadataAdapter(ref, InternalHolder(_fileMetadataFinders)));
extension FileMetadataAdapterX on Adapter<FileMetadata> {
NostrAdapter<FileMetadata> get nostrAdapter =>
this as NostrAdapter<FileMetadata>;
FileMetadataAdapter get fileMetadataAdapter => this as FileMetadataAdapter;
}
extension FileMetadataRelationshipGraphNodeX
on RelationshipGraphNode<FileMetadata> {
RelationshipGraphNode<Release> get release {
final meta =
_$FileMetadataAdapter._kFileMetadataRelationshipMetas['release']
as RelationshipMeta<Release>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
RelationshipGraphNode<User> get signer {
final meta = _$FileMetadataAdapter._kFileMetadataRelationshipMetas['signer']
as RelationshipMeta<User>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
}
-187
View File
@@ -1,187 +0,0 @@
import 'dart:io';
import 'package:android_package_manager/android_package_manager.dart';
import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_data/flutter_data.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/app.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/system_info.dart';
import 'package:zapstore/utils/version.dart';
part 'local_app.g.dart';
@DataAdapter([LocalAppAdapter])
@JsonSerializable()
class LocalApp extends DataModel<LocalApp> {
@override
final Object? id;
final String? installedVersion;
final int? installedVersionCode;
final AppInstallStatus? status;
LocalApp(
{required this.id,
this.installedVersion,
this.installedVersionCode,
this.status});
LocalApp copyWith(
{String? installedVersion,
int? installedVersionCode,
AppInstallStatus? status}) {
return LocalApp(
id: id,
installedVersion: installedVersion ?? this.installedVersion,
installedVersionCode: installedVersionCode ?? this.installedVersionCode,
status: status ?? this.status,
);
}
}
mixin LocalAppAdapter on Adapter<LocalApp> {
Future<void> refreshUpdateStatus({String? appId}) async {
if (!Platform.isAndroid) {
return;
}
// NOTE: Using packageManager.getPackageInfo(packageName: appId)
// throws an uncatchable error every time it queries a non-installed package
final infos = await packageManager.getInstalledPackages(
flags: PackageInfoFlags(
{PMFlag.getPermissions, PMFlag.getSigningCertificates},
));
final installedPackageInfos = infos!.where((i) =>
!kExcludedAppIdNamespaces.any((e) => i.packageName!.startsWith(e)));
final ids = installedPackageInfos.map((i) => i.packageName).nonNulls;
// appId == null is the case of all apps
if (appId == null) {
final localApps = findAllLocal();
final apps = ref.apps.appAdapter.findWhereIdentifierInLocal(ids);
final localAppsToSave = <LocalApp>[];
for (final i in installedPackageInfos) {
final appId = i.packageName!;
final app = apps.firstWhereOrNull((a) => a.identifier == appId);
final installedVersion = i.versionName;
final installedVersionCode = i.versionCode;
final signingCertificateBytes =
i.signingInfo?.signingCertificateHistory?.firstOrNull;
final status = determineUpdateStatus(app, installedVersion,
installedVersionCode, signingCertificateBytes);
if (status == null) {
continue;
}
final localApp = localApps.firstWhereOrNull((app) => appId == app.id) ??
LocalApp(id: i.packageName!);
localAppsToSave.add(
localApp.copyWith(
installedVersion: installedVersion,
installedVersionCode: installedVersionCode,
status: status,
),
);
}
// Save local apps
await saveManyLocal(localAppsToSave, async: true);
// Remove uninstalled apps
final uninstalledApps = localApps.where((a) => !ids.contains(a.id));
if (uninstalledApps.isNotEmpty) {
deleteLocalByKeys(uninstalledApps.map((a) => DataModel.keyFor(a)));
}
} else {
final app =
ref.apps.appAdapter.findWhereIdentifierInLocal({appId}).firstOrNull;
final i =
installedPackageInfos.firstWhereOrNull((i) => i.packageName == appId);
final installedVersion = i?.versionName;
final installedVersionCode = i?.versionCode;
final signingCertificateBytes =
i?.signingInfo?.signingCertificateHistory?.firstOrNull;
final status = determineUpdateStatus(
app, installedVersion, installedVersionCode, signingCertificateBytes);
if (status != null) {
final localApp = app!.localApp.value ?? LocalApp(id: i!.packageName!);
localApp
.copyWith(
installedVersion: installedVersion,
installedVersionCode: installedVersionCode,
status: status,
)
.saveLocal();
}
}
updateNumberOfApps();
}
void updateNumberOfApps() {
final rs = db.select(
'SELECT count(*) as c FROM localApps WHERE json_extract(data, \'\$.status\') is \'updatable\'');
ref.read(appsToUpdateProvider.notifier).state = rs.first['c'];
}
AppInstallStatus? determineUpdateStatus(App? app, String? installedVersion,
int? installedVersionCode, List<int>? signingCertificateBytes) {
if (app == null || app.releases.isEmpty || app.latestMetadata == null) {
return null;
}
if (signingCertificateBytes != null) {
final installedApkSigHash =
sha256.convert(signingCertificateBytes).toString().toLowerCase();
final metadataSigHashes =
app.latestMetadata?.event.getTagSet('apk_signature_hash') ?? {};
final matches = metadataSigHashes
.any((msh) => msh.toLowerCase() == installedApkSigHash);
if (!matches) {
return AppInstallStatus.certificateMismatch;
}
}
if (installedVersion == null || installedVersionCode == null) {
return null;
}
var comp = 0;
if (app.latestMetadata!.versionCode != null) {
// NOTE: Zapstore development versions always carry a lower version code
// (e.g. 12) than published ones (e.g. 2012)
final code = app.identifier == kZapstoreAppIdentifier && !kReleaseMode
? installedVersionCode + 2000
: installedVersionCode;
comp = app.latestMetadata!.versionCode!.compareTo(code);
} else {
// If versionCode was not available, compare string version
try {
comp = compareVersions(installedVersion, app.latestMetadata!.version!);
} catch (_) {}
}
if (comp == 1) return AppInstallStatus.updatable;
if (comp == 0) return AppInstallStatus.updated;
// else it's a downgrade, which is not installable
return AppInstallStatus.downgrade;
}
}
enum AppInstallStatus {
updated,
updatable,
downgrade,
certificateMismatch,
}
-68
View File
@@ -1,68 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'local_app.dart';
// **************************************************************************
// AdapterGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
mixin _$LocalAppAdapter on Adapter<LocalApp> {
static final Map<String, RelationshipMeta> _kLocalAppRelationshipMetas = {};
@override
Map<String, RelationshipMeta> get relationshipMetas =>
_kLocalAppRelationshipMetas;
@override
LocalApp deserializeLocal(map, {String? key}) {
map = transformDeserialize(map);
return internalWrapStopInit(() => _$LocalAppFromJson(map), key: key);
}
@override
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
final map = _$LocalAppToJson(model);
return transformSerialize(map, withRelationships: withRelationships);
}
}
final _localAppsFinders = <String, dynamic>{};
class $LocalAppAdapter = Adapter<LocalApp>
with _$LocalAppAdapter, LocalAppAdapter;
final localAppsAdapterProvider = Provider<Adapter<LocalApp>>(
(ref) => $LocalAppAdapter(ref, InternalHolder(_localAppsFinders)));
extension LocalAppAdapterX on Adapter<LocalApp> {
LocalAppAdapter get localAppAdapter => this as LocalAppAdapter;
}
extension LocalAppRelationshipGraphNodeX on RelationshipGraphNode<LocalApp> {}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LocalApp _$LocalAppFromJson(Map<String, dynamic> json) => LocalApp(
id: json['id'],
installedVersion: json['installedVersion'] as String?,
installedVersionCode: (json['installedVersionCode'] as num?)?.toInt(),
status: $enumDecodeNullable(_$AppInstallStatusEnumMap, json['status']),
);
Map<String, dynamic> _$LocalAppToJson(LocalApp instance) => <String, dynamic>{
'id': instance.id,
'installedVersion': instance.installedVersion,
'installedVersionCode': instance.installedVersionCode,
'status': _$AppInstallStatusEnumMap[instance.status],
};
const _$AppInstallStatusEnumMap = {
AppInstallStatus.updated: 'updated',
AppInstallStatus.updatable: 'updatable',
AppInstallStatus.downgrade: 'downgrade',
AppInstallStatus.certificateMismatch: 'certificateMismatch',
};
-218
View File
@@ -1,218 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter_data/flutter_data.dart';
import 'package:purplebase/purplebase.dart' hide Release;
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/release.dart';
import 'package:zapstore/utils/system_info.dart';
import 'package:zapstore/widgets/latest_releases_container.dart';
// NOTE: Very important to use const in relay args to preserve equality in Riverpod families
// const kAppRelays = {'ws://10.0.2.2:3000'};
const kAppRelays = {'wss://relay.zapstore.dev'};
const kSocialRelays = {
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://nos.lol',
'ndk'
};
const kVertexRelay = {'wss://relay.vertexlab.io'};
mixin NostrAdapter<T extends DataModelMixin<T>> on Adapter<T> {
RelayMessageNotifier get relay =>
ref.read(relayProviderFamily(kAppRelays).notifier);
RelayMessageNotifier get socialRelays =>
ref.read(relayProviderFamily(kSocialRelays).notifier);
RelayMessageNotifier get vertexRelay =>
ref.read(relayProviderFamily(kVertexRelay).notifier);
@override
Future<void> onInitialized() async {
await super.onInitialized();
// DO NOT run on isolates, the mere fact of calling the
// relay getter will trigger its initialization
// as providers are obviously not cached across isolates
if (!inIsolate) {
// Upon adapter initialization, configure relays
// with the event verification caching function
relay.configure(
isEventVerified: (Map<String, dynamic> map) {
// If replaceable, we check for that ID
final identifier = (map['tags'] as Iterable)
.firstWhereOrNull((t) => t[0] == 'd')?[1]
?.toString();
final id = identifier != null
? (map['kind'] as int, map['pubkey'].toString(), identifier)
.formatted
: map['id'];
return ref.apps.nostrAdapter.existsId(id);
},
);
socialRelays.configure(
isEventVerified: (map) => ref.apps.nostrAdapter.existsId(map['pubkey']),
);
}
}
int get kind {
var pbType = internalType.singularize().capitalize();
// handle FileMetadata edge case
pbType = pbType.replaceFirst('datum', 'data');
return Event.types[pbType]!.kind;
}
@override
DeserializedData<T> deserialize(Object? data, {String? key}) {
final list = data is Iterable ? data : [data as Map];
final models = <T>[];
final included = <DataModelMixin>[];
for (final e in list) {
final map = e as Map<String, dynamic>;
map['signer'] = map['pubkey'];
// ID should be the replaceable link/reference so as to make it replaceable in local db too
final tags = map['tags'];
final isReplaceable = BaseUtil.containsTag(tags, 'd');
if (isReplaceable) {
map['id'] = (
map['kind'] as int,
map['pubkey'].toString(),
BaseUtil.getTag(tags, 'd')!,
).formatted;
}
// Remove signature as it's already been verified
map.remove('sig');
// Collect models for current kind and included for others
final eventType = Event.types.entries
.firstWhere((e) => e.value.kind == kind)
.key
.pluralize()
.decapitalize();
if (eventType == internalType) {
final newData = super.deserialize(map);
models.addAll(newData.models as Iterable<T>);
} else {
final newData = adapters[eventType]!.deserialize(map);
included.addAll(newData.models as Iterable<DataModelMixin>);
}
}
return DeserializedData<T>(models, included: included);
}
@override
bool existsId(Object id) {
final r = db.select('SELECT 1 FROM _keys WHERE id = ?', [id]);
return r.isNotEmpty;
}
Iterable<String> existingIds(Iterable<Object> ids) {
final r = db.select(
'SELECT id FROM _keys WHERE id in (${ids.map((e) => '?').join(',')})',
ids.toList());
return r.map((e) => e['id']);
}
@override
Future<List<T>> findAll(
{bool? remote,
bool? background,
Map<String, dynamic>? params,
Map<String, String>? headers,
bool? syncLocal,
OnSuccessAll<T>? onSuccess,
OnErrorAll<T>? onError,
DataRequestLabel? label}) async {
params ??= {};
if (remote == false) {
return findAllLocal();
}
final additionalKinds = params.remove('kinds');
final limit = params.remove('limit');
final since = params.remove('since');
final until = params.remove('until');
final ignoreReturn = params.remove('ignoreReturn');
if (['apps', 'fileMetadatas'].contains(internalType)) {
if (Platform.isAndroid) {
final info =
await ref.read(systemInfoNotifierProvider.notifier).fetch();
params['#f'] = info.androidInfo.supportedAbis.map((a) => 'android-$a');
}
}
final req = RelayRequest(
kinds: {kind, ...?additionalKinds},
tags: params,
limit: limit,
until: until,
// Disable since when until is present
since: until != null ? null : since,
);
final result = await relay.queryRaw(req);
final deserialized = await deserializeAsync(result,
save: true, ignoreReturn: ignoreReturn == true);
return deserialized.models;
}
Future<void> loadArtifactsAndUsers(Iterable<Release> releases) async {
final metadataIds =
releases.map((r) => r.event.linkedEvents).nonNulls.expand((_) => _);
final apps = releases.map((r) => r.app.value).nonNulls;
final userIds = apps.map((a) => a.signer.id).nonNulls;
// Metadata and users probably go to separate relays
// so query in parallel
// Can use ignoreReturn on both as we only care about saved models
await Future.wait([
if (metadataIds.isNotEmpty)
ref.fileMetadata.findAll(
params: {
'kinds': {1063, 3063},
'ids': metadataIds,
'#m': [kAndroidMimeType],
'ignoreReturn': true,
},
),
if (userIds.isNotEmpty)
ref.users.findAll(
params: {
'authors': userIds,
'ignoreReturn': true,
},
),
]);
}
}
class RelayListenerNotifier extends Notifier<void> {
@override
void build() {
final timer = Timer.periodic(Duration(minutes: 30), (_) => fetch());
// This will get disposed when clearing and restarting the app
ref.onDispose(() {
timer.cancel();
});
}
Future<void> fetch() async {
await Future.microtask(() async {
await ref.read(latestReleasesAppProvider.notifier).fetchRemote();
await ref.apps.appAdapter.checkForUpdates();
});
}
}
final relayListenerProvider =
NotifierProvider<RelayListenerNotifier, void>(RelayListenerNotifier.new);
-56
View File
@@ -1,56 +0,0 @@
import 'package:collection/collection.dart';
import 'package:flutter_data/flutter_data.dart';
import 'package:purplebase/purplebase.dart' as base;
import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/file_metadata.dart';
import 'package:zapstore/models/nostr_adapter.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/utils/extensions.dart';
part 'release.g.dart';
@DataAdapter([NostrAdapter, ReleaseAdapter])
class Release extends base.Release with DataModelMixin<Release> {
@override
Object? get id => event.id;
final HasMany<FileMetadata> artifacts;
final BelongsTo<App> app;
final BelongsTo<User> signer;
// Release(
// {super.createdAt,
// super.content,
// super.tags,
// required this.artifacts,
// required this.app,
// required this.signer});
Release.fromJson(super.map)
: app = belongsTo(map['app']),
artifacts = hasMany(map['artifacts']),
signer = belongsTo(map['signer']),
super.fromJson();
Map<String, dynamic> toJson() => super.toMap();
}
mixin ReleaseAdapter on Adapter<Release> {
@override
DeserializedData<Release> deserialize(Object? data, {String? key}) {
final list = data is Iterable ? data : [data as Map];
for (final Map<String, dynamic> map in list) {
final tags = map['tags'];
map['app'] = base.BaseUtil.getTag(tags, 'a') ??
'32267:${map['pubkey']}:${base.BaseUtil.getTag(tags, 'i')}';
map['artifacts'] = base.BaseUtil.getTagSet(tags, 'e').toList();
}
return super.deserialize(data);
}
}
extension ReleaseExt on Iterable<Release> {
List<Release> get sortedByLatest =>
sorted((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
}
-86
View File
@@ -1,86 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'release.dart';
// **************************************************************************
// AdapterGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
mixin _$ReleaseAdapter on Adapter<Release> {
static final Map<String, RelationshipMeta> _kReleaseRelationshipMetas = {
'artifacts': RelationshipMeta<FileMetadata>(
name: 'artifacts',
inverseName: 'release',
type: 'fileMetadata',
kind: 'HasMany',
instance: (_) => (_ as Release).artifacts,
),
'app': RelationshipMeta<App>(
name: 'app',
inverseName: 'releases',
type: 'apps',
kind: 'BelongsTo',
instance: (_) => (_ as Release).app,
),
'signer': RelationshipMeta<User>(
name: 'signer',
type: 'users',
kind: 'BelongsTo',
instance: (_) => (_ as Release).signer,
)
};
@override
Map<String, RelationshipMeta> get relationshipMetas =>
_kReleaseRelationshipMetas;
@override
Release deserializeLocal(map, {String? key}) {
map = transformDeserialize(map);
return internalWrapStopInit(() => Release.fromJson(map), key: key);
}
@override
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
final map = model.toJson();
return transformSerialize(map, withRelationships: withRelationships);
}
}
final _releasesFinders = <String, dynamic>{};
class $ReleaseAdapter = Adapter<Release>
with _$ReleaseAdapter, NostrAdapter<Release>, ReleaseAdapter;
final releasesAdapterProvider = Provider<Adapter<Release>>(
(ref) => $ReleaseAdapter(ref, InternalHolder(_releasesFinders)));
extension ReleaseAdapterX on Adapter<Release> {
NostrAdapter<Release> get nostrAdapter => this as NostrAdapter<Release>;
ReleaseAdapter get releaseAdapter => this as ReleaseAdapter;
}
extension ReleaseRelationshipGraphNodeX on RelationshipGraphNode<Release> {
RelationshipGraphNode<FileMetadata> get artifacts {
final meta = _$ReleaseAdapter._kReleaseRelationshipMetas['artifacts']
as RelationshipMeta<FileMetadata>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
RelationshipGraphNode<App> get app {
final meta = _$ReleaseAdapter._kReleaseRelationshipMetas['app']
as RelationshipMeta<App>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
RelationshipGraphNode<User> get signer {
final meta = _$ReleaseAdapter._kReleaseRelationshipMetas['signer']
as RelationshipMeta<User>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
}
-34
View File
@@ -1,34 +0,0 @@
import 'package:flutter_data/flutter_data.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:zapstore/models/user.dart';
part 'settings.g.dart';
@JsonSerializable()
@DataAdapter([SettingsAdapter])
class Settings extends DataModel<Settings> {
@override
String get id => '_';
final BelongsTo<User> user = BelongsTo();
final HasMany<User> trustedUsers = HasMany();
SignInMethod? signInMethod;
}
enum SignInMethod {
pubkey,
nip55,
}
mixin SettingsAdapter on Adapter<Settings> {
@override
Future<void> onInitialized() async {
await super.onInitialized();
if (inIsolate) {
return;
}
// ensure it's always present
if (!existsId('_')) {
Settings().saveLocal();
}
}
}
-89
View File
@@ -1,89 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'settings.dart';
// **************************************************************************
// AdapterGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
mixin _$SettingsAdapter on Adapter<Settings> {
static final Map<String, RelationshipMeta> _kSettingsRelationshipMetas = {
'user': RelationshipMeta<User>(
name: 'user',
inverseName: 'settings',
type: 'users',
kind: 'BelongsTo',
instance: (_) => (_ as Settings).user,
),
'trustedUsers': RelationshipMeta<User>(
name: 'trustedUsers',
inverseName: 'settings',
type: 'users',
kind: 'HasMany',
instance: (_) => (_ as Settings).trustedUsers,
)
};
@override
Map<String, RelationshipMeta> get relationshipMetas =>
_kSettingsRelationshipMetas;
@override
Settings deserializeLocal(map, {String? key}) {
map = transformDeserialize(map);
return internalWrapStopInit(() => _$SettingsFromJson(map), key: key);
}
@override
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
final map = _$SettingsToJson(model);
return transformSerialize(map, withRelationships: withRelationships);
}
}
final _settingsFinders = <String, dynamic>{};
class $SettingsAdapter = Adapter<Settings>
with _$SettingsAdapter, SettingsAdapter;
final settingsAdapterProvider = Provider<Adapter<Settings>>(
(ref) => $SettingsAdapter(ref, InternalHolder(_settingsFinders)));
extension SettingsAdapterX on Adapter<Settings> {
SettingsAdapter get settingsAdapter => this as SettingsAdapter;
}
extension SettingsRelationshipGraphNodeX on RelationshipGraphNode<Settings> {
RelationshipGraphNode<User> get user {
final meta = _$SettingsAdapter._kSettingsRelationshipMetas['user']
as RelationshipMeta<User>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
RelationshipGraphNode<User> get trustedUsers {
final meta = _$SettingsAdapter._kSettingsRelationshipMetas['trustedUsers']
as RelationshipMeta<User>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Settings _$SettingsFromJson(Map<String, dynamic> json) => Settings()
..signInMethod =
$enumDecodeNullable(_$SignInMethodEnumMap, json['signInMethod']);
Map<String, dynamic> _$SettingsToJson(Settings instance) => <String, dynamic>{
'signInMethod': _$SignInMethodEnumMap[instance.signInMethod],
};
const _$SignInMethodEnumMap = {
SignInMethod.pubkey: 'pubkey',
SignInMethod.nip55: 'nip55',
};
-285
View File
@@ -1,285 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter_data/flutter_data.dart';
import 'package:purplebase/purplebase.dart' as base;
import 'package:http/http.dart' as http;
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/nostr_adapter.dart';
import 'package:zapstore/models/settings.dart';
import 'package:zapstore/models/verify_reputation_dvm.dart';
import 'package:zapstore/navigation/app_initializer.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/nwc.dart';
part 'user.g.dart';
@DataAdapter([NostrAdapter, UserAdapter])
class User extends base.User with DataModelMixin<User> {
@override
Object? get id => event.id;
User.fromJson(super.map)
: followers = hasMany(map['followers']),
following = hasMany(map['following']),
settings = belongsTo(map['settings']),
apps = hasMany(map['apps']),
super.fromJson();
User.fromPubkey(String pubkey)
: this.fromJson({
'id': pubkey,
'kind': 0,
'pubkey': pubkey.hexKey,
'created_at': DateTime.now().millisecondsSinceEpoch ~/ 1000,
'content': '{}',
'tags': [],
});
Map<String, dynamic> toJson() => super.toMap();
@DataRelationship(inverse: 'followers')
final HasMany<User> following;
@DataRelationship(inverse: 'following')
final HasMany<User> followers;
@DataRelationship(inverse: 'user')
final BelongsTo<Settings> settings;
@DataRelationship(inverse: 'signer')
final HasMany<App> apps;
String get nameOrNpub => name ?? npub.shorten;
// Zaps
Future<void> zap(int amountInSats,
{required base.Event event, String? comment, base.Signer? signer}) async {
final adapter = DataModel.adapterFor(this) as NostrAdapter;
// First ensure connection is available
final nwcConnection = adapter.ref.read(nwcConnectionProvider).asData?.value;
if (nwcConnection == null) {
throw 'No NWC connection';
}
final authorPubkey = event.event.pubkey;
final author = adapter.ref.users.findOneLocalById(authorPubkey);
if (author?.lud16 == null) {
throw 'Recipient has no Lightning address';
}
final amountInMillisats = amountInSats * 1000;
final partialZapRequest = base.PartialZapRequest();
partialZapRequest
..addLinkedEvent(event)
..addLinkedUser(author!)
..relays = kSocialRelays.where((r) => r != 'ndk')
..amount = amountInMillisats
..comment = comment;
final zapRequest = await partialZapRequest.signWith(signer ?? amberSigner,
withPubkey: pubkey);
// Now we fetch the invoice
final lnResponse = await author.fetchLightningAddress();
final recipientAllowsNostr = lnResponse['allowsNostr'];
final recipientPubkey = lnResponse['nostrPubkey'];
// All amounts are in millisats
final recipientMin = lnResponse['minSendable'];
final recipientMax = lnResponse['maxSendable'];
if (recipientAllowsNostr != true && recipientPubkey != null) {
throw 'Recipient does not allow nostr';
}
if ((recipientMin != null && amountInMillisats < recipientMin) ||
(recipientMax != null && amountInMillisats > recipientMax)) {
throw 'Amount not between min and max sendable';
}
final commentLength = lnResponse['commentAllowed'];
comment =
commentLength != null ? comment?.safeSubstring(commentLength) : comment;
var callbackUri = Uri.parse(lnResponse['callback']!);
callbackUri = callbackUri.replace(
queryParameters: {
...callbackUri.queryParameters,
'amount': amountInMillisats.toString(),
if (comment != null) 'comment': comment,
'nostr': jsonEncode(zapRequest.toMap()),
},
);
final invoiceResponse = await http.get(callbackUri);
final invoiceMap = jsonDecode(invoiceResponse.body);
final invoice = invoiceMap['pr'];
if (invoice == null) {
throw 'No invoice';
}
// Pay the invoice via NWC (errors are thrown)
await adapter.socialRelays.ndk!.nwc.payInvoice(nwcConnection,
invoice: invoice, timeout: Duration(seconds: 20));
}
Future<Map<String, dynamic>> fetchLightningAddress() async {
final [userName, domainName] = lud16!.split('@');
final lnurl = 'https://$domainName/.well-known/lnurlp/$userName';
final response = await http.get(Uri.parse(lnurl));
final map = jsonDecode(response.body);
return map;
}
}
mixin UserAdapter on NostrAdapter<User> {
@override
Future<List<User>> findAll(
{bool? remote,
bool? background,
Map<String, dynamic>? params,
Map<String, String>? headers,
bool? syncLocal,
OnSuccessAll<User>? onSuccess,
OnErrorAll<User>? onError,
DataRequestLabel? label}) async {
final authors = params!['authors'] as Iterable;
if (authors.isEmpty) {
return [];
}
final request = base.RelayRequest(
kinds: {0},
authors: {...authors},
);
final result = await socialRelays.queryRaw(request);
if (onSuccess != null) {
return await onSuccess.call(DataResponse(statusCode: 200, body: result),
label ?? DataRequestLabel('findAll', type: type), this);
}
final data = await deserializeAsync(result, save: true);
return data.models;
}
@override
bool isOfflineError(Object? error) {
return false;
}
@override
Future<User?> findOne(Object id,
{bool remote = true,
bool background = false,
Map<String, dynamic>? params,
Map<String, String>? headers,
OnSuccessOne<User>? onSuccess,
OnErrorOne<User>? onError,
DataRequestLabel? label}) async {
if (id.toString().isEmpty) return null;
var publicKey = id.toString();
if (publicKey.startsWith('npub')) {
publicKey = publicKey.hexKey;
} else if (publicKey.contains('@')) {
// If it's not an npub and it has a @ instead, we treat the string as NIP-05
final [username, domain] = publicKey.split('@');
publicKey = await sendRequest<dynamic>(
Uri.parse('https://$domain/.well-known/nostr.json?name=$username'),
onSuccess: (response, label) {
var body = response.body;
if (body is String) {
body = jsonDecode(body);
}
return (body as Map)['names']?[username];
},
onError: (e, _) {
throw e;
},
);
}
params ??= {};
final result = await socialRelays.queryRaw(base.RelayRequest(
kinds: {0},
tags: params,
authors: {publicKey},
));
final data = await deserializeAsync(result, save: true);
return data.models.firstWhereOrNull((e) {
return e.id == publicKey;
});
}
@override
DeserializedData<User> deserialize(Object? data, {String? key}) {
final list = data is Iterable ? data : [data as Map];
for (final Map<String, dynamic> map in list) {
// if (map['kind'] == 3) {
// map['following'] = {
// for (final [name, value] in map['tags'] as Iterable)
// if (name == 'p') value
// };
// }
map['id'] = map['pubkey'];
}
return super.deserialize(data);
}
Future<List<User>> getRelevantWhoFollow(
String fromNpub, String toNpub) async {
final dvmReq = await PartialVerifyReputationRequest(
source: fromNpub.hexKey, target: toNpub.hexKey)
.signWith(amberSigner, withPubkey: fromNpub.hexKey);
await ref.users.nostrAdapter.vertexRelay.publish(dvmReq);
final req = base.RelayRequest(kinds: {
6312,
7000
}, tags: {
'e': [dvmReq.event.id]
});
final response = await ref.users.nostrAdapter.vertexRelay.queryRaw(req);
if (response.isEmpty) {
return [];
}
if (response.first['kind'] == 7000) {
final error = (response.first['tags'] as Iterable?)
?.firstWhereOrNull((t) => t[0] == 'status')?[2];
if (error.toString().contains('credits')) {
throw 'Unable to check followers';
} else {
throw error;
}
}
final result = response.first['content'];
final pubkeyList = (jsonDecode(result) as List).map((e) => e['pubkey']);
final users = await findAll(params: {'authors': pubkeyList});
return users;
}
}
final signedInUserProvider = Provider<User?>((ref) {
return ref.settings
.watchOne('_', alsoWatch: (_) => {_.user})
.model
?.user
.value;
});
const kZapstorePubkey =
'78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d';
const kFranzapPubkey =
'726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11';
-101
View File
@@ -1,101 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'user.dart';
// **************************************************************************
// AdapterGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
mixin _$UserAdapter on Adapter<User> {
static final Map<String, RelationshipMeta> _kUserRelationshipMetas = {
'following': RelationshipMeta<User>(
name: 'following',
inverseName: 'followers',
type: 'users',
kind: 'HasMany',
instance: (_) => (_ as User).following,
),
'followers': RelationshipMeta<User>(
name: 'followers',
inverseName: 'following',
type: 'users',
kind: 'HasMany',
instance: (_) => (_ as User).followers,
),
'settings': RelationshipMeta<Settings>(
name: 'settings',
inverseName: 'user',
type: 'settings',
kind: 'BelongsTo',
instance: (_) => (_ as User).settings,
),
'apps': RelationshipMeta<App>(
name: 'apps',
inverseName: 'signer',
type: 'apps',
kind: 'HasMany',
instance: (_) => (_ as User).apps,
)
};
@override
Map<String, RelationshipMeta> get relationshipMetas =>
_kUserRelationshipMetas;
@override
User deserializeLocal(map, {String? key}) {
map = transformDeserialize(map);
return internalWrapStopInit(() => User.fromJson(map), key: key);
}
@override
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
final map = model.toJson();
return transformSerialize(map, withRelationships: withRelationships);
}
}
final _usersFinders = <String, dynamic>{};
class $UserAdapter = Adapter<User>
with _$UserAdapter, NostrAdapter<User>, UserAdapter;
final usersAdapterProvider = Provider<Adapter<User>>(
(ref) => $UserAdapter(ref, InternalHolder(_usersFinders)));
extension UserAdapterX on Adapter<User> {
NostrAdapter<User> get nostrAdapter => this as NostrAdapter<User>;
UserAdapter get userAdapter => this as UserAdapter;
}
extension UserRelationshipGraphNodeX on RelationshipGraphNode<User> {
RelationshipGraphNode<User> get following {
final meta = _$UserAdapter._kUserRelationshipMetas['following']
as RelationshipMeta<User>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
RelationshipGraphNode<User> get followers {
final meta = _$UserAdapter._kUserRelationshipMetas['followers']
as RelationshipMeta<User>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
RelationshipGraphNode<Settings> get settings {
final meta = _$UserAdapter._kUserRelationshipMetas['settings']
as RelationshipMeta<Settings>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
RelationshipGraphNode<App> get apps {
final meta =
_$UserAdapter._kUserRelationshipMetas['apps'] as RelationshipMeta<App>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
}
-16
View File
@@ -1,16 +0,0 @@
import 'package:purplebase/purplebase.dart';
class VerifyReputationRequest extends RegularEvent<VerifyReputationRequest> {
VerifyReputationRequest.fromJson(super.map) : super.fromJson();
}
class PartialVerifyReputationRequest
extends RegularPartialEvent<VerifyReputationRequest> {
PartialVerifyReputationRequest({
required String source,
required String target,
}) {
event.setTag('param', ['source', source]);
event.setTag('param', ['target', target]);
}
}
-42
View File
@@ -1,42 +0,0 @@
import 'package:flutter_data/flutter_data.dart';
import 'package:purplebase/purplebase.dart' as base;
import 'package:zapstore/models/nostr_adapter.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/utils/extensions.dart';
part 'zap_receipt.g.dart';
@DataAdapter([NostrAdapter, ZapReceiptAdapter])
class ZapReceipt extends base.ZapReceipt with DataModelMixin<ZapReceipt> {
final BelongsTo<User> recipient;
final BelongsTo<User> signer;
@override
Object? get id => event.id;
ZapReceipt.fromJson(super.map)
: recipient = belongsTo(map['recipient']),
signer = belongsTo(map['signer']),
super.fromJson();
Map<String, dynamic> toJson() => super.toMap();
}
mixin ZapReceiptAdapter on Adapter<ZapReceipt> {
@override
DeserializedData<ZapReceipt> deserialize(Object? data, {String? key}) {
final list = data is Iterable ? data : [data as Map];
for (final e in list) {
final map = e as Map<String, dynamic>;
map['recipient'] = base.BaseUtil.getTagSet(map['tags'], 'p');
}
return super.deserialize(data);
}
List<ZapReceipt> findByEventId(String eventId) {
final result = db.select(
"SELECT z.key, z.data from zapReceipts z, json_each(json_extract(data, '\$.tags')) WHERE json_extract(value, '\$[0]') = 'e' AND json_extract(value, '\$[1]') = ?",
[eventId]);
return deserializeFromResult(result);
}
}
-72
View File
@@ -1,72 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'zap_receipt.dart';
// **************************************************************************
// AdapterGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
mixin _$ZapReceiptAdapter on Adapter<ZapReceipt> {
static final Map<String, RelationshipMeta> _kZapReceiptRelationshipMetas = {
'recipient': RelationshipMeta<User>(
name: 'recipient',
type: 'users',
kind: 'BelongsTo',
instance: (_) => (_ as ZapReceipt).recipient,
),
'signer': RelationshipMeta<User>(
name: 'signer',
type: 'users',
kind: 'BelongsTo',
instance: (_) => (_ as ZapReceipt).signer,
)
};
@override
Map<String, RelationshipMeta> get relationshipMetas =>
_kZapReceiptRelationshipMetas;
@override
ZapReceipt deserializeLocal(map, {String? key}) {
map = transformDeserialize(map);
return internalWrapStopInit(() => ZapReceipt.fromJson(map), key: key);
}
@override
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
final map = model.toJson();
return transformSerialize(map, withRelationships: withRelationships);
}
}
final _zapReceiptsFinders = <String, dynamic>{};
class $ZapReceiptAdapter = Adapter<ZapReceipt>
with _$ZapReceiptAdapter, NostrAdapter<ZapReceipt>, ZapReceiptAdapter;
final zapReceiptsAdapterProvider = Provider<Adapter<ZapReceipt>>(
(ref) => $ZapReceiptAdapter(ref, InternalHolder(_zapReceiptsFinders)));
extension ZapReceiptAdapterX on Adapter<ZapReceipt> {
NostrAdapter<ZapReceipt> get nostrAdapter => this as NostrAdapter<ZapReceipt>;
ZapReceiptAdapter get zapReceiptAdapter => this as ZapReceiptAdapter;
}
extension ZapReceiptRelationshipGraphNodeX
on RelationshipGraphNode<ZapReceipt> {
RelationshipGraphNode<User> get recipient {
final meta = _$ZapReceiptAdapter._kZapReceiptRelationshipMetas['recipient']
as RelationshipMeta<User>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
RelationshipGraphNode<User> get signer {
final meta = _$ZapReceiptAdapter._kZapReceiptRelationshipMetas['signer']
as RelationshipMeta<User>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
}
-107
View File
@@ -1,107 +0,0 @@
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_data/flutter_data.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:zapstore/main.dart';
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/local_app.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/models/verify_reputation_dvm.dart';
import 'package:zapstore/navigation/router.dart';
import 'package:zapstore/utils/signers.dart';
import 'package:zapstore/widgets/app_curation_container.dart';
import 'package:purplebase/purplebase.dart' as base;
AppLifecycleListener? _lifecycleListener;
SharedPreferences? sharedPreferences;
User? anonUser;
final amberSigner = AmberSigner();
final appInitializer = FutureProvider<void>((ref) async {
sharedPreferences = await SharedPreferences.getInstance();
final dbVersion = sharedPreferences!.getInt('dbVersion');
// Perform migration
if (dbVersion == null || dbVersion < kDbVersion) {
final storage = ref.read(localStorageProvider);
await storage.initialize();
await storage.destroy();
await sharedPreferences!.setInt('dbVersion', kDbVersion);
}
// Initialize Flutter Data
await ref.read(initializeFlutterData(adapterProvidersMap).future);
// NOTE: Do not use ignoreReturn here
if (ref.appCurationSets.countLocal == 0) {
// If we are here, local storage is empty
// Preload curation sets
await ref.appCurationSets.findAll(syncLocal: true);
// Preload updates in the background (no await)
ref.apps.appAdapter.checkForUpdates();
} else {
// Preload curation sets in the background (no await)
ref.appCurationSets.findAll(syncLocal: true, background: true);
}
// Preload zapstore's nostr curation set
ref.read(appCurationSetProvider(kNostrCurationSetLink).notifier).fetch();
// Initialize signer
await amberSigner.initialize();
// Set up anon user (pubkey derives from pkSigner secret key)
anonUser ??= User.fromPubkey(
'c86eda2daae768374526bc54903f388d9a866c00740ec8db418d7ef2dca77b5b')
.init()
.saveLocal();
// Register event types
base.Event.types['VerifyReputationRequest'] =
(kind: 5312, constructor: VerifyReputationRequest.fromJson);
// App-wide listeners
// Register app lifecycle listener
_lifecycleListener = AppLifecycleListener(
onStateChange: (state) {
if (state == AppLifecycleState.resumed) {
ref.localApps.localAppAdapter.refreshUpdateStatus();
}
},
);
// Handle deep links
final appLinksSub = appLinks.uriLinkStream.listen((uri) async {
String? appId;
if (uri.scheme == 'https' && uri.hasFragment) {
appId = uri.fragment;
} else if (uri.scheme == 'zapstore') {
// zapstore://com.app.id
// zapstore://com.app.id?signer=npub1xyz
appId = uri.host;
}
if (appId != null) {
final adapter = ref.apps.appAdapter;
final apps = adapter.findWhereIdentifierInLocal({appId});
// Filter by signer npub, if present, otherwise pick first
final appSignerNpub = uri.queryParameters['signer'];
var goToApp =
apps.firstWhereOrNull((a) => a.signer.value?.npub == appSignerNpub);
if (appSignerNpub == null) {
goToApp = apps.first;
}
if (goToApp != null) {
appRouter.go('/details', extra: goToApp);
} else {
appRouter.go('/');
}
}
});
ref.onDispose(() {
_lifecycleListener?.dispose();
appLinksSub.cancel();
});
});
-55
View File
@@ -1,55 +0,0 @@
import 'package:flutter/material.dart';
class DesktopScaffold extends StatelessWidget {
const DesktopScaffold({
super.key,
required this.body,
required this.selectedIndex,
required this.onDestinationSelected,
});
final Widget body;
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(
children: [
NavigationRail(
minWidth: 120,
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected,
labelType: NavigationRailLabelType.all,
destinations: [
const NavigationRailDestination(
label: Text('Home'),
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home_filled),
),
const NavigationRailDestination(
label: Text('Updates'),
icon: Icon(Icons.download_for_offline_outlined),
selectedIcon: Icon(Icons.download_for_offline),
),
const NavigationRailDestination(
label: Text('Settings'),
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
),
],
),
Flexible(
child: Container(
constraints: BoxConstraints(
maxWidth: 768,
),
padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
child: body,
),
),
],
),
);
}
}
-68
View File
@@ -1,68 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:zapstore/models/app.dart';
import 'package:zapstore/utils/theme.dart';
import 'package:zapstore/widgets/drawer_container.dart';
class MobileScaffold extends HookConsumerWidget {
const MobileScaffold({
super.key,
required this.body,
required this.selectedIndex,
required this.onDestinationSelected,
});
final Widget body;
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
@override
Widget build(BuildContext context, WidgetRef ref) {
final appsToUpdate = ref.watch(appsToUpdateProvider);
return Scaffold(
key: scaffoldKey,
body: Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
child: body,
),
bottomNavigationBar: NavigationBar(
height: 60,
backgroundColor: kBackgroundColor,
indicatorColor: Colors.transparent,
selectedIndex: selectedIndex,
labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
destinations: [
NavigationDestination(
label: 'Home',
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home_filled),
),
NavigationDestination(
label: 'Updates',
icon: appsToUpdate > 0
? Badge(
label: Text(appsToUpdate.toString()),
child: Icon(Icons.update_outlined),
)
: Icon(Icons.update_outlined),
selectedIcon: appsToUpdate > 0
? Badge(
label: Text(appsToUpdate.toString()),
child: Icon(Icons.update),
)
: Icon(Icons.update),
),
NavigationDestination(
label: 'Settings',
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
),
],
onDestinationSelected: onDestinationSelected,
),
drawer: DrawerContainer(),
);
}
}
final scaffoldKey = GlobalKey<ScaffoldState>();
-141
View File
@@ -1,141 +0,0 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:zapstore/main.dart';
import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/nostr_adapter.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/navigation/app_initializer.dart';
import 'package:zapstore/navigation/desktop_scaffold.dart';
import 'package:zapstore/navigation/mobile_scaffold.dart';
import 'package:zapstore/screens/app_detail_screen.dart';
import 'package:zapstore/screens/developer_screen.dart';
import 'package:zapstore/screens/search_screen.dart';
import 'package:zapstore/screens/settings_screen.dart';
import 'package:zapstore/screens/updates_screen.dart';
import 'package:zapstore/widgets/error_container.dart';
import 'package:zapstore/widgets/spinning_logo.dart';
/// Application navigation.
/// - Initializes Go Router, dispatches routes
/// - Initializes Flutter Data (database) and Purplebase (nostr library)
/// - Builds media query dependent scaffold (mobile, desktop)
final appRouter = GoRouter(
initialLocation: '/',
// * Passing a navigatorKey causes an issue on hot reload:
// * https://github.com/flutter/flutter/issues/113757#issuecomment-1518421380
// * However it's still necessary otherwise the navigator pops back to
// * root on hot reload
navigatorKey: _rootNavigatorKey,
routes: [
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return ScaffoldWithNestedNavigation(navigationShell: navigationShell);
},
branches: [
StatefulShellBranch(
navigatorKey: _searchNavigatorKey,
routes: [
GoRoute(
path: '/',
builder: (context, state) => SearchScreen(),
routes: [appDetailsRoute, developerRoute],
),
],
),
StatefulShellBranch(
navigatorKey: _updatesNavigatorKey,
routes: [
GoRoute(
path: '/updates',
builder: (context, state) => UpdatesScreen(),
routes: [appDetailsRoute, developerRoute],
),
],
),
StatefulShellBranch(
navigatorKey: _settingsNavigatorKey,
routes: [
GoRoute(
path: '/settings',
pageBuilder: (context, state) => NoTransitionPage(
child: SettingsScreen(),
),
),
],
),
],
),
],
);
final appDetailsRoute = GoRoute(
path: 'details',
builder: (context, state) => AppDetailScreen(model: state.extra as App),
);
final developerRoute = GoRoute(
path: 'developer',
builder: (context, state) => DeveloperScreen(model: state.extra as User),
);
class ScaffoldWithNestedNavigation extends HookConsumerWidget {
const ScaffoldWithNestedNavigation({
Key? key,
required this.navigationShell,
}) : super(key: key ?? const ValueKey('ScaffoldWithNestedNavigation'));
final StatefulNavigationShell navigationShell;
void onDestinationSelected(int index) {
navigationShell.goBranch(
index,
// A common pattern when using bottom navigation bars is to support
// navigating to the initial location when tapping the item that is
// already active. This example demonstrates how to support this behavior,
// using the initialLocation parameter of goBranch.
initialLocation: index == navigationShell.currentIndex,
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final initializer = ref.watch(appInitializer);
return SafeArea(
child: initializer.when(
data: (_) => LayoutBuilder(
builder: (context, constraints) {
// At this point initialization is done
// listen to relay
ref.watch(relayListenerProvider);
if (constraints.maxWidth < 550) {
return MobileScaffold(
body: navigationShell,
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: onDestinationSelected,
);
} else {
return DesktopScaffold(
body: navigationShell,
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: onDestinationSelected,
);
}
},
),
error: (e, stack) {
errorHandler(e, stack);
return ErrorContainer(exception: e, stack: stack);
},
loading: () => Center(child: SpinningLogo()),
),
);
}
}
// Keys
final _rootNavigatorKey = GlobalKey<NavigatorState>();
final _searchNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'search');
final _updatesNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'updates');
final _settingsNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'settings');
+133
View File
@@ -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(),
],
),
],
),
],
),
],
);
});
+223 -323
View File
@@ -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),
],
),
),
);
}
+86
View File
@@ -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),
],
),
),
);
}
}
+475 -69
View File
@@ -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');
},
),
],
],
),
),
),
],
);
}
}

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