From 7ce406ee85899d964eb169721931a3d090bf30bc Mon Sep 17 00:00:00 2001 From: Anas Sulaiman Date: Mon, 26 Jan 2026 13:18:09 -0500 Subject: [PATCH 1/3] Implement SHOW_APP_INFO action. When a SHOW_APP_INFO intent is received, the app details page for the package is displayed if available. Otherwise, a hint is shown to let the user know the app is not unknown to Obtainium. Disclaimer: I don't know Flutter or Dart. I generated the code with AI, but I tested on my phone and confirmed it works as inteded. Fixes #2200 --- android/app/src/main/AndroidManifest.xml | 4 ++ .../dev/imranr/obtainium/MainActivity.kt | 50 ++++++++++++++- assets/translations/en.json | 1 + assets/translations/zh.json | 1 + lib/pages/home.dart | 62 +++++++++++++++++++ 5 files changed, 117 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 333199965..a14efcb3d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -40,6 +40,10 @@ + + + + diff --git a/android/app/src/main/kotlin/dev/imranr/obtainium/MainActivity.kt b/android/app/src/main/kotlin/dev/imranr/obtainium/MainActivity.kt index 71920acf6..5187b1db3 100644 --- a/android/app/src/main/kotlin/dev/imranr/obtainium/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/imranr/obtainium/MainActivity.kt @@ -1,5 +1,53 @@ package dev.imranr.obtainium +import android.content.Intent +import android.os.Bundle import io.flutter.embedding.android.FlutterActivity +import io.flutter.plugin.common.MethodChannel -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + private val CHANNEL = "dev.imranr.obtainium/intent" + private var pendingPackageName: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + handleIntent(intent) + setupMethodChannel() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleIntent(intent) + } + + private fun setupMethodChannel() { + flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger -> + MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result -> + when (call.method) { + "getPendingPackageName" -> { + val packageName = pendingPackageName + pendingPackageName = null + result.success(packageName) + } + else -> result.notImplemented() + } + } + } + } + + private fun handleIntent(intent: Intent?) { + intent?.let { + if (it.action == "android.intent.action.SHOW_APP_INFO") { + val packageName = it.getStringExtra("android.intent.extra.PACKAGE_NAME") + packageName?.let { + pendingPackageName = packageName + flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger -> + MethodChannel(messenger, CHANNEL).invokeMethod("showAppInfo", packageName) + } + pendingPackageName = null + } + } + } + } +} \ No newline at end of file diff --git a/assets/translations/en.json b/assets/translations/en.json index 7d90b95c6..ab5a7e90f 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -135,6 +135,7 @@ "close": "Close", "share": "Share", "appNotFound": "App not found", + "appNotManagedByObtainium": "This app is not managed by Obtainium", "obtainiumExportHyphenatedLowercase": "obtainium-export", "pickAnAPK": "Pick an APK", "appHasMoreThanOnePackage": "{} has more than one package:", diff --git a/assets/translations/zh.json b/assets/translations/zh.json index c0a9e44b7..9bd7c141d 100644 --- a/assets/translations/zh.json +++ b/assets/translations/zh.json @@ -135,6 +135,7 @@ "close": "关闭", "share": "分享", "appNotFound": "未找到应用", + "appNotManagedByObtainium": "此应用不由 Obtainium 管理", "obtainiumExportHyphenatedLowercase": "obtainium-export", "pickAnAPK": "选择一个 APK 文件", "appHasMoreThanOnePackage": "“{}”有多个架构可用:", diff --git a/lib/pages/home.dart b/lib/pages/home.dart index 685cf0d2c..b642069d9 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -39,6 +39,7 @@ class _HomePageState extends State { bool prevIsLoading = true; late AppLinks _appLinks; StreamSubscription? _linkSubscription; + static const platform = MethodChannel('dev.imranr.obtainium/intent'); bool isLinkActivity = false; List pages = [ @@ -179,7 +180,50 @@ class _HomePageState extends State { (pages[0].widget.key as GlobalKey?)?.currentState ?.openAppById(appId); } + handleShowAppInfo(String packageName) async { + isLinkActivity = true; + try { + // Ensure apps are loaded + AppsProvider appsProvider = context.read(); + while (appsProvider.loadingApps) { + await Future.delayed(const Duration(milliseconds: 10)); + } + // Find app by package name + AppInMemory? app = appsProvider.apps.values + .where((AppInMemory a) => a.app.id == packageName) + .firstOrNull; + + if (app != null) { + await goToExistingApp(app.app.id); + } else { + // Show error dialog + if (mounted) { + await showDialog( + context: context, + builder: (BuildContext ctx) { + return AlertDialog( + title: Text(tr('appNotFound')), + content: Text(tr('appNotManagedByObtainium')), + actions: [ + TextButton( + onPressed: () { + Navigator.of(ctx).pop(); + }, + child: Text(tr('ok')), + ), + ], + ); + }, + ); + } + } + } catch (e) { + if (mounted) { + showError(e, context); + } + } + } interpretLink(Uri uri) async { isLinkActivity = true; var action = uri.host; @@ -274,6 +318,24 @@ class _HomePageState extends State { initLinked = false; } }); + + // Handle Android intents + platform.setMethodCallHandler((call) async { + if (call.method == 'showAppInfo') { + String packageName = call.arguments; + await handleShowAppInfo(packageName); + } + }); + + // Check for pending package name from initial intent + try { + String? pendingPackage = await platform.invokeMethod('getPendingPackageName'); + if (pendingPackage != null) { + await handleShowAppInfo(pendingPackage); + } + } catch (e) { + // Ignore if method not implemented or error + } } void setIsReversing(int targetIndex) { From 92efda320c79571010421c692426ab53f71a9788 Mon Sep 17 00:00:00 2001 From: Anas Sulaiman Date: Fri, 30 Jan 2026 14:48:43 -0500 Subject: [PATCH 2/3] Fix potential concurrency issues --- .../dev/imranr/obtainium/MainActivity.kt | 28 +++++++++---------- lib/pages/home.dart | 7 +++-- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/android/app/src/main/kotlin/dev/imranr/obtainium/MainActivity.kt b/android/app/src/main/kotlin/dev/imranr/obtainium/MainActivity.kt index 5187b1db3..af4156a68 100644 --- a/android/app/src/main/kotlin/dev/imranr/obtainium/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/imranr/obtainium/MainActivity.kt @@ -3,16 +3,17 @@ package dev.imranr.obtainium import android.content.Intent import android.os.Bundle import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.ConcurrentLinkedQueue class MainActivity : FlutterActivity() { private val CHANNEL = "dev.imranr.obtainium/intent" - private var pendingPackageName: String? = null + private val pendingPackages = ConcurrentLinkedQueue() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) handleIntent(intent) - setupMethodChannel() } override fun onNewIntent(intent: Intent) { @@ -21,19 +22,21 @@ class MainActivity : FlutterActivity() { handleIntent(intent) } - private fun setupMethodChannel() { - flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger -> - MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result -> + // Register method channel when the FlutterEngine is configured (embedding v2 recommended pattern). + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) + .setMethodCallHandler { call, result -> when (call.method) { "getPendingPackageName" -> { - val packageName = pendingPackageName - pendingPackageName = null - result.success(packageName) + // Return next pending package name or null if none. + val pkg = pendingPackages.poll() + result.success(pkg) } else -> result.notImplemented() } } - } } private fun handleIntent(intent: Intent?) { @@ -41,11 +44,8 @@ class MainActivity : FlutterActivity() { if (it.action == "android.intent.action.SHOW_APP_INFO") { val packageName = it.getStringExtra("android.intent.extra.PACKAGE_NAME") packageName?.let { - pendingPackageName = packageName - flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger -> - MethodChannel(messenger, CHANNEL).invokeMethod("showAppInfo", packageName) - } - pendingPackageName = null + // Queue it so Dart can pick it up when ready. + pendingPackages.add(packageName) } } } diff --git a/lib/pages/home.dart b/lib/pages/home.dart index b642069d9..691578063 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -329,8 +329,11 @@ class _HomePageState extends State { // Check for pending package name from initial intent try { - String? pendingPackage = await platform.invokeMethod('getPendingPackageName'); - if (pendingPackage != null) { + // Keep polling the native side until it returns null. + while (true) { + final pendingPackage = + await platform.invokeMethod('getPendingPackageName'); + if (pendingPackage == null) break; await handleShowAppInfo(pendingPackage); } } catch (e) { From 3d1e80d470976c2f44ec322fbb5e26f7a8d2321f Mon Sep 17 00:00:00 2001 From: Anas Sulaiman Date: Fri, 30 Jan 2026 16:31:32 -0500 Subject: [PATCH 3/3] Improve coordination between native and dart --- .../dev/imranr/obtainium/MainActivity.kt | 73 +++++++++++--- lib/pages/home.dart | 97 +++++++++++++------ 2 files changed, 127 insertions(+), 43 deletions(-) diff --git a/android/app/src/main/kotlin/dev/imranr/obtainium/MainActivity.kt b/android/app/src/main/kotlin/dev/imranr/obtainium/MainActivity.kt index af4156a68..6d317fc93 100644 --- a/android/app/src/main/kotlin/dev/imranr/obtainium/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/imranr/obtainium/MainActivity.kt @@ -8,11 +8,19 @@ import io.flutter.plugin.common.MethodChannel import java.util.concurrent.ConcurrentLinkedQueue class MainActivity : FlutterActivity() { - private val CHANNEL = "dev.imranr.obtainium/intent" + private val channel = "dev.imranr.obtainium/intent" private val pendingPackages = ConcurrentLinkedQueue() + // Whether Dart has signalled it's ready to receive pushed intents + @Volatile + private var dartIsReady = false + + // Save the latest messenger for invoking methods when Dart is ready + private var lastBinaryMessenger: io.flutter.plugin.common.BinaryMessenger? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Capture initial intent (cold start) handleIntent(intent) } @@ -25,18 +33,32 @@ class MainActivity : FlutterActivity() { // Register method channel when the FlutterEngine is configured (embedding v2 recommended pattern). override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + lastBinaryMessenger = flutterEngine.dartExecutor.binaryMessenger - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) - .setMethodCallHandler { call, result -> - when (call.method) { - "getPendingPackageName" -> { - // Return next pending package name or null if none. - val pkg = pendingPackages.poll() - result.success(pkg) - } - else -> result.notImplemented() + val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel) + channel.setMethodCallHandler { call, result -> + when (call.method) { + "getPendingPackageName" -> { + // Return next pending package name or null if none. + val pkg = pendingPackages.poll() + result.success(pkg) } + + "readyForIntents" -> { + // Dart is ready to receive pushed intents. Drain queue by invoking showAppInfo. + dartIsReady = true + drainQueueToDart() + result.success(true) + } + + else -> result.notImplemented() } + } + + // If Dart later registers, we still have the messenger for invokes + if (dartIsReady) { + drainQueueToDart() + } } private fun handleIntent(intent: Intent?) { @@ -44,10 +66,37 @@ class MainActivity : FlutterActivity() { if (it.action == "android.intent.action.SHOW_APP_INFO") { val packageName = it.getStringExtra("android.intent.extra.PACKAGE_NAME") packageName?.let { - // Queue it so Dart can pick it up when ready. - pendingPackages.add(packageName) + if (dartIsReady && lastBinaryMessenger != null) { + // Deliver immediately to Dart + try { + MethodChannel(lastBinaryMessenger!!, channel) + .invokeMethod("showAppInfo", packageName) + } catch (_: Exception) { + // If invoke fails, fallback to queueing + pendingPackages.add(packageName) + } + } else { + // Queue it so Dart can pick it up when ready. + pendingPackages.add(packageName) + } } } } } + + private fun drainQueueToDart() { + val messenger = lastBinaryMessenger ?: return + val channel = MethodChannel(messenger, channel) + var pkg = pendingPackages.poll() + while (pkg != null) { + try { + channel.invokeMethod("showAppInfo", pkg) + } catch (_: Exception) { + // If invoke fails, requeue and stop draining to avoid busy loop + pendingPackages.add(pkg) + break + } + pkg = pendingPackages.poll() + } + } } \ No newline at end of file diff --git a/lib/pages/home.dart b/lib/pages/home.dart index 691578063..0ca6c3970 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -157,17 +157,6 @@ class _HomePageState extends State { Future initDeepLinks() async { _appLinks = AppLinks(); - goToAddApp(String data) async { - switchToPage(1); - while ((pages[1].widget.key as GlobalKey?) - ?.currentState == - null) { - await Future.delayed(const Duration(microseconds: 1)); - } - (pages[1].widget.key as GlobalKey?)?.currentState - ?.linkFn(data); - } - goToExistingApp(String appId) async { // Go to Apps page switchToPage(0); @@ -180,6 +169,7 @@ class _HomePageState extends State { (pages[0].widget.key as GlobalKey?)?.currentState ?.openAppById(appId); } + handleShowAppInfo(String packageName) async { isLinkActivity = true; try { @@ -197,26 +187,34 @@ class _HomePageState extends State { if (app != null) { await goToExistingApp(app.app.id); } else { - // Show error dialog - if (mounted) { - await showDialog( - context: context, - builder: (BuildContext ctx) { - return AlertDialog( - title: Text(tr('appNotFound')), - content: Text(tr('appNotManagedByObtainium')), - actions: [ - TextButton( - onPressed: () { - Navigator.of(ctx).pop(); - }, - child: Text(tr('ok')), - ), - ], - ); - }, - ); - } + // Defer showing the dialog until after the current frame and use the root navigator + if (!mounted) return; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + try { + await showDialog( + context: context, + useRootNavigator: true, + // ensure dialog is shown above any other navigator/dialog + builder: (BuildContext ctx) { + return AlertDialog( + title: Text(tr('appNotFound')), + content: Text(tr('appNotManagedByObtainium')), + actions: [ + TextButton( + onPressed: () { + Navigator.of(ctx).pop(); + }, + child: Text(tr('ok')), + ), + ], + ); + }, + ); + } catch (e) { + // ignore showDialog errors in production; optionally log in debug builds + } + }); } } catch (e) { if (mounted) { @@ -224,6 +222,43 @@ class _HomePageState extends State { } } } + // Register handler to receive immediate pushes from native + platform.setMethodCallHandler((call) async { + if (call.method == 'showAppInfo') { + final String packageName = call.arguments as String; + await handleShowAppInfo(packageName); + } + // other native->dart calls can be handled here + }); + // Tell native that Dart is ready to receive pushed intents and to drain any queued items. + try { + await platform.invokeMethod('readyForIntents'); + } catch (e) { + // If method not implemented on older native code, ignore. + } + // Also poll for any pending package names (cold-start fallback). + try { + while (true) { + final pendingPackage = + await platform.invokeMethod('getPendingPackageName'); + if (pendingPackage == null) break; + await handleShowAppInfo(pendingPackage); + } + } catch (e) { + // ignore + } + + goToAddApp(String data) async { + switchToPage(1); + while ((pages[1].widget.key as GlobalKey?) + ?.currentState == + null) { + await Future.delayed(const Duration(microseconds: 1)); + } + (pages[1].widget.key as GlobalKey?)?.currentState + ?.linkFn(data); + } + interpretLink(Uri uri) async { isLinkActivity = true; var action = uri.host;