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..6d317fc93 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,102 @@ 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() +class MainActivity : FlutterActivity() { + 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) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleIntent(intent) + } + + // Register method channel when the FlutterEngine is configured (embedding v2 recommended pattern). + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + lastBinaryMessenger = flutterEngine.dartExecutor.binaryMessenger + + 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?) { + intent?.let { + if (it.action == "android.intent.action.SHOW_APP_INFO") { + val packageName = it.getStringExtra("android.intent.extra.PACKAGE_NAME") + packageName?.let { + 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/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..0ca6c3970 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 = [ @@ -156,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 +170,95 @@ class _HomePageState extends State { ?.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 { + // 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) { + showError(e, context); + } + } + } + // 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; @@ -274,6 +353,27 @@ 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 { + // 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) { + // Ignore if method not implemented or error + } } void setIsReversing(int targetIndex) {