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) { 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 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..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 @@ -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) { @@ -447,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") } @@ -623,7 +630,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() }