Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="obtainium" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SHOW_APP_INFO" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
Expand Down
99 changes: 98 additions & 1 deletion android/app/src/main/kotlin/dev/imranr/obtainium/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -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<String>()

// 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()
}
}
}
1 change: 1 addition & 0 deletions assets/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
1 change: 1 addition & 0 deletions assets/translations/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@
"close": "关闭",
"share": "分享",
"appNotFound": "未找到应用",
"appNotManagedByObtainium": "此应用不由 Obtainium 管理",
"obtainiumExportHyphenatedLowercase": "obtainium-export",
"pickAnAPK": "选择一个 APK 文件",
"appHasMoreThanOnePackage": "“{}”有多个架构可用:",
Expand Down
122 changes: 111 additions & 11 deletions lib/pages/home.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class _HomePageState extends State<HomePage> {
bool prevIsLoading = true;
late AppLinks _appLinks;
StreamSubscription<Uri>? _linkSubscription;
static const platform = MethodChannel('dev.imranr.obtainium/intent');
bool isLinkActivity = false;

List<NavigationPageItem> pages = [
Expand Down Expand Up @@ -156,17 +157,6 @@ class _HomePageState extends State<HomePage> {
Future<void> initDeepLinks() async {
_appLinks = AppLinks();

goToAddApp(String data) async {
switchToPage(1);
while ((pages[1].widget.key as GlobalKey<AddAppPageState>?)
?.currentState ==
null) {
await Future.delayed(const Duration(microseconds: 1));
}
(pages[1].widget.key as GlobalKey<AddAppPageState>?)?.currentState
?.linkFn(data);
}

goToExistingApp(String appId) async {
// Go to Apps page
switchToPage(0);
Expand All @@ -180,6 +170,95 @@ class _HomePageState extends State<HomePage> {
?.openAppById(appId);
}

handleShowAppInfo(String packageName) async {
isLinkActivity = true;
try {
// Ensure apps are loaded
AppsProvider appsProvider = context.read<AppsProvider>();
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<String>('getPendingPackageName');
if (pendingPackage == null) break;
await handleShowAppInfo(pendingPackage);
}
} catch (e) {
// ignore
}

goToAddApp(String data) async {
switchToPage(1);
while ((pages[1].widget.key as GlobalKey<AddAppPageState>?)
?.currentState ==
null) {
await Future.delayed(const Duration(microseconds: 1));
}
(pages[1].widget.key as GlobalKey<AddAppPageState>?)?.currentState
?.linkFn(data);
}

interpretLink(Uri uri) async {
isLinkActivity = true;
var action = uri.host;
Expand Down Expand Up @@ -274,6 +353,27 @@ class _HomePageState extends State<HomePage> {
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<String>('getPendingPackageName');
if (pendingPackage == null) break;
await handleShowAppInfo(pendingPackage);
}
} catch (e) {
// Ignore if method not implemented or error
}
}

void setIsReversing(int targetIndex) {
Expand Down