diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index cdde32a..8b770cc 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -45,6 +45,9 @@ jobs: npm ci --ignore-scripts npm run build + - name: Check event-driven power behavior + run: ./tools/check-power-behavior.sh + - name: Prepare signing key if: github.event_name != 'pull_request' env: diff --git a/README.md b/README.md index 0285d48..e1e9c57 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,15 @@ Root-first Android voice typing that stays available, transcribes through Wispr ## Architecture -- **Floating APK service:** draggable tap-to-record bubble using a foreground service. +- **Gboard trigger:** in-process, event-driven mic interception and transcription. It does not ping or wake Gboard in the background. +- **Optional floating APK service:** draggable tap-to-record bubble using a foreground service only while the bubble is enabled. - **Wispr client:** email login or session JSON import, automatic refresh-token renewal, HTTP transcription fallback. - **Selectable insertion:** - **Auto:** direct `InputConnection.commitText()` through an LSPosed IME bridge, then root clipboard + `KEYCODE_PASTE` fallback. - **LSPosed:** direct InputConnection only. - **Clipboard/root paste:** compatibility fallback. -- **KernelSU module:** grants the overlay AppOp, keeps the service alive, lowers its OOM score, and checks releases. -- **Hot update:** the KernelSU Action downloads a SHA-256-verified runtime release, installs the APK in place, restarts the service, and does not request a reboot. +- **KernelSU module:** grants required permissions once at boot and restores the bubble only when it was enabled. It does not run a persistent watchdog or adjust the app's OOM score. +- **Manual hot update:** the KernelSU Action downloads a SHA-256-verified runtime release, installs the APK in place, and restores the bubble only when enabled. It does not request a reboot. ## First install @@ -20,7 +21,9 @@ Root-first Android voice typing that stays available, transcribes through Wispr 3. For direct insertion, enable betterFlow in LSPosed and scope it to your current keyboard. Gboard and AOSP LatinIME are predeclared. 4. Choose **Auto** in betterFlow. If the LSPosed bridge is unavailable, it falls back to root paste. -KernelSU's **Action** button is the fast-update button. It fetches and applies the newest release without rebooting. The watchdog also checks every six hours by default. +KernelSU's **Action** button checks for and applies the newest release without rebooting. Updates are manual, so betterFlow performs no periodic network or process polling. + +With the floating microphone disabled, betterFlow has no long-running app service. Gboard voice typing remains available through the LSPosed hook and only does work in response to keyboard lifecycle and touch events. ## CI diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 64e62c7..1aade1c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,6 +80,7 @@ dependencies { implementation(libs.compose.ui) implementation(libs.compose.ui.tooling.preview) debugImplementation(libs.compose.ui.tooling) + testImplementation("junit:junit:4.13.2") } protobuf { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1813938..cb5eb44 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,7 +7,6 @@ - - - - - - - - - Bundle().apply { - putBoolean(KEY_OK, true) - putString(KEY_VOICE_STATE, VoiceRuntimeState.wireName) - putBoolean(KEY_GBOARD_MIC_ENABLED, Prefs.gboardMicEnabled(ctx)) - } - METHOD_GET_TOGGLE_PENDING_INTENT -> { - val requestCode = (SystemClock.elapsedRealtimeNanos() and 0x7fffffffL).toInt() - val pendingIntent = PendingIntent.getForegroundService( - ctx, - requestCode, - Intent(ctx, OverlayService::class.java).setAction(OverlayService.ACTION_TOGGLE), - PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE, - ) - Bundle().apply { - putBoolean(KEY_OK, true) - putParcelable(KEY_TOGGLE_PENDING_INTENT, pendingIntent) - } - } - else -> rejected("unknown method") - } - } - - private fun rejected(message: String): Bundle = Bundle().apply { - putBoolean(KEY_OK, false) - putString(KEY_ERROR, message) - } - - override fun query( - uri: Uri, - projection: Array?, - selection: String?, - selectionArgs: Array?, - sortOrder: String?, - ): Cursor? = null - - override fun getType(uri: Uri): String? = null - override fun insert(uri: Uri, values: ContentValues?): Uri? = null - override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 - override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0 - - companion object { - const val AUTHORITY = "com.jadenjsj.betterflow.gboard-bridge" - const val METHOD_SNAPSHOT = "snapshot" - const val METHOD_GET_TOGGLE_PENDING_INTENT = "toggle_pending_intent" - const val KEY_OK = "ok" - const val KEY_ERROR = "error" - const val KEY_VOICE_STATE = "voice_state" - const val KEY_GBOARD_MIC_ENABLED = "gboard_mic_enabled" - const val KEY_TOGGLE_PENDING_INTENT = "toggle_pending_intent" - private const val GBOARD_PACKAGE = "com.google.android.inputmethod.latin" - private const val TAG = "betterFlow/GboardProvider" - } -} diff --git a/app/src/main/java/com/jadenjsj/betterflow/GboardBridgeReceiver.kt b/app/src/main/java/com/jadenjsj/betterflow/GboardBridgeReceiver.kt deleted file mode 100644 index 5e7fdba..0000000 --- a/app/src/main/java/com/jadenjsj/betterflow/GboardBridgeReceiver.kt +++ /dev/null @@ -1,109 +0,0 @@ -package com.jadenjsj.betterflow - -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.os.ResultReceiver -import android.os.SystemClock -import android.util.Log - -/** - * Explicit, authenticated bridge for the injected Gboard hook. - * - * Some HyperOS builds return false from cross-app bindService() even for an - * exported explicit service. Explicit broadcasts remain reliable. We verify - * the framework-reported sender UID belongs to Gboard before returning either - * state/config or a one-shot PendingIntent capability for OverlayService. - */ -class GboardBridgeReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action != ACTION_BRIDGE) return - val resultReceiver = if (Build.VERSION.SDK_INT >= 33) { - intent.getParcelableExtra(EXTRA_RESULT_RECEIVER, ResultReceiver::class.java) - } else { - @Suppress("DEPRECATION") - intent.getParcelableExtra(EXTRA_RESULT_RECEIVER) - } - val senderProof = if (Build.VERSION.SDK_INT >= 33) { - intent.getParcelableExtra(EXTRA_SENDER_PROOF, PendingIntent::class.java) - } else { - @Suppress("DEPRECATION") - intent.getParcelableExtra(EXTRA_SENDER_PROOF) - } - val proofPackage = senderProof?.creatorPackage - val proofUid = senderProof?.creatorUid ?: -1 - val proofPackages = if (proofUid >= 0) { - context.packageManager.getPackagesForUid(proofUid).orEmpty() - } else { - emptyArray() - } - val directSenderUid = if (Build.VERSION.SDK_INT >= 34) sentFromUid else -1 - val directSenderPackages = if (directSenderUid >= 0) { - context.packageManager.getPackagesForUid(directSenderUid).orEmpty() - } else { - emptyArray() - } - val directIdentityValid = directSenderUid >= 0 && GBOARD_PACKAGE in directSenderPackages - val proofIdentityValid = proofPackage == GBOARD_PACKAGE && proofUid >= 0 && - (proofPackages.isEmpty() || GBOARD_PACKAGE in proofPackages) - if (!directIdentityValid && !proofIdentityValid) { - Log.w( - TAG, - "Rejected bridge broadcast directUid=$directSenderUid " + - "proofUid=$proofUid proofPackage=$proofPackage", - ) - resultReceiver?.send(RESULT_REJECTED, Bundle.EMPTY) - return - } - Log.i( - TAG, - "Accepted Gboard bridge directUid=$directSenderUid proofUid=$proofUid proofPackage=$proofPackage", - ) - - when (intent.getStringExtra(EXTRA_COMMAND)) { - COMMAND_SNAPSHOT -> { - resultReceiver?.send( - RESULT_OK, - Bundle().apply { - putString(EXTRA_VOICE_STATE, VoiceRuntimeState.wireName) - putBoolean(EXTRA_GBOARD_MIC_ENABLED, Prefs.gboardMicEnabled(context)) - }, - ) - } - COMMAND_GET_TOGGLE_PENDING_INTENT -> { - val requestCode = (SystemClock.elapsedRealtime() and 0x7fffffffL).toInt() - val pendingIntent = PendingIntent.getForegroundService( - context, - requestCode, - Intent(context, OverlayService::class.java) - .setAction(OverlayService.ACTION_TOGGLE), - PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE, - ) - resultReceiver?.send( - RESULT_OK, - Bundle().apply { putParcelable(EXTRA_TOGGLE_PENDING_INTENT, pendingIntent) }, - ) - } - else -> resultReceiver?.send(RESULT_REJECTED, Bundle.EMPTY) - } - } - - companion object { - const val ACTION_BRIDGE = "com.jadenjsj.betterflow.action.GBOARD_BRIDGE" - const val COMMAND_SNAPSHOT = "snapshot" - const val COMMAND_GET_TOGGLE_PENDING_INTENT = "toggle_pending_intent" - const val EXTRA_COMMAND = "bridge_command" - const val EXTRA_RESULT_RECEIVER = "bridge_result_receiver" - const val EXTRA_SENDER_PROOF = "bridge_sender_proof" - const val EXTRA_VOICE_STATE = "bridge_voice_state" - const val EXTRA_GBOARD_MIC_ENABLED = "bridge_gboard_mic_enabled" - const val EXTRA_TOGGLE_PENDING_INTENT = "bridge_toggle_pending_intent" - const val RESULT_OK = 1 - const val RESULT_REJECTED = 0 - private const val GBOARD_PACKAGE = "com.google.android.inputmethod.latin" - private const val TAG = "betterFlow/GboardReceiver" - } -} diff --git a/app/src/main/java/com/jadenjsj/betterflow/GboardBridgeService.kt b/app/src/main/java/com/jadenjsj/betterflow/GboardBridgeService.kt deleted file mode 100644 index 5a4f09c..0000000 --- a/app/src/main/java/com/jadenjsj/betterflow/GboardBridgeService.kt +++ /dev/null @@ -1,109 +0,0 @@ -package com.jadenjsj.betterflow - -import android.app.PendingIntent -import android.app.Service -import android.content.Intent -import android.os.Binder -import android.os.IBinder -import android.os.Parcel -import android.util.Log - -class GboardBridgeService : Service() { - private val bridge = object : Binder() { - override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { - if ( - code != TRANSACTION_TOGGLE && - code != TRANSACTION_GET_STATE && - code != TRANSACTION_GET_CONFIG && - code != TRANSACTION_GET_TOGGLE_PENDING_INTENT - ) { - return super.onTransact(code, data, reply, flags) - } - data.enforceInterface(DESCRIPTOR) - - val callerUid = Binder.getCallingUid() - val callerPackages = packageManager.getPackagesForUid(callerUid).orEmpty() - if (GBOARD_PACKAGE !in callerPackages) { - Log.w(TAG, "Rejected Gboard bridge call from uid=$callerUid packages=${callerPackages.joinToString()}") - reply?.writeNoException() - when (code) { - TRANSACTION_TOGGLE -> reply?.writeInt(0) - TRANSACTION_GET_STATE -> reply?.writeString("idle") - TRANSACTION_GET_CONFIG -> reply?.writeInt(0) - TRANSACTION_GET_TOGGLE_PENDING_INTENT -> reply?.let { PendingIntent.writePendingIntentOrNullToParcel(null, it) } - } - return true - } - - if (code == TRANSACTION_GET_STATE) { - reply?.writeNoException() - reply?.writeString(VoiceRuntimeState.wireName) - return true - } - - if (code == TRANSACTION_GET_CONFIG) { - reply?.writeNoException() - reply?.writeInt(if (Prefs.gboardMicEnabled(this@GboardBridgeService)) 1 else 0) - return true - } - - if (code == TRANSACTION_GET_TOGGLE_PENDING_INTENT) { - val pendingIntent = PendingIntent.getForegroundService( - this@GboardBridgeService, - TOGGLE_PENDING_INTENT_REQUEST_CODE, - Intent(this@GboardBridgeService, OverlayService::class.java) - .setAction(OverlayService.ACTION_TOGGLE), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - reply?.writeNoException() - reply?.let { PendingIntent.writePendingIntentOrNullToParcel(pendingIntent, it) } - Log.i(TAG, "Issued authenticated Gboard foreground-service toggle PendingIntent") - return true - } - - Log.i(TAG, "Accepted Gboard Binder toggle from uid=$callerUid") - val identity = Binder.clearCallingIdentity() - val ok = try { - startForegroundService( - Intent(this@GboardBridgeService, OverlayService::class.java) - .setAction(OverlayService.ACTION_TOGGLE), - ) - true - } catch (t: Throwable) { - Log.e(TAG, "Could not start OverlayService from Gboard Binder trigger", t) - false - } finally { - Binder.restoreCallingIdentity(identity) - } - reply?.writeNoException() - reply?.writeInt(if (ok) 1 else 0) - return true - } - } - - override fun onCreate() { - super.onCreate() - Log.i(TAG, "Gboard Binder bridge created") - } - - override fun onBind(intent: Intent?): IBinder { - Log.i(TAG, "Gboard Binder bridge bound") - return bridge - } - - override fun onDestroy() { - Log.i(TAG, "Gboard Binder bridge destroyed") - super.onDestroy() - } - - companion object { - const val DESCRIPTOR = "com.jadenjsj.betterflow.GboardBridge" - const val TRANSACTION_TOGGLE = IBinder.FIRST_CALL_TRANSACTION - const val TRANSACTION_GET_STATE = IBinder.FIRST_CALL_TRANSACTION + 1 - const val TRANSACTION_GET_CONFIG = IBinder.FIRST_CALL_TRANSACTION + 2 - const val TRANSACTION_GET_TOGGLE_PENDING_INTENT = IBinder.FIRST_CALL_TRANSACTION + 3 - private const val TOGGLE_PENDING_INTENT_REQUEST_CODE = 41 - private const val GBOARD_PACKAGE = "com.google.android.inputmethod.latin" - private const val TAG = "betterFlow/GboardBridge" - } -} diff --git a/app/src/main/java/com/jadenjsj/betterflow/MainActivity.kt b/app/src/main/java/com/jadenjsj/betterflow/MainActivity.kt index 3aa1b4c..d0b914a 100644 --- a/app/src/main/java/com/jadenjsj/betterflow/MainActivity.kt +++ b/app/src/main/java/com/jadenjsj/betterflow/MainActivity.kt @@ -11,6 +11,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts +import androidx.lifecycle.lifecycleScope import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -47,17 +48,15 @@ import kotlin.math.roundToInt class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - runCatching { - startForegroundService( - Intent(this, OverlayService::class.java) - .setAction(OverlayService.ACTION_WAKE), - ) + lifecycleScope.launch { + RootShell.setBubbleBootEnabled(Prefs.bubbleVisible(this@MainActivity)) } setContent { MaterialTheme { SettingsScreen() } } } } private fun refreshOverlayConfig(context: Context) { + if (!Prefs.bubbleVisible(context)) return runCatching { context.startForegroundService( Intent(context, OverlayService::class.java) @@ -231,9 +230,13 @@ private fun SettingsScreen() { } else { bubbleEnabled = enabled Prefs.setBubbleVisible(context, enabled) + coroutine.launch { RootShell.setBubbleBootEnabled(enabled) } val serviceIntent = Intent(context, OverlayService::class.java) - .setAction(if (enabled) OverlayService.ACTION_SHOW else OverlayService.ACTION_HIDE) - context.startForegroundService(serviceIntent) + if (enabled) { + context.startForegroundService(serviceIntent.setAction(OverlayService.ACTION_SHOW)) + } else { + context.stopService(serviceIntent) + } status = if (enabled) "Floating microphone enabled" else "Floating microphone disabled" } }, diff --git a/app/src/main/java/com/jadenjsj/betterflow/OverlayService.kt b/app/src/main/java/com/jadenjsj/betterflow/OverlayService.kt index 3e0a328..362282b 100644 --- a/app/src/main/java/com/jadenjsj/betterflow/OverlayService.kt +++ b/app/src/main/java/com/jadenjsj/betterflow/OverlayService.kt @@ -77,20 +77,51 @@ class OverlayService : Service() { ensureNotificationChannel() VoiceRuntimeState.wireName = BubbleState.IDLE.wireName updateForeground(BubbleState.IDLE) + RootShell.retireLegacyWatchdog() broadcastVoiceState(BubbleState.IDLE) if (Prefs.bubbleVisible(this)) showBubble(persist = false) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - when (intent?.action) { - ACTION_HIDE -> hideBubble(persist = true) - ACTION_SHOW -> showBubble(persist = true) + val remainRunning = when (intent?.action) { + ACTION_HIDE -> { + hideBubble(persist = true, updateNotification = false) + RootShell.setBubbleBootEnabledAsync(false) + false + } + ACTION_SHOW -> { + showBubble(persist = true) + if (bubble != null) RootShell.setBubbleBootEnabledAsync(true) + bubble != null + } ACTION_WAKE -> restoreBubbleVisibility() - ACTION_REFRESH_CONFIG -> refreshRuntimeConfig() - ACTION_TOGGLE -> toggleRecording() - ACTION_STOP -> stopSelf() + ACTION_REFRESH_CONFIG -> { + if (Prefs.bubbleVisible(this)) { + refreshRuntimeConfig() + true + } else { + false + } + } + ACTION_TOGGLE -> { + if (Prefs.bubbleVisible(this)) { + toggleRecording() + true + } else { + false + } + } + ACTION_STOP -> { + hideBubble(persist = true, updateNotification = false) + RootShell.setBubbleBootEnabledAsync(false) + false + } else -> restoreBubbleVisibility() } + if (!remainRunning) { + stopSelf(startId) + return START_NOT_STICKY + } return START_STICKY } @@ -114,16 +145,18 @@ class OverlayService : Service() { state = BubbleState.IDLE VoiceRuntimeState.wireName = BubbleState.IDLE.wireName broadcastVoiceState(BubbleState.IDLE) - hideBubble(persist = false) + hideBubble(persist = false, updateNotification = false) scope.cancel() super.onDestroy() } - private fun restoreBubbleVisibility() { - if (Prefs.bubbleVisible(this)) { + private fun restoreBubbleVisibility(): Boolean { + return if (Prefs.bubbleVisible(this)) { showBubble(persist = false) + bubble != null } else { - hideBubble(persist = false) + hideBubble(persist = false, updateNotification = false) + false } } @@ -386,13 +419,13 @@ class OverlayService : Service() { runCatching { windowManager.updateViewLayout(container, p) } } - private fun hideBubble(persist: Boolean = true) { + private fun hideBubble(persist: Boolean = true, updateNotification: Boolean = true) { if (persist) Prefs.setBubbleVisible(this, false) bubble?.let { runCatching { windowManager.removeView(it) } } bubble = null bubbleImage = null params = null - updateForeground(state) + if (updateNotification) updateForeground(state) } private fun toggleRecording() { @@ -661,6 +694,7 @@ class OverlayService : Service() { currentPcm = ByteArray(0) Log.i(TAG, "processing cancelled by user") updateState(BubbleState.IDLE) + if (!Prefs.bubbleVisible(this)) stopSelf() } private fun cancelStreamOnly(reason: String) { @@ -682,6 +716,7 @@ class OverlayService : Service() { streamingFailure = null currentPcm = ByteArray(0) updateState(BubbleState.IDLE) + if (!Prefs.bubbleVisible(this)) stopSelf() } private fun updateState(next: BubbleState) { diff --git a/app/src/main/java/com/jadenjsj/betterflow/RootShell.kt b/app/src/main/java/com/jadenjsj/betterflow/RootShell.kt index c6abf0b..e53f306 100644 --- a/app/src/main/java/com/jadenjsj/betterflow/RootShell.kt +++ b/app/src/main/java/com/jadenjsj/betterflow/RootShell.kt @@ -2,8 +2,16 @@ package com.jadenjsj.betterflow import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.util.concurrent.ExecutionException +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException object RootShell { + private val bubbleBootStateWorker = BubbleBootStateWorker { enabled -> + runFixed(bubbleBootCommand(enabled)).first == 0 + } + suspend fun hasRoot(): Boolean = withContext(Dispatchers.IO) { val result = runFixed("id -u") result.first == 0 && result.second.trim() == "0" @@ -13,6 +21,33 @@ object RootShell { runFixed("input keyevent 279").first == 0 } + suspend fun setBubbleBootEnabled(enabled: Boolean): Boolean = withContext(Dispatchers.IO) { + bubbleBootStateWorker.set(enabled) + } + + fun setBubbleBootEnabledAsync(enabled: Boolean) { + bubbleBootStateWorker.setAsync(enabled) + } + + fun retireLegacyWatchdog() { + Thread({ + runFixed( + "setprop ctl.stop betterflow_watchdog 2>/dev/null; " + + "pid=\$(cat /data/adb/betterflow-data/watchdog.pid 2>/dev/null); " + + "case \"\$pid\" in ''|*[!0-9]*) ;; *) " + + "if tr '\\000' ' ' < /proc/\$pid/cmdline 2>/dev/null | " + + "grep -q '/betterflow/scripts/watchdog.sh'; then kill \$pid 2>/dev/null; fi;; esac; " + + "rm -f /data/adb/betterflow-data/watchdog.pid", + ) + }, "betterflow-retire-watchdog").start() + } + + private fun bubbleBootCommand(enabled: Boolean): String = if (enabled) { + "mkdir -p /data/adb/betterflow-data && echo 1 > /data/adb/betterflow-data/bubble_enabled" + } else { + "rm -f /data/adb/betterflow-data/bubble_enabled" + } + private fun runFixed(command: String): Pair { return try { val process = ProcessBuilder("su", "-c", command).redirectErrorStream(true).start() @@ -23,3 +58,30 @@ object RootShell { } } } + +internal class BubbleBootStateWorker( + private val updateState: (Boolean) -> Boolean, +) { + private val executor: ExecutorService = Executors.newSingleThreadExecutor { task -> + Thread(task, "betterflow-bubble-boot-state").apply { isDaemon = true } + } + + fun set(enabled: Boolean): Boolean = try { + executor.submit { updateState(enabled) }.get() + } catch (_: ExecutionException) { + false + } catch (_: RejectedExecutionException) { + false + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + false + } + + fun setAsync(enabled: Boolean) { + try { + executor.execute { updateState(enabled) } + } catch (_: RejectedExecutionException) { + // The process is already shutting down; there is no state to preserve. + } + } +} diff --git a/app/src/main/java/com/jadenjsj/betterflow/WakeActivity.kt b/app/src/main/java/com/jadenjsj/betterflow/WakeActivity.kt deleted file mode 100644 index c4c7cf9..0000000 --- a/app/src/main/java/com/jadenjsj/betterflow/WakeActivity.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.jadenjsj.betterflow - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import android.util.Log - -/** - * Invisible root-module recovery entry point. - * - * HyperOS does not expose `cmd package set-stopped-state`, and a force-stopped - * package cannot have a service resolved directly. Starting an activity is the - * platform-supported way to clear that state. The KernelSU watchdog invokes - * this no-display activity only after a direct OverlayService wake failed. - */ -class WakeActivity : Activity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - runCatching { - startForegroundService( - Intent(this, OverlayService::class.java) - .setAction(OverlayService.ACTION_WAKE), - ) - }.onFailure { - Log.e(TAG, "Could not wake OverlayService", it) - } - finish() - } - - companion object { - private const val TAG = "betterFlow/WakeActivity" - } -} diff --git a/app/src/main/java/com/jadenjsj/betterflow/xposed/BetterFlowXposedModule.kt b/app/src/main/java/com/jadenjsj/betterflow/xposed/BetterFlowXposedModule.kt index e945497..dfb15da 100644 --- a/app/src/main/java/com/jadenjsj/betterflow/xposed/BetterFlowXposedModule.kt +++ b/app/src/main/java/com/jadenjsj/betterflow/xposed/BetterFlowXposedModule.kt @@ -1,23 +1,17 @@ package com.jadenjsj.betterflow.xposed -import android.app.PendingIntent import android.content.BroadcastReceiver -import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.ServiceConnection import android.content.SharedPreferences import android.graphics.Rect import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable import android.inputmethodservice.InputMethodService -import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Handler -import android.os.IBinder -import android.os.Parcel import android.os.ResultReceiver import android.os.SystemClock import android.view.HapticFeedbackConstants @@ -95,8 +89,15 @@ class BetterFlowXposedModule( ) // Gboard's visible SoftKeyView is semantic only; actual gestures are // dispatched by the surrounding SoftKeyboardView. + val touchMethod = runCatching { + Class.forName(SOFT_KEYBOARD_CLASS, false, param.classLoader) + .getDeclaredMethod("dispatchTouchEvent", MotionEvent::class.java) + }.getOrElse { + log("$TAG using ViewGroup touch-hook fallback: ${it.message}") + ViewGroup::class.java.getDeclaredMethod("dispatchTouchEvent", MotionEvent::class.java) + } hook( - ViewGroup::class.java.getDeclaredMethod("dispatchTouchEvent", MotionEvent::class.java), + touchMethod, GboardTouchHooker::class.java, ) prepareHookConfig() @@ -115,7 +116,6 @@ class BetterFlowXposedModule( when (intent?.action) { InputInjector.ACTION_VOICE_STATE -> { if (hookOwnsVoiceSession) return - pendingToggle = false voiceState = VoiceState.fromWire(intent.getStringExtra(InputInjector.EXTRA_VOICE_STATE)) applyVoiceStateVisual(service, voiceState) log("$TAG voice state <- ${voiceState.wireName}") @@ -207,7 +207,6 @@ class BetterFlowXposedModule( } if (currentIme?.get() === service) { cancelHookVoice("IME destroyed") - unbindBridge(service) currentIme = null } micPressed = false @@ -640,353 +639,6 @@ class BetterFlowXposedModule( hookStreamReady.countDown() } - private fun bindBridge(service: InputMethodService) { - if (bridgeConnection != null) return - val connection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { - if (bridgeConnection !== this) return - bridgeBinder = binder - syncBridgeConfig() - if (pendingToggle) { - pendingToggle = false - if (transactToggleFromGboard()) { - scheduleBridgeStateSync(service) - } else { - syncBridgeState() - } - } else { - syncBridgeState() - } - log("$TAG Gboard Binder bridge connected: $name state=${voiceState.wireName}") - } - - override fun onServiceDisconnected(name: ComponentName?) { - clearBridgeConnection(service, this) - log("$TAG Gboard Binder bridge disconnected: $name") - } - - override fun onBindingDied(name: ComponentName?) { - clearBridgeConnection(service, this) - log("$TAG Gboard Binder bridge binding died: $name") - } - - override fun onNullBinding(name: ComponentName?) { - clearBridgeConnection(service, this) - log("$TAG Gboard Binder bridge returned null binding: $name") - } - } - bridgeConnection = connection - val intent = Intent() - .setClassName(BETTERFLOW_PACKAGE, GBOARD_BRIDGE_SERVICE) - .addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES) - val ok = runCatching { - // Explicitly wake the bridge service first. Some Android 16/MIUI builds - // reject a direct bind from an injected IME process when the target app - // process is not already alive. - runCatching { - service.startService(intent) - log("$TAG Gboard Binder bridge startService requested") - }.onFailure { - log("$TAG Gboard Binder bridge startService failed: ${it.message}", it) - } - service.bindService(intent, connection, Context.BIND_AUTO_CREATE or Context.BIND_IMPORTANT) - }.getOrElse { - log("$TAG Gboard Binder bridge bind failed: ${it.message}", it) - false - } - if (!ok) { - bridgeConnection = null - log("$TAG Gboard Binder bridge bind returned false") - } else { - log("$TAG Gboard Binder bridge binding requested") - } - } - - private fun clearBridgeConnection(service: InputMethodService, connection: ServiceConnection) { - if (bridgeConnection !== connection) return - bridgeConnection = null - bridgeBinder = null - runCatching { service.unbindService(connection) } - Handler(service.mainLooper).postDelayed({ - if (currentIme?.get() === service && bridgeConnection == null) bindBridge(service) - }, BRIDGE_REBIND_DELAY_MS) - } - - private fun unbindBridge(service: InputMethodService) { - val connection = bridgeConnection ?: return - bridgeConnection = null - bridgeBinder = null - pendingToggle = false - runCatching { service.unbindService(connection) } - } - - private fun transactTogglePendingIntent(): Boolean { - val binder = bridgeBinder?.takeIf { it.isBinderAlive } ?: return false - val data = Parcel.obtain() - val reply = Parcel.obtain() - return try { - data.writeInterfaceToken(GBOARD_BRIDGE_DESCRIPTOR) - if (!binder.transact(GBOARD_TRANSACTION_GET_TOGGLE_PENDING_INTENT, data, reply, 0)) return false - reply.readException() - val pendingIntent = PendingIntent.readPendingIntentOrNullFromParcel(reply) ?: return false - pendingIntent.send() - true - } catch (t: Throwable) { - log("$TAG Gboard PendingIntent toggle failed: ${t.message}", t) - bridgeBinder = null - false - } finally { - data.recycle() - reply.recycle() - } - } - - private fun transactToggleFromGboard(): Boolean { - if (transactTogglePendingIntent()) return true - // Compatibility fallback while an older bridge APK is still alive during an update. - return transactToggle() - } - - private fun transactToggle(): Boolean { - val binder = bridgeBinder?.takeIf { it.isBinderAlive } ?: return false - val data = Parcel.obtain() - val reply = Parcel.obtain() - return try { - data.writeInterfaceToken(GBOARD_BRIDGE_DESCRIPTOR) - if (!binder.transact(GBOARD_TRANSACTION_TOGGLE, data, reply, 0)) return false - reply.readException() - reply.readInt() != 0 - } catch (t: Throwable) { - log("$TAG Gboard Binder transaction failed: ${t.message}", t) - bridgeBinder = null - false - } finally { - data.recycle() - reply.recycle() - } - } - - private fun syncBridgeState() { - val binder = bridgeBinder?.takeIf { it.isBinderAlive } ?: return - val data = Parcel.obtain() - val reply = Parcel.obtain() - try { - data.writeInterfaceToken(GBOARD_BRIDGE_DESCRIPTOR) - if (!binder.transact(GBOARD_TRANSACTION_GET_STATE, data, reply, 0)) return - reply.readException() - voiceState = VoiceState.fromWire(reply.readString()) - currentIme?.get()?.let { applyVoiceStateVisual(it, voiceState) } ?: setMicVisual(voiceState) - } catch (t: Throwable) { - log("$TAG Gboard Binder state query failed: ${t.message}", t) - } finally { - data.recycle() - reply.recycle() - } - } - - private fun syncBridgeConfig() { - val binder = bridgeBinder?.takeIf { it.isBinderAlive } ?: return - val data = Parcel.obtain() - val reply = Parcel.obtain() - try { - data.writeInterfaceToken(GBOARD_BRIDGE_DESCRIPTOR) - if (!binder.transact(GBOARD_TRANSACTION_GET_CONFIG, data, reply, 0)) return - reply.readException() - gboardMicEnabled = reply.readInt() != 0 - if (gboardMicEnabled) { - currentIme?.get()?.let(::locateGboardMic) - } else { - micGestureActive = false - micPressed = false - restoreMicVisual(micKeyView?.get()) - } - log("$TAG Gboard mic config synced enabled=$gboardMicEnabled") - } catch (t: Throwable) { - log("$TAG Gboard Binder config query failed: ${t.message}", t) - } finally { - data.recycle() - reply.recycle() - } - } - - private fun callBridgeProvider(service: InputMethodService, method: String): Bundle? = - runCatching { - service.contentResolver.call( - Uri.parse("content://$GBOARD_BRIDGE_PROVIDER_AUTHORITY"), - method, - null, - null, - ) - }.onFailure { - log("$TAG Gboard provider call method=$method failed: ${it.message}", it) - }.getOrNull() - - private fun applyBridgeSnapshot(service: InputMethodService, data: Bundle): Boolean { - if (!data.getBoolean(GBOARD_BRIDGE_PROVIDER_KEY_OK, false)) return false - gboardMicEnabled = data.getBoolean(GBOARD_BRIDGE_PROVIDER_KEY_GBOARD_MIC_ENABLED, true) - voiceState = VoiceState.fromWire(data.getString(GBOARD_BRIDGE_PROVIDER_KEY_VOICE_STATE)) - if (gboardMicEnabled) { - locateGboardMic(service) - applyVoiceStateVisual(service, voiceState) - } else { - micGestureActive = false - micPressed = false - restoreMicVisual(micKeyView?.get()) - } - log("$TAG Gboard provider snapshot state=${voiceState.wireName} enabled=$gboardMicEnabled") - return true - } - - private fun sendBridgeBroadcast( - service: InputMethodService, - command: String, - onResult: (Int, Bundle?) -> Unit, - ): Boolean { - val receiver = object : ResultReceiver(null) { - override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { - Handler(service.mainLooper).post { onResult(resultCode, resultData) } - } - } - // HyperOS reports BroadcastReceiver.sentFromUid as -1 for this explicit - // cross-app broadcast. Attach a PendingIntent created inside Gboard as an - // unforgeable framework-issued proof of the caller instead. Its creator - // UID/package are assigned by Android, not by data in the Intent. - val senderProof = PendingIntent.getBroadcast( - service, - (SystemClock.elapsedRealtimeNanos() and 0x7fffffffL).toInt(), - Intent(GBOARD_BRIDGE_PROOF_ACTION).setPackage(GBOARD_PACKAGE), - PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE, - ) - val intent = Intent(GBOARD_BRIDGE_BROADCAST_ACTION) - .setClassName(BETTERFLOW_PACKAGE, GBOARD_BRIDGE_RECEIVER) - .addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES or Intent.FLAG_RECEIVER_FOREGROUND) - .putExtra(GBOARD_BRIDGE_EXTRA_COMMAND, command) - .putExtra(GBOARD_BRIDGE_EXTRA_RESULT_RECEIVER, receiver) - .putExtra(GBOARD_BRIDGE_EXTRA_SENDER_PROOF, senderProof) - return runCatching { - service.sendBroadcast(intent) - true - }.getOrElse { - log("$TAG Gboard bridge broadcast failed: ${it.message}", it) - false - } - } - - private fun requestBridgeSnapshot(service: InputMethodService) { - val providerData = callBridgeProvider(service, GBOARD_BRIDGE_PROVIDER_METHOD_SNAPSHOT) - if (providerData != null && applyBridgeSnapshot(service, providerData)) return - - sendBridgeBroadcast(service, GBOARD_BRIDGE_COMMAND_SNAPSHOT) { resultCode, data -> - if (resultCode != GBOARD_BRIDGE_RESULT_OK || data == null) return@sendBridgeBroadcast - gboardMicEnabled = data.getBoolean(GBOARD_BRIDGE_EXTRA_GBOARD_MIC_ENABLED, true) - voiceState = VoiceState.fromWire(data.getString(GBOARD_BRIDGE_EXTRA_VOICE_STATE)) - if (gboardMicEnabled) { - locateGboardMic(service) - applyVoiceStateVisual(service, voiceState) - } else { - micGestureActive = false - micPressed = false - restoreMicVisual(micKeyView?.get()) - } - log("$TAG Gboard broadcast snapshot state=${voiceState.wireName} enabled=$gboardMicEnabled") - } - } - - private fun scheduleBridgeStateSync(service: InputMethodService) { - BRIDGE_STATE_SYNC_DELAYS_MS.forEach { delayMs -> - Handler(service.mainLooper).postDelayed({ requestBridgeSnapshot(service) }, delayMs) - } - } - - private fun schedulePendingToggleTimeout( - service: InputMethodService, - previous: VoiceState, - generation: Long, - ) { - Handler(service.mainLooper).postDelayed({ - if (!pendingToggle || bridgeRequestGeneration != generation) return@postDelayed - pendingToggle = false - voiceState = previous - applyVoiceStateVisual(service, voiceState) - log("$TAG Gboard bridge toggle timed out; restored pre-tap state=${previous.wireName}") - requestBridgeSnapshot(service) - }, BRIDGE_TOGGLE_TIMEOUT_MS) - } - - private fun triggerBetterFlow(previous: VoiceState): Boolean { - val service = currentIme?.get() ?: run { - log("$TAG cannot trigger betterFlow: no live IME service") - return false - } - val providerData = callBridgeProvider(service, GBOARD_BRIDGE_PROVIDER_METHOD_TOGGLE) - if (providerData?.getBoolean(GBOARD_BRIDGE_PROVIDER_KEY_OK, false) == true) { - val pendingIntent = if (Build.VERSION.SDK_INT >= 33) { - providerData.getParcelable( - GBOARD_BRIDGE_PROVIDER_KEY_TOGGLE_PENDING_INTENT, - PendingIntent::class.java, - ) - } else { - @Suppress("DEPRECATION") - providerData.getParcelable(GBOARD_BRIDGE_PROVIDER_KEY_TOGGLE_PENDING_INTENT) - } - if (pendingIntent != null) { - return runCatching { pendingIntent.send() } - .onSuccess { - pendingToggle = false - log("$TAG authenticated Gboard provider PendingIntent toggle sent") - scheduleBridgeStateSync(service) - } - .onFailure { - log("$TAG Gboard provider PendingIntent send failed: ${it.message}", it) - } - .isSuccess - } - } - - val generation = ++bridgeRequestGeneration - pendingToggle = true - val sent = sendBridgeBroadcast(service, GBOARD_BRIDGE_COMMAND_TOGGLE) { resultCode, data -> - if (bridgeRequestGeneration != generation) return@sendBridgeBroadcast - if (resultCode != GBOARD_BRIDGE_RESULT_OK || data == null) { - pendingToggle = false - voiceState = previous - applyVoiceStateVisual(service, voiceState) - log("$TAG authenticated Gboard bridge rejected toggle request") - return@sendBridgeBroadcast - } - val pendingIntent = if (Build.VERSION.SDK_INT >= 33) { - data.getParcelable(GBOARD_BRIDGE_EXTRA_TOGGLE_PENDING_INTENT, PendingIntent::class.java) - } else { - @Suppress("DEPRECATION") - data.getParcelable(GBOARD_BRIDGE_EXTRA_TOGGLE_PENDING_INTENT) - } - if (pendingIntent == null) { - pendingToggle = false - voiceState = previous - applyVoiceStateVisual(service, voiceState) - log("$TAG authenticated Gboard bridge returned no toggle PendingIntent") - return@sendBridgeBroadcast - } - runCatching { pendingIntent.send() } - .onSuccess { - log("$TAG authenticated Gboard broadcast PendingIntent toggle sent") - scheduleBridgeStateSync(service) - } - .onFailure { - pendingToggle = false - voiceState = previous - applyVoiceStateVisual(service, voiceState) - log("$TAG Gboard broadcast PendingIntent send failed: ${it.message}", it) - } - } - if (!sent) { - pendingToggle = false - return false - } - schedulePendingToggleTimeout(service, previous, generation) - return true - } - private fun clearMicStateOverlay(mic: View?) { val overlay = micStateOverlay?.get() ?: return mic?.overlay?.remove(overlay) @@ -1112,32 +764,6 @@ class BetterFlowXposedModule( companion object { private const val TAG = "betterFlow/Xposed" private const val GBOARD_PACKAGE = "com.google.android.inputmethod.latin" - private const val BETTERFLOW_PACKAGE = "com.jadenjsj.betterflow" - private const val GBOARD_BRIDGE_SERVICE = "com.jadenjsj.betterflow.GboardBridgeService" - private const val GBOARD_BRIDGE_RECEIVER = "com.jadenjsj.betterflow.GboardBridgeReceiver" - private const val GBOARD_BRIDGE_PROVIDER_AUTHORITY = "com.jadenjsj.betterflow.gboard-bridge" - private const val GBOARD_BRIDGE_PROVIDER_METHOD_SNAPSHOT = "snapshot" - private const val GBOARD_BRIDGE_PROVIDER_METHOD_TOGGLE = "toggle_pending_intent" - private const val GBOARD_BRIDGE_PROVIDER_KEY_OK = "ok" - private const val GBOARD_BRIDGE_PROVIDER_KEY_VOICE_STATE = "voice_state" - private const val GBOARD_BRIDGE_PROVIDER_KEY_GBOARD_MIC_ENABLED = "gboard_mic_enabled" - private const val GBOARD_BRIDGE_PROVIDER_KEY_TOGGLE_PENDING_INTENT = "toggle_pending_intent" - private const val GBOARD_BRIDGE_BROADCAST_ACTION = "com.jadenjsj.betterflow.action.GBOARD_BRIDGE" - private const val GBOARD_BRIDGE_PROOF_ACTION = "com.jadenjsj.betterflow.action.GBOARD_SENDER_PROOF" - private const val GBOARD_BRIDGE_COMMAND_SNAPSHOT = "snapshot" - private const val GBOARD_BRIDGE_COMMAND_TOGGLE = "toggle_pending_intent" - private const val GBOARD_BRIDGE_EXTRA_COMMAND = "bridge_command" - private const val GBOARD_BRIDGE_EXTRA_RESULT_RECEIVER = "bridge_result_receiver" - private const val GBOARD_BRIDGE_EXTRA_SENDER_PROOF = "bridge_sender_proof" - private const val GBOARD_BRIDGE_EXTRA_VOICE_STATE = "bridge_voice_state" - private const val GBOARD_BRIDGE_EXTRA_GBOARD_MIC_ENABLED = "bridge_gboard_mic_enabled" - private const val GBOARD_BRIDGE_EXTRA_TOGGLE_PENDING_INTENT = "bridge_toggle_pending_intent" - private const val GBOARD_BRIDGE_RESULT_OK = 1 - private const val GBOARD_BRIDGE_DESCRIPTOR = "com.jadenjsj.betterflow.GboardBridge" - private const val GBOARD_TRANSACTION_TOGGLE = IBinder.FIRST_CALL_TRANSACTION - private const val GBOARD_TRANSACTION_GET_STATE = IBinder.FIRST_CALL_TRANSACTION + 1 - private const val GBOARD_TRANSACTION_GET_CONFIG = IBinder.FIRST_CALL_TRANSACTION + 2 - private const val GBOARD_TRANSACTION_GET_TOGGLE_PENDING_INTENT = IBinder.FIRST_CALL_TRANSACTION + 3 private const val REMOTE_AUTH_GROUP = "betterflow_auth" private const val REMOTE_KEY_EMAIL = "email" private const val REMOTE_KEY_ACCESS = "access_token" @@ -1152,9 +778,6 @@ class BetterFlowXposedModule( private const val HOOK_STREAM_RESULT_TIMEOUT_MS = 30_000L private const val COMMIT_DEDUPE_TTL_MS = 10_000L private val MIC_REACQUIRE_DELAYS_MS = longArrayOf(0L, 60L, 140L, 280L, 520L, 900L) - private val BRIDGE_STATE_SYNC_DELAYS_MS = longArrayOf(120L, 350L, 900L) - private const val BRIDGE_REBIND_DELAY_MS = 120L - private const val BRIDGE_TOGGLE_TIMEOUT_MS = 3_000L private const val SOFT_KEY_CLASS = "com.google.android.libraries.inputmethod.widgets.SoftKeyView" private const val SOFT_KEYBOARD_CLASS = "com.google.android.libraries.inputmethod.widgets.SoftKeyboardView" private val VOICE_RESOURCE_TOKENS = listOf("voice", "microphone", "dictat", "speech", "mic_") @@ -1166,10 +789,6 @@ class BetterFlowXposedModule( @Volatile private var activeModule: BetterFlowXposedModule? = null @Volatile private var currentIme: WeakReference? = null - @Volatile private var bridgeBinder: IBinder? = null - @Volatile private var bridgeConnection: ServiceConnection? = null - @Volatile private var pendingToggle = false - @Volatile private var bridgeRequestGeneration = 0L @Volatile private var gboardMicEnabled = true @Volatile private var micKeyView: WeakReference? = null @Volatile private var micOriginalContentDescription: CharSequence? = null diff --git a/app/src/test/java/com/jadenjsj/betterflow/BubbleBootStateWorkerTest.kt b/app/src/test/java/com/jadenjsj/betterflow/BubbleBootStateWorkerTest.kt new file mode 100644 index 0000000..3a54288 --- /dev/null +++ b/app/src/test/java/com/jadenjsj/betterflow/BubbleBootStateWorkerTest.kt @@ -0,0 +1,45 @@ +package com.jadenjsj.betterflow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +class BubbleBootStateWorkerTest { + @Test + fun rapidEnableThenDisableLeavesMarkerDisabled() { + val markerEnabled = AtomicBoolean(false) + val updates = Collections.synchronizedList(mutableListOf()) + val enableStarted = CountDownLatch(1) + val releaseEnable = CountDownLatch(1) + val disableFinished = CountDownLatch(1) + val disableSucceeded = AtomicBoolean(false) + val worker = BubbleBootStateWorker { enabled -> + updates += enabled + if (enabled) { + enableStarted.countDown() + check(releaseEnable.await(5, TimeUnit.SECONDS)) + } + markerEnabled.set(enabled) + true + } + + worker.setAsync(true) + assertTrue(enableStarted.await(5, TimeUnit.SECONDS)) + + Thread { + disableSucceeded.set(worker.set(false)) + disableFinished.countDown() + }.start() + releaseEnable.countDown() + + assertTrue(disableFinished.await(5, TimeUnit.SECONDS)) + assertTrue(disableSucceeded.get()) + assertEquals(listOf(true, false), updates) + assertFalse(markerEnabled.get()) + } +} diff --git a/module/boot-completed.sh b/module/boot-completed.sh index bf66204..a1f5718 100755 --- a/module/boot-completed.sh +++ b/module/boot-completed.sh @@ -1,5 +1,6 @@ #!/system/bin/sh MODDIR=${0%/*} . "$MODDIR/scripts/common.sh" +stop_legacy_watchdog ensure_permissions -start_app +start_bubble_if_enabled diff --git a/module/customize.sh b/module/customize.sh index 031a2a7..bc8273f 100755 --- a/module/customize.sh +++ b/module/customize.sh @@ -4,6 +4,19 @@ PKG=com.jadenjsj.betterflow DATA_DIR=/data/adb/betterflow-data ui_print "- betterFlow: installing hot-reloadable runtime" mkdir -p "$DATA_DIR" "$DATA_DIR/backups" "$DATA_DIR/tmp" +setprop ctl.stop betterflow_watchdog 2>/dev/null || true +if [ -f "$DATA_DIR/watchdog.pid" ]; then + old_watchdog=$(cat "$DATA_DIR/watchdog.pid" 2>/dev/null || true) + case "$old_watchdog" in + ''|*[!0-9]*) ;; + *) + if [ -r "/proc/$old_watchdog/cmdline" ] && tr '\000' ' ' < "/proc/$old_watchdog/cmdline" | grep -q '/betterflow/scripts/watchdog.sh'; then + [ "$old_watchdog" = "$$" ] || kill "$old_watchdog" 2>/dev/null || true + fi + ;; + esac + rm -f "$DATA_DIR/watchdog.pid" +fi VERSION=$(grep '^version=' "$MODPATH/module.prop" | cut -d= -f2-) VERSION_CODE=$(grep '^versionCode=' "$MODPATH/module.prop" | cut -d= -f2-) [ -n "$VERSION" ] && echo "$VERSION" > "$DATA_DIR/current_version_name" @@ -18,6 +31,8 @@ appops set "$PKG" SYSTEM_ALERT_WINDOW allow >/dev/null 2>&1 || appops set "$PKG" pm grant "$PKG" android.permission.RECORD_AUDIO >/dev/null 2>&1 || true pm grant "$PKG" android.permission.POST_NOTIFICATIONS >/dev/null 2>&1 || true chmod 0755 "$MODPATH"/*.sh "$MODPATH"/scripts/*.sh 2>/dev/null || true -MODDIR="$MODPATH" nohup sh "$MODPATH/scripts/watchdog.sh" >"$DATA_DIR/watchdog.log" 2>&1 & +. "$MODPATH/scripts/common.sh" +sync_bubble_marker_from_prefs +start_bubble_if_enabled ui_print "- Action button = check/download/apply latest release now" -ui_print "- WebUI can do the same; routine updates do not need reboot" +ui_print "- Gboard mode is event-driven; no persistent watchdog is installed" diff --git a/module/initrc/betterflow.rc b/module/initrc/betterflow.rc index 6ac6538..6f8e97f 100644 --- a/module/initrc/betterflow.rc +++ b/module/initrc/betterflow.rc @@ -1,10 +1,2 @@ -service betterflow_watchdog /system/bin/sh /data/adb/modules/betterflow/scripts/watchdog.sh - class late_start - user root - group root - seclabel u:r:ksu:s0 - disabled - oom_score_adjust -1000 - -on property:sys.boot_completed=1 - start betterflow_watchdog +# Persistent watchdog removed. This file intentionally replaces the legacy +# init service definition during a full KernelSU module update. diff --git a/module/module.prop b/module/module.prop index 19eb5c8..6ed5b8e 100644 --- a/module/module.prop +++ b/module/module.prop @@ -3,5 +3,5 @@ name=betterFlow version=0.1.0-dev versionCode=1 author=JadenJSJ -description=Root-persistent floating Wispr voice typing with selectable LSPosed/InputConnection or root-paste insertion. Action = hot update. +description=Event-driven Gboard voice typing with an optional floating mic. No persistent polling; Action = manual hot update. updateJson=https://github.com/JSJ-Experiments/betterFlow/releases/latest/download/update.json diff --git a/module/scripts/common.sh b/module/scripts/common.sh index ae6a855..10dc2b7 100755 --- a/module/scripts/common.sh +++ b/module/scripts/common.sh @@ -2,9 +2,11 @@ MODID=betterflow PKG=com.jadenjsj.betterflow SERVICE=com.jadenjsj.betterflow/.OverlayService -WAKE_ACTIVITY=com.jadenjsj.betterflow/.WakeActivity ACTION_WAKE=com.jadenjsj.betterflow.action.WAKE +ACTION_SHOW=com.jadenjsj.betterflow.action.SHOW +ACTION_HIDE=com.jadenjsj.betterflow.action.HIDE DATA_DIR=/data/adb/betterflow-data +BUBBLE_MARKER=$DATA_DIR/bubble_enabled REPO=JSJ-Experiments/betterFlow LATEST_BASE=https://github.com/$REPO/releases/latest/download mkdir -p "$DATA_DIR" "$DATA_DIR/tmp" "$DATA_DIR/backups" 2>/dev/null @@ -61,6 +63,39 @@ service_running() { dumpsys activity services "$PKG" 2>/dev/null | grep -q "${PKG}/.OverlayService" } +stop_legacy_watchdog() { + setprop ctl.stop betterflow_watchdog 2>/dev/null || true + pid=$(cat "$DATA_DIR/watchdog.pid" 2>/dev/null || true) + case "$pid" in ''|*[!0-9]*) rm -f "$DATA_DIR/watchdog.pid"; return;; esac + if [ -r "/proc/$pid/cmdline" ] && tr '\000' ' ' < "/proc/$pid/cmdline" | grep -q '/betterflow/scripts/watchdog.sh'; then + [ "$pid" = "$$" ] || kill "$pid" 2>/dev/null || true + fi + rm -f "$DATA_DIR/watchdog.pid" +} + +sync_bubble_marker_from_prefs() { + prefs=/data/user/0/$PKG/shared_prefs/betterflow.xml + [ -r "$prefs" ] || prefs=/data/data/$PKG/shared_prefs/betterflow.xml + [ -r "$prefs" ] || return 0 + if grep -q 'name="bubble_visible" value="true"' "$prefs"; then + : > "$BUBBLE_MARKER" + elif grep -q 'name="bubble_visible" value="false"' "$prefs"; then + rm -f "$BUBBLE_MARKER" + fi +} + +bubble_enabled() { + [ -f "$BUBBLE_MARKER" ] +} + +set_bubble_enabled() { + if [ "$1" = "1" ]; then + : > "$BUBBLE_MARKER" + else + rm -f "$BUBBLE_MARKER" + fi +} + ensure_permissions() { appops set "$PKG" SYSTEM_ALERT_WINDOW allow >/dev/null 2>&1 || appops set "$PKG" android:system_alert_window allow >/dev/null 2>&1 || true pm grant "$PKG" android.permission.RECORD_AUDIO >/dev/null 2>&1 || true @@ -73,20 +108,19 @@ unstop_app() { } start_app() { + action=${1:-$ACTION_WAKE} unstop_app - if am start-foreground-service --user 0 -a "$ACTION_WAKE" -n "$SERVICE" >/dev/null 2>&1; then + if am start-foreground-service --user 0 -a "$action" -n "$SERVICE" >/dev/null 2>&1; then return 0 fi - if am startservice --user 0 -a "$ACTION_WAKE" -n "$SERVICE" >/dev/null 2>&1; then + if am startservice --user 0 -a "$action" -n "$SERVICE" >/dev/null 2>&1; then return 0 fi - # Fallback for ROMs that still reject a direct protected service start. - # This no-display Activity immediately starts OverlayService and finishes. - am start --user 0 --include-stopped-packages -n "$WAKE_ACTIVITY" >/dev/null 2>&1 || true + return 1 } -boost_app() { - for pid in $(pidof "$PKG" 2>/dev/null); do - echo -900 > "/proc/$pid/oom_score_adj" 2>/dev/null || true - done +start_bubble_if_enabled() { + sync_bubble_marker_from_prefs + bubble_enabled || return 0 + start_app "$ACTION_WAKE" } diff --git a/module/scripts/control.sh b/module/scripts/control.sh index d2503d3..56a44fb 100755 --- a/module/scripts/control.sh +++ b/module/scripts/control.sh @@ -1,30 +1,16 @@ #!/system/bin/sh MODDIR=${MODDIR:-$(cd "${0%/*}/.." 2>/dev/null && pwd)} . "$MODDIR/scripts/common.sh" -CONFIG="$DATA_DIR/module.conf" -ensure_config() { - [ -f "$CONFIG" ] || cat > "$CONFIG" <<'CFG' -auto_update=1 -update_interval_seconds=21600 -watchdog_interval_seconds=12 -CFG -} -set_auto() { - ensure_config - value="$1" - if grep -q '^auto_update=' "$CONFIG" 2>/dev/null; then - sed -i "s/^auto_update=.*/auto_update=$value/" "$CONFIG" - else - echo "auto_update=$value" >> "$CONFIG" - fi -} case "${1:-status}" in status) MODDIR="$MODDIR" sh "$MODDIR/scripts/status.sh" ;; update) MODDIR="$MODDIR" sh "$MODDIR/scripts/hot-update.sh" ;; - start) ensure_permissions; start_app ;; - stop) am force-stop "$PKG" >/dev/null 2>&1 || true ;; + start) set_bubble_enabled 1; ensure_permissions; start_app "$ACTION_SHOW" ;; + stop) + set_bubble_enabled 0 + am startservice --user 0 -a "$ACTION_HIDE" -n "$SERVICE" >/dev/null 2>&1 || true + sleep 1 + service_running && am force-stop "$PKG" >/dev/null 2>&1 || true + ;; settings) am start -n "$PKG/.MainActivity" >/dev/null 2>&1 || true ;; - auto-on) set_auto 1 ;; - auto-off) set_auto 0 ;; - *) echo "usage: $0 {status|update|start|stop|settings|auto-on|auto-off}" >&2; exit 2 ;; + *) echo "usage: $0 {status|update|start|stop|settings}" >&2; exit 2 ;; esac diff --git a/module/scripts/hot-update.sh b/module/scripts/hot-update.sh index 0dee763..a35f119 100755 --- a/module/scripts/hot-update.sh +++ b/module/scripts/hot-update.sh @@ -101,7 +101,7 @@ echo "$VERSION_NAME" > "$DATA_DIR/current_version_name" date +%s > "$DATA_DIR/last_update_epoch" 2>/dev/null || true ensure_permissions am force-stop "$PKG" >/dev/null 2>&1 || true -start_app -sleep 1 -boost_app +unstop_app +start_bubble_if_enabled +stop_legacy_watchdog say "betterFlow: hot update applied — $VERSION_NAME ($VERSION_CODE), no reboot requested" diff --git a/module/scripts/status.sh b/module/scripts/status.sh index a1425bb..5a13618 100755 --- a/module/scripts/status.sh +++ b/module/scripts/status.sh @@ -3,13 +3,13 @@ MODDIR=${MODDIR:-$(cd "${0%/*}/.." 2>/dev/null && pwd)} . "$MODDIR/scripts/common.sh" VERSION=$(cat "$DATA_DIR/current_version_name" 2>/dev/null || echo unknown) CODE=$(cat "$DATA_DIR/current_version" 2>/dev/null || echo 0) -WPID=$(cat "$DATA_DIR/watchdog.pid" 2>/dev/null || true) APID=$(pidof "$PKG" 2>/dev/null || true) -AUTO=$(manifest_value auto_update "$DATA_DIR/module.conf" 2>/dev/null); [ -n "$AUTO" ] || AUTO=1 LAST=$(cat "$DATA_DIR/last_update_epoch" 2>/dev/null || echo never) +if bubble_enabled; then BUBBLE=enabled; else BUBBLE=disabled; fi echo "version=$VERSION" echo "versionCode=$CODE" -echo "watchdogPid=${WPID:-stopped}" +echo "backgroundPolling=disabled" echo "appPid=${APID:-stopped}" -echo "autoUpdate=$AUTO" +echo "bubble=$BUBBLE" +echo "updateMode=manual" echo "lastUpdate=$LAST" diff --git a/module/scripts/watchdog.sh b/module/scripts/watchdog.sh index 869e5de..74ad13e 100755 --- a/module/scripts/watchdog.sh +++ b/module/scripts/watchdog.sh @@ -1,43 +1,8 @@ #!/system/bin/sh -MODDIR=${MODDIR:-$(cd "${0%/*}/.." 2>/dev/null && pwd)} -. "$MODDIR/scripts/common.sh" -# Make fallback mode as hard to OOM-kill as Android allows; initrc mode also sets this. -echo -1000 > /proc/$$/oom_score_adj 2>/dev/null || true -PIDFILE="$DATA_DIR/watchdog.pid" -if [ -f "$PIDFILE" ]; then - old=$(cat "$PIDFILE" 2>/dev/null) - [ -n "$old" ] && kill -0 "$old" 2>/dev/null && exit 0 -fi -echo $$ > "$PIDFILE" -trap 'rm -f "$PIDFILE"' EXIT INT TERM - -CONFIG="$DATA_DIR/module.conf" -[ -f "$CONFIG" ] || cat > "$CONFIG" <<'CFG' -auto_update=1 -update_interval_seconds=21600 -watchdog_interval_seconds=12 -CFG -last_check=0 - -while true; do - if pm path "$PKG" >/dev/null 2>&1; then - ensure_permissions - if ! service_running; then - start_app - sleep 1 - fi - boost_app - elif [ -s "$DATA_DIR/current.apk" ]; then - install_apk "$DATA_DIR/current.apk" || true - fi - - AUTO=$(manifest_value auto_update "$CONFIG"); [ -n "$AUTO" ] || AUTO=1 - INTERVAL=$(manifest_value update_interval_seconds "$CONFIG"); case "$INTERVAL" in ''|*[!0-9]*) INTERVAL=21600;; esac - NOW=$(date +%s 2>/dev/null || echo 0) - if [ "$AUTO" = "1" ] && [ "$NOW" -gt 0 ] && [ $((NOW - last_check)) -ge "$INTERVAL" ]; then - last_check=$NOW - MODDIR="$MODDIR" sh "$MODDIR/scripts/hot-update.sh" --quiet --if-newer >/dev/null 2>&1 || true - fi - SLEEP=$(manifest_value watchdog_interval_seconds "$CONFIG"); case "$SLEEP" in ''|*[!0-9]*) SLEEP=12;; esac - sleep "$SLEEP" -done +# Compatibility shim for old runtime bundles. Persistent polling was removed; +# boot restoration is handled once by boot-completed.sh/service.sh. +rm -f /data/adb/betterflow-data/watchdog.pid +# A hot update cannot replace an old init .rc file, so explicitly disable that +# legacy service if it launches this updated shim. +setprop ctl.stop betterflow_watchdog 2>/dev/null || true +exit 0 diff --git a/module/service.sh b/module/service.sh index a203038..c0a78a5 100755 --- a/module/service.sh +++ b/module/service.sh @@ -2,12 +2,13 @@ MODDIR=${0%/*} DATA_DIR=/data/adb/betterflow-data mkdir -p "$DATA_DIR" -# Current KernelSU will supervise betterflow_watchdog through initrc after boot_completed. -# Keep a delayed fallback for late-load/custom-rc-disabled installations. +# A one-shot fallback for KernelSU builds that do not invoke boot-completed.sh. +# It exits after boot and never polls while the device is running. ( while [ "$(getprop sys.boot_completed)" != "1" ]; do sleep 3; done sleep 8 - if [ "$(getprop init.svc.betterflow_watchdog)" != "running" ]; then - MODDIR="$MODDIR" nohup sh "$MODDIR/scripts/watchdog.sh" >"$DATA_DIR/watchdog.log" 2>&1 & - fi + . "$MODDIR/scripts/common.sh" + stop_legacy_watchdog + ensure_permissions + start_bubble_if_enabled ) >/dev/null 2>&1 & diff --git a/tools/check-power-behavior.sh b/tools/check-power-behavior.sh new file mode 100755 index 0000000..18e9afb --- /dev/null +++ b/tools/check-power-behavior.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +fail() { + echo "power-behavior check failed: $*" >&2 + exit 1 +} + +for script in module/*.sh module/scripts/*.sh; do + sh -n "$script" +done + +if grep -q 'android.permission.WAKE_LOCK' app/src/main/AndroidManifest.xml; then + fail "the APK must not request WAKE_LOCK" +fi +if grep -Eq '^[[:space:]]*service[[:space:]]+betterflow_watchdog' module/initrc/betterflow.rc; then + fail "the module must not install a persistent watchdog service" +fi +if grep -Eq 'watchdog\.sh|hot-update\.sh|oom_score_adj' module/service.sh module/boot-completed.sh; then + fail "boot scripts must not start polling, updates, or OOM tuning" +fi +if grep -Eq 'auto-on|auto-off' module/scripts/control.sh webui/src/main.js webui/src/index.html; then + fail "periodic update controls must stay removed" +fi + +echo "Power behavior checks passed" diff --git a/webui/src/index.html b/webui/src/index.html index 43700ea..5211b77 100644 --- a/webui/src/index.html +++ b/webui/src/index.html @@ -9,22 +9,22 @@
-

betterFlow

Hot-reloadable voice typing runtime

+

betterFlow

Event-driven voice typing runtime

Status

Runtime
-
Watchdog
+
Background polling
App
-
Auto update
+
Bubble

Update

-

Fetch, verify, install, and restart the latest GitHub runtime without rebooting the phone.

+

Manually fetch, verify, and install the latest GitHub runtime without rebooting. The bubble restarts only if enabled.


     
@@ -35,10 +35,6 @@

Controls

-
- - -
diff --git a/webui/src/main.js b/webui/src/main.js index 25b0cd2..64dbc0d 100644 --- a/webui/src/main.js +++ b/webui/src/main.js @@ -21,9 +21,9 @@ async function refresh() { try { const s = parseKv(await run(ctl('status'))); $('version').textContent = `${s.version || 'unknown'} (${s.versionCode || '?'})`; - $('watchdog').textContent = s.watchdogPid === 'stopped' ? 'stopped' : `pid ${s.watchdogPid}`; + $('background').textContent = 'off'; $('app').textContent = s.appPid === 'stopped' ? 'stopped' : `pid ${s.appPid}`; - $('auto').textContent = s.autoUpdate === '1' ? 'on' : 'off'; + $('bubble').textContent = s.bubble || 'unknown'; $('dot').className = `dot ${s.appPid && s.appPid !== 'stopped' ? 'ok' : 'bad'}`; } catch (error) { $('dot').className = 'dot bad'; @@ -52,8 +52,6 @@ for (const [id, verb] of [ ['settings', 'settings'], ['start', 'start'], ['stop', 'stop'], - ['autoOn', 'auto-on'], - ['autoOff', 'auto-off'], ]) { $(id).onclick = () => run(ctl(verb)).then(refresh).catch((error) => toast(String(error))); }