From 498bfe7a0ca3643022fbbc87cbf2cb758024d40b Mon Sep 17 00:00:00 2001 From: LeaderOnePro Date: Tue, 21 Jul 2026 02:44:11 +0800 Subject: [PATCH 1/4] fix(android): request runtime permissions on API 23-29 at startup On API 23-29 (Android 6-9), WRITE_EXTERNAL_STORAGE and READ_PHONE_STATE are dangerous permissions that must be granted at runtime. Without this the service panicked with "mkdir ... permission denied" when trying to create its workspace in shared storage. Prompts the user from MainActivity.onCreate on first launch. API < 23 (install-time grants) and API 30+ (MANAGE_EXTERNAL_STORAGE path, already handled in onResume) are skipped. Co-Authored-By: Claude Fable 5 --- .../com/sipeed/picoclaw/MainActivity.kt | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/android/app/src/main/kotlin/com/sipeed/picoclaw/MainActivity.kt b/android/app/src/main/kotlin/com/sipeed/picoclaw/MainActivity.kt index 674ece9..a53eafc 100644 --- a/android/app/src/main/kotlin/com/sipeed/picoclaw/MainActivity.kt +++ b/android/app/src/main/kotlin/com/sipeed/picoclaw/MainActivity.kt @@ -1,18 +1,22 @@ package com.sipeed.picoclaw import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Environment import android.provider.Settings import android.util.Log +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine class MainActivity : FlutterActivity() { companion object { private const val TAG = "MainActivity" + private const val PERMISSION_REQUEST_CODE = 1001 } private var methodChannel: PicoClawMethodChannel? = null @@ -20,6 +24,56 @@ class MainActivity : FlutterActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) logIncomingIntent(intent) + requestRequiredPermissions() + } + + /** + * Request runtime permissions that the service needs. + * + * - API 23–29: WRITE_EXTERNAL_STORAGE / READ_EXTERNAL_STORAGE are required + * to write workspace files to shared storage. + * - API 30+: MANAGE_EXTERNAL_STORAGE is handled in onResume() instead. + * - READ_PHONE_STATE: required by Umeng analytics. + * + * On API < 23 all of these are granted at install time, so no prompt. + */ + private fun requestRequiredPermissions() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return + // API 30+ uses MANAGE_EXTERNAL_STORAGE (handled in onResume). + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) return + + val needed = mutableListOf() + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + // WRITE_EXTERNAL_STORAGE is the only way to write to shared + // storage on API 23–29. + if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.WRITE_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED + ) needed.add(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) + if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.READ_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED + ) needed.add(android.Manifest.permission.READ_EXTERNAL_STORAGE) + } + if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.READ_PHONE_STATE) + != PackageManager.PERMISSION_GRANTED + ) needed.add(android.Manifest.permission.READ_PHONE_STATE) + + if (needed.isNotEmpty()) { + ActivityCompat.requestPermissions(this, needed.toTypedArray(), PERMISSION_REQUEST_CODE) + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == PERMISSION_REQUEST_CODE) { + for (i in permissions.indices) { + val granted = grantResults.getOrElse(i) { PackageManager.PERMISSION_GRANTED } + Log.d(TAG, "Permission ${permissions[i]}: ${if (granted == PackageManager.PERMISSION_GRANTED) "GRANTED" else "DENIED"}") + } + } } override fun configureFlutterEngine(flutterEngine: FlutterEngine) { From b82dd8d453268ca265dd00c04344ef372eaeb027 Mon Sep 17 00:00:00 2001 From: LeaderOnePro Date: Tue, 21 Jul 2026 02:44:21 +0800 Subject: [PATCH 2/4] fix(android): replace API 26+ calls in service start/stop with compat equivalents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crash 1 — on service START (on API < 26): java.lang.NoSuchMethodError: startForegroundService (added in API 26) Fix: ContextCompat.startForegroundService (falls back to startService) Crash 2 — on service STOP (on API < 26): java.lang.NoSuchMethodError: Process.isAlive (added in API 26) Fix: thread.isAlive() — logically equivalent since the wait-thread exits exactly when the process does, and available since API 1. Co-Authored-By: Claude Fable 5 --- .../com/sipeed/picoclaw/service/PicoClawService.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/kotlin/com/sipeed/picoclaw/service/PicoClawService.kt b/android/app/src/main/kotlin/com/sipeed/picoclaw/service/PicoClawService.kt index 4a16feb..9a2a011 100644 --- a/android/app/src/main/kotlin/com/sipeed/picoclaw/service/PicoClawService.kt +++ b/android/app/src/main/kotlin/com/sipeed/picoclaw/service/PicoClawService.kt @@ -9,6 +9,7 @@ import android.os.IBinder import android.os.PowerManager import android.util.Log import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat import com.sipeed.picoclaw.PicoClawApp import com.sipeed.picoclaw.MainActivity import java.io.BufferedReader @@ -55,7 +56,9 @@ class PicoClawService : Service() { action = ACTION_START putExtra(EXTRA_PUBLIC_MODE, publicMode) } - context.startForegroundService(intent) + // ContextCompat.startForegroundService calls startService on API < 26, + // automatically upgrading to startForegroundService on API 26+. + ContextCompat.startForegroundService(context, intent) } fun stop(context: Context) { @@ -623,7 +626,10 @@ class PicoClawService : Service() { thread.start() thread.join(10_000) - if (proc.isAlive) { + // Use thread.isAlive() in place of Process.isAlive() which is + // only available on API 26+. After join() timed out, the + // wait-thread being alive means the process is still running. + if (thread.isAlive()) { Log.w(TAG, "Force killing web service process") proc.destroyForcibly() } From f6104b4aafb679fc88fa96f18ca652c924375a83 Mon Sep 17 00:00:00 2001 From: LeaderOnePro Date: Tue, 21 Jul 2026 02:44:29 +0800 Subject: [PATCH 3/4] fix(android): guard NotificationChannel creation behind API 26 check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App crashed on launch on API 25 (Android 7.x) with: java.lang.NoClassDefFoundError: android.app.NotificationChannel NotificationChannel was added in API 26 — the unconditional reference prevented theApplication from even finishing onCreate. Skips channel creation on API < 26; the foreground service still works, it just uses the default notification style on those older devices. Co-Authored-By: Claude Fable 5 --- .../main/kotlin/com/sipeed/picoclaw/PicoClawApp.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/kotlin/com/sipeed/picoclaw/PicoClawApp.kt b/android/app/src/main/kotlin/com/sipeed/picoclaw/PicoClawApp.kt index 8480c36..a49dd8d 100644 --- a/android/app/src/main/kotlin/com/sipeed/picoclaw/PicoClawApp.kt +++ b/android/app/src/main/kotlin/com/sipeed/picoclaw/PicoClawApp.kt @@ -1,6 +1,6 @@ package com.sipeed.picoclaw -import android.app.NotificationChannel +import android.os.Build import android.app.NotificationManager import io.flutter.app.FlutterApplication @@ -18,7 +18,15 @@ class PicoClawApp : FlutterApplication() { } private fun createNotificationChannel() { - val channel = NotificationChannel( + // NotificationChannel is only available on API 26 (Android 8.0)+. + // On API 25 and below (e.g. Android 7.x devices), skip channel + // creation entirely — the foreground service still works, it just + // won't have a dedicated notification channel. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + + val channel = android.app.NotificationChannel( CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW From 68fc8eac694b2338e53d700bba498ea9607d6311 Mon Sep 17 00:00:00 2001 From: LeaderOnePro Date: Tue, 21 Jul 2026 19:37:06 +0800 Subject: [PATCH 4/4] fix(android): use -host 0.0.0.0 instead of -public for public mode on Android 7 On API 25 (Android 7.x) the Go binary's -public flag is not detected by flag.Visit, causing effectivePublic to fall back to the config default (false) and the service binds to 127.0.0.1 only. Switch to -host 0.0.0.0 which takes the exact-binding code path and binds to all interfaces reliably on every API level. Co-Authored-By: Claude Fable 5 --- .../com/sipeed/picoclaw/service/PicoClawService.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/kotlin/com/sipeed/picoclaw/service/PicoClawService.kt b/android/app/src/main/kotlin/com/sipeed/picoclaw/service/PicoClawService.kt index 9a2a011..0129c04 100644 --- a/android/app/src/main/kotlin/com/sipeed/picoclaw/service/PicoClawService.kt +++ b/android/app/src/main/kotlin/com/sipeed/picoclaw/service/PicoClawService.kt @@ -450,10 +450,14 @@ class PicoClawService : Service() { "--no-browser" ) - // 只有在公共模式开启时才添加 -public 参数 + // 公共模式:绑定到 0.0.0.0 使其他设备可访问。 + // 使用 -host 而非 -public:-public 依赖 Go 的 effectivePublic 计算, + // 旧版二进制中 flag.Visit 可能检测不到该 flag 导致回退到 localhost。 + // -host 走 exact binding 路径,更可靠。 if (publicMode) { - cmdList.add("-public") - Log.i(TAG, "Public mode enabled, adding -public flag") + cmdList.add("-host") + cmdList.add("0.0.0.0") + Log.i(TAG, "Public mode enabled, binding to 0.0.0.0") } else { Log.i(TAG, "Public mode disabled, service will listen on localhost only") }