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) {