From 9fd4b1954ff87dcbf7e28e4ff18a4e00407eb56a Mon Sep 17 00:00:00 2001 From: Rowen S Date: Wed, 3 Sep 2025 07:43:32 -0400 Subject: [PATCH 01/17] Add AppState class. Add method to launch permissions intent based on appstate. Add ConfigurationViewModel to manage appstate flow. Use ConfigurationViewModel in MainActivity and launch slideshow only if state is not adbConnected. Convert AdbViewModel to AdbManager class. --- .../bugbane/MainActivity.kt | 111 +++++++++--------- .../bugbane/SlideshowActivity.kt | 4 +- .../bugbane/screens/ScanScreen.kt | 17 +-- .../{AdbViewModel.java => AdbManager.java} | 40 +++---- .../bugbane/utils/AppState.kt | 30 +++++ .../bugbane/utils/ConfigurationManager.kt | 43 +++++++ .../bugbane/utils/ConfigurationViewModel.kt | 73 ++++++++++++ 7 files changed, 228 insertions(+), 90 deletions(-) rename app/src/main/java/org/osservatorionessuno/bugbane/utils/{AdbViewModel.java => AdbManager.java} (90%) create mode 100644 app/src/main/java/org/osservatorionessuno/bugbane/utils/AppState.kt create mode 100644 app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/MainActivity.kt b/app/src/main/java/org/osservatorionessuno/bugbane/MainActivity.kt index f6670e1..94bf968 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/MainActivity.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/MainActivity.kt @@ -3,7 +3,6 @@ package org.osservatorionessuno.bugbane import android.content.Intent import android.os.Bundle import android.util.Log -import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -18,9 +17,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import kotlinx.coroutines.launch import io.github.muntashirakon.adb.PRNGFixes -import androidx.lifecycle.lifecycleScope import androidx.work.* -import kotlinx.coroutines.Dispatchers import org.osservatorionessuno.libmvt.common.IndicatorsUpdates import org.osservatorionessuno.bugbane.workers.IndicatorsUpdateWorker import java.util.concurrent.TimeUnit @@ -30,64 +27,76 @@ import org.osservatorionessuno.bugbane.components.NavigationTabs import org.osservatorionessuno.bugbane.screens.ScanScreen import org.osservatorionessuno.bugbane.screens.AcquisitionsScreen import org.osservatorionessuno.bugbane.ui.theme.Theme -import org.osservatorionessuno.bugbane.utils.SlideshowManager -import org.osservatorionessuno.bugbane.utils.AdbViewModel -import org.osservatorionessuno.bugbane.utils.AdbPairingService -import org.osservatorionessuno.bugbane.utils.ConfigurationManager +import org.osservatorionessuno.bugbane.utils.AdbManager +import org.osservatorionessuno.bugbane.utils.AppState +import org.osservatorionessuno.bugbane.utils.ConfigurationViewModel class MainActivity : ComponentActivity() { - private val viewModel: AdbViewModel by viewModels() - private var setLacksPermissionsCallback: ((Boolean) -> Unit)? = null + private val viewModel: AdbManager by viewModels() + private val configViewModel: ConfigurationViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) PRNGFixes.apply() - // Observers - viewModel.watchConnectAdb().observe(this) { isConnected -> - if (!isConnected) { - setLacksPermissionsCallback?.invoke(true) - } - } - - viewModel.watchAskPairAdb().observe(this) { resetPairing -> - if (resetPairing) { - setLacksPermissionsCallback?.invoke(true) - } - } - - viewModel.watchCommandOutput().observe(this) { output -> - // TODO: blibla - Toast.makeText(this@MainActivity, output.toString(), Toast.LENGTH_SHORT).show() - Log.d("COMMAND OUTPUT", output.toString()) - } - - // Try auto-connecting - viewModel.autoConnect() - // Fetch indicators on first launch and schedule daily background updates setupIndicatorsUpdates() - if (!ConfigurationManager.isNotificationPermissionGranted(this) || !ConfigurationManager.isWirelessDebuggingEnabled( - this - ) - ) { - setLacksPermissionsCallback?.invoke(true) - } - - if (!SlideshowManager.hasSeenHomepage(this)) { - // On first start, run the SlideshowActivity manually - SlideshowActivity.start(this) - } - enableEdgeToEdge() setContent { Theme { - MainContent { callback -> - setLacksPermissionsCallback = callback + val permissionState by configViewModel.configurationState.collectAsState() + + LaunchedEffect(permissionState) { + if (permissionState != AppState.AdbConnected) { + // Permissions slideshow + val startPage = AppState.valuesInOrder().indexOf(permissionState) + val intent = Intent(this@MainActivity, SlideshowActivity::class.java) + .putExtra("startPage", startPage) + startActivity(intent) + } + } + + if (permissionState == AppState.AdbConnected) { + MainContent() // Real app content } + // Otherwise: maybe waiting for the state to be returned. todo } } + + // Observers +// viewModel.watchConnectAdb().observe(this) { isConnected -> +// if (!isConnected) { +// setLacksPermissionsCallback?.invoke(true) +// } +// } +// +// viewModel.watchAskPairAdb().observe(this) { resetPairing -> +// if (resetPairing) { +// setLacksPermissionsCallback?.invoke(true) +// } +// } +// +// viewModel.watchCommandOutput().observe(this) { output -> +// // TODO: blibla +// Toast.makeText(this@MainActivity, output.toString(), Toast.LENGTH_SHORT).show() +// Log.d("COMMAND OUTPUT", output.toString()) +// } +// +// // Try auto-connecting +// viewModel.autoConnect() +// +// if (!ConfigurationManager.isNotificationPermissionGranted(this) || !ConfigurationManager.isWirelessDebuggingEnabled( +// this +// ) +// ) { +// setLacksPermissionsCallback?.invoke(true) +// } + +// if (!SlideshowManager.hasSeenHomepage(this)) { +// // On first start, run the SlideshowActivity manually +// SlideshowActivity.start(this) +// } } private fun setupIndicatorsUpdates() { @@ -124,10 +133,9 @@ class MainActivity : ComponentActivity() { Log.i("MainActivity", "Scheduled daily indicator update worker") } } - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) + @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable -fun MainContent(onSetLacksPermissionsCallback: ((Boolean) -> Unit) -> Unit) { +fun MainContent() { val context = androidx.compose.ui.platform.LocalContext.current val configuration = LocalConfiguration.current val pagerState = rememberPagerState(pageCount = { 2 }) @@ -145,13 +153,6 @@ fun MainContent(onSetLacksPermissionsCallback: ((Boolean) -> Unit) -> Unit) { lacksPermissions = lacks } - // Provide the callback to the parent - LaunchedEffect(Unit) { - onSetLacksPermissionsCallback { lacks -> - setLacksPermissions(lacks) - } - } - Scaffold( topBar = { if (isLandscape) { diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt b/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt index f5bcc03..67e787f 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt @@ -27,10 +27,10 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import org.osservatorionessuno.bugbane.components.SlideshowPage import org.osservatorionessuno.bugbane.pages.* import org.osservatorionessuno.bugbane.ui.theme.Theme -import org.osservatorionessuno.bugbane.utils.AdbViewModel +import org.osservatorionessuno.bugbane.utils.AdbManager class SlideshowActivity : ComponentActivity() { - private val viewModel: AdbViewModel by viewModels() + private val viewModel: AdbManager by viewModels() private val totalPages = 6 override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt b/app/src/main/java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt index 01a9410..57c148f 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt @@ -1,13 +1,5 @@ package org.osservatorionessuno.bugbane.screens -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import android.content.Intent import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* @@ -33,6 +25,7 @@ import org.osservatorionessuno.bugbane.R import org.osservatorionessuno.bugbane.utils.ConfigurationManager import org.osservatorionessuno.bugbane.SlideshowActivity import org.osservatorionessuno.bugbane.AcquisitionActivity +import org.osservatorionessuno.bugbane.utils.AdbManager import java.io.File @Composable @@ -42,7 +35,7 @@ fun ScanScreen( ) { val coroutineScope = rememberCoroutineScope() val context = LocalContext.current - val viewModel = androidx.lifecycle.viewmodel.compose.viewModel() + val adbManager = AdbManager(context.applicationContext) var isScanning by remember { mutableStateOf(false) } var showDisableDialog by remember { mutableStateOf(false) } var completedModules by remember { mutableStateOf(0) } @@ -135,7 +128,7 @@ fun ScanScreen( } } Button( - onClick = { viewModel.cancelQuickForensics() }, + onClick = { adbManager.cancelQuickForensics() }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.error @@ -290,7 +283,7 @@ fun ScanScreen( moduleBytes.clear() completedModules = 0 totalModules = 0 - viewModel.runQuickForensics(baseDir, object : org.osservatorionessuno.bugbane.qf.QuickForensics.ProgressListener { + adbManager.runQuickForensics(baseDir, object : org.osservatorionessuno.bugbane.qf.QuickForensics.ProgressListener { override fun onModuleStart(name: String, completed: Int, total: Int) { coroutineScope.launch { totalModules = total @@ -321,7 +314,7 @@ fun ScanScreen( } } - override fun isCancelled(): Boolean = viewModel.isQuickForensicsCancelled() + override fun isCancelled(): Boolean = adbManager.isQuickForensicsCancelled() override fun onFinished(cancelled: Boolean) { coroutineScope.launch { diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbViewModel.java b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.java similarity index 90% rename from app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbViewModel.java rename to app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.java index 2d6dc97..7815584 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbViewModel.java +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.java @@ -1,13 +1,11 @@ package org.osservatorionessuno.bugbane.utils; -import android.app.Application; +import android.content.Context; import android.os.Build; -import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; -import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; @@ -32,7 +30,7 @@ import io.github.muntashirakon.adb.android.AdbMdns; import io.github.muntashirakon.adb.android.AndroidUtils; -public class AdbViewModel extends AndroidViewModel { +public class AdbManager { private final ExecutorService executor = Executors.newFixedThreadPool(3); private final MutableLiveData connectAdb = new MutableLiveData<>(); private final MutableLiveData pairAdb = new MutableLiveData<>(); @@ -46,14 +44,16 @@ public class AdbViewModel extends AndroidViewModel { private String mPairingHost; private int mPairingPort = -1; + private Context appContext = null; + @Nullable private AdbStream adbShellStream; - public AdbViewModel(@NonNull Application application) { - super(application); + public AdbManager(@NonNull Context applicationContext) { + this.appContext = applicationContext; } - public LiveData watchConnectAdb() { + public MutableLiveData watchConnectAdb() { return connectAdb; } @@ -73,9 +73,7 @@ public LiveData watchPairingPort() { return pairingPort; } - @Override - protected void onCleared() { - super.onCleared(); + public void handleOnCleared() { executor.submit(() -> { try { if (adbShellStream != null) { @@ -92,10 +90,10 @@ protected void onCleared() { public void connect(int port) { executor.submit(() -> { try { - AbsAdbConnectionManager manager = AdbConnectionManager.getInstance(getApplication()); + AbsAdbConnectionManager manager = AdbConnectionManager.getInstance(this.appContext); boolean connectionStatus; try { - connectionStatus = manager.connect(AndroidUtils.getHostIpAddress(getApplication()), port); + connectionStatus = manager.connect(AndroidUtils.getHostIpAddress(this.appContext), port); } catch (Throwable th) { th.printStackTrace(); connectionStatus = false; @@ -115,7 +113,7 @@ public void autoConnect() { public void disconnect() { executor.submit(() -> { try { - AbsAdbConnectionManager manager = AdbConnectionManager.getInstance(getApplication()); + AbsAdbConnectionManager manager = AdbConnectionManager.getInstance(this.appContext); manager.disconnect(); connectAdb.postValue(false); } catch (Throwable th) { @@ -131,7 +129,7 @@ public void getPairingPort() { final String[] host = {null}; CountDownLatch resolveHostAndPort = new CountDownLatch(1); - AdbMdns adbMdns = new AdbMdns(getApplication(), AdbMdns.SERVICE_TYPE_TLS_PAIRING, (hostAddress, port) -> { + AdbMdns adbMdns = new AdbMdns(this.appContext, AdbMdns.SERVICE_TYPE_TLS_PAIRING, (hostAddress, port) -> { atomicPort.set(port); if (hostAddress != null) { host[0] = hostAddress.getHostAddress(); @@ -160,8 +158,8 @@ public void pair(int port, String pairingCode) { try { boolean pairingStatus; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - AbsAdbConnectionManager manager = AdbConnectionManager.getInstance(getApplication()); - String host = mPairingHost != null ? mPairingHost : AndroidUtils.getHostIpAddress(getApplication()); + AbsAdbConnectionManager manager = AdbConnectionManager.getInstance(this.appContext); + String host = mPairingHost != null ? mPairingHost : AndroidUtils.getHostIpAddress(this.appContext); int p = port > 0 ? port : mPairingPort; pairingStatus = manager.pair(host, p, pairingCode); } else pairingStatus = false; @@ -177,11 +175,11 @@ public void pair(int port, String pairingCode) { @WorkerThread private void autoConnectInternal() { try { - AbsAdbConnectionManager manager = AdbConnectionManager.getInstance(getApplication()); + AbsAdbConnectionManager manager = AdbConnectionManager.getInstance(this.appContext); boolean connected = false; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { try { - connected = manager.connectTls(getApplication(), 5000); + connected = manager.connectTls(this.appContext, 5000); } catch (AdbPairingRequiredException | InterruptedException ie) { askPairAdb.postValue(true); } catch (Throwable th) { @@ -218,7 +216,7 @@ public void execute(String command) { executor.submit(() -> { try { if (adbShellStream == null || adbShellStream.isClosed()) { - AbsAdbConnectionManager manager = AdbConnectionManager.getInstance(getApplication()); + AbsAdbConnectionManager manager = AdbConnectionManager.getInstance(this.appContext); adbShellStream = manager.openStream(LocalServices.SHELL); new Thread(outputGenerator).start(); } @@ -257,9 +255,9 @@ public void runQuickForensics(@NonNull File baseDir, qfCancelled.set(false); qfFuture = executor.submit(() -> { try { - AbsAdbConnectionManager manager = AdbConnectionManager.getInstance(getApplication()); + AbsAdbConnectionManager manager = AdbConnectionManager.getInstance(this.appContext); File out = new org.osservatorionessuno.bugbane.qf.QuickForensics() - .run(getApplication(), manager, baseDir, listener); + .run(this.appContext, manager, baseDir, listener); if (qfCancelled.get()) { commandOutput.postValue("QuickForensics cancelled"); } else { diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AppState.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AppState.kt new file mode 100644 index 0000000..4518046 --- /dev/null +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AppState.kt @@ -0,0 +1,30 @@ +package org.osservatorionessuno.bugbane.utils + +// All states should be defined here. +// Ordering/requisites are defined in the ConfigurationViewModel +sealed class AppState(val index: Int) { + // Flow: Wifi, App Notification Perm, Dev Options, AdbConnection + object NeedWelcomeScreen: AppState(0) + object NeedWifi : AppState(1) + object NeedNotificationConfiguration : AppState(2) + object NeedDeveloperOptions : AppState(3) + object NeedWirelessDebugging : AppState(4) + object NeedAdbPairingService : AppState(5) + //object NeedAdbConnectService : ConfigurationState(6) + object NeedAdbConnection : AppState(6) + object AdbConnected : AppState(7) + + companion object { + fun valuesInOrder(): List = listOf( + NeedWelcomeScreen, + NeedWifi, + NeedNotificationConfiguration, + NeedDeveloperOptions, + NeedWirelessDebugging, + NeedAdbPairingService, +// NeedAdbConnectService, + NeedAdbConnection, + AdbConnected + ) + } +} diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt index 342043a..655a164 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt @@ -11,6 +11,9 @@ import android.provider.Settings import android.net.ConnectivityManager import android.net.NetworkCapabilities +private const val PREFS_NAME = "app_prefs" +private const val KEY_HAS_SEEN_HOMEPAGE = "has_seen_homepage" + object ConfigurationManager { fun openDeviceSettings(context: Context) { @@ -103,4 +106,44 @@ object ConfigurationManager { false } } + + fun needWelcomeScreen(context: Context): Boolean { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(KEY_HAS_SEEN_HOMEPAGE, false) + } + + + fun wirelessDebuggingIntent(): Intent { + // Open wireless debugging settings + val EXTRA_FRAGMENT_ARG_KEY = ":settings:fragment_args_key" + val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args" + val settingsIntent = Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS).apply { + putExtra(EXTRA_FRAGMENT_ARG_KEY, "toggle_adb_wireless") + val bundle = Bundle().apply { + putString(EXTRA_FRAGMENT_ARG_KEY, "toggle_adb_wireless") + } + putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, bundle) + } + return settingsIntent + } + + fun launchPermissionsIntent(context: Context, state: AppState) { + val intent = when (state) { + AppState.NeedWifi -> Intent(Settings.ACTION_WIFI_SETTINGS) + AppState.NeedNotificationConfiguration -> Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } + AppState.NeedDeveloperOptions -> Intent(Settings.ACTION_DEVICE_INFO_SETTINGS) + AppState.NeedWirelessDebugging -> wirelessDebuggingIntent() + + // TODO: anything else that needs an activity launched? + else -> null + } + intent?.let { + try { + context.startActivity(it) + } catch (e: Exception) { + } + } + } } diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt new file mode 100644 index 0000000..3b7224a --- /dev/null +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt @@ -0,0 +1,73 @@ +package org.osservatorionessuno.bugbane.utils + +import android.app.Application +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + + +class ConfigurationViewModel ( + private val application: Application + +) : ViewModel() { + + private val _configurationState = MutableStateFlow(AppState.NeedWifi) + val configurationState: StateFlow = _configurationState.asStateFlow() + + // Only need the context, but don't pass it in directly since Android complains about + // memory leaks. + private val appContext = application.applicationContext // todo: check with strictmode + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + init { + scope.launch { + // TODO: this is a bit hacky, but it polls for active state- it's not required + while (isActive) { + val newState = checkState() + if (_configurationState.value != newState) { + _configurationState.value = newState + } + delay(5000) + } + } + } + + private fun checkState(): AppState { + // Get the "best" state - all requisites/logic enforced here. + // If a user has completed the welcome screen, is connected to + // wifi and has an active adb connection, skip the other checks. + // If a user is missing one of those, they will need to pair again, + // so check for notifications enabled, etc + val needWelcomeScreen = ConfigurationManager.needWelcomeScreen(appContext) + if (needWelcomeScreen) return AppState.NeedWelcomeScreen + + val wifi = ConfigurationManager.isConnectedToWifi(appContext) + if (!wifi) return AppState.NeedWifi + + val devOptions = ConfigurationManager.isDeveloperOptionsEnabled(appContext) + if (!devOptions) return AppState.NeedDeveloperOptions + + // Skip other checks if we are already connected + // TODO: on first view, show "Get started"; on later views, jump to acquisitions screen + val adbConnected = ConfigurationManager.isAdbEnabled(appContext) + if (adbConnected) return AppState.AdbConnected + + val notifications = ConfigurationManager.isNotificationPermissionGranted(appContext) + if (!notifications) return AppState.NeedNotificationConfiguration + + val wirelessDebugging = ConfigurationManager.isWirelessDebuggingEnabled(appContext) + if (!wirelessDebugging) return AppState.NeedWirelessDebugging + + // TODO: this can be better with watching adb connection as a state + return AppState.NeedAdbPairingService + // If we get here, we are probably pairing + } +} From 5d7a485887d43dbdb95cd5cc6877e8d8c599de6e Mon Sep 17 00:00:00 2001 From: Rowen S Date: Thu, 11 Sep 2025 18:39:57 -0400 Subject: [PATCH 02/17] add button strings for continue and exit --- app/src/main/res/values-it/strings.xml | 4 ++++ app/src/main/res/values/strings.xml | 2 ++ 2 files changed, 6 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 81449c2..28f6b9b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -7,10 +7,14 @@ Benvenuto in Bugbane Questa applicazione aiuta con l\'analisi forense consensuale di dispositivi Android per identificare segnali di compromissione o sorveglianza.\n\nL\'assenza di rilevazioni da quest\'app tuttavia non significa che il tuo dispositivo è sicuro o protetto. \n Ci dispiace, Bugbane non è supportato sulle versioni di Android inferiori a 11 (API level 30). Si consiglia l\'uso di AndroidQF o MVT da un computer fidato. + + Chiudi Ho capito Connetti a una rete Wi-Fi fidata Per continuare, connetti il tuo dispositivo ad una rete Wi-Fi protetta, ad esempio alla tua rete casalinga o ad un hotspot personale. Apri impostazioni Wi-Fi + + Continuare Abilita autorizzazioni alle notifiche Per utilizzare questa applicazione, è necessario abilitare l\'autorizzazione alle notifiche.\n\nBugbane visualizzerà una notifica dove puoi inserire il codice di abbinamento per l\' ADB Wireless Debugging. Abilita diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d37981b..67517c8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -13,11 +13,13 @@ The absence of detection from this app does not mean that your device is safe or secure. \n Sorry, Bugbane is not supported on Android versions lower than 11 (API level 30). Please use AndroidQF or MVT from a trusted computer. + Exit I understand Connect to a Trusted Wi-Fi Network To continue, connect your device to a protected Wi-Fi network, such as your home network or a personal hotspot. Open Wi-Fi Settings + Continue Enable Notification Permissions To use this app, you need to enable notification permissions. From 61f489169043579d9ee1cbd7095a6b9646de1aac Mon Sep 17 00:00:00 2001 From: Rowen S Date: Wed, 3 Sep 2025 11:14:57 -0400 Subject: [PATCH 03/17] Add single SlideShowPage that shows content based on AppState and use for all permission pages (WifiPage, FinalPage, etc) --- .../bugbane/SlideshowActivity.kt | 88 +++++++++---------- .../bugbane/components/SlideshowPage.kt | 69 ++++++++++++++- .../bugbane/utils/ConfigurationManager.kt | 2 +- 3 files changed, 107 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt b/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt index 67e787f..2d5a049 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt @@ -17,21 +17,20 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment -import kotlinx.coroutines.launch import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import org.osservatorionessuno.bugbane.components.SlideshowPage -import org.osservatorionessuno.bugbane.pages.* import org.osservatorionessuno.bugbane.ui.theme.Theme import org.osservatorionessuno.bugbane.utils.AdbManager +import org.osservatorionessuno.bugbane.utils.AppState +import org.osservatorionessuno.bugbane.utils.ConfigurationManager +import org.osservatorionessuno.bugbane.utils.ConfigurationViewModel class SlideshowActivity : ComponentActivity() { - private val viewModel: AdbManager by viewModels() - private val totalPages = 6 + private val viewModel = AdbManager(application) + private val configViewModel: ConfigurationViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -57,10 +56,10 @@ class SlideshowActivity : ComponentActivity() { setContent { Theme { SlideshowScreen( + configViewModel, onSlideshowComplete = { restartMainActivity() - }, - totalPages = totalPages + } ) } } @@ -84,52 +83,46 @@ class SlideshowActivity : ComponentActivity() { @OptIn(ExperimentalFoundationApi::class) @Composable fun SlideshowScreen( + viewModel: ConfigurationViewModel, onSlideshowComplete: () -> Unit, - totalPages: Int ) { - val pagerState = rememberPagerState(pageCount = { totalPages }) + val allStates = AppState.valuesInOrder().filter { it != AppState.AdbConnected } + val context = LocalContext.current + val permissionState by viewModel.configurationState.collectAsState() + + val pagerState = rememberPagerState(initialPage = 0, pageCount = { allStates.size }) + val currentPage by remember { derivedStateOf { pagerState.currentPage } } val coroutineScope = rememberCoroutineScope() - - val goToNextPage = { - coroutineScope.launch { - pagerState.animateScrollToPage(currentPage + 1) - } - } - val slideshowPages = listOf( - WelcomePage.create { goToNextPage() }, - WifiConnectionPage.create { goToNextPage() }, - NotificationPermissionPage.create { goToNextPage() }, - DeveloperOptionsPage.create { goToNextPage() }, - WirelessDebuggingPage.create { goToNextPage() }, - FinalPage.create(onSlideshowComplete) - ) - - // Check if current page should be skipped and automatically advance - LaunchedEffect(currentPage) { - val currentPageData = slideshowPages.getOrNull(currentPage) - if (currentPageData?.shouldSkip?.invoke() == true) { - goToNextPage() + suspend fun updatePager(permissionState: AppState) { + val currentIndex = allStates.indexOf(permissionState) + if (currentIndex in allStates.indices) { + pagerState.animateScrollToPage(currentIndex) + } else if (permissionState == AppState.AdbConnected) { + onSlideshowComplete() } } - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner, currentPage) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - val currentPageData = slideshowPages.getOrNull(currentPage) - if (currentPageData?.shouldSkip?.invoke() == true) { - goToNextPage() - } - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } + // Skip screens already satisfied + LaunchedEffect(permissionState) { + updatePager(permissionState) } + // todo: test onResume then remove this block +// val lifecycleOwner = LocalLifecycleOwner.current +// DisposableEffect(lifecycleOwner, currentPage) { +// val observer = LifecycleEventObserver { _, event -> +// if (event == Lifecycle.Event.ON_RESUME) { +// updatePager(permissionState) +// } +// } +// lifecycleOwner.lifecycle.addObserver(observer) +// onDispose { +// lifecycleOwner.lifecycle.removeObserver(observer) +// } +// } + Column( modifier = Modifier .fillMaxSize() @@ -144,7 +137,7 @@ fun SlideshowScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { - slideshowPages.forEachIndexed { index, _ -> + allStates.forEachIndexed { index, _ -> Box( modifier = Modifier .padding(4.dp) @@ -169,7 +162,8 @@ fun SlideshowScreen( modifier = Modifier.weight(1f), userScrollEnabled = false ) { pageIndex -> - SlideshowPage(page = slideshowPages[pageIndex]) + val state = allStates[pageIndex] + SlideshowPage(state = state, onClickContinue = { ConfigurationManager.launchIntentByState(context, state) }) } } } diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/components/SlideshowPage.kt b/app/src/main/java/org/osservatorionessuno/bugbane/components/SlideshowPage.kt index c84a0ea..d9958da 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/components/SlideshowPage.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/components/SlideshowPage.kt @@ -2,6 +2,12 @@ package org.osservatorionessuno.bugbane.components import android.app.Activity import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Wifi import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -9,26 +15,81 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.osservatorionessuno.bugbane.R +import org.osservatorionessuno.bugbane.utils.AppState +import org.osservatorionessuno.bugbane.utils.ConfigurationManager data class SlideshowPageData( val title: String, val description: String, val icon: ImageVector? = null, val buttonText: String? = null, - val onClick: (() -> Unit)? = null, - val shouldSkip: (() -> Boolean)? = null, + val onClick: (() -> Unit)? = null, // todo launching activity +// val shouldSkip: (() -> Boolean)? = null, val shouldContinue: Boolean = true ) @Composable -fun SlideshowPage(page: SlideshowPageData) { +fun getSlideshowScreenContent(state: AppState): SlideshowPageData { + when (state) { + AppState.NeedWelcomeScreen -> return SlideshowPageData( + title = stringResource(R.string.slideshow_welcome_title), + description = stringResource(R.string.slideshow_welcome_description), + icon = ImageVector.Companion.vectorResource(R.drawable.ic_bugbane_zoom), + buttonText = "", + ) + AppState.NeedWifi -> return SlideshowPageData( + title = stringResource(R.string.slideshow_wifi_title), + description = stringResource(R.string.slideshow_wifi_description), + icon = Icons.Filled.Wifi, + buttonText = stringResource(R.string.slideshow_wifi_button), + ) + AppState.NeedNotificationConfiguration -> return SlideshowPageData( + title = stringResource(R.string.slideshow_notification_title), + description = stringResource(R.string.slideshow_notification_description), + icon = Icons.Default.Notifications, + buttonText = stringResource(R.string.slideshow_notification_button), + ) + AppState.NeedDeveloperOptions -> return SlideshowPageData( + title = stringResource(R.string.slideshow_developer_title), + description = stringResource(R.string.slideshow_developer_description), + icon = Icons.Default.Settings, + buttonText = stringResource(R.string.slideshow_developer_button), + ) + AppState.NeedWirelessDebugging -> return SlideshowPageData(title = stringResource(R.string.slideshow_wireless_title), + description = stringResource(R.string.slideshow_wireless_description), + icon = Icons.Filled.Build, + buttonText = stringResource(R.string.slideshow_wireless_button), + ) + // TODO +// ConfigurationState.NeedAdbPairingService -> SlideshowScreenContent(title = stringResource(R.string.slideshow_wireless_title), +// description = stringResource(R.string.slideshow_wireless_description), +// icon = Icons.Filled.Build, +// buttonText = "Pairing in progress..." +// ) + AppState.AdbConnected -> return SlideshowPageData( + title = stringResource(R.string.slideshow_ready_title), + description = stringResource(R.string.slideshow_ready_description), + icon = Icons.AutoMirrored.Filled.ArrowForward, + buttonText = stringResource(R.string.slideshow_ready_button) + ) + // TODO: probably pairing, but checks can be better + else -> return getSlideshowScreenContent(AppState.NeedAdbPairingService) //TODO + } + // Unreachable? +} + + +@Composable +fun SlideshowPage(state: AppState, onClickContinue: (() -> Unit)?) { val context = LocalContext.current - + val page = getSlideshowScreenContent(state) + Column( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt index 655a164..1026863 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt @@ -127,7 +127,7 @@ object ConfigurationManager { return settingsIntent } - fun launchPermissionsIntent(context: Context, state: AppState) { + fun launchIntentByState(context: Context, state: AppState) { val intent = when (state) { AppState.NeedWifi -> Intent(Settings.ACTION_WIFI_SETTINGS) AppState.NeedNotificationConfiguration -> Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { From f951b0ed7511feee6fa824102ff7b845f91a292b Mon Sep 17 00:00:00 2001 From: Rowen S Date: Thu, 11 Sep 2025 18:24:11 -0400 Subject: [PATCH 04/17] Delete individual slideshow page files --- .../bugbane/pages/DeveloperOptionsPage.kt | 42 --------- .../bugbane/pages/FinalPage.kt | 31 ------- .../pages/NotificationPermissionPage.kt | 51 ----------- .../bugbane/pages/WelcomePage.kt | 42 --------- .../bugbane/pages/WifiConnectionPage.kt | 34 -------- .../bugbane/pages/WirelessDebuggingPage.kt | 85 ------------------- 6 files changed, 285 deletions(-) delete mode 100644 app/src/main/java/org/osservatorionessuno/bugbane/pages/DeveloperOptionsPage.kt delete mode 100644 app/src/main/java/org/osservatorionessuno/bugbane/pages/FinalPage.kt delete mode 100644 app/src/main/java/org/osservatorionessuno/bugbane/pages/NotificationPermissionPage.kt delete mode 100644 app/src/main/java/org/osservatorionessuno/bugbane/pages/WelcomePage.kt delete mode 100644 app/src/main/java/org/osservatorionessuno/bugbane/pages/WifiConnectionPage.kt delete mode 100644 app/src/main/java/org/osservatorionessuno/bugbane/pages/WirelessDebuggingPage.kt diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/pages/DeveloperOptionsPage.kt b/app/src/main/java/org/osservatorionessuno/bugbane/pages/DeveloperOptionsPage.kt deleted file mode 100644 index d2695cf..0000000 --- a/app/src/main/java/org/osservatorionessuno/bugbane/pages/DeveloperOptionsPage.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.osservatorionessuno.bugbane.pages - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Settings -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource - -import org.osservatorionessuno.bugbane.R -import org.osservatorionessuno.bugbane.components.SlideshowPageData -import org.osservatorionessuno.bugbane.utils.ConfigurationManager - -object DeveloperOptionsPage { - @Composable - fun create(onNext: () -> Unit): SlideshowPageData { - val context = LocalContext.current - - fun shouldSkip(): Boolean { - return ConfigurationManager.isDeveloperOptionsEnabled(context) - } - - - fun handleNext() { - // User is returning to the page, so we don't need to open the settings - if (shouldSkip()) { - onNext() - return - } - - ConfigurationManager.openDeviceSettings(context) - } - - return SlideshowPageData( - title = stringResource(R.string.slideshow_developer_title), - description = stringResource(R.string.slideshow_developer_description), - icon = Icons.Default.Settings, - buttonText = stringResource(R.string.slideshow_developer_button), - onClick = { handleNext() }, - shouldSkip = { shouldSkip() } - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/pages/FinalPage.kt b/app/src/main/java/org/osservatorionessuno/bugbane/pages/FinalPage.kt deleted file mode 100644 index 5c11a8d..0000000 --- a/app/src/main/java/org/osservatorionessuno/bugbane/pages/FinalPage.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.osservatorionessuno.bugbane.pages - -import android.content.Context -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import org.osservatorionessuno.bugbane.R -import org.osservatorionessuno.bugbane.components.SlideshowPageData -import org.osservatorionessuno.bugbane.utils.SlideshowManager - -object FinalPage { - @Composable - fun create(onComplete: () -> Unit): SlideshowPageData { - val context = LocalContext.current - - fun handleNext(context: Context) { - SlideshowManager.markHomepageAsSeen(context) - onComplete() - } - return SlideshowPageData( - title = stringResource(R.string.slideshow_ready_title), - description = stringResource(R.string.slideshow_ready_description), - icon = Icons.AutoMirrored.Filled.ArrowForward, - buttonText = stringResource(R.string.slideshow_ready_button), - onClick = { handleNext(context) }, - shouldSkip = { SlideshowManager.hasSeenHomepage(context) } - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/pages/NotificationPermissionPage.kt b/app/src/main/java/org/osservatorionessuno/bugbane/pages/NotificationPermissionPage.kt deleted file mode 100644 index b680623..0000000 --- a/app/src/main/java/org/osservatorionessuno/bugbane/pages/NotificationPermissionPage.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.osservatorionessuno.bugbane.pages - -import android.Manifest -import android.content.pm.PackageManager -import android.os.Build -import android.util.Log -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.runtime.* -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import org.osservatorionessuno.bugbane.components.SlideshowPageData -import org.osservatorionessuno.bugbane.utils.ConfigurationManager -import org.osservatorionessuno.bugbane.R - -object NotificationPermissionPage { - @Composable - fun create(onNext: () -> Unit): SlideshowPageData { - val context = LocalContext.current - - // Prepare a permission launcher to request the permission - val permissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission() - ) { isGranted -> - if (isGranted) { - onNext() - } - } - - fun shouldSkip(): Boolean { - return ConfigurationManager.isNotificationPermissionGranted(context) - } - - fun handleNext() { - Log.d("NotificationPermissionPage", "handleNext called") - // Request the permission when the button is clicked - permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - - return SlideshowPageData( - title = stringResource(R.string.slideshow_notification_title), - description = stringResource(R.string.slideshow_notification_description), - icon = Icons.Default.Notifications, - buttonText = stringResource(R.string.slideshow_notification_button), - onClick = { handleNext() }, - shouldSkip = { shouldSkip() } - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/pages/WelcomePage.kt b/app/src/main/java/org/osservatorionessuno/bugbane/pages/WelcomePage.kt deleted file mode 100644 index e8650de..0000000 --- a/app/src/main/java/org/osservatorionessuno/bugbane/pages/WelcomePage.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.osservatorionessuno.bugbane.pages - -import android.content.Context -import android.os.Build -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.res.stringResource -import org.osservatorionessuno.bugbane.R -import org.osservatorionessuno.bugbane.components.SlideshowPageData -import org.osservatorionessuno.bugbane.utils.SlideshowManager - -object WelcomePage { - @Composable - fun create(onNext: () -> Unit): SlideshowPageData { - val context = LocalContext.current - - val isSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R - - fun handleNext(context: Context) { - if (isSupported) { - onNext() - } - } - - return SlideshowPageData( - title = stringResource(R.string.slideshow_welcome_title), - description = if (isSupported) { - stringResource(R.string.slideshow_welcome_description) - } else { - stringResource(R.string.slideshow_unsupported_version) - }, - icon = ImageVector.vectorResource(id = R.drawable.ic_bugbane_zoom), - onClick = { handleNext(context) }, - shouldSkip = { SlideshowManager.hasSeenHomepage(context) }, - shouldContinue = isSupported - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/pages/WifiConnectionPage.kt b/app/src/main/java/org/osservatorionessuno/bugbane/pages/WifiConnectionPage.kt deleted file mode 100644 index 5f83db3..0000000 --- a/app/src/main/java/org/osservatorionessuno/bugbane/pages/WifiConnectionPage.kt +++ /dev/null @@ -1,34 +0,0 @@ -package org.osservatorionessuno.bugbane.pages - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Wifi -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import org.osservatorionessuno.bugbane.R -import org.osservatorionessuno.bugbane.components.SlideshowPageData -import org.osservatorionessuno.bugbane.utils.ConfigurationManager - -object WifiConnectionPage { - @Composable - fun create(onNext: () -> Unit): SlideshowPageData { - val context = LocalContext.current - - fun shouldSkip(): Boolean { - return ConfigurationManager.isConnectedToWifi(context) - } - - fun handleNext() { - ConfigurationManager.openWifiSettings(context) - } - - return SlideshowPageData( - title = stringResource(R.string.slideshow_wifi_title), - description = stringResource(R.string.slideshow_wifi_description), - icon = Icons.Filled.Wifi, - buttonText = stringResource(R.string.slideshow_wifi_button), - onClick = { handleNext() }, - shouldSkip = { shouldSkip() } - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/pages/WirelessDebuggingPage.kt b/app/src/main/java/org/osservatorionessuno/bugbane/pages/WirelessDebuggingPage.kt deleted file mode 100644 index e31f0e3..0000000 --- a/app/src/main/java/org/osservatorionessuno/bugbane/pages/WirelessDebuggingPage.kt +++ /dev/null @@ -1,85 +0,0 @@ -package org.osservatorionessuno.bugbane.pages - -import android.os.Build -import android.content.Intent -import android.content.Context -import android.content.IntentFilter -import android.provider.Settings -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Build -import androidx.compose.runtime.* -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import org.osservatorionessuno.bugbane.components.SlideshowPageData -import org.osservatorionessuno.bugbane.utils.AdbPairingService -import org.osservatorionessuno.bugbane.utils.AdbPairingResultReceiver -import org.osservatorionessuno.bugbane.utils.ConfigurationManager -import org.osservatorionessuno.bugbane.R -import android.os.Bundle - -object WirelessDebuggingPage { - @Composable - fun create(onNext: () -> Unit): SlideshowPageData { - val context = LocalContext.current - var isPairingInProgress by remember { mutableStateOf(false) } - - // Create BroadcastReceiver for pairing results - val pairingReceiver = remember { - AdbPairingResultReceiver( - onSuccess = { - isPairingInProgress = false - onNext() - }, - onFailure = { errorMessage -> - isPairingInProgress = false - // You could show an error message here if needed - } - ) - } - - DisposableEffect(context) { - val filter = IntentFilter(AdbPairingService.ACTION_PAIRING_RESULT) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - // This broadcast is internal to the app, so keep it private - context.registerReceiver(pairingReceiver, filter, Context.RECEIVER_NOT_EXPORTED) - } else { - // Pre-Android 13: old two-argument API - @Suppress("UnspecifiedRegisterReceiverFlag") - context.registerReceiver(pairingReceiver, filter) - } - - onDispose { - try { - context.unregisterReceiver(pairingReceiver) - } catch (_: Exception) { } - } - } - - - fun handleNext() { - ConfigurationManager.openWirelessDebugging(context) - - // Start ADB pairing service - val pairingIntent = AdbPairingService.startIntent(context) - try { - context.startForegroundService(pairingIntent) - isPairingInProgress = true - } catch (ignored: Throwable) { - context.startService(pairingIntent) - isPairingInProgress = true - } - - // Don't call onNext() here - wait for pairing result - } - - return SlideshowPageData( - title = stringResource(R.string.slideshow_wireless_title), - description = stringResource(R.string.slideshow_wireless_description), - icon = Icons.Filled.Build, - buttonText = if (isPairingInProgress) "Pairing..." else stringResource(R.string.slideshow_wireless_button), - onClick = { handleNext() }, - shouldSkip = { false } - ) - } -} \ No newline at end of file From 97ac7ae6ac993dcb423851cbb263adbbd4e0278c Mon Sep 17 00:00:00 2001 From: Rowen S Date: Thu, 11 Sep 2025 11:09:52 -0400 Subject: [PATCH 05/17] AppState, ConfigurationManager, and SlideShowManager fixups: add AdbConnectedFinishOnboarding state and move preferences checks to slideshowmanager --- .../bugbane/SlideshowActivity.kt | 10 ++- .../bugbane/utils/AppState.kt | 29 +++++--- .../bugbane/utils/ConfigurationManager.kt | 48 ++---------- .../bugbane/utils/ConfigurationViewModel.kt | 45 ++++++------ .../bugbane/utils/SlideshowManager.kt | 73 ++++++++++++++++++- 5 files changed, 124 insertions(+), 81 deletions(-) diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt b/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt index 2d5a049..a4871a4 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt @@ -1,8 +1,10 @@ package org.osservatorionessuno.bugbane +import android.app.Activity import android.content.Context import android.content.Intent import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -27,6 +29,7 @@ import org.osservatorionessuno.bugbane.utils.AdbManager import org.osservatorionessuno.bugbane.utils.AppState import org.osservatorionessuno.bugbane.utils.ConfigurationManager import org.osservatorionessuno.bugbane.utils.ConfigurationViewModel +import org.osservatorionessuno.bugbane.utils.SlideshowManager class SlideshowActivity : ComponentActivity() { private val viewModel = AdbManager(application) @@ -87,7 +90,6 @@ fun SlideshowScreen( onSlideshowComplete: () -> Unit, ) { val allStates = AppState.valuesInOrder().filter { it != AppState.AdbConnected } - val context = LocalContext.current val permissionState by viewModel.configurationState.collectAsState() val pagerState = rememberPagerState(initialPage = 0, pageCount = { allStates.size }) @@ -163,9 +165,13 @@ fun SlideshowScreen( userScrollEnabled = false ) { pageIndex -> val state = allStates[pageIndex] - SlideshowPage(state = state, onClickContinue = { ConfigurationManager.launchIntentByState(context, state) }) + SlideshowPage( + state = state, + onClickContinue = { SlideshowManager::handleOnContinue } + ) // todo } } } + \ No newline at end of file diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AppState.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AppState.kt index 4518046..08038d7 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AppState.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AppState.kt @@ -1,21 +1,24 @@ package org.osservatorionessuno.bugbane.utils -// All states should be defined here. +// All permissions-related states should be defined here. // Ordering/requisites are defined in the ConfigurationViewModel sealed class AppState(val index: Int) { - // Flow: Wifi, App Notification Perm, Dev Options, AdbConnection - object NeedWelcomeScreen: AppState(0) - object NeedWifi : AppState(1) - object NeedNotificationConfiguration : AppState(2) - object NeedDeveloperOptions : AppState(3) - object NeedWirelessDebugging : AppState(4) - object NeedAdbPairingService : AppState(5) + object DeviceUnsupported : AppState(0) + object NeedWelcomeScreen: AppState(1) + object NeedWifi : AppState(2) + object NeedNotificationConfiguration : AppState(3) + object NeedDeveloperOptions : AppState(4) + object NeedWirelessDebugging : AppState(5) + // sub-states in ADB pairing flow (waiting to pair, mdns, etc) are handled by AdbManager + object NeedAdbPairingService : AppState(6) //object NeedAdbConnectService : ConfigurationState(6) - object NeedAdbConnection : AppState(6) - object AdbConnected : AppState(7) + // We've connected to ADB, but we've never completed the onboarding screen ("Get started") + object AdbConnectedFinishOnboarding : AppState(7) + object AdbConnected : AppState(8) companion object { fun valuesInOrder(): List = listOf( + DeviceUnsupported, NeedWelcomeScreen, NeedWifi, NeedNotificationConfiguration, @@ -23,8 +26,12 @@ sealed class AppState(val index: Int) { NeedWirelessDebugging, NeedAdbPairingService, // NeedAdbConnectService, - NeedAdbConnection, + AdbConnectedFinishOnboarding, AdbConnected ) + // Error states can adjust the theme's UI + fun isErrorState(state: AppState): Boolean { + return (state is DeviceUnsupported) + } } } diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt index 1026863..a5435c0 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt @@ -10,9 +10,7 @@ import android.os.Bundle import android.provider.Settings import android.net.ConnectivityManager import android.net.NetworkCapabilities - -private const val PREFS_NAME = "app_prefs" -private const val KEY_HAS_SEEN_HOMEPAGE = "has_seen_homepage" +import androidx.core.content.edit object ConfigurationManager { @@ -60,6 +58,11 @@ object ConfigurationManager { } } + // Can just do this via the manifest, but including here is ~harmless + fun isSupportedDevice() : Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + } + fun isNotificationPermissionGranted(context: Context): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { return true @@ -107,43 +110,4 @@ object ConfigurationManager { } } - fun needWelcomeScreen(context: Context): Boolean { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getBoolean(KEY_HAS_SEEN_HOMEPAGE, false) - } - - - fun wirelessDebuggingIntent(): Intent { - // Open wireless debugging settings - val EXTRA_FRAGMENT_ARG_KEY = ":settings:fragment_args_key" - val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args" - val settingsIntent = Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS).apply { - putExtra(EXTRA_FRAGMENT_ARG_KEY, "toggle_adb_wireless") - val bundle = Bundle().apply { - putString(EXTRA_FRAGMENT_ARG_KEY, "toggle_adb_wireless") - } - putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, bundle) - } - return settingsIntent - } - - fun launchIntentByState(context: Context, state: AppState) { - val intent = when (state) { - AppState.NeedWifi -> Intent(Settings.ACTION_WIFI_SETTINGS) - AppState.NeedNotificationConfiguration -> Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) - } - AppState.NeedDeveloperOptions -> Intent(Settings.ACTION_DEVICE_INFO_SETTINGS) - AppState.NeedWirelessDebugging -> wirelessDebuggingIntent() - - // TODO: anything else that needs an activity launched? - else -> null - } - intent?.let { - try { - context.startActivity(it) - } catch (e: Exception) { - } - } - } } diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt index 3b7224a..bfa0274 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt @@ -35,39 +35,40 @@ class ConfigurationViewModel ( if (_configurationState.value != newState) { _configurationState.value = newState } - delay(5000) + delay(1000) } } } - private fun checkState(): AppState { - // Get the "best" state - all requisites/logic enforced here. - // If a user has completed the welcome screen, is connected to - // wifi and has an active adb connection, skip the other checks. - // If a user is missing one of those, they will need to pair again, - // so check for notifications enabled, etc - val needWelcomeScreen = ConfigurationManager.needWelcomeScreen(appContext) - if (needWelcomeScreen) return AppState.NeedWelcomeScreen + fun checkState(): AppState { + // Get the "best" state - all requisites/logic enforced here and order matters. + // If a user has completed the welcome screen, is connected to wifi and has an + // active adb connection, skip the other checks. + // If a user is missing one of those, they will need pairing flow again. - val wifi = ConfigurationManager.isConnectedToWifi(appContext) - if (!wifi) return AppState.NeedWifi + // TODO: This can be defined in the manifest if it's just about API level + if (!ConfigurationManager.isSupportedDevice()) return AppState.DeviceUnsupported - val devOptions = ConfigurationManager.isDeveloperOptionsEnabled(appContext) - if (!devOptions) return AppState.NeedDeveloperOptions + // Consent page + if (!SlideshowManager.canSkipWelcomeScreen(appContext)) return AppState.NeedWelcomeScreen - // Skip other checks if we are already connected - // TODO: on first view, show "Get started"; on later views, jump to acquisitions screen + // See if we're already connected. If yes but it's the first time, we're in state + // AdbConnectedFinishOnboarding, otherwise state AdbConnected + // (TODO - check if need wifi check explicitly or not) + // also TODO: ContentObserver (https://developer.android.com/reference/android/database/ContentObserver) val adbConnected = ConfigurationManager.isAdbEnabled(appContext) - if (adbConnected) return AppState.AdbConnected + if (adbConnected && SlideshowManager.hasSeenHomepage(appContext)) return AppState.AdbConnected + if (adbConnected && !SlideshowManager.hasSeenHomepage(appContext)) return AppState.AdbConnectedFinishOnboarding - val notifications = ConfigurationManager.isNotificationPermissionGranted(appContext) - if (!notifications) return AppState.NeedNotificationConfiguration + // Notifications aren't strictly needed unless we're not connected to adb + if (!ConfigurationManager.isNotificationPermissionGranted(appContext)) return AppState.NeedNotificationConfiguration - val wirelessDebugging = ConfigurationManager.isWirelessDebuggingEnabled(appContext) - if (!wirelessDebugging) return AppState.NeedWirelessDebugging + // Wifi, developer options, wireless debugging + if (!ConfigurationManager.isConnectedToWifi(appContext)) return AppState.NeedWifi + if (!ConfigurationManager.isDeveloperOptionsEnabled(appContext)) return AppState.NeedDeveloperOptions + if (!ConfigurationManager.isWirelessDebuggingEnabled(appContext)) return AppState.NeedWirelessDebugging - // TODO: this can be better with watching adb connection as a state + // If we get here, we need and are ready for the ADB pairing wizard flow (todo handled by another viewmodel?) return AppState.NeedAdbPairingService - // If we get here, we are probably pairing } } diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/SlideshowManager.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/SlideshowManager.kt index 8f7227d..b947506 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/SlideshowManager.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/SlideshowManager.kt @@ -1,17 +1,27 @@ package org.osservatorionessuno.bugbane.utils +import android.app.Activity import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.provider.Settings +import android.util.Log object SlideshowManager { private const val PREFS_NAME = "app_prefs" + + // Skips the logo/splashscreen page after the first onboarding flow + private const val KEY_HAS_SEEN_WELCOME_SCREEN = "has_seen_welcome_screen" + + // Skips the "Get Started" page after the first onboarding flow private const val KEY_HAS_SEEN_HOMEPAGE = "has_seen_homepage" - + fun hasSeenHomepage(context: Context): Boolean { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) return prefs.getBoolean(KEY_HAS_SEEN_HOMEPAGE, false) } - fun markHomepageAsSeen(context: Context) { + private fun markHomepageAsSeen(context: Context) { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) prefs.edit().putBoolean(KEY_HAS_SEEN_HOMEPAGE, true).apply() } @@ -21,5 +31,60 @@ object SlideshowManager { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) prefs.edit().putBoolean(KEY_HAS_SEEN_HOMEPAGE, false).apply() } - } -} \ No newline at end of file + } + + fun canSkipWelcomeScreen(context: Context): Boolean { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(KEY_HAS_SEEN_WELCOME_SCREEN, false) + } + + private fun setHasSeenWelcomeScreen(context: Context) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit().putBoolean(KEY_HAS_SEEN_WELCOME_SCREEN, true).apply() + } + + // Desired "next" state for permissions flow slideshow handled here. + // There are two types of slides: informational/consent slides (shown once) and + // permissions slides (launch an activity via intent, shown as needed). + fun handleOnContinue(context: Context, state: AppState, viewModel: ConfigurationViewModel) : Unit { + // TODO: startActivityForResult? + getIntentForAppState(context, state)?.let {it -> (context as? Activity)?.startActivity(it) } ?: + when (state) { + is AppState.DeviceUnsupported -> { + (context as? Activity)?.finishAffinity() + } + is AppState.NeedWelcomeScreen -> { + // Clicked "I understand," nothing else to do + setHasSeenWelcomeScreen(context) + viewModel.checkUpdateState() + } + else -> { // Should be unreachable + Log.e(context.applicationContext.packageName, "$state not handled") + } + } + } + private fun getIntentForAppState(context: Context, state: AppState): Intent? { + return when (state) { + AppState.NeedWifi -> Intent(Settings.ACTION_WIFI_SETTINGS) + AppState.NeedNotificationConfiguration -> Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } + AppState.NeedDeveloperOptions -> Intent(Settings.ACTION_DEVICE_INFO_SETTINGS) + AppState.NeedWirelessDebugging -> wirelessDebuggingIntent() + else -> null + } + } + private fun wirelessDebuggingIntent(): Intent { + // Open wireless debugging settings + val EXTRA_FRAGMENT_ARG_KEY = ":settings:fragment_args_key" + val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args" + val settingsIntent = Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS).apply { + putExtra(EXTRA_FRAGMENT_ARG_KEY, "toggle_adb_wireless") + val bundle = Bundle().apply { + putString(EXTRA_FRAGMENT_ARG_KEY, "toggle_adb_wireless") + } + putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, bundle) + } + return settingsIntent + } +} From 46d8902ead9ec1648f278eccc7de4e74bd84e2c6 Mon Sep 17 00:00:00 2001 From: Rowen S Date: Fri, 12 Sep 2025 14:05:11 -0400 Subject: [PATCH 06/17] Add AdbState class. Convert AdbManager to Kotlin. --- .../bugbane/utils/AdbManager.java | 272 ----------------- .../bugbane/utils/AdbManager.kt | 279 ++++++++++++++++++ .../bugbane/utils/AdbState.kt | 34 +++ .../bugbane/utils/AppState.kt | 45 ++- .../bugbane/utils/ConfigurationManager.kt | 39 +-- .../bugbane/utils/ConfigurationViewModel.kt | 240 ++++++++++++--- .../bugbane/utils/SlideshowManager.kt | 65 +--- .../bugbane/utils/WifiConnectivityMonitor.kt | 61 ++++ 8 files changed, 608 insertions(+), 427 deletions(-) delete mode 100644 app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.java create mode 100644 app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.kt create mode 100644 app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbState.kt create mode 100644 app/src/main/java/org/osservatorionessuno/bugbane/utils/WifiConnectivityMonitor.kt diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.java b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.java deleted file mode 100644 index 7815584..0000000 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.java +++ /dev/null @@ -1,272 +0,0 @@ -package org.osservatorionessuno.bugbane.utils; - -import android.content.Context; -import android.os.Build; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; - -import io.github.muntashirakon.adb.AbsAdbConnectionManager; -import io.github.muntashirakon.adb.AdbPairingRequiredException; -import io.github.muntashirakon.adb.AdbStream; -import io.github.muntashirakon.adb.LocalServices; -import io.github.muntashirakon.adb.android.AdbMdns; -import io.github.muntashirakon.adb.android.AndroidUtils; - -public class AdbManager { - private final ExecutorService executor = Executors.newFixedThreadPool(3); - private final MutableLiveData connectAdb = new MutableLiveData<>(); - private final MutableLiveData pairAdb = new MutableLiveData<>(); - private final MutableLiveData askPairAdb = new MutableLiveData<>(); - private final MutableLiveData commandOutput = new MutableLiveData<>(); - private final MutableLiveData pairingPort = new MutableLiveData<>(); - - private Future qfFuture; - private final AtomicBoolean qfCancelled = new AtomicBoolean(false); - - private String mPairingHost; - private int mPairingPort = -1; - - private Context appContext = null; - - @Nullable - private AdbStream adbShellStream; - - public AdbManager(@NonNull Context applicationContext) { - this.appContext = applicationContext; - } - - public MutableLiveData watchConnectAdb() { - return connectAdb; - } - - public LiveData watchPairAdb() { - return pairAdb; - } - - public LiveData watchAskPairAdb() { - return askPairAdb; - } - - public LiveData watchCommandOutput() { - return commandOutput; - } - - public LiveData watchPairingPort() { - return pairingPort; - } - - public void handleOnCleared() { - executor.submit(() -> { - try { - if (adbShellStream != null) { - adbShellStream.close(); - adbShellStream = null; - } - } catch (Exception e) { - e.printStackTrace(); - } - }); - executor.shutdown(); - } - - public void connect(int port) { - executor.submit(() -> { - try { - AbsAdbConnectionManager manager = AdbConnectionManager.getInstance(this.appContext); - boolean connectionStatus; - try { - connectionStatus = manager.connect(AndroidUtils.getHostIpAddress(this.appContext), port); - } catch (Throwable th) { - th.printStackTrace(); - connectionStatus = false; - } - connectAdb.postValue(connectionStatus); - } catch (Throwable th) { - th.printStackTrace(); - connectAdb.postValue(false); - } - }); - } - - public void autoConnect() { - executor.submit(this::autoConnectInternal); - } - - public void disconnect() { - executor.submit(() -> { - try { - AbsAdbConnectionManager manager = AdbConnectionManager.getInstance(this.appContext); - manager.disconnect(); - connectAdb.postValue(false); - } catch (Throwable th) { - th.printStackTrace(); - connectAdb.postValue(true); - } - }); - } - - public void getPairingPort() { - executor.submit(() -> { - AtomicInteger atomicPort = new AtomicInteger(-1); - final String[] host = {null}; - CountDownLatch resolveHostAndPort = new CountDownLatch(1); - - AdbMdns adbMdns = new AdbMdns(this.appContext, AdbMdns.SERVICE_TYPE_TLS_PAIRING, (hostAddress, port) -> { - atomicPort.set(port); - if (hostAddress != null) { - host[0] = hostAddress.getHostAddress(); - } - resolveHostAndPort.countDown(); - }); - adbMdns.start(); - - try { - if (!resolveHostAndPort.await(1, TimeUnit.MINUTES)) { - return; - } - } catch (InterruptedException ignore) { - } finally { - adbMdns.stop(); - } - - mPairingPort = atomicPort.get(); - mPairingHost = host[0]; - pairingPort.postValue(mPairingPort); - }); - } - - public void pair(int port, String pairingCode) { - executor.submit(() -> { - try { - boolean pairingStatus; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - AbsAdbConnectionManager manager = AdbConnectionManager.getInstance(this.appContext); - String host = mPairingHost != null ? mPairingHost : AndroidUtils.getHostIpAddress(this.appContext); - int p = port > 0 ? port : mPairingPort; - pairingStatus = manager.pair(host, p, pairingCode); - } else pairingStatus = false; - pairAdb.postValue(pairingStatus); - autoConnectInternal(); - } catch (Throwable th) { - th.printStackTrace(); - pairAdb.postValue(false); - } - }); - } - - @WorkerThread - private void autoConnectInternal() { - try { - AbsAdbConnectionManager manager = AdbConnectionManager.getInstance(this.appContext); - boolean connected = false; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - try { - connected = manager.connectTls(this.appContext, 5000); - } catch (AdbPairingRequiredException | InterruptedException ie) { - askPairAdb.postValue(true); - } catch (Throwable th) { - th.printStackTrace(); - } - } - if (connected) { - connectAdb.postValue(true); - } - } catch (Throwable th) { - th.printStackTrace(); - } - } - - private volatile boolean clearEnabled; - private final Runnable outputGenerator = () -> { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(adbShellStream.openInputStream()))) { - StringBuilder sb = new StringBuilder(); - String s; - while ((s = reader.readLine()) != null) { - if (clearEnabled) { - sb.delete(0, sb.length()); - clearEnabled = false; - } - sb.append(s).append("\n"); - commandOutput.postValue(sb); - } - } catch (IOException e) { - e.printStackTrace(); - } - }; - - public void execute(String command) { - executor.submit(() -> { - try { - if (adbShellStream == null || adbShellStream.isClosed()) { - AbsAdbConnectionManager manager = AdbConnectionManager.getInstance(this.appContext); - adbShellStream = manager.openStream(LocalServices.SHELL); - new Thread(outputGenerator).start(); - } - if (command.equals("clear")) { - clearEnabled = true; - } - try (OutputStream os = adbShellStream.openOutputStream()) { - os.write(String.format("%1$s\n", command).getBytes(StandardCharsets.UTF_8)); - os.flush(); - os.write("\n".getBytes(StandardCharsets.UTF_8)); - } - } catch (Exception e) { - e.printStackTrace(); - askPairAdb.postValue(true); - } - }); - } - - public synchronized boolean isQuickForensicsRunning() { - return qfFuture != null && !qfFuture.isDone(); - } - - public synchronized boolean isQuickForensicsCancelled() { - return qfCancelled.get(); - } - - public synchronized void cancelQuickForensics() { - qfCancelled.set(true); - } - - public void runQuickForensics(@NonNull File baseDir, - @NonNull org.osservatorionessuno.bugbane.qf.QuickForensics.ProgressListener listener) { - if (isQuickForensicsRunning()) { - return; - } - qfCancelled.set(false); - qfFuture = executor.submit(() -> { - try { - AbsAdbConnectionManager manager = AdbConnectionManager.getInstance(this.appContext); - File out = new org.osservatorionessuno.bugbane.qf.QuickForensics() - .run(this.appContext, manager, baseDir, listener); - if (qfCancelled.get()) { - commandOutput.postValue("QuickForensics cancelled"); - } else { - commandOutput.postValue("QuickForensics completed: " + out.getAbsolutePath()); - } - } catch (Exception e) { - e.printStackTrace(); - commandOutput.postValue("Error running QuickForensics: " + e.getMessage()); - } - }); - } -} diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.kt new file mode 100644 index 0000000..d879a2c --- /dev/null +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.kt @@ -0,0 +1,279 @@ +package org.osservatorionessuno.bugbane.utils + +import android.content.Context +import android.os.Build +import androidx.annotation.WorkerThread +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.github.muntashirakon.adb.AdbPairingRequiredException +import io.github.muntashirakon.adb.AdbStream +import io.github.muntashirakon.adb.LocalServices +import io.github.muntashirakon.adb.android.AdbMdns +import io.github.muntashirakon.adb.android.AdbMdns.OnAdbDaemonDiscoveredListener +import io.github.muntashirakon.adb.android.AndroidUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.osservatorionessuno.bugbane.qf.QuickForensics +import java.io.BufferedReader +import java.io.File +import java.io.IOException +import java.io.InputStreamReader +import java.net.InetAddress +import java.nio.charset.StandardCharsets +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.Volatile + +class AdbManager(applicationContext: Context) { + private val executor: ExecutorService = Executors.newFixedThreadPool(3) + private val _adbState = MutableStateFlow(AdbState.ReadyToPair) + val adbState: StateFlow = _adbState.asStateFlow() + private val commandOutput = MutableLiveData() + private val pairingPort = MutableLiveData() + private var qfFuture: Future<*>? = null + private val qfCancelled = AtomicBoolean(false) + + private var mPairingHost: String? = null + private var mPairingPort = -1 + + private var appContext: Context? = null + + private var adbShellStream: AdbStream? = null + + fun watchCommandOutput(): LiveData { + return commandOutput + } + + fun cleanup() { + val stream = adbShellStream + adbShellStream = null + executor.submit(Runnable { + try { + stream?.close() + } catch (e: java.lang.Exception) { + e.printStackTrace() + } + }) + executor.shutdown() + } + + fun connect(port: Int) { + executor.submit(Runnable { + try { + val manager = AdbConnectionManager.getInstance(this.appContext!!) + try { + if (manager.connect(AndroidUtils.getHostIpAddress(this.appContext!!), port)) { + _adbState.value = AdbState.ConnectedIdle + } // no "else" - method can return false if there is an existing connection already + } catch (th: Throwable) { + th.printStackTrace() + _adbState.value = AdbState.ErrorPair + } + } catch (th: Throwable) { + th.printStackTrace() + _adbState.value = AdbState.ErrorPair + } + }) + } + + fun autoConnect() { + executor.submit(Runnable { this.autoConnectInternal() }) + } + + fun disconnect() { + executor.submit(Runnable { + try { + val manager = AdbConnectionManager.getInstance(this.appContext!!) + manager.disconnect() + _adbState.value = AdbState.ReadyToConnect + } catch (th: Throwable) { + th.printStackTrace() + // TODO: ConnectedIdle not be accurate here, but it follows the prior logic + _adbState.value = AdbState.ConnectedIdle + } + }) + } + + fun getPairingPort() { + executor.submit(Runnable { + val atomicPort = AtomicInteger(-1) + val host = arrayOf(null) + val resolveHostAndPort = CountDownLatch(1) + + val adbMdns = AdbMdns( + this.appContext!!, + AdbMdns.SERVICE_TYPE_TLS_PAIRING, + OnAdbDaemonDiscoveredListener { hostAddress: InetAddress?, port: Int -> + atomicPort.set(port) + if (hostAddress != null) { + host[0] = hostAddress.getHostAddress() + } + resolveHostAndPort.countDown() + }) + adbMdns.start() + + try { + if (!resolveHostAndPort.await(1, TimeUnit.MINUTES)) { + return@Runnable + } + } catch (ignore: InterruptedException) { + } finally { + adbMdns.stop() + } + + mPairingPort = atomicPort.get() + mPairingHost = host[0] + pairingPort.postValue(mPairingPort) + _adbState.value = AdbState.ReadyToPair + }) + } + + fun pair(port: Int, pairingCode: String) { + executor.submit(Runnable { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val manager = AdbConnectionManager.getInstance(this.appContext!!) + val host = + (if (mPairingHost != null) mPairingHost else AndroidUtils.getHostIpAddress( + this.appContext!! + ))!! + val p = if (port > 0) port else mPairingPort + if (manager.pair(host, p, pairingCode)) { + _adbState.value = AdbState.ReadyToConnect + } else { + _adbState.value = AdbState.ErrorPair + } + } + autoConnectInternal() + } catch (th: Throwable) { + th.printStackTrace() + _adbState.value = AdbState.ErrorPair + } + }) + } + + @WorkerThread + private fun autoConnectInternal() { + try { + val manager = AdbConnectionManager.getInstance(this.appContext!!) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + try { + if (manager.connectTls(this.appContext!!, 5000)) { + _adbState.value = AdbState.ConnectedIdle // no "else", because false could mean existing connection + } + } catch (ie: AdbPairingRequiredException) { + _adbState.value = AdbState.RequisitesMissing + } catch (ie: InterruptedException) { + _adbState.value = AdbState.ErrorConnect + } catch (th: Throwable) { + th.printStackTrace() + _adbState.value = AdbState.ErrorConnect + } + } + } catch (th: Throwable) { + th.printStackTrace() + _adbState.value = AdbState.ErrorConnect + } + } + + @Volatile + private var clearEnabled = false + private val outputGenerator = Runnable { + try { + BufferedReader(InputStreamReader(adbShellStream?.openInputStream())).use { reader -> + val sb = StringBuilder() + var s: String? + while ((reader.readLine().also { s = it }) != null) { + if (clearEnabled) { + sb.delete(0, sb.length) + clearEnabled = false + } + sb.append(s).append("\n") + commandOutput.postValue(sb) + } + } + } catch (e: IOException) { + e.printStackTrace() + } + } + + init { + this.appContext = applicationContext + } + + fun execute(command: String) { + executor.submit(Runnable { + try { + if (adbShellStream == null || adbShellStream!!.isClosed) { + val manager = AdbConnectionManager.getInstance(this.appContext!!) + adbShellStream = manager.openStream(LocalServices.SHELL) + Thread(outputGenerator).start() + } + if (command == "clear") { + clearEnabled = true + } + adbShellStream!!.openOutputStream().use { os -> + os.write(String.format("%1\$s\n", command).toByteArray(StandardCharsets.UTF_8)) + os.flush() + os.write("\n".toByteArray(StandardCharsets.UTF_8)) + } + } catch (e: java.lang.Exception) { + e.printStackTrace() + _adbState.value = AdbState.RequisitesMissing + } + }) + } + + @get:Synchronized + val isQuickForensicsRunning: Boolean + get() = qfFuture != null && !qfFuture!!.isDone() + + @get:Synchronized + val isQuickForensicsCancelled: Boolean + get() = qfCancelled.get() + + @Synchronized + fun cancelQuickForensics() { + qfCancelled.set(true) + if (_adbState.value == AdbState.ConnectedAcquiring) { + _adbState.value = AdbState.ConnectedIdle + } + } + + fun runQuickForensics( + baseDir: File, + listener: QuickForensics.ProgressListener + ) { + if (this.isQuickForensicsRunning) { + return + } + qfCancelled.set(false) + _adbState.value = AdbState.ConnectedAcquiring + qfFuture = executor.submit(Runnable { + try { + val manager = AdbConnectionManager.getInstance(this.appContext!!) + val out = QuickForensics() + .run(this.appContext!!, manager, baseDir, listener) + if (qfCancelled.get()) { + commandOutput.postValue("QuickForensics cancelled") + _adbState.value = AdbState.ConnectedIdle + } else { + commandOutput.postValue("QuickForensics completed: " + out.getAbsolutePath()) + _adbState.value = AdbState.ConnectedIdle + } + } catch (e: java.lang.Exception) { + e.printStackTrace() + commandOutput.postValue("Error running QuickForensics: " + e.message) + _adbState.value = AdbState.ErrorAcquisition + } + }) + } +} diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbState.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbState.kt new file mode 100644 index 0000000..419f3ad --- /dev/null +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbState.kt @@ -0,0 +1,34 @@ +package org.osservatorionessuno.bugbane.utils + +// AdbStates represent the adb connection status. +// AdbManager emits a flow of AdbStates. Other components can listen for +// errorStates or successStates, then recalculate their own state if needed +// (see ConfigurationViewModel). +enum class AdbState(val index: Int) { + RequisitesMissing(0), // AppStates < AppState.NeedWirelessDebuggingAndPair + ReadyToPair(1), + ErrorPair(2), + ReadyToConnect(3), + ErrorConnect(4), + ConnectedIdle(5), // AppStates >= AppState.AdbConnected + ConnectedAcquiring(6), + ErrorAcquisition(7); + + + companion object { + + // An error requiring user interaction. Right now we don't differentiate + // between pairing and connection errors, we just ask user to re-pair + fun errorStates(): List = listOf( + RequisitesMissing, + ErrorPair, + ErrorAcquisition, + ) + + // A successful ADB connection state + fun successStates(): List = listOf( + ConnectedIdle, + ConnectedAcquiring, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AppState.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AppState.kt index 08038d7..fb5b565 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AppState.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AppState.kt @@ -1,37 +1,34 @@ package org.osservatorionessuno.bugbane.utils -// All permissions-related states should be defined here. -// Ordering/requisites are defined in the ConfigurationViewModel -sealed class AppState(val index: Int) { - object DeviceUnsupported : AppState(0) - object NeedWelcomeScreen: AppState(1) - object NeedWifi : AppState(2) - object NeedNotificationConfiguration : AppState(3) - object NeedDeveloperOptions : AppState(4) - object NeedWirelessDebugging : AppState(5) - // sub-states in ADB pairing flow (waiting to pair, mdns, etc) are handled by AdbManager - object NeedAdbPairingService : AppState(6) - //object NeedAdbConnectService : ConfigurationState(6) - // We've connected to ADB, but we've never completed the onboarding screen ("Get started") - object AdbConnectedFinishOnboarding : AppState(7) - object AdbConnected : AppState(8) +// AppStates represent the high-level device configuration and permissions status; +// state ordering/requisites are defined in the ViewModel (see ConfigurationViewModel). +// An AppState requires user interaction to change to a different state. +enum class AppState(val index: Int) { + DeviceUnsupported(8), + NeedWelcomeScreen(0), // one-time consent + NeedNotificationConfiguration(1), + NeedWifi(2), + NeedDeveloperOptions(3), + NeedWirelessDebuggingAndPair(4), // sub-states are handled by AdbManager +// NeedAdbPairingService(5), + AdbConnectedFinishOnboarding(6), // one-time onboarding complete + AdbConnected(7); companion object { fun valuesInOrder(): List = listOf( - DeviceUnsupported, NeedWelcomeScreen, - NeedWifi, NeedNotificationConfiguration, + NeedWifi, NeedDeveloperOptions, - NeedWirelessDebugging, - NeedAdbPairingService, -// NeedAdbConnectService, + NeedWirelessDebuggingAndPair, +// NeedAdbPairingService, AdbConnectedFinishOnboarding, - AdbConnected - ) - // Error states can adjust the theme's UI + AdbConnected, + DeviceUnsupported, + ) + // Error states have different UI implications fun isErrorState(state: AppState): Boolean { - return (state is DeviceUnsupported) + return (state == AppState.DeviceUnsupported) } } } diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt index a5435c0..f47d2dc 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt @@ -6,23 +6,13 @@ import android.content.pm.PackageManager import android.os.Build import android.util.Log import android.content.Intent -import android.os.Bundle import android.provider.Settings import android.net.ConnectivityManager import android.net.NetworkCapabilities -import androidx.core.content.edit +// Manages phone settings (App-specific settings belong in SlideshowManager) object ConfigurationManager { - fun openDeviceSettings(context: Context) { - // Open the developer options settings - val intent = Intent(Settings.ACTION_DEVICE_INFO_SETTINGS) - try { - context.startActivity(intent) - } catch (ignored: Exception) { - } - } - fun openDeveloperOptions(context: Context) { // Open the developer options settings val intent = Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS) @@ -32,32 +22,6 @@ object ConfigurationManager { } } - fun openWifiSettings(context: Context) { - // Open Wi-Fi settings - val intent = Intent(Settings.ACTION_WIFI_SETTINGS) - try { - context.startActivity(intent) - } catch (e: Exception) { - } - } - - fun openWirelessDebugging(context: Context) { - // Open wireless debugging settings - val EXTRA_FRAGMENT_ARG_KEY = ":settings:fragment_args_key" - val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args" - val settingsIntent = Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS).apply { - putExtra(EXTRA_FRAGMENT_ARG_KEY, "toggle_adb_wireless") - val bundle = Bundle().apply { - putString(EXTRA_FRAGMENT_ARG_KEY, "toggle_adb_wireless") - } - putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, bundle) - } - try { - context.startActivity(settingsIntent) - } catch (e: Exception) { - } - } - // Can just do this via the manifest, but including here is ~harmless fun isSupportedDevice() : Boolean { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R @@ -109,5 +73,4 @@ object ConfigurationManager { false } } - } diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt index bfa0274..f6cf4d4 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt @@ -1,74 +1,242 @@ package org.osservatorionessuno.bugbane.utils +import android.app.Activity import android.app.Application +import android.content.Context +import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.content.IntentFilter +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import android.util.Log import androidx.lifecycle.ViewModel -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import org.osservatorionessuno.bugbane.MainActivity +class ConfigurationViewModel private constructor( + val appContext: Context, + // adb state: needs pair, paired, scanning, etc + val adbManager: AdbManager, + // wifi connectivity listener + val wifiConnectivityMonitor: WifiConnectivityMonitor, + ) : ViewModel() { -class ConfigurationViewModel ( - private val application: Application - -) : ViewModel() { + // There Can Be Only One (see factory below) + companion object { + fun create(application: Application): ConfigurationViewModel { + return ConfigurationViewModel( + application.applicationContext, + AdbManager(application.applicationContext), + WifiConnectivityMonitor(application.applicationContext) + ) + } + } - private val _configurationState = MutableStateFlow(AppState.NeedWifi) + // AppState is MutableFlow collected for UI listeners + private val _configurationState = MutableStateFlow(AppState.NeedWelcomeScreen) val configurationState: StateFlow = _configurationState.asStateFlow() - // Only need the context, but don't pass it in directly since Android complains about - // memory leaks. - private val appContext = application.applicationContext // todo: check with strictmode - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val adbPairingReceiver = + AdbPairingResultReceiver( + onSuccess = { + Log.d("Bugbane", "paired successfully") + _configurationState.value = AppState.AdbConnected + stopAdbPairingService() + }, + onFailure = { errorMessage -> + // TODO (should be an adbManager state) + Log.e("Bugbane", "failed pairing attempt") + _configurationState.value = AppState.NeedWirelessDebuggingAndPair + stopAdbPairingService() + } + ) init { - scope.launch { - // TODO: this is a bit hacky, but it polls for active state- it's not required - while (isActive) { - val newState = checkState() - if (_configurationState.value != newState) { - _configurationState.value = newState + // Set up listeners for states that can affect AppState + observeAdbConfiguration() + observeWifiConnectivity() + } + internal fun observeAdbConfiguration() { + viewModelScope.launch { + adbManager.adbState.collect { adbState -> checkUpdateState() + } + } + } + internal fun observeWifiConnectivity() { + viewModelScope.launch { + wifiConnectivityMonitor.wifiState.collect { isConnected -> + if (!isConnected) { + // Could also directly set _appState to AppState.NeedsWifi, + // but better to keep all the state logic in one place, + // especially for evolving logic (eg looking at past acquisitions) + checkUpdateState() } - delay(1000) } } + } - fun checkState(): AppState { - // Get the "best" state - all requisites/logic enforced here and order matters. + internal fun checkState(): AppState { + // Get the "best" state - requisites/logic enforced here. // If a user has completed the welcome screen, is connected to wifi and has an // active adb connection, skip the other checks. // If a user is missing one of those, they will need pairing flow again. + // Note: User could also scan an prior acquisition, where permissions aren't needed, + // but that is independent of the device's permissions/states, so address it in UI. // TODO: This can be defined in the manifest if it's just about API level if (!ConfigurationManager.isSupportedDevice()) return AppState.DeviceUnsupported - // Consent page if (!SlideshowManager.canSkipWelcomeScreen(appContext)) return AppState.NeedWelcomeScreen - // See if we're already connected. If yes but it's the first time, we're in state - // AdbConnectedFinishOnboarding, otherwise state AdbConnected - // (TODO - check if need wifi check explicitly or not) - // also TODO: ContentObserver (https://developer.android.com/reference/android/database/ContentObserver) - val adbConnected = ConfigurationManager.isAdbEnabled(appContext) + // TODO: ContentObserver? (https://developer.android.com/reference/android/database/ContentObserver) + val adbConnected = ConfigurationManager.isAdbEnabled(appContext) && adbManager.adbState.value in AdbState.successStates() if (adbConnected && SlideshowManager.hasSeenHomepage(appContext)) return AppState.AdbConnected if (adbConnected && !SlideshowManager.hasSeenHomepage(appContext)) return AppState.AdbConnectedFinishOnboarding - // Notifications aren't strictly needed unless we're not connected to adb + // If we get here, we need to go through the pairing workflow again, so ensure prereqs are met if (!ConfigurationManager.isNotificationPermissionGranted(appContext)) return AppState.NeedNotificationConfiguration - - // Wifi, developer options, wireless debugging if (!ConfigurationManager.isConnectedToWifi(appContext)) return AppState.NeedWifi if (!ConfigurationManager.isDeveloperOptionsEnabled(appContext)) return AppState.NeedDeveloperOptions - if (!ConfigurationManager.isWirelessDebuggingEnabled(appContext)) return AppState.NeedWirelessDebugging - // If we get here, we need and are ready for the ADB pairing wizard flow (todo handled by another viewmodel?) - return AppState.NeedAdbPairingService + if (!ConfigurationManager.isWirelessDebuggingEnabled(appContext) ||!ConfigurationManager.isAdbEnabled(appContext)) return AppState.NeedWirelessDebuggingAndPair + + // TODO: could do NeedAdbPairingService here (autoconnect optimization) if the above check could tell if we have a saved connection + return AppState.NeedWirelessDebuggingAndPair + } + + fun checkUpdateState() { + // StateFlow doesn't emit duplicates, so this is fine + _configurationState.value = checkState() + } + + // Handle state transition (user-initiated) + fun onChangeStateRequest(currentState: AppState) { + when (currentState) { + AppState.DeviceUnsupported -> { + (appContext as? Activity)?.finishAffinity() + } + AppState.NeedWelcomeScreen -> { + // Clicked "I understand," nothing else to do + SlideshowManager.setHasSeenWelcomeScreen(appContext) + } + AppState.NeedWifi, AppState.NeedNotificationConfiguration, AppState.NeedDeveloperOptions -> { + getIntentForAppState(currentState)?.let { it -> + it.addFlags(FLAG_ACTIVITY_NEW_TASK) + appContext.startActivity(it) + } + } + AppState.NeedWirelessDebuggingAndPair -> { + getIntentForAppState(currentState)?.let { it -> + it.addFlags(FLAG_ACTIVITY_NEW_TASK) + appContext.startActivity(it) + } + startAdbPairingService() + } + AppState.AdbConnectedFinishOnboarding -> { + SlideshowManager.markHomepageAsSeen(appContext) + } + else -> { // Should be unreachable + Log.e(appContext.packageName, "$currentState not handled") + } + } + checkUpdateState() + } + + internal fun stopAdbPairingService() { + adbPairingReceiver.let { it -> + try { + appContext.unregisterReceiver(it) + } catch (e: Exception) { + // TODO + Log.w("Bugbane", "Error unregistering adbBroadcastreceiver") + } + } + } + + internal fun startAdbPairingService() { + // Create BroadcastReceiver for pairing results. + Log.d("Bugbane", "Start pairing service...") + // TODO: AdbManager should handle all the pairing code and report state back directly + + val filter = IntentFilter(AdbPairingService.ACTION_PAIRING_RESULT) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // This broadcast is internal to the app, so keep it private + appContext.registerReceiver(adbPairingReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + // Pre-Android 13: old two-argument API + @Suppress("UnspecifiedRegisterReceiverFlag") + appContext.registerReceiver(adbPairingReceiver, filter) + } + // ConfigurationManager.openWirelessDebugging(appContext) + + // Start ADB pairing service + val pairingIntent = AdbPairingService.startIntent(appContext) + try { + appContext.startForegroundService(pairingIntent) + } catch (ignored: Throwable) { + appContext.startService(pairingIntent) + } + // (Wait for pairing result, then update state and stop the service) + } + + internal fun getIntentForAppState(state: AppState): Intent? { + return when (state) { + AppState.NeedWifi -> Intent(Settings.ACTION_WIFI_SETTINGS) + AppState.NeedNotificationConfiguration -> Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, appContext.packageName) + } + AppState.NeedDeveloperOptions -> Intent(Settings.ACTION_DEVICE_INFO_SETTINGS) + AppState.NeedWirelessDebuggingAndPair -> wirelessDebuggingIntent() + AppState.AdbConnectedFinishOnboarding -> { + val restartIntent = Intent(appContext, MainActivity::class.java) + restartIntent.addFlags(FLAG_ACTIVITY_CLEAR_TOP) + } + else -> null + } + } + internal fun wirelessDebuggingIntent(): Intent { + // Open wireless debugging settings + val EXTRA_FRAGMENT_ARG_KEY = ":settings:fragment_args_key" + val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args" + val settingsIntent = Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS).apply { + putExtra(EXTRA_FRAGMENT_ARG_KEY, "toggle_adb_wireless") + val bundle = Bundle().apply { + putString(EXTRA_FRAGMENT_ARG_KEY, "toggle_adb_wireless") + } + putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, bundle) + } + return settingsIntent + } + + override fun onCleared() { + super.onCleared() + // Unregister listeners/close sockets + wifiConnectivityMonitor.cleanup() + adbManager.cleanup() + stopAdbPairingService() + } +} + +// Enforce singleton pattern with viewModel: +// - We genuinely want to sync AppState across varying activities +// - We don't want multiple instances of adbManager +// - We hold applicationContext and avoid holding ui references that could cause memory leaks +object ViewModelFactory { + + @Volatile + private var instance: ConfigurationViewModel? = null + + fun get(application: Application): ConfigurationViewModel { + return instance ?: synchronized(this) { + instance ?: ConfigurationViewModel.create(application).also { instance = it } + } } } diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/SlideshowManager.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/SlideshowManager.kt index b947506..3947ac9 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/SlideshowManager.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/SlideshowManager.kt @@ -1,27 +1,23 @@ package org.osservatorionessuno.bugbane.utils -import android.app.Activity import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.provider.Settings -import android.util.Log +// Manages app-specific onboarding preferences in the onboarding slideshow object SlideshowManager { - private const val PREFS_NAME = "app_prefs" + const val PREFS_NAME = "app_prefs" // Skips the logo/splashscreen page after the first onboarding flow - private const val KEY_HAS_SEEN_WELCOME_SCREEN = "has_seen_welcome_screen" + const val KEY_HAS_SEEN_WELCOME_SCREEN = "has_seen_welcome_screen" // Skips the "Get Started" page after the first onboarding flow - private const val KEY_HAS_SEEN_HOMEPAGE = "has_seen_homepage" + const val KEY_HAS_SEEN_HOMEPAGE = "has_seen_homepage" fun hasSeenHomepage(context: Context): Boolean { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) return prefs.getBoolean(KEY_HAS_SEEN_HOMEPAGE, false) } - private fun markHomepageAsSeen(context: Context) { + fun markHomepageAsSeen(context: Context) { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) prefs.edit().putBoolean(KEY_HAS_SEEN_HOMEPAGE, true).apply() } @@ -32,59 +28,14 @@ object SlideshowManager { prefs.edit().putBoolean(KEY_HAS_SEEN_HOMEPAGE, false).apply() } } - + fun canSkipWelcomeScreen(context: Context): Boolean { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) return prefs.getBoolean(KEY_HAS_SEEN_WELCOME_SCREEN, false) } - private fun setHasSeenWelcomeScreen(context: Context) { + fun setHasSeenWelcomeScreen(context: Context) { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) prefs.edit().putBoolean(KEY_HAS_SEEN_WELCOME_SCREEN, true).apply() } - - // Desired "next" state for permissions flow slideshow handled here. - // There are two types of slides: informational/consent slides (shown once) and - // permissions slides (launch an activity via intent, shown as needed). - fun handleOnContinue(context: Context, state: AppState, viewModel: ConfigurationViewModel) : Unit { - // TODO: startActivityForResult? - getIntentForAppState(context, state)?.let {it -> (context as? Activity)?.startActivity(it) } ?: - when (state) { - is AppState.DeviceUnsupported -> { - (context as? Activity)?.finishAffinity() - } - is AppState.NeedWelcomeScreen -> { - // Clicked "I understand," nothing else to do - setHasSeenWelcomeScreen(context) - viewModel.checkUpdateState() - } - else -> { // Should be unreachable - Log.e(context.applicationContext.packageName, "$state not handled") - } - } - } - private fun getIntentForAppState(context: Context, state: AppState): Intent? { - return when (state) { - AppState.NeedWifi -> Intent(Settings.ACTION_WIFI_SETTINGS) - AppState.NeedNotificationConfiguration -> Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) - } - AppState.NeedDeveloperOptions -> Intent(Settings.ACTION_DEVICE_INFO_SETTINGS) - AppState.NeedWirelessDebugging -> wirelessDebuggingIntent() - else -> null - } - } - private fun wirelessDebuggingIntent(): Intent { - // Open wireless debugging settings - val EXTRA_FRAGMENT_ARG_KEY = ":settings:fragment_args_key" - val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args" - val settingsIntent = Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS).apply { - putExtra(EXTRA_FRAGMENT_ARG_KEY, "toggle_adb_wireless") - val bundle = Bundle().apply { - putString(EXTRA_FRAGMENT_ARG_KEY, "toggle_adb_wireless") - } - putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, bundle) - } - return settingsIntent - } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/WifiConnectivityMonitor.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/WifiConnectivityMonitor.kt new file mode 100644 index 0000000..0cc6dc2 --- /dev/null +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/WifiConnectivityMonitor.kt @@ -0,0 +1,61 @@ +package org.osservatorionessuno.bugbane.utils + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.net.wifi.WifiManager +import android.util.Log +import androidx.core.content.ContextCompat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +// Monitor for changes in wifi connectivity +class WifiConnectivityMonitor(context: Context) { + private val appContext = context.applicationContext + + private val _wifiState = MutableStateFlow(isWifiEnabled()) + val wifiState: StateFlow = _wifiState.asStateFlow() + + private fun hasWifiPermissions(): Boolean { + return ContextCompat.checkSelfPermission( + appContext, + android.Manifest.permission.ACCESS_WIFI_STATE + ) == PackageManager.PERMISSION_GRANTED + } + private val wifiReceiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context?, intent: Intent?) { + if (intent?.action == WifiManager.WIFI_STATE_CHANGED_ACTION) { + val state = intent.getIntExtra(WifiManager.EXTRA_WIFI_STATE, -1) + _wifiState.value = state == WifiManager.WIFI_STATE_ENABLED + } + } + } + + fun registerWifiMonitor() { + val filter = IntentFilter(WifiManager.WIFI_STATE_CHANGED_ACTION) + if (hasWifiPermissions()) { + appContext.registerReceiver(wifiReceiver, filter) + } + } + + fun cleanup() { + try { + appContext.unregisterReceiver(wifiReceiver) + } + catch (ex: IllegalArgumentException) { + // Unregistered already? + Log.i(appContext.packageName, "WifiConnectivityMonitor cleanup failed (already unregistered?)") + } + } + + private fun isWifiEnabled(): Boolean { + if (hasWifiPermissions()) { + val wifiManager = appContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + return wifiManager.isWifiEnabled + } + return false // TODO + } +} From 8fd07caedad1a6af7f18c846ba1b08c141a1c620 Mon Sep 17 00:00:00 2001 From: Rowen S Date: Sat, 13 Sep 2025 10:08:51 -0400 Subject: [PATCH 07/17] Handle connected, error, and pairing states in scan screen via adbState. Add TryAutoConnect state to AppState and include adbManager logic to support it. Add additional transitional adb and app states. Improve wifi connectivity monitor checks. Use checkState in AdbManager to report connection. --- .../bugbane/screens/ScanScreen.kt | 174 ++++++----- .../bugbane/screens/SettingsScreen.kt | 1 - .../bugbane/utils/AdbManager.kt | 273 ++++++++++-------- .../bugbane/utils/AdbState.kt | 21 +- .../bugbane/utils/AppState.kt | 49 ++-- .../bugbane/utils/ConfigurationManager.kt | 10 - .../bugbane/utils/ConfigurationViewModel.kt | 156 +++++----- .../bugbane/utils/WifiConnectivityMonitor.kt | 80 ++--- 8 files changed, 406 insertions(+), 358 deletions(-) diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt b/app/src/main/java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt index 57c148f..403b6e9 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt @@ -1,5 +1,6 @@ package org.osservatorionessuno.bugbane.screens +import android.app.Application import android.content.Intent import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* @@ -15,28 +16,39 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalConfiguration import android.content.res.Configuration +import android.util.Log +import android.widget.Toast import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import org.osservatorionessuno.bugbane.R import org.osservatorionessuno.bugbane.utils.ConfigurationManager import org.osservatorionessuno.bugbane.SlideshowActivity import org.osservatorionessuno.bugbane.AcquisitionActivity import org.osservatorionessuno.bugbane.utils.AdbManager +import org.osservatorionessuno.bugbane.utils.AdbState +import org.osservatorionessuno.bugbane.utils.AppState +import org.osservatorionessuno.bugbane.utils.ConfigurationViewModel +import org.osservatorionessuno.bugbane.utils.ViewModelFactory import java.io.File @Composable -fun ScanScreen( - lacksPermissions: Boolean = false, - onLacksPermissionsChange: (Boolean) -> Unit = {} -) { +fun ScanScreen() { val coroutineScope = rememberCoroutineScope() val context = LocalContext.current - val adbManager = AdbManager(context.applicationContext) - var isScanning by remember { mutableStateOf(false) } + + val application = LocalContext.current.applicationContext as Application + val viewModel = remember { ViewModelFactory.get(application) } + + // todo: eventually just the adbState? + val appState = viewModel.configurationState.collectAsStateWithLifecycle() + val adbManager = viewModel.adbManager + val adbState: State = adbManager.adbState.collectAsStateWithLifecycle() + var showDisableDialog by remember { mutableStateOf(false) } var completedModules by remember { mutableStateOf(0) } var totalModules by remember { mutableStateOf(0) } @@ -46,11 +58,10 @@ fun ScanScreen( val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE - // Update lacksPermissions based on current permissions - LaunchedEffect(Unit) { - val hasPermissions = ConfigurationManager.isNotificationPermissionGranted(context) && - ConfigurationManager.isWirelessDebuggingEnabled(context) - onLacksPermissionsChange(!hasPermissions) + LaunchedEffect(adbState.value) { + if (adbState.value == AdbState.ReadyToPair || adbState.value == AdbState.ReadyToConnect) { + adbManager.autoConnect() + } } Column( @@ -58,7 +69,7 @@ fun ScanScreen( .fillMaxSize() .padding(8.dp) ) { - if (isScanning) { + if (adbState.value == AdbState.ConnectedAcquiring) { Column(modifier = Modifier.fillMaxSize()) { if (isLandscape) { Row(modifier = Modifier.weight(1f)) { @@ -270,81 +281,94 @@ fun ScanScreen( // Scan Button fixed at the bottom Button( onClick = { - if (lacksPermissions) { - SlideshowActivity.start(context) - return@Button - } - - if (!isScanning) { - val baseDir = File(context.filesDir, "acquisitions") - isScanning = true - progressLogs.clear() - moduleLogIndex.clear() - moduleBytes.clear() - completedModules = 0 - totalModules = 0 - adbManager.runQuickForensics(baseDir, object : org.osservatorionessuno.bugbane.qf.QuickForensics.ProgressListener { - override fun onModuleStart(name: String, completed: Int, total: Int) { - coroutineScope.launch { - totalModules = total - moduleLogIndex[name] = progressLogs.size - moduleBytes[name] = 0L - progressLogs.add("Running $name: 0 B") + when (adbState.value) { + AdbState.RequisitesMissing, AdbState.ErrorConnect, AdbState.ErrorPair -> { + SlideshowActivity.start(context) + return@Button + } + AdbState.ConnectedIdle -> { + val baseDir = File(context.filesDir, "acquisitions") + progressLogs.clear() + moduleLogIndex.clear() + moduleBytes.clear() + completedModules = 0 + totalModules = 0 + adbManager.runQuickForensics(baseDir, object : org.osservatorionessuno.bugbane.qf.QuickForensics.ProgressListener { + override fun onModuleStart(name: String, completed: Int, total: Int) { + coroutineScope.launch { + totalModules = total + moduleLogIndex[name] = progressLogs.size + moduleBytes[name] = 0L + progressLogs.add("Running $name: 0 B") + } } - } - override fun onModuleProgress(name: String, bytes: Long) { - coroutineScope.launch { - val idx = moduleLogIndex[name] ?: return@launch - moduleBytes[name] = bytes - progressLogs[idx] = "Running $name: ${formatBytes(bytes)}" + override fun onModuleProgress(name: String, bytes: Long) { + coroutineScope.launch { + val idx = moduleLogIndex[name] ?: return@launch + moduleBytes[name] = bytes + progressLogs[idx] = "Running $name: ${formatBytes(bytes)}" + } } - } - override fun onModuleComplete(name: String, completed: Int, total: Int) { - coroutineScope.launch { - completedModules = completed - val idx = moduleLogIndex[name] - val finalBytes = moduleBytes[name] ?: 0L - if (idx != null) { - progressLogs[idx] = "Completed $name: ${formatBytes(finalBytes)}" - } else { - progressLogs.add("Completed $name: ${formatBytes(finalBytes)}") + override fun onModuleComplete(name: String, completed: Int, total: Int) { + coroutineScope.launch { + completedModules = completed + val idx = moduleLogIndex[name] + val finalBytes = moduleBytes[name] ?: 0L + if (idx != null) { + progressLogs[idx] = "Completed $name: ${formatBytes(finalBytes)}" + } else { + progressLogs.add("Completed $name: ${formatBytes(finalBytes)}") + } } } - } - override fun isCancelled(): Boolean = adbManager.isQuickForensicsCancelled() + override fun isCancelled(): Boolean = adbManager.isQuickForensicsCancelled - override fun onFinished(cancelled: Boolean) { - coroutineScope.launch { - if (!cancelled) { - val latest = baseDir.listFiles()?.filter { it.isDirectory }?.maxByOrNull { it.lastModified() } - if (latest != null) { - val intent = Intent(context, AcquisitionActivity::class.java).apply { - putExtra(AcquisitionActivity.EXTRA_PATH, latest.absolutePath) + override fun onFinished(cancelled: Boolean) { + coroutineScope.launch { + if (!cancelled) { + val latest = baseDir.listFiles()?.filter { it.isDirectory }?.maxByOrNull { it.lastModified() } + if (latest != null) { + val intent = Intent(context, AcquisitionActivity::class.java).apply { + putExtra(AcquisitionActivity.EXTRA_PATH, latest.absolutePath) + } + context.startActivity(intent) } - context.startActivity(intent) + showDisableDialog = true } - showDisableDialog = true } - isScanning = false } - } - }) + }) + } + AdbState.ConnectedAcquiring -> { + // Scan already in progress; button is disabled below + } + else -> { + // ReadyToPair, ReadyToConnect (connection in progress?) + // TODO + Log.e("Bugbane", "Unhandled state $adbState.value") + Toast.makeText( + context, + R.string.notification_adb_pairing_working_title, + Toast.LENGTH_SHORT + ).show() + } } }, modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth(), - enabled = !isScanning, + enabled = (adbState.value != AdbState.ConnectedAcquiring), colors = ButtonDefaults.buttonColors( - containerColor = if (isScanning) - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) - else if (lacksPermissions) - MaterialTheme.colorScheme.error.copy(alpha = 0.9f) - else - MaterialTheme.colorScheme.secondary + containerColor = when (adbState.value) { + AdbState.ConnectedAcquiring -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + in AdbState.errorStates() -> + MaterialTheme.colorScheme.error.copy(alpha = 0.9f) + + else -> MaterialTheme.colorScheme.secondary + } ) ) { Icon( @@ -354,12 +378,12 @@ fun ScanScreen( ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = if (isScanning) - stringResource(R.string.home_scanning_button) - else if (lacksPermissions) - stringResource(R.string.home_permissions_button) - else - stringResource(R.string.home_scan_button), + text = when (adbState.value) { + AdbState.ConnectedAcquiring -> stringResource(R.string.home_scanning_button) + AdbState.ConnectedIdle -> stringResource(R.string.home_scan_button) + else // (adbState is AdbState.RequisitesMissing or pairing is in progress) + -> stringResource(R.string.home_permissions_button) + }, style = MaterialTheme.typography.bodyLarge.copy( fontWeight = FontWeight.Medium ) diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/screens/SettingsScreen.kt b/app/src/main/java/org/osservatorionessuno/bugbane/screens/SettingsScreen.kt index 5b29484..d7eca21 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/screens/SettingsScreen.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/screens/SettingsScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.osservatorionessuno.bugbane.R import org.osservatorionessuno.bugbane.utils.ConfigurationManager -import org.osservatorionessuno.bugbane.utils.SlideshowManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.osservatorionessuno.libmvt.common.IndicatorsUpdates diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.kt index d879a2c..b37303f 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.kt @@ -1,18 +1,15 @@ package org.osservatorionessuno.bugbane.utils import android.content.Context +import android.content.IntentFilter import android.os.Build +import android.util.Log import androidx.annotation.WorkerThread import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import io.github.muntashirakon.adb.AdbPairingRequiredException import io.github.muntashirakon.adb.AdbStream import io.github.muntashirakon.adb.LocalServices -import io.github.muntashirakon.adb.android.AdbMdns -import io.github.muntashirakon.adb.android.AdbMdns.OnAdbDaemonDiscoveredListener -import io.github.muntashirakon.adb.android.AndroidUtils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -21,30 +18,41 @@ import java.io.BufferedReader import java.io.File import java.io.IOException import java.io.InputStreamReader -import java.net.InetAddress import java.nio.charset.StandardCharsets -import java.util.concurrent.CountDownLatch import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.concurrent.Future -import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger import kotlin.concurrent.Volatile +private const val TAG = "AdbManager" + class AdbManager(applicationContext: Context) { private val executor: ExecutorService = Executors.newFixedThreadPool(3) - private val _adbState = MutableStateFlow(AdbState.ReadyToPair) + private var _adbState = MutableStateFlow(AdbState.Initial) val adbState: StateFlow = _adbState.asStateFlow() + + private val adbPairingReceiver = + AdbPairingResultReceiver( + onSuccess = { + Log.d(TAG, "paired successfully") + _adbState.value = AdbState.Ready + stopAdbPairingService() + autoConnect() + }, + onFailure = { errorMessage -> + Log.e(TAG, "Failed pairing attempt: $errorMessage") + _adbState.value = AdbState.ErrorConnect + stopAdbPairingService() + } + ) + private val commandOutput = MutableLiveData() - private val pairingPort = MutableLiveData() private var qfFuture: Future<*>? = null private val qfCancelled = AtomicBoolean(false) - private var mPairingHost: String? = null - private var mPairingPort = -1 - private var appContext: Context? = null + private var adbConnectionManager: AdbConnectionManager private var adbShellStream: AdbStream? = null @@ -52,138 +60,144 @@ class AdbManager(applicationContext: Context) { return commandOutput } + internal fun stopAdbPairingService() { + adbPairingReceiver.let { it -> + try { + appContext?.unregisterReceiver(it) + } catch (_: IllegalArgumentException) { + Log.i(TAG, "Can't unregister adbBroadcastReceiver (already unregistered?)") + } catch (e: Exception) { + Log.e(TAG, "Error unregistering adbBroadcastreceiver: $e") + } + } + } + + internal fun startAdbPairingService() { + // Create BroadcastReceiver for pairing results. + Log.d(TAG, "Start pairing service...") + val filter = IntentFilter(AdbPairingService.ACTION_PAIRING_RESULT) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // This broadcast is internal to the app, so keep it private + appContext?.registerReceiver(adbPairingReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + // Pre-Android 13: old two-argument API + @Suppress("UnspecifiedRegisterReceiverFlag") + appContext?.registerReceiver(adbPairingReceiver, filter) + } + + // Start ADB pairing service + val pairingIntent = AdbPairingService.startIntent(appContext) + try { + appContext?.startForegroundService(pairingIntent) + } catch (ignored: Throwable) { + appContext?.startService(pairingIntent) + } + // (Wait for pairing result, then update state and stop the service) + } + + fun cleanup() { + // Might not be running, but just in case. + stopAdbPairingService() + val stream = adbShellStream adbShellStream = null executor.submit(Runnable { try { stream?.close() } catch (e: java.lang.Exception) { + Log.e(TAG, "Error during cleanup: ${e.message}") e.printStackTrace() } }) executor.shutdown() } - fun connect(port: Int) { - executor.submit(Runnable { - try { - val manager = AdbConnectionManager.getInstance(this.appContext!!) - try { - if (manager.connect(AndroidUtils.getHostIpAddress(this.appContext!!), port)) { - _adbState.value = AdbState.ConnectedIdle - } // no "else" - method can return false if there is an existing connection already - } catch (th: Throwable) { - th.printStackTrace() - _adbState.value = AdbState.ErrorPair - } - } catch (th: Throwable) { - th.printStackTrace() - _adbState.value = AdbState.ErrorPair - } - }) - } - fun autoConnect() { - executor.submit(Runnable { this.autoConnectInternal() }) - } - - fun disconnect() { - executor.submit(Runnable { - try { - val manager = AdbConnectionManager.getInstance(this.appContext!!) - manager.disconnect() - _adbState.value = AdbState.ReadyToConnect - } catch (th: Throwable) { - th.printStackTrace() - // TODO: ConnectedIdle not be accurate here, but it follows the prior logic - _adbState.value = AdbState.ConnectedIdle - } - }) + val state = _adbState.value + if (state !in arrayOf(AdbState.ConnectedIdle, AdbState.ConnectedAcquiring, AdbState.Connecting)) { + executor.submit(Runnable { this.autoConnectInternal() }) + } else { + Log.w("Bugbane", "autoConnect called but adbState is $state") + } } - fun getPairingPort() { - executor.submit(Runnable { - val atomicPort = AtomicInteger(-1) - val host = arrayOf(null) - val resolveHostAndPort = CountDownLatch(1) - - val adbMdns = AdbMdns( - this.appContext!!, - AdbMdns.SERVICE_TYPE_TLS_PAIRING, - OnAdbDaemonDiscoveredListener { hostAddress: InetAddress?, port: Int -> - atomicPort.set(port) - if (hostAddress != null) { - host[0] = hostAddress.getHostAddress() - } - resolveHostAndPort.countDown() - }) - adbMdns.start() - - try { - if (!resolveHostAndPort.await(1, TimeUnit.MINUTES)) { - return@Runnable + fun checkState() { + Log.d(TAG, "Adb received request to re-evaluate state.") + try { + // connection isn't null, isConnected, connection is established + if (adbConnectionManager.isConnected) { + if (_adbState.value != AdbState.ConnectedAcquiring) { + _adbState.value = AdbState.ConnectedIdle + } else { + _adbState.value = AdbState.ConnectedAcquiring } - } catch (ignore: InterruptedException) { - } finally { - adbMdns.stop() - } - - mPairingPort = atomicPort.get() - mPairingHost = host[0] - pairingPort.postValue(mPairingPort) - _adbState.value = AdbState.ReadyToPair - }) - } - - fun pair(port: Int, pairingCode: String) { - executor.submit(Runnable { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val manager = AdbConnectionManager.getInstance(this.appContext!!) - val host = - (if (mPairingHost != null) mPairingHost else AndroidUtils.getHostIpAddress( - this.appContext!! - ))!! - val p = if (port > 0) port else mPairingPort - if (manager.pair(host, p, pairingCode)) { - _adbState.value = AdbState.ReadyToConnect - } else { - _adbState.value = AdbState.ErrorPair - } + } else { + // connection isn't null, isConnected (not yet established) + if (adbConnectionManager.adbConnection != null && adbConnectionManager.adbConnection!!.isConnected) { + Log.d(TAG, "manager reports ready") + _adbState.value = AdbState.Ready } - autoConnectInternal() - } catch (th: Throwable) { - th.printStackTrace() - _adbState.value = AdbState.ErrorPair } - }) + } catch (e: Exception) { + Log.d(TAG, "Couldn't get adbState: ${e.message}") + } + Log.d(TAG, "State is unknown") } @WorkerThread private fun autoConnectInternal() { try { - val manager = AdbConnectionManager.getInstance(this.appContext!!) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - try { - if (manager.connectTls(this.appContext!!, 5000)) { - _adbState.value = AdbState.ConnectedIdle // no "else", because false could mean existing connection + if (adbConnectionManager.isConnected) { + Log.d(TAG, "already connected") + if (_adbState.value != AdbState.ConnectedAcquiring) { + _adbState.value = AdbState.ConnectedIdle + } + return + } + else if (_adbState.value in arrayOf(AdbState.Connecting, AdbState.ConnectedIdle, AdbState.ConnectedAcquiring)) { + // This isn't necessarily an error (could be Connecting), but it's sus + Log.w(TAG, "skipping autoConnectInternal: manager.isConnected was false but AdbState is ${adbState.value}.") + return + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Log.d(TAG, "AdbState ${adbState.value}, try autoConnectInternal") + _adbState.value = AdbState.Connecting + try { + // Slight TOCTOU here, but only if manager transitioned from connecting -> + // connected while we were running this method. + // manager.connectTls returns false if the connection failed *or* there + // was another existing connection; we check manager.isConnected first + // so that we can try to distinguish between those two cases. + if (adbConnectionManager.connectTls(this.appContext!!, 5000)) { + Log.d(TAG, "autoconnect successful") + _adbState.value = AdbState.ConnectedIdle + } else { + // Probably an error but could also be a race :( + Log.w(TAG, "connectTls returned false") + _adbState.value = AdbState.Ready + } + } catch (ie: AdbPairingRequiredException) { + Log.i(TAG, "AdbPairingRequiredException during autoconnect") + _adbState.value = AdbState.RequisitesMissing + } catch (ie: InterruptedException) { + Log.w(TAG, "Error during autoconnect") + ie.printStackTrace() + _adbState.value = AdbState.ErrorConnect + } catch (th: Throwable) { + Log.e(TAG, "$th during autoconnect") + th.printStackTrace() + _adbState.value = AdbState.ErrorConnect } - } catch (ie: AdbPairingRequiredException) { - _adbState.value = AdbState.RequisitesMissing - } catch (ie: InterruptedException) { - _adbState.value = AdbState.ErrorConnect - } catch (th: Throwable) { - th.printStackTrace() - _adbState.value = AdbState.ErrorConnect } } } catch (th: Throwable) { + Log.e(TAG, "Error retrieving AdbConnectionManager instance") th.printStackTrace() _adbState.value = AdbState.ErrorConnect } } - @Volatile private var clearEnabled = false private val outputGenerator = Runnable { @@ -201,20 +215,22 @@ class AdbManager(applicationContext: Context) { } } } catch (e: IOException) { + Log.d(TAG, "${e.message} (adbStream error?)") + _adbState.value = AdbState.Cancelled e.printStackTrace() } } init { this.appContext = applicationContext + this.adbConnectionManager = AdbConnectionManager.getInstance(appContext!!) as AdbConnectionManager } fun execute(command: String) { executor.submit(Runnable { try { if (adbShellStream == null || adbShellStream!!.isClosed) { - val manager = AdbConnectionManager.getInstance(this.appContext!!) - adbShellStream = manager.openStream(LocalServices.SHELL) + adbShellStream = adbConnectionManager.openStream(LocalServices.SHELL) Thread(outputGenerator).start() } if (command == "clear") { @@ -227,7 +243,8 @@ class AdbManager(applicationContext: Context) { } } catch (e: java.lang.Exception) { e.printStackTrace() - _adbState.value = AdbState.RequisitesMissing + Log.w(TAG, "adbShelLStream error ${e.message}") + _adbState.value = AdbState.Cancelled } }) } @@ -242,10 +259,10 @@ class AdbManager(applicationContext: Context) { @Synchronized fun cancelQuickForensics() { - qfCancelled.set(true) if (_adbState.value == AdbState.ConnectedAcquiring) { _adbState.value = AdbState.ConnectedIdle } + qfCancelled.set(true) } fun runQuickForensics( @@ -253,23 +270,35 @@ class AdbManager(applicationContext: Context) { listener: QuickForensics.ProgressListener ) { if (this.isQuickForensicsRunning) { + Log.d(TAG, "QuickForensics already running") + commandOutput.postValue("QuickForensics is still running") + return + } else if (!adbConnectionManager.isConnected) { + Log.i(TAG, "Need to reconnect first") + commandOutput.postValue("Reconnect adb first") + _adbState.value = AdbState.Ready return } qfCancelled.set(false) _adbState.value = AdbState.ConnectedAcquiring qfFuture = executor.submit(Runnable { try { - val manager = AdbConnectionManager.getInstance(this.appContext!!) val out = QuickForensics() - .run(this.appContext!!, manager, baseDir, listener) + .run(this.appContext!!, adbConnectionManager, baseDir, listener) if (qfCancelled.get()) { commandOutput.postValue("QuickForensics cancelled") - _adbState.value = AdbState.ConnectedIdle + _adbState.value = AdbState.Cancelled } else { commandOutput.postValue("QuickForensics completed: " + out.getAbsolutePath()) _adbState.value = AdbState.ConnectedIdle } - } catch (e: java.lang.Exception) { + } catch (io: IOException) { + // Could be reconnection issue + io.printStackTrace() + commandOutput.postValue("Error running QuickForensics: " + io.message) + _adbState.value = AdbState.Cancelled + } + catch (e: java.lang.Exception) { e.printStackTrace() commandOutput.postValue("Error running QuickForensics: " + e.message) _adbState.value = AdbState.ErrorAcquisition diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbState.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbState.kt index 419f3ad..fb4006e 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbState.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbState.kt @@ -5,24 +5,23 @@ package org.osservatorionessuno.bugbane.utils // errorStates or successStates, then recalculate their own state if needed // (see ConfigurationViewModel). enum class AdbState(val index: Int) { - RequisitesMissing(0), // AppStates < AppState.NeedWirelessDebuggingAndPair - ReadyToPair(1), - ErrorPair(2), - ReadyToConnect(3), - ErrorConnect(4), - ConnectedIdle(5), // AppStates >= AppState.AdbConnected - ConnectedAcquiring(6), - ErrorAcquisition(7); - + RequisitesMissing(0), // AppStates < AppState.NeedWirelessDebugging + Ready(1), + Connecting(2), + ConnectedIdle(3), // AppStates >= AppState.AdbConnected + ConnectedAcquiring(4), + Cancelled(5), + ErrorConnect(6), + ErrorAcquisition(7), + Initial(8); // Initializing state companion object { // An error requiring user interaction. Right now we don't differentiate // between pairing and connection errors, we just ask user to re-pair fun errorStates(): List = listOf( - RequisitesMissing, - ErrorPair, ErrorAcquisition, + ErrorConnect ) // A successful ADB connection state diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AppState.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AppState.kt index fb5b565..a378e99 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AppState.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AppState.kt @@ -1,34 +1,41 @@ package org.osservatorionessuno.bugbane.utils // AppStates represent the high-level device configuration and permissions status; -// state ordering/requisites are defined in the ViewModel (see ConfigurationViewModel). +// state requisites are defined in the ViewModel (see ConfigurationViewModel). // An AppState requires user interaction to change to a different state. -enum class AppState(val index: Int) { - DeviceUnsupported(8), - NeedWelcomeScreen(0), // one-time consent - NeedNotificationConfiguration(1), +private const val EXCLUDED_STEP = 999 +enum class AppState(val step: Int) { + NeedWelcomeScreen(0), + NeedNotificationPermission(1), + DeviceUnsupported(1), // Alternative to step 1: device isn't compatible NeedWifi(2), NeedDeveloperOptions(3), - NeedWirelessDebuggingAndPair(4), // sub-states are handled by AdbManager -// NeedAdbPairingService(5), - AdbConnectedFinishOnboarding(6), // one-time onboarding complete - AdbConnected(7); + + // Step 4: ADB/Wireless ADB/Pairing + NeedWirelessDebuggingAndPair(4), + NeedWirelessDebugging(4), + + // Step 5: ADB connection attempt + AdbConnecting(5), + AdbConnectedFinishOnboarding(5), + AdbConnected(5), + TryAutoConnect(5), + AdbConnectionError(5), + + // Not part of our slideshow + AdbScanning(EXCLUDED_STEP); companion object { - fun valuesInOrder(): List = listOf( - NeedWelcomeScreen, - NeedNotificationConfiguration, - NeedWifi, - NeedDeveloperOptions, - NeedWirelessDebuggingAndPair, -// NeedAdbPairingService, - AdbConnectedFinishOnboarding, - AdbConnected, - DeviceUnsupported, - ) // Error states have different UI implications fun isErrorState(state: AppState): Boolean { - return (state == AppState.DeviceUnsupported) + return (state in arrayOf(DeviceUnsupported, AdbConnectionError)) + } + fun distinctSteps(): Int { + return entries + .map { it.step } + .filterNot { it == EXCLUDED_STEP } + .toSet() + .size } } } diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt index f47d2dc..4178d92 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt @@ -7,8 +7,6 @@ import android.os.Build import android.util.Log import android.content.Intent import android.provider.Settings -import android.net.ConnectivityManager -import android.net.NetworkCapabilities // Manages phone settings (App-specific settings belong in SlideshowManager) object ConfigurationManager { @@ -36,14 +34,6 @@ object ConfigurationManager { return false } - fun isConnectedToWifi(context: Context): Boolean { - val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager - ?: return false - val network = connectivityManager.activeNetwork ?: return false - val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false - return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) - } - fun isWirelessDebuggingEnabled(context: Context): Boolean { return try { val developerOptionsEnabled = isDeveloperOptionsEnabled(context) diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt index f6cf4d4..e2f6edc 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt @@ -6,11 +6,10 @@ import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP import android.content.Intent.FLAG_ACTIVITY_NEW_TASK -import android.content.IntentFilter -import android.os.Build import android.os.Bundle import android.provider.Settings import android.util.Log +import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow @@ -18,7 +17,26 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.osservatorionessuno.bugbane.MainActivity - +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +private const val TAG = "ConfigurationViewModel" + +/** ViewModel that holds and emits AppState, the overall state flow, + * and manages transitions between states. + * States are not inherently ordered or aware of their position in the StateFlow; + * state flow is controlled via: + * ConfigurationViewModel::getState() (runs checks and returns current state) + * ConfiguationViewModel::checkUpdateState() (emits that value in a StateFlow), and + * ConfigurationViewModel::onChangeStateRequest() defines transition ("next") + * behaviour for each state. + * + * A single ConfigurationViewModel instance is used and is scoped to the application's + * lifecycle (all Context references are to Application Context). This means the + * ViewModel is responsible for registering and de-registering services/listeners. + */ +@OptIn(ExperimentalAtomicApi::class) class ConfigurationViewModel private constructor( val appContext: Context, // adb state: needs pair, paired, scanning, etc @@ -33,7 +51,7 @@ class ConfigurationViewModel private constructor( return ConfigurationViewModel( application.applicationContext, AdbManager(application.applicationContext), - WifiConnectivityMonitor(application.applicationContext) + WifiConnectivityMonitor(application) ) } } @@ -42,82 +60,93 @@ class ConfigurationViewModel private constructor( private val _configurationState = MutableStateFlow(AppState.NeedWelcomeScreen) val configurationState: StateFlow = _configurationState.asStateFlow() - private val adbPairingReceiver = - AdbPairingResultReceiver( - onSuccess = { - Log.d("Bugbane", "paired successfully") - _configurationState.value = AppState.AdbConnected - stopAdbPairingService() - }, - onFailure = { errorMessage -> - // TODO (should be an adbManager state) - Log.e("Bugbane", "failed pairing attempt") - _configurationState.value = AppState.NeedWirelessDebuggingAndPair - stopAdbPairingService() - } - ) + private val hasTriedAutoConnect = AtomicBoolean(false) init { // Set up listeners for states that can affect AppState observeAdbConfiguration() observeWifiConnectivity() + observeAppState() } internal fun observeAdbConfiguration() { viewModelScope.launch { - adbManager.adbState.collect { adbState -> checkUpdateState() + // Use main checkUpdate method + adbManager.adbState.collect { adbState -> + checkUpdateState() } } } internal fun observeWifiConnectivity() { viewModelScope.launch { + // Use main checkUpdate method wifiConnectivityMonitor.wifiState.collect { isConnected -> - if (!isConnected) { - // Could also directly set _appState to AppState.NeedsWifi, - // but better to keep all the state logic in one place, - // especially for evolving logic (eg looking at past acquisitions) - checkUpdateState() - } + Log.d(TAG, "Wifi connectivity change, tell ADB manager") + adbManager.checkState() } } + } + // Handle transition states that should require no user interaction (autoconnect) + internal fun observeAppState() { + viewModelScope.launch { + configurationState.collect { appState -> + if (appState == AppState.TryAutoConnect && !hasTriedAutoConnect.load()) { + Log.d(TAG, "Try autoconnect") + adbManager.autoConnect() + hasTriedAutoConnect.store(true) + } else if (appState !in arrayOf(AppState.AdbConnecting, AppState.TryAutoConnect)) { + hasTriedAutoConnect.store(false) + } + checkUpdateState() + } + } } + internal fun checkState(): AppState { // Get the "best" state - requisites/logic enforced here. // If a user has completed the welcome screen, is connected to wifi and has an // active adb connection, skip the other checks. // If a user is missing one of those, they will need pairing flow again. - // Note: User could also scan an prior acquisition, where permissions aren't needed, - // but that is independent of the device's permissions/states, so address it in UI. + // Check only: don't introduce side-effects here. // TODO: This can be defined in the manifest if it's just about API level + val isOnboarding = !SlideshowManager.hasSeenHomepage(appContext) if (!ConfigurationManager.isSupportedDevice()) return AppState.DeviceUnsupported - if (!SlideshowManager.canSkipWelcomeScreen(appContext)) return AppState.NeedWelcomeScreen + val isConnectedToWifi = wifiConnectivityMonitor.wifiState.value + + // adbConnected -> we're connected, connected+scanning, or connected for the first time (connected finish onboarding). + // Don't just rely on adb, since it's async and may lag to report its status + val isWirelessDebug = ConfigurationManager.isAdbEnabled(appContext) && isConnectedToWifi - // TODO: ContentObserver? (https://developer.android.com/reference/android/database/ContentObserver) - val adbConnected = ConfigurationManager.isAdbEnabled(appContext) && adbManager.adbState.value in AdbState.successStates() - if (adbConnected && SlideshowManager.hasSeenHomepage(appContext)) return AppState.AdbConnected - if (adbConnected && !SlideshowManager.hasSeenHomepage(appContext)) return AppState.AdbConnectedFinishOnboarding + if (isWirelessDebug && adbManager.adbState.value == AdbState.ConnectedIdle && !isOnboarding) return AppState.AdbConnected + if (isWirelessDebug && adbManager.adbState.value == AdbState.ConnectedIdle && isOnboarding) return AppState.AdbConnectedFinishOnboarding + if (isWirelessDebug && adbManager.adbState.value == AdbState.ConnectedAcquiring) return AppState.AdbScanning - // If we get here, we need to go through the pairing workflow again, so ensure prereqs are met + // Are we trying to connect already? + if (adbManager.adbState.value == AdbState.Connecting) return AppState.AdbConnecting + + // If we get here, we may need to go through the pairing workflow again, so ensure prereqs are met if (!ConfigurationManager.isNotificationPermissionGranted(appContext)) return AppState.NeedNotificationConfiguration - if (!ConfigurationManager.isConnectedToWifi(appContext)) return AppState.NeedWifi + if (!isConnectedToWifi) return AppState.NeedWifi if (!ConfigurationManager.isDeveloperOptionsEnabled(appContext)) return AppState.NeedDeveloperOptions + if (!isWirelessDebug || hasTriedAutoConnect.load()) return AppState.NeedWirelessDebuggingAndPair - if (!ConfigurationManager.isWirelessDebuggingEnabled(appContext) ||!ConfigurationManager.isAdbEnabled(appContext)) return AppState.NeedWirelessDebuggingAndPair - - // TODO: could do NeedAdbPairingService here (autoconnect optimization) if the above check could tell if we have a saved connection - return AppState.NeedWirelessDebuggingAndPair + Log.d(TAG, "checkState: isAdbEnabled=${ConfigurationManager.isAdbEnabled(appContext)} adbManager ${adbManager.adbState.value}") + return AppState.TryAutoConnect } fun checkUpdateState() { // StateFlow doesn't emit duplicates, so this is fine - _configurationState.value = checkState() + val newState = checkState() + Log.d(TAG, "checkUpdateState: $newState") + _configurationState.value = newState } // Handle state transition (user-initiated) fun onChangeStateRequest(currentState: AppState) { + Log.d(TAG, "onChangeRequest from $currentState" ) when (currentState) { AppState.DeviceUnsupported -> { (appContext as? Activity)?.finishAffinity() @@ -125,67 +154,31 @@ class ConfigurationViewModel private constructor( AppState.NeedWelcomeScreen -> { // Clicked "I understand," nothing else to do SlideshowManager.setHasSeenWelcomeScreen(appContext) + checkUpdateState() } AppState.NeedWifi, AppState.NeedNotificationConfiguration, AppState.NeedDeveloperOptions -> { getIntentForAppState(currentState)?.let { it -> - it.addFlags(FLAG_ACTIVITY_NEW_TASK) + it.addFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP) appContext.startActivity(it) } } AppState.NeedWirelessDebuggingAndPair -> { getIntentForAppState(currentState)?.let { it -> - it.addFlags(FLAG_ACTIVITY_NEW_TASK) + it.addFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP) appContext.startActivity(it) } - startAdbPairingService() + adbManager.startAdbPairingService() } AppState.AdbConnectedFinishOnboarding -> { SlideshowManager.markHomepageAsSeen(appContext) + checkUpdateState() } else -> { // Should be unreachable - Log.e(appContext.packageName, "$currentState not handled") - } - } - checkUpdateState() - } - - internal fun stopAdbPairingService() { - adbPairingReceiver.let { it -> - try { - appContext.unregisterReceiver(it) - } catch (e: Exception) { - // TODO - Log.w("Bugbane", "Error unregistering adbBroadcastreceiver") + Log.w(TAG, "$currentState not handled by onChangeStateRequest") } } } - internal fun startAdbPairingService() { - // Create BroadcastReceiver for pairing results. - Log.d("Bugbane", "Start pairing service...") - // TODO: AdbManager should handle all the pairing code and report state back directly - - val filter = IntentFilter(AdbPairingService.ACTION_PAIRING_RESULT) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - // This broadcast is internal to the app, so keep it private - appContext.registerReceiver(adbPairingReceiver, filter, Context.RECEIVER_NOT_EXPORTED) - } else { - // Pre-Android 13: old two-argument API - @Suppress("UnspecifiedRegisterReceiverFlag") - appContext.registerReceiver(adbPairingReceiver, filter) - } - // ConfigurationManager.openWirelessDebugging(appContext) - - // Start ADB pairing service - val pairingIntent = AdbPairingService.startIntent(appContext) - try { - appContext.startForegroundService(pairingIntent) - } catch (ignored: Throwable) { - appContext.startService(pairingIntent) - } - // (Wait for pairing result, then update state and stop the service) - } internal fun getIntentForAppState(state: AppState): Intent? { return when (state) { @@ -221,7 +214,6 @@ class ConfigurationViewModel private constructor( // Unregister listeners/close sockets wifiConnectivityMonitor.cleanup() adbManager.cleanup() - stopAdbPairingService() } } diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/WifiConnectivityMonitor.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/WifiConnectivityMonitor.kt index 0cc6dc2..72ef916 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/WifiConnectivityMonitor.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/WifiConnectivityMonitor.kt @@ -1,61 +1,69 @@ package org.osservatorionessuno.bugbane.utils -import android.content.BroadcastReceiver import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.PackageManager -import android.net.wifi.WifiManager +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities import android.util.Log -import androidx.core.content.ContextCompat import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlin.context -// Monitor for changes in wifi connectivity -class WifiConnectivityMonitor(context: Context) { - private val appContext = context.applicationContext +private const val TAG = "WifiConnectivityMonitor" - private val _wifiState = MutableStateFlow(isWifiEnabled()) +object WifiConnectivityMonitor { + + private lateinit var connectivityManager: ConnectivityManager + + private val _wifiState = MutableStateFlow(false) val wifiState: StateFlow = _wifiState.asStateFlow() - private fun hasWifiPermissions(): Boolean { - return ContextCompat.checkSelfPermission( - appContext, - android.Manifest.permission.ACCESS_WIFI_STATE - ) == PackageManager.PERMISSION_GRANTED + fun initialize(appContext: Context) { + connectivityManager = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + // Register the callback to track connectivity changes + connectivityManager.registerDefaultNetworkCallback(networkCallback) + // Initialize state by checking current connectivity + _wifiState.value = checkCurrentWifiConnected() } - private val wifiReceiver = object : BroadcastReceiver() { - override fun onReceive(ctx: Context?, intent: Intent?) { - if (intent?.action == WifiManager.WIFI_STATE_CHANGED_ACTION) { - val state = intent.getIntExtra(WifiManager.EXTRA_WIFI_STATE, -1) - _wifiState.value = state == WifiManager.WIFI_STATE_ENABLED + + private val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + // available != connected + } + + override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { + val isConnected = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + + if (_wifiState.value != isConnected) { + Log.d(TAG, "wifi connection state ${_wifiState.value} -> $isConnected") + _wifiState.value = isConnected } } - } - fun registerWifiMonitor() { - val filter = IntentFilter(WifiManager.WIFI_STATE_CHANGED_ACTION) - if (hasWifiPermissions()) { - appContext.registerReceiver(wifiReceiver, filter) + override fun onLost(network: Network) { + if (_wifiState.value) { + Log.d(TAG, "wifi lost") + _wifiState.value = false + } } } fun cleanup() { try { - appContext.unregisterReceiver(wifiReceiver) - } - catch (ex: IllegalArgumentException) { - // Unregistered already? - Log.i(appContext.packageName, "WifiConnectivityMonitor cleanup failed (already unregistered?)") + connectivityManager.unregisterNetworkCallback(networkCallback) + } catch (ex: IllegalArgumentException) { + Log.i(TAG, "NetworkCallback already unregistered") } } - private fun isWifiEnabled(): Boolean { - if (hasWifiPermissions()) { - val wifiManager = appContext.getSystemService(Context.WIFI_SERVICE) as WifiManager - return wifiManager.isWifiEnabled - } - return false // TODO + fun checkCurrentWifiConnected(): Boolean { + val activeNetwork = connectivityManager.activeNetwork ?: return false + val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false + return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } } From 455932990e13bd0d9b83725bc9890ab0475dff81 Mon Sep 17 00:00:00 2001 From: Rowen S Date: Fri, 19 Sep 2025 15:44:49 -0400 Subject: [PATCH 08/17] Check appstate on wifi disconnect or reconnect. Exclude NeedWirelessDebuggingAndPair from autoconnect states Check for wireless adb directly in configuration manager using content resolver. Include navigation to build number when launching settings. Refactor ConfigurationManager to use contentobservers and listen for and emit configuration changes. Refactor ConfigurationViewModel to combine stateflows of components and calculate state. Deprecate checkUpdateState since state is automatically emitted on value change. Refactor slideshow manager so that it emits updates. --- .../bugbane/utils/ConfigurationManager.kt | 187 +++++++--- .../bugbane/utils/ConfigurationViewModel.kt | 323 ++++++++++++------ .../bugbane/utils/SlideshowManager.kt | 106 ++++-- 3 files changed, 439 insertions(+), 177 deletions(-) diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt index 4178d92..f6fe003 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationManager.kt @@ -1,66 +1,171 @@ package org.osservatorionessuno.bugbane.utils -import android.Manifest import android.content.Context -import android.content.pm.PackageManager -import android.os.Build -import android.util.Log import android.content.Intent +import android.database.ContentObserver +import android.os.Handler +import android.os.Looper import android.provider.Settings +import android.util.Log +import androidx.core.app.NotificationManagerCompat +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +private const val TAG = "ConfigurationManager" -// Manages phone settings (App-specific settings belong in SlideshowManager) +/** + * Observe device configuration (developer mode, ADB, wireless debug) and emit values as StateFlow. + * Also observes state of notification permissions, but those can't be subscribed to except in + * a Composable function (with rememberPermissionState), so expose a handler to invoke from + * Composable. + * + * Content observers observe in a coroutine off the main thread, and emit updates on the main + * thread so that viewmodel can react. + * + * Requires initialization and cleanup due to registering ContentObservers. + */ object ConfigurationManager { - fun openDeveloperOptions(context: Context) { - // Open the developer options settings - val intent = Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS) - try { - context.startActivity(intent) - } catch (e: Exception) { + private lateinit var appContext: Context + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val contentResolver get() = appContext.contentResolver + private var developerOptsObserver: ContentObserver? = null + private var wirelessDebugObserver: ContentObserver? = null + private var adbObserver: ContentObserver? = null + private val _developerOptionsEnabled = MutableStateFlow(false) + val developerOptionsEnabled: StateFlow = _developerOptionsEnabled.asStateFlow() + private val _wirelessDebuggingEnabled = MutableStateFlow(false) + val wirelessDebuggingEnabled: StateFlow = _wirelessDebuggingEnabled.asStateFlow() + private val _adbEnabled = MutableStateFlow(false) + val adbEnabled: StateFlow = _adbEnabled.asStateFlow() + private val _notificationsEnabled = MutableStateFlow(false) + val notificationsEnabled: StateFlow = _notificationsEnabled.asStateFlow() + + fun initialize(context: Context) { + if (!::appContext.isInitialized) { + appContext = context.applicationContext + registerObservers() + // First set of values + checkAll() + } else { + Log.e(TAG, "Failed to initialize ConfigurationManager") + // TODO: raise? } } - // Can just do this via the manifest, but including here is ~harmless - fun isSupportedDevice() : Boolean { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + /** + * Register ContentObservers for specific system settings/preferences + * (https://developer.android.com/reference/kotlin/android/database/ContentObserver) + */ + private fun registerObservers() { + developerOptsObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) = developerOptionsCheck() + }.also { + contentResolver.registerContentObserver( + Settings.Global.getUriFor(Settings.Global.DEVELOPMENT_SETTINGS_ENABLED), + false, + it + ) + } + + wirelessDebugObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) = wirelessDebugCheck() + }.also { + contentResolver.registerContentObserver( + Settings.Global.getUriFor("adb_wifi_enabled"), + false, + it + ) + } + + adbObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) = adbObserverCheck() + }.also { + contentResolver.registerContentObserver( + Settings.Global.getUriFor(Settings.Global.ADB_ENABLED), + false, + it + ) + } } - fun isNotificationPermissionGranted(context: Context): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - return true - } else if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { - return true + // Run all checks + fun checkAll() { + developerOptionsCheck() + wirelessDebugCheck() + notificationsCheck() + adbObserverCheck() + } + + private fun developerOptionsCheck() { + scope.launch { + val enabled = Settings.Global.getInt( + contentResolver, + Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, 0 + ) == 1 + _developerOptionsEnabled.emit(enabled) } - return false } - fun isWirelessDebuggingEnabled(context: Context): Boolean { - return try { - val developerOptionsEnabled = isDeveloperOptionsEnabled(context) - val adbEnabled = isAdbEnabled(context) + // A bit dirty: + // https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/provider/Settings.java;l=13465?q=adb_wifi_enabled&ss=android%2Fplatform%2Fsuperproject%2Fmain + private fun wirelessDebugCheck() { + scope.launch { + val enabled = Settings.Global.getInt( + contentResolver, + "adb_wifi_enabled", 0 + ) == 1 + _wirelessDebuggingEnabled.emit(enabled) + } + } - developerOptionsEnabled && adbEnabled - } catch (e: Exception) { - Log.e("ConfigurationManager", "Error checking wireless debugging status", e) - false + private fun adbObserverCheck() { + scope.launch { + val enabled = + Settings.Global.getInt(contentResolver, Settings.Global.ADB_ENABLED, 0) == 1 + _adbEnabled.emit(enabled) } } - - fun isDeveloperOptionsEnabled(context: Context): Boolean { - return try { - Settings.Global.getInt(context.contentResolver, Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, 0) == 1 + + fun notificationsCheck() { + val enabled = NotificationManagerCompat.from(appContext).areNotificationsEnabled() + _notificationsEnabled.value = enabled + } + + fun openDeveloperOptions(context: Context) { + // Open the developer options settings + val intent = Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS) + try { + context.startActivity(intent) } catch (e: Exception) { - Log.e("ConfigurationManager", "Error checking developer options status", e) - false } } - - fun isAdbEnabled(context: Context): Boolean { - return try { - Settings.Global.getInt(context.contentResolver, Settings.Global.ADB_ENABLED, 0) == 1 - } catch (e: Exception) { - Log.e("ConfigurationManager", "Error checking ADB status", e) - false + + fun cleanup() { + developerOptsObserver?.let { + contentResolver.unregisterContentObserver(it) + developerOptsObserver = null } + + wirelessDebugObserver?.let { + contentResolver.unregisterContentObserver(it) + wirelessDebugObserver = null + } + + adbObserver?.let { + contentResolver.unregisterContentObserver(it) + adbObserver = null + } + + scope.cancel() + } + + // To make notifications permission also emit StateFLow, a Composable needs to + // use rememberPermissionsState and invoke this handler when the value changes. + // (See SlideshowScreen) + fun setHasNotificationPermission(granted: Boolean) { + _notificationsEnabled.value = granted } } diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt index e2f6edc..c8b4af6 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt @@ -6,217 +6,318 @@ import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.os.Build import android.os.Bundle import android.provider.Settings import android.util.Log -import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import org.osservatorionessuno.bugbane.MainActivity -import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi private const val TAG = "ConfigurationViewModel" -/** ViewModel that holds and emits AppState, the overall state flow, - * and manages transitions between states. - * States are not inherently ordered or aware of their position in the StateFlow; - * state flow is controlled via: - * ConfigurationViewModel::getState() (runs checks and returns current state) - * ConfiguationViewModel::checkUpdateState() (emits that value in a StateFlow), and - * ConfigurationViewModel::onChangeStateRequest() defines transition ("next") - * behaviour for each state. +/** ViewModel that holds and emits AppState and manages transitions between states. + * States are not inherently ordered or aware of their position; ordering is managed + * via ConfigurationViewModel::getState() and ConfigurationViewModel::onChangeStateRequest() + * defines transition ("next") behaviour for each state. + * + * State-checking is done by manager classes and should happen async (coroutine) when possible. + * These managers implement listeners and must be registered and de-registered on cleanup. * * A single ConfigurationViewModel instance is used and is scoped to the application's - * lifecycle (all Context references are to Application Context). This means the - * ViewModel is responsible for registering and de-registering services/listeners. + * lifecycle (all Context references are to Application Context). */ @OptIn(ExperimentalAtomicApi::class) class ConfigurationViewModel private constructor( val appContext: Context, - // adb state: needs pair, paired, scanning, etc val adbManager: AdbManager, - // wifi connectivity listener - val wifiConnectivityMonitor: WifiConnectivityMonitor, - ) : ViewModel() { + val appManager: SlideshowManager = SlideshowManager, + val wifiConnectivityMonitor: WifiConnectivityMonitor = WifiConnectivityMonitor, + val configurationManager: ConfigurationManager = ConfigurationManager, +) : ViewModel() { // There Can Be Only One (see factory below) companion object { fun create(application: Application): ConfigurationViewModel { + val appContext = application.applicationContext + ConfigurationManager.initialize(appContext) + SlideshowManager.initialize(appContext) + WifiConnectivityMonitor.initialize(appContext) return ConfigurationViewModel( - application.applicationContext, - AdbManager(application.applicationContext), - WifiConnectivityMonitor(application) + appContext, + AdbManager(appContext) ) } } - // AppState is MutableFlow collected for UI listeners + // UI listeners collect AppState private val _configurationState = MutableStateFlow(AppState.NeedWelcomeScreen) val configurationState: StateFlow = _configurationState.asStateFlow() - private val hasTriedAutoConnect = AtomicBoolean(false) + // Some states take a while to broadcast a result; don't confuse the user while waiting + val _needsAsyncStateResult: MutableStateFlow = MutableStateFlow(false) + val needsAsyncStateResult: StateFlow = _needsAsyncStateResult.asStateFlow() + + private val autoConnectAttempts = AtomicInt(0) + private val _MAX_AUTOCONNECT_ATTEMPTS = 2 init { - // Set up listeners for states that can affect AppState - observeAdbConfiguration() - observeWifiConnectivity() + observeCombinedState() observeAppState() } - internal fun observeAdbConfiguration() { - viewModelScope.launch { - // Use main checkUpdate method - adbManager.adbState.collect { adbState -> - checkUpdateState() - } - } - } - internal fun observeWifiConnectivity() { - viewModelScope.launch { - // Use main checkUpdate method - wifiConnectivityMonitor.wifiState.collect { isConnected -> - Log.d(TAG, "Wifi connectivity change, tell ADB manager") - adbManager.checkState() - } - } - } - // Handle transition states that should require no user interaction (autoconnect) - internal fun observeAppState() { + // Kotlin complained about combining Flow with Flow, + // so use a wrapper class for all the boolean values, and then combine + // SettingsState, AppPogress, AdbState + data class SettingsState( + val notificationsEnabled: Boolean, + val developerOptionsEnabled: Boolean, + val adbEnabled: Boolean, + val wirelessDebuggingEnabled: Boolean, + val wifiConnected: Boolean + ) + + /** + * Merge all the stateflow objects and recalculate using checkState() + * when one of our components posts an update, then (synchronously) + * emit new AppState. + */ + private fun observeCombinedState() { viewModelScope.launch { - configurationState.collect { appState -> - if (appState == AppState.TryAutoConnect && !hasTriedAutoConnect.load()) { - Log.d(TAG, "Try autoconnect") - adbManager.autoConnect() - hasTriedAutoConnect.store(true) - } else if (appState !in arrayOf(AppState.AdbConnecting, AppState.TryAutoConnect)) { - hasTriedAutoConnect.store(false) + val settingsFlow = combine(configurationManager.notificationsEnabled, + configurationManager.developerOptionsEnabled, + configurationManager.adbEnabled, + configurationManager.wirelessDebuggingEnabled, + wifiConnectivityMonitor.wifiState, + ) { notifications, devOpts, adbEnabled, wirelessDebug, wifiConnected -> + SettingsState(notifications, devOpts, adbEnabled, wirelessDebug, wifiConnected) } + + combine(settingsFlow, adbManager.adbState, appManager.appProgress) { settings, adbState, appProgress -> + checkState( + settings.notificationsEnabled, + settings.developerOptionsEnabled, + settings.adbEnabled, + settings.wirelessDebuggingEnabled, + settings.wifiConnected, + adbState, + appProgress) + }.distinctUntilChanged() + .collect { newState -> + Log.d(TAG, "New appState $newState") + _configurationState.value = newState } - checkUpdateState() - } } } - - internal fun checkState(): AppState { - // Get the "best" state - requisites/logic enforced here. - // If a user has completed the welcome screen, is connected to wifi and has an - // active adb connection, skip the other checks. - // If a user is missing one of those, they will need pairing flow again. - // Check only: don't introduce side-effects here. - + /** + * Determine the AppState - all requisites/state logic enforced here. + * + * If a user has completed the welcome screen, is connected to wifi and has an + * active adb wireless debugging connection, skip the other checks. + * If a user has previously connected to ADB, try to reconnect (autoconnect) + * as long as the pre-requisites are met. Otherwise, they will need pairing flow again. + * + * Check only: don't introduce side-effects here. + */ + private fun checkState( + notificationsEnabled: Boolean, + developerOptionsEnabled: Boolean, + adbEnabled: Boolean, + wirelessDebuggingEnabled: Boolean, + isConnectedToWifi: Boolean, + adbState: AdbState, + appProgress: SlideshowManager.AppProgress, + ): AppState { // TODO: This can be defined in the manifest if it's just about API level - val isOnboarding = !SlideshowManager.hasSeenHomepage(appContext) - if (!ConfigurationManager.isSupportedDevice()) return AppState.DeviceUnsupported - if (!SlideshowManager.canSkipWelcomeScreen(appContext)) return AppState.NeedWelcomeScreen - val isConnectedToWifi = wifiConnectivityMonitor.wifiState.value - - // adbConnected -> we're connected, connected+scanning, or connected for the first time (connected finish onboarding). - // Don't just rely on adb, since it's async and may lag to report its status - val isWirelessDebug = ConfigurationManager.isAdbEnabled(appContext) && isConnectedToWifi + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return AppState.DeviceUnsupported + if (!appProgress.hasSeenWelcomeScreen) return AppState.NeedWelcomeScreen - if (isWirelessDebug && adbManager.adbState.value == AdbState.ConnectedIdle && !isOnboarding) return AppState.AdbConnected - if (isWirelessDebug && adbManager.adbState.value == AdbState.ConnectedIdle && isOnboarding) return AppState.AdbConnectedFinishOnboarding - if (isWirelessDebug && adbManager.adbState.value == AdbState.ConnectedAcquiring) return AppState.AdbScanning + if (wirelessDebuggingEnabled && adbEnabled) { + if (adbState == AdbState.ConnectedIdle && !appProgress.hasCompletedOnboarding) return AppState.AdbConnectedFinishOnboarding + if (adbState == AdbState.ConnectedIdle) return AppState.AdbConnected + if (adbState == AdbState.ConnectedAcquiring) return AppState.AdbScanning + if (adbState == AdbState.Connecting) return AppState.AdbConnecting - // Are we trying to connect already? - if (adbManager.adbState.value == AdbState.Connecting) return AppState.AdbConnecting + if (appProgress.hasCompletedOnboarding && autoConnectAttempts.load() < _MAX_AUTOCONNECT_ATTEMPTS) return AppState.TryAutoConnect + // Wireless debugging is on, but our preconditions weren't met. + // Maybe we failed autoconnect, or maybe we're connecting for the first time and need to pair first. + Log.i(TAG, "checkState: wirelessDebug true, adbState $adbState, hasTriedAutoconnect=${autoConnectAttempts.load()}") + } - // If we get here, we may need to go through the pairing workflow again, so ensure prereqs are met - if (!ConfigurationManager.isNotificationPermissionGranted(appContext)) return AppState.NeedNotificationConfiguration + // We need to go through some part of the pairing flow + // (The order here informs the onboarding order) + if (!notificationsEnabled) return AppState.NeedNotificationPermission if (!isConnectedToWifi) return AppState.NeedWifi - if (!ConfigurationManager.isDeveloperOptionsEnabled(appContext)) return AppState.NeedDeveloperOptions - if (!isWirelessDebug || hasTriedAutoConnect.load()) return AppState.NeedWirelessDebuggingAndPair + if (!developerOptionsEnabled) return AppState.NeedDeveloperOptions + if ((!wirelessDebuggingEnabled || !adbEnabled) && !appProgress.hasCompletedOnboarding) return AppState.NeedWirelessDebuggingAndPair + + if ((!wirelessDebuggingEnabled || !adbEnabled)) { + // Wireless adb or adb are off, but we've connected before. Enable wireless adb. Then autoconnect will be attempted. + Log.d(TAG, "Wireless adb is disabled, but we have previously connected successfully.") + return AppState.NeedWirelessDebugging + } + + // We don't have a past connection, but nothing else is wrong. We can try autoconnect. + if (autoConnectAttempts.load() < _MAX_AUTOCONNECT_ATTEMPTS) return AppState.TryAutoConnect - Log.d(TAG, "checkState: isAdbEnabled=${ConfigurationManager.isAdbEnabled(appContext)} adbManager ${adbManager.adbState.value}") - return AppState.TryAutoConnect + // We did not want to get here. That means no other conditions were met and autoconnect failed once. + Log.w(TAG, "checkState: default to re-pairing and debug connection logic.") + Log.d(TAG, "adbState=$adbState, notifications=$notificationsEnabled, wifi=$isConnectedToWifi, devOpts=$developerOptionsEnabled, has past adb connection=${appProgress.hasCompletedOnboarding}, tried autoconnect=${autoConnectAttempts.load()}") + return AppState.NeedWirelessDebuggingAndPair } - fun checkUpdateState() { - // StateFlow doesn't emit duplicates, so this is fine - val newState = checkState() - Log.d(TAG, "checkUpdateState: $newState") - _configurationState.value = newState + /** + * Observe AppState and manage automatic state transitions here. + */ + private fun observeAppState() { + viewModelScope.launch { + configurationState.collect { appState -> + // Reset async waiting flag, since we received a new state + _needsAsyncStateResult.value = false + + if (appState == AppState.TryAutoConnect && autoConnectAttempts.fetchAndAdd(1) < _MAX_AUTOCONNECT_ATTEMPTS) { + Log.d(TAG, "Auto-connect to ADB (attempt ${autoConnectAttempts.load()} / $_MAX_AUTOCONNECT_ATTEMPTS)") + adbManager.autoConnect() + } else if (appState !in arrayOf( + AppState.AdbConnecting, + AppState.TryAutoConnect, + AppState.NeedWirelessDebugging, + ) + ) { + Log.d(TAG, "State $appState, reset autoconnection attempts to 0") + autoConnectAttempts.store(0) + } + } + } } - // Handle state transition (user-initiated) fun onChangeStateRequest(currentState: AppState) { - Log.d(TAG, "onChangeRequest from $currentState" ) + Log.d(TAG, "onChangeRequest from $currentState") when (currentState) { AppState.DeviceUnsupported -> { (appContext as? Activity)?.finishAffinity() } + AppState.NeedWelcomeScreen -> { - // Clicked "I understand," nothing else to do - SlideshowManager.setHasSeenWelcomeScreen(appContext) - checkUpdateState() + appManager.setHasSeenWelcomeScreen() } - AppState.NeedWifi, AppState.NeedNotificationConfiguration, AppState.NeedDeveloperOptions -> { - getIntentForAppState(currentState)?.let { it -> + + AppState.NeedWifi, + AppState.NeedNotificationPermission, + AppState.NeedDeveloperOptions -> { + _needsAsyncStateResult.value = true + + getIntentForAppState(currentState)?.let { it.addFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP) appContext.startActivity(it) } } + AppState.NeedWirelessDebuggingAndPair -> { - getIntentForAppState(currentState)?.let { it -> + // transitory state + _needsAsyncStateResult.value = true + + getIntentForAppState(currentState)?.let { it.addFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP) appContext.startActivity(it) } adbManager.startAdbPairingService() } + AppState.NeedWirelessDebugging -> { + // transitory state + _needsAsyncStateResult.value = true + + // Just open settings, don't launch a new pairing service. + // Once wireless debugging is re-enabled the state will be updated and autoconnect will be attempted + getIntentForAppState(currentState)?.let { + it.addFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP) + appContext.startActivity(it) + } + } AppState.AdbConnectedFinishOnboarding -> { - SlideshowManager.markHomepageAsSeen(appContext) - checkUpdateState() + appManager.markHomepageAsSeen() + _needsAsyncStateResult.value = true } - else -> { // Should be unreachable + + else -> { Log.w(TAG, "$currentState not handled by onChangeStateRequest") } } } - - internal fun getIntentForAppState(state: AppState): Intent? { + private fun getIntentForAppState(state: AppState): Intent? { return when (state) { AppState.NeedWifi -> Intent(Settings.ACTION_WIFI_SETTINGS) - AppState.NeedNotificationConfiguration -> Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + + AppState.NeedNotificationPermission -> Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { putExtra(Settings.EXTRA_APP_PACKAGE, appContext.packageName) } - AppState.NeedDeveloperOptions -> Intent(Settings.ACTION_DEVICE_INFO_SETTINGS) - AppState.NeedWirelessDebuggingAndPair -> wirelessDebuggingIntent() - AppState.AdbConnectedFinishOnboarding -> { - val restartIntent = Intent(appContext, MainActivity::class.java) - restartIntent.addFlags(FLAG_ACTIVITY_CLEAR_TOP) + + AppState.NeedDeveloperOptions -> developerOptionsIntent() + + AppState.NeedWirelessDebuggingAndPair, AppState.NeedWirelessDebugging -> wirelessDebuggingIntent() + + AppState.AdbConnectedFinishOnboarding -> Intent(appContext, MainActivity::class.java).apply { + addFlags(FLAG_ACTIVITY_CLEAR_TOP) } + else -> null } } - internal fun wirelessDebuggingIntent(): Intent { - // Open wireless debugging settings + + private fun wirelessDebuggingIntent(): Intent { val EXTRA_FRAGMENT_ARG_KEY = ":settings:fragment_args_key" val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args" - val settingsIntent = Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS).apply { + + return Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS).apply { putExtra(EXTRA_FRAGMENT_ARG_KEY, "toggle_adb_wireless") - val bundle = Bundle().apply { + putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, Bundle().apply { putString(EXTRA_FRAGMENT_ARG_KEY, "toggle_adb_wireless") - } - putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, bundle) + }) + } + } + + private fun developerOptionsIntent(): Intent { + val EXTRA_FRAGMENT_ARG_KEY = ":settings:fragment_args_key" + val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args" + + return Intent(Settings.ACTION_DEVICE_INFO_SETTINGS).apply { + putExtra(EXTRA_FRAGMENT_ARG_KEY, "my_device_info_pref_screen") + putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, Bundle().apply { + putString(EXTRA_FRAGMENT_ARG_KEY, "build_number") + }) } - return settingsIntent } + fun refreshState() { + viewModelScope.launch { + configurationManager.checkAll() + wifiConnectivityMonitor.checkCurrentWifiConnected() + adbManager.checkState() + appManager.checkState() + } + } + + + override fun onCleared() { super.onCleared() - // Unregister listeners/close sockets wifiConnectivityMonitor.cleanup() adbManager.cleanup() + configurationManager.cleanup() + appManager.cleanup() } } + // Enforce singleton pattern with viewModel: // - We genuinely want to sync AppState across varying activities // - We don't want multiple instances of adbManager diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/SlideshowManager.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/SlideshowManager.kt index 3947ac9..88eeb48 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/SlideshowManager.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/SlideshowManager.kt @@ -1,41 +1,97 @@ package org.osservatorionessuno.bugbane.utils import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import androidx.core.content.edit +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + -// Manages app-specific onboarding preferences in the onboarding slideshow object SlideshowManager { - const val PREFS_NAME = "app_prefs" + private lateinit var appContext: Context + data class AppProgress(val hasCompletedOnboarding: Boolean, val hasSeenWelcomeScreen: Boolean) + private var _appProgress: MutableStateFlow = MutableStateFlow(AppProgress(false, false)) + var appProgress: StateFlow = _appProgress.asStateFlow() + private lateinit var sharedPrefs: SharedPreferences + private var sharedPrefsListener: SharedPreferences.OnSharedPreferenceChangeListener = + SharedPreferences.OnSharedPreferenceChangeListener { prefs, key -> + when (key) { + Keys.KEY_HAS_SEEN_HOMEPAGE, Keys.KEY_HAS_SEEN_WELCOME_SCREEN -> { + checkState() + } + } + } - // Skips the logo/splashscreen page after the first onboarding flow - const val KEY_HAS_SEEN_WELCOME_SCREEN = "has_seen_welcome_screen" + fun initialize(context: Context) { + if (!::appContext.isInitialized) { + appContext = context.applicationContext + sharedPrefs = + appContext.getSharedPreferences(Keys.PREFS_NAME, Context.MODE_PRIVATE) + // initial values + checkState() + registerListener() + } + } - // Skips the "Get Started" page after the first onboarding flow - const val KEY_HAS_SEEN_HOMEPAGE = "has_seen_homepage" - fun hasSeenHomepage(context: Context): Boolean { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getBoolean(KEY_HAS_SEEN_HOMEPAGE, false) + fun registerListener() { + sharedPrefs.registerOnSharedPreferenceChangeListener(sharedPrefsListener) + } + + fun checkState() { + val newState = AppProgress( + hasCompletedOnboarding = sharedPrefs.getBoolean( + Keys.KEY_HAS_SEEN_HOMEPAGE, + false + ), + hasSeenWelcomeScreen = sharedPrefs.getBoolean( + Keys.KEY_HAS_SEEN_WELCOME_SCREEN, + false + ) + ) + if (_appProgress.value != newState) { + Log.d("SlideshowManager", "update appprogress (onboardcomplete=${newState.hasCompletedOnboarding}, welcomecomplete=${newState.hasSeenWelcomeScreen})") + _appProgress.value = newState + } } - - fun markHomepageAsSeen(context: Context) { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit().putBoolean(KEY_HAS_SEEN_HOMEPAGE, true).apply() + + fun hasSeenHomepage(): Boolean { + return sharedPrefs.getBoolean(Keys.KEY_HAS_SEEN_HOMEPAGE, false) + } + + fun markHomepageAsSeen() { + sharedPrefs.edit { putBoolean(Keys.KEY_HAS_SEEN_HOMEPAGE, true) } } - - fun resetHomepageState(context: Context, force: Boolean = false) { - if (!hasSeenHomepage(context) || force) { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit().putBoolean(KEY_HAS_SEEN_HOMEPAGE, false).apply() + + fun resetHomepageState(force: Boolean = false) { + if (!hasSeenHomepage() || force) { + sharedPrefs.edit { putBoolean(Keys.KEY_HAS_SEEN_HOMEPAGE, false) } } } - - fun canSkipWelcomeScreen(context: Context): Boolean { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getBoolean(KEY_HAS_SEEN_WELCOME_SCREEN, false) + + fun canSkipWelcomeScreen(): Boolean { + return sharedPrefs.getBoolean(Keys.KEY_HAS_SEEN_WELCOME_SCREEN, false) } - fun setHasSeenWelcomeScreen(context: Context) { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit().putBoolean(KEY_HAS_SEEN_WELCOME_SCREEN, true).apply() + fun setHasSeenWelcomeScreen() { + sharedPrefs.edit { putBoolean(Keys.KEY_HAS_SEEN_WELCOME_SCREEN, true) } } + + + fun cleanup() { + sharedPrefs.unregisterOnSharedPreferenceChangeListener(sharedPrefsListener) + + } +} + +object Keys { + const val PREFS_NAME = "app_prefs" + + // Skips the logo/splashscreen page after the first onboarding flow + const val KEY_HAS_SEEN_WELCOME_SCREEN = "has_seen_welcome_screen" + + // Skips the "Get Started" page after the first onboarding flow + const val KEY_HAS_SEEN_HOMEPAGE = "has_seen_homepage" } \ No newline at end of file From 9735264b8a00d398d098c3f21c7328f3b8f3285a Mon Sep 17 00:00:00 2001 From: Rowen S Date: Wed, 24 Sep 2025 22:48:37 -0400 Subject: [PATCH 09/17] Add accompanist and experimental permissions API to gradle --- app/build.gradle.kts | 1 + gradle/libs.versions.toml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 117d746..a6636fb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -69,6 +69,7 @@ dependencies { implementation(libs.compose.material.icons.extended) implementation(libs.androidx.work.runtime.ktx) implementation(libs.snakeyaml) + implementation(libs.google.accompanist.permissions) // libadb-android and its dependency implementation(libs.libadb.android) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2715b4c..281e6cf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ work = "2.10.3" snakeyaml = "2.5" kage = "0.3.0" json = "20250517" +accompanist-permissions = "0.37.3" [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -44,6 +45,7 @@ androidx-material3 = { group = "androidx.compose.material3", nam androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-foundation = { group = "androidx.compose.foundation", name = "foundation" } compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +google-accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist-permissions" } libadb-android = { group = "com.github.MuntashirAkon", name = "libadb-android", version.ref = "libadb" } sun-security-android = { group = "com.github.MuntashirAkon", name = "sun-security-android", version.ref = "sunSecurityAndroid" } conscrypt-android = { group = "org.conscrypt", name = "conscrypt-android", version.ref = "conscrypt" } From f4aec42e4bfc9cad88882ada1b83def3d88c9a0d Mon Sep 17 00:00:00 2001 From: Rowen S Date: Fri, 12 Sep 2025 19:10:04 -0400 Subject: [PATCH 10/17] Use config manager to determine current slide. Use lazy viewmodel in slideshowactivity and check onResume() for updated Slideshow state. Keep ScanScreen in the backstack if permissions screen is re-launched from slideshow. Use renamed AppState AdbConnectedScanning. SlideshowActivity: add logging, use AppState.hiddenStates() to filter onboarding screens. --- .../bugbane/SlideshowActivity.kt | 147 +++++++++++------- .../bugbane/screens/ScanScreen.kt | 59 +++---- 2 files changed, 112 insertions(+), 94 deletions(-) diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt b/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt index a4871a4..9e954ac 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt @@ -1,15 +1,15 @@ package org.osservatorionessuno.bugbane -import android.app.Activity +import android.Manifest import android.content.Context import android.content.Intent +import android.os.Build import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.OnBackPressedCallback -import androidx.activity.viewModels import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -21,40 +21,52 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import kotlinx.coroutines.flow.collectLatest import org.osservatorionessuno.bugbane.components.SlideshowPage import org.osservatorionessuno.bugbane.ui.theme.Theme -import org.osservatorionessuno.bugbane.utils.AdbManager import org.osservatorionessuno.bugbane.utils.AppState -import org.osservatorionessuno.bugbane.utils.ConfigurationManager import org.osservatorionessuno.bugbane.utils.ConfigurationViewModel -import org.osservatorionessuno.bugbane.utils.SlideshowManager +import org.osservatorionessuno.bugbane.utils.ViewModelFactory + +const val INTENT_EXIT_BACKPRESS = "EXIT_ON_BACK" +private const val TAG = "SlideshowActivity" class SlideshowActivity : ComponentActivity() { - private val viewModel = AdbManager(application) - private val configViewModel: ConfigurationViewModel by viewModels() + + private val configViewModel by lazy { + ViewModelFactory.get(application) + } + + private var shouldExitOnBackPress: Boolean = true override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - + // During first-time onboarding and by default, a backpress quits the app, + // but re-launching the slideshow from another screen (ScanScreen) should not + shouldExitOnBackPress = intent.getBooleanExtra(INTENT_EXIT_BACKPRESS, shouldExitOnBackPress) + // Exit application when back is pressed during slideshow onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - // Exit the application - finishAffinity() + if (shouldExitOnBackPress) { + // Exit the application + finishAffinity() + } else { + // Default back button behaviour + isEnabled = false + onBackPressedDispatcher.onBackPressed() + } } }) - viewModel.watchConnectAdb().observe(this) { isConnected -> - // Successfully connected to ADB, skip ahead - if (isConnected) { - restartMainActivity() - } - } - // Try auto-connecting - viewModel.autoConnect() - enableEdgeToEdge() setContent { Theme { @@ -74,56 +86,75 @@ class SlideshowActivity : ComponentActivity() { startActivity(intent) finish() } - - companion object { - fun start(context: Context) { - val intent = Intent(context, SlideshowActivity::class.java) - context.startActivity(intent) - } - } } -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalPermissionsApi::class) @Composable fun SlideshowScreen( viewModel: ConfigurationViewModel, onSlideshowComplete: () -> Unit, ) { - val allStates = AppState.valuesInOrder().filter { it != AppState.AdbConnected } - val permissionState by viewModel.configurationState.collectAsState() + val totalSteps = AppState.distinctSteps() + val state = viewModel.configurationState.collectAsStateWithLifecycle() - val pagerState = rememberPagerState(initialPage = 0, pageCount = { allStates.size }) + // Initial page index (for the circle indicators at the top of the slideshow) + var initialPage = 0 + if (state.value.step < totalSteps) { + initialPage = state.value.step + } + val pagerState = rememberPagerState(initialPage = initialPage, pageCount = { totalSteps }) val currentPage by remember { derivedStateOf { pagerState.currentPage } } - val coroutineScope = rememberCoroutineScope() - suspend fun updatePager(permissionState: AppState) { - val currentIndex = allStates.indexOf(permissionState) - if (currentIndex in allStates.indices) { - pagerState.animateScrollToPage(currentIndex) - } else if (permissionState == AppState.AdbConnected) { + val lifecycleOwner = LocalLifecycleOwner.current + + suspend fun updatePager(state: AppState) { + if (state == AppState.AdbConnected) { + Log.d(TAG, "Adb connected - slideshow complete") onSlideshowComplete() + } else if (state.step < totalSteps) { + Log.d(TAG, "updatePager to $state (${state.step})") + pagerState.animateScrollToPage(state.step) + } else { + Log.w(TAG, "No pager update for $state (${state.step})") + } + } + + // Compose can listen for and update permission status (Android 13+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val notificationPermissionState = rememberPermissionState( + Manifest.permission.POST_NOTIFICATIONS + ) + when { + notificationPermissionState.status.isGranted || !notificationPermissionState.status.isGranted -> { + viewModel.configurationManager.setHasNotificationPermission( + notificationPermissionState.status.isGranted + ) + } } } // Skip screens already satisfied - LaunchedEffect(permissionState) { - updatePager(permissionState) + LaunchedEffect(state.value) { + Log.d(TAG, "SlideShowActivity got new state ${state.value}") + updatePager(state.value) } - // todo: test onResume then remove this block -// val lifecycleOwner = LocalLifecycleOwner.current -// DisposableEffect(lifecycleOwner, currentPage) { -// val observer = LifecycleEventObserver { _, event -> -// if (event == Lifecycle.Event.ON_RESUME) { -// updatePager(permissionState) -// } -// } -// lifecycleOwner.lifecycle.addObserver(observer) -// onDispose { -// lifecycleOwner.lifecycle.removeObserver(observer) -// } -// } + // Re-check state when the user resumes the app + DisposableEffect(Unit) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + Log.d(TAG, "onResume ($state)") + viewModel.refreshState() + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } Column( modifier = Modifier @@ -139,7 +170,7 @@ fun SlideshowScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { - allStates.forEachIndexed { index, _ -> + repeat(totalSteps) { index -> Box( modifier = Modifier .padding(4.dp) @@ -164,11 +195,13 @@ fun SlideshowScreen( modifier = Modifier.weight(1f), userScrollEnabled = false ) { pageIndex -> - val state = allStates[pageIndex] SlideshowPage( - state = state, - onClickContinue = { SlideshowManager::handleOnContinue } - ) // todo + state = state.value, + onClickContinue = { + Log.d(TAG, "onClickContinue with state $state") + viewModel.onChangeStateRequest(state.value) + } + ) } } } diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt b/app/src/main/java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt index 403b6e9..e84891f 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt @@ -17,7 +17,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalConfiguration import android.content.res.Configuration import android.util.Log -import android.widget.Toast import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -29,13 +28,13 @@ import org.osservatorionessuno.bugbane.R import org.osservatorionessuno.bugbane.utils.ConfigurationManager import org.osservatorionessuno.bugbane.SlideshowActivity import org.osservatorionessuno.bugbane.AcquisitionActivity -import org.osservatorionessuno.bugbane.utils.AdbManager +import org.osservatorionessuno.bugbane.INTENT_EXIT_BACKPRESS import org.osservatorionessuno.bugbane.utils.AdbState import org.osservatorionessuno.bugbane.utils.AppState -import org.osservatorionessuno.bugbane.utils.ConfigurationViewModel import org.osservatorionessuno.bugbane.utils.ViewModelFactory import java.io.File +private const val TAG = "ScanScreen" @Composable fun ScanScreen() { val coroutineScope = rememberCoroutineScope() @@ -44,10 +43,9 @@ fun ScanScreen() { val application = LocalContext.current.applicationContext as Application val viewModel = remember { ViewModelFactory.get(application) } - // todo: eventually just the adbState? val appState = viewModel.configurationState.collectAsStateWithLifecycle() val adbManager = viewModel.adbManager - val adbState: State = adbManager.adbState.collectAsStateWithLifecycle() + val adbState = adbManager.adbState.collectAsStateWithLifecycle() var showDisableDialog by remember { mutableStateOf(false) } var completedModules by remember { mutableStateOf(0) } @@ -58,12 +56,6 @@ fun ScanScreen() { val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE - LaunchedEffect(adbState.value) { - if (adbState.value == AdbState.ReadyToPair || adbState.value == AdbState.ReadyToConnect) { - adbManager.autoConnect() - } - } - Column( modifier = Modifier .fillMaxSize() @@ -281,12 +273,8 @@ fun ScanScreen() { // Scan Button fixed at the bottom Button( onClick = { - when (adbState.value) { - AdbState.RequisitesMissing, AdbState.ErrorConnect, AdbState.ErrorPair -> { - SlideshowActivity.start(context) - return@Button - } - AdbState.ConnectedIdle -> { + when (appState.value) { + AppState.AdbConnected -> { val baseDir = File(context.filesDir, "acquisitions") progressLogs.clear() moduleLogIndex.clear() @@ -342,32 +330,28 @@ fun ScanScreen() { } }) } - AdbState.ConnectedAcquiring -> { - // Scan already in progress; button is disabled below + AppState.AdbConnecting, AppState.TryAutoConnect -> { + // No-op and button is disabled below } else -> { - // ReadyToPair, ReadyToConnect (connection in progress?) - // TODO - Log.e("Bugbane", "Unhandled state $adbState.value") - Toast.makeText( - context, - R.string.notification_adb_pairing_working_title, - Toast.LENGTH_SHORT - ).show() + // Restart the slideshow, but leave the option to return to this activity + val intent = Intent(context, SlideshowActivity::class.java) + intent.putExtra(INTENT_EXIT_BACKPRESS, false) + context.startActivity(intent) + return@Button } } }, modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth(), - enabled = (adbState.value != AdbState.ConnectedAcquiring), + enabled = (appState.value !in arrayOf(AppState.AdbScanning, AppState.TryAutoConnect, AppState.AdbConnecting)), colors = ButtonDefaults.buttonColors( - containerColor = when (adbState.value) { - AdbState.ConnectedAcquiring -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) - in AdbState.errorStates() -> + containerColor = when (appState.value) { + AppState.AdbScanning, AppState.AdbConnecting, AppState.TryAutoConnect -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + AppState.AdbConnected -> MaterialTheme.colorScheme.secondary + else -> MaterialTheme.colorScheme.error.copy(alpha = 0.9f) - - else -> MaterialTheme.colorScheme.secondary } ) ) { @@ -378,10 +362,11 @@ fun ScanScreen() { ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = when (adbState.value) { - AdbState.ConnectedAcquiring -> stringResource(R.string.home_scanning_button) - AdbState.ConnectedIdle -> stringResource(R.string.home_scan_button) - else // (adbState is AdbState.RequisitesMissing or pairing is in progress) + text = when (appState.value) { + AppState.AdbScanning -> stringResource(R.string.home_scanning_button) + AppState.AdbConnected -> stringResource(R.string.home_scan_button) + AppState.TryAutoConnect, AppState.AdbConnecting -> stringResource(R.string.notification_adb_pairing_working_title) + else -> stringResource(R.string.home_permissions_button) }, style = MaterialTheme.typography.bodyLarge.copy( From 820ddb6251509f99e05f737abaf48e885563e27c Mon Sep 17 00:00:00 2001 From: Rowen S Date: Thu, 11 Sep 2025 18:23:11 -0400 Subject: [PATCH 11/17] Support autoconnect through slideshow page. Use renamed NeedWirelessDebugging AppState. Add button strings. --- .../bugbane/components/SlideshowPage.kt | 121 +++++++++--------- app/src/main/res/values-it/strings.xml | 10 +- app/src/main/res/values/strings.xml | 11 +- 3 files changed, 70 insertions(+), 72 deletions(-) diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/components/SlideshowPage.kt b/app/src/main/java/org/osservatorionessuno/bugbane/components/SlideshowPage.kt index d9958da..5dc9d04 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/components/SlideshowPage.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/components/SlideshowPage.kt @@ -1,6 +1,5 @@ package org.osservatorionessuno.bugbane.components -import android.app.Activity import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward @@ -13,7 +12,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.font.FontWeight @@ -22,26 +20,29 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.osservatorionessuno.bugbane.R import org.osservatorionessuno.bugbane.utils.AppState -import org.osservatorionessuno.bugbane.utils.ConfigurationManager +import androidx.compose.material3.MaterialTheme data class SlideshowPageData( val title: String, val description: String, val icon: ImageVector? = null, - val buttonText: String? = null, - val onClick: (() -> Unit)? = null, // todo launching activity -// val shouldSkip: (() -> Boolean)? = null, - val shouldContinue: Boolean = true + val buttonText: String? = null ) @Composable fun getSlideshowScreenContent(state: AppState): SlideshowPageData { when (state) { + AppState.DeviceUnsupported -> return SlideshowPageData( + title = stringResource(R.string.slideshow_welcome_title), + description = stringResource(R.string.slideshow_unsupported_version), + icon = ImageVector.Companion.vectorResource(R.drawable.ic_bugbane_zoom), + buttonText = stringResource(R.string.slideshow_button_exit), + ) AppState.NeedWelcomeScreen -> return SlideshowPageData( title = stringResource(R.string.slideshow_welcome_title), description = stringResource(R.string.slideshow_welcome_description), icon = ImageVector.Companion.vectorResource(R.drawable.ic_bugbane_zoom), - buttonText = "", + buttonText = stringResource(R.string.slideshow_welcome_button), ) AppState.NeedWifi -> return SlideshowPageData( title = stringResource(R.string.slideshow_wifi_title), @@ -49,7 +50,7 @@ fun getSlideshowScreenContent(state: AppState): SlideshowPageData { icon = Icons.Filled.Wifi, buttonText = stringResource(R.string.slideshow_wifi_button), ) - AppState.NeedNotificationConfiguration -> return SlideshowPageData( + AppState.NeedNotificationPermission -> return SlideshowPageData( title = stringResource(R.string.slideshow_notification_title), description = stringResource(R.string.slideshow_notification_description), icon = Icons.Default.Notifications, @@ -59,35 +60,41 @@ fun getSlideshowScreenContent(state: AppState): SlideshowPageData { title = stringResource(R.string.slideshow_developer_title), description = stringResource(R.string.slideshow_developer_description), icon = Icons.Default.Settings, - buttonText = stringResource(R.string.slideshow_developer_button), + buttonText = stringResource(R.string.slideshow_button_text_enable), ) - AppState.NeedWirelessDebugging -> return SlideshowPageData(title = stringResource(R.string.slideshow_wireless_title), - description = stringResource(R.string.slideshow_wireless_description), - icon = Icons.Filled.Build, - buttonText = stringResource(R.string.slideshow_wireless_button), - ) - // TODO -// ConfigurationState.NeedAdbPairingService -> SlideshowScreenContent(title = stringResource(R.string.slideshow_wireless_title), -// description = stringResource(R.string.slideshow_wireless_description), -// icon = Icons.Filled.Build, -// buttonText = "Pairing in progress..." -// ) - AppState.AdbConnected -> return SlideshowPageData( + AppState.AdbConnectedFinishOnboarding -> return SlideshowPageData( title = stringResource(R.string.slideshow_ready_title), description = stringResource(R.string.slideshow_ready_description), icon = Icons.AutoMirrored.Filled.ArrowForward, buttonText = stringResource(R.string.slideshow_ready_button) ) - // TODO: probably pairing, but checks can be better - else -> return getSlideshowScreenContent(AppState.NeedAdbPairingService) //TODO + AppState.NeedWirelessDebuggingAndPair -> return SlideshowPageData(title = stringResource(R.string.slideshow_wireless_and_pair_title), + description = stringResource(R.string.slideshow_wireless_and_pair_description), + icon = Icons.Filled.Build, + buttonText = stringResource(R.string.slideshow_wireless_and_pair_button), + ) + AppState.TryAutoConnect, AppState.AdbConnecting, -> return SlideshowPageData(title = stringResource(R.string.notification_channel_adb_pairing), //todo + description = stringResource(R.string.notification_adb_pairing_working_title), + icon = Icons.Filled.Build, + buttonText = "please wait", //todo + ) + AppState.NeedWirelessDebugging -> return SlideshowPageData(title = stringResource(R.string.slideshow_wireless_title), + description = stringResource(R.string.slideshow_wireless_description), + icon = Icons.Filled.Build, + buttonText = stringResource(R.string.slideshow_button_text_enable), + ) + // Should be unreachable + else -> return SlideshowPageData(title = stringResource(R.string.slideshow_wireless_and_pair_title), + description = stringResource(R.string.slideshow_wireless_and_pair_description), + icon = Icons.Filled.Build, + buttonText = stringResource(R.string.slideshow_wireless_and_pair_button), + ) } - // Unreachable? } @Composable -fun SlideshowPage(state: AppState, onClickContinue: (() -> Unit)?) { - val context = LocalContext.current +fun SlideshowPage(state: AppState, onClickContinue: (() -> Unit)) { val page = getSlideshowScreenContent(state) Column( @@ -121,48 +128,34 @@ fun SlideshowPage(state: AppState, onClickContinue: (() -> Unit)?) { text = page.description, style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, - color = if (page.shouldContinue) { + color = if (!AppState.isErrorState(state)) { MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) } else { MaterialTheme.colorScheme.error } ) - - page.onClick?.let { onClick -> - Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = { - if (page.shouldContinue) { - onClick() - } else { - (context as? Activity)?.let { activity -> - //activity.moveTaskToBack(true) - activity.finishAffinity() - } - } + Spacer(modifier = Modifier.height(16.dp)) + Button( + enabled = (state != AppState.AdbConnecting && state != AppState.TryAutoConnect), + onClick = onClickContinue, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (!AppState.isErrorState(state)) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp, vertical = 8.dp), - colors = ButtonDefaults.buttonColors( - containerColor = if (page.shouldContinue) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) - }, - contentColor = if (page.shouldContinue) { - androidx.compose.ui.graphics.Color.White - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - } - ) - ) { - Text( - text = if (page.shouldContinue) { - page.buttonText ?: stringResource(R.string.slideshow_welcome_button) - } else { - "Exit" - }, + contentColor = if (!AppState.isErrorState(state)) { + androidx.compose.ui.graphics.Color.White + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + } + ) + ) { + page.buttonText?.let { buttonText -> + Text(text = buttonText, style = MaterialTheme.typography.bodyLarge.copy( fontWeight = FontWeight.Medium ) @@ -170,4 +163,4 @@ fun SlideshowPage(state: AppState, onClickContinue: (() -> Unit)?) { } } } -} \ No newline at end of file +} \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 28f6b9b..5b76b02 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -20,10 +20,11 @@ Abilita Abilita opzioni sviluppatore Per utilizzare quesa applicazione, è necessario abilitare le opzioni sviluppatore.\n\nL\'applicazione acquisisce dati usando l\'ADB Wireless Debugging, che è disponibile solo quando le opzioni sviluppatore sono abilitate. - Abilita - Abilita Wireless Debugging e accoppia - Connettiti ad una rete Wi-Fi, abilita l\'opzione ADB Wireless Debugging, premi \"Accoppia dispositivo con codice di accoppiamento\", poi inserirsci il codice visualizzato nella notifica di Bugbane. - Accoppia + Abilita + Abilita Wireless Debugging e accoppia + Connettiti ad una rete Wi-Fi, abilita l\'opzione ADB Wireless Debugging, premi \"Accoppia dispositivo con codice di accoppiamento\", poi inserirsci il codice visualizzato nella notifica di Bugbane. + Accoppia + Connettiti ad una rete Wi-Fi e abilita l\'opzione ADB Wireless Debugging. Pronto per iniziare Tutto pronto. Ora puoi acquisire dati ed eseguire analisi, successivamente condividere i risultati con terze parti fidate. Inizia @@ -90,4 +91,5 @@ UUID acquisizione: %1$s Acquisizione completata: %1$s Analisi completata: %1$s + Abilita Wireless Debugging diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 67517c8..cc63c43 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,11 +31,14 @@ To use this app, enable Developer Options in your device settings. \n\n The application acquires data using ADB Wireless Debugging, which is available only when Developer Options are enabled. - Enable + Enable - Enable Wireless Debugging and Pair - Connect to Wi-Fi, enable ADB Wireless Debugging, tap “Pair device with pairing code,” then enter the code in the Bugbane notification. - Pair + Enable Wireless Debugging and Pair + Connect to Wi-Fi, enable ADB Wireless Debugging, tap “Pair device with pairing code,” then enter the code in the Bugbane notification. + Pair + + Enable Wireless Debugging + Connect to Wi-Fi and enable ADB Wireless Debugging. Ready to Start You\'re all set. You can now acquire data and run analyses, then share results with trusted third parties. From 5f8e2005f29e2b7c8c875d6eaae9d94cc8cf94b1 Mon Sep 17 00:00:00 2001 From: Rowen S Date: Fri, 12 Sep 2025 19:16:38 -0400 Subject: [PATCH 12/17] Support autoconnect from MainActivity and from SlideshowActivity. Simplify MainActivity slideshow vs acquisition screen selection logic. --- .../bugbane/MainActivity.kt | 88 ++++++------------- 1 file changed, 29 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/MainActivity.kt b/app/src/main/java/org/osservatorionessuno/bugbane/MainActivity.kt index 94bf968..43d6414 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/MainActivity.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/MainActivity.kt @@ -3,10 +3,10 @@ package org.osservatorionessuno.bugbane import android.content.Intent import android.os.Bundle import android.util.Log +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.* import androidx.compose.foundation.pager.HorizontalPager @@ -15,6 +15,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration +import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import io.github.muntashirakon.adb.PRNGFixes import androidx.work.* @@ -27,13 +28,17 @@ import org.osservatorionessuno.bugbane.components.NavigationTabs import org.osservatorionessuno.bugbane.screens.ScanScreen import org.osservatorionessuno.bugbane.screens.AcquisitionsScreen import org.osservatorionessuno.bugbane.ui.theme.Theme -import org.osservatorionessuno.bugbane.utils.AdbManager import org.osservatorionessuno.bugbane.utils.AppState import org.osservatorionessuno.bugbane.utils.ConfigurationViewModel +import org.osservatorionessuno.bugbane.utils.SlideshowManager +import org.osservatorionessuno.bugbane.utils.ViewModelFactory +private const val TAG = "MainActivity" class MainActivity : ComponentActivity() { - private val viewModel: AdbManager by viewModels() - private val configViewModel: ConfigurationViewModel by viewModels() + + private val configViewModel : ConfigurationViewModel by lazy { + ViewModelFactory.get(application) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -45,58 +50,31 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { Theme { - val permissionState by configViewModel.configurationState.collectAsState() + val appState = configViewModel.configurationState.collectAsStateWithLifecycle() + val appProgress: State = configViewModel.appManager.appProgress.collectAsStateWithLifecycle() - LaunchedEffect(permissionState) { - if (permissionState != AppState.AdbConnected) { + if (appProgress.value.hasCompletedOnboarding) { + MainContent() + } else { + // Avoid flicker before the slideshow while compose is calculating the appstate + Box(modifier = Modifier.fillMaxSize()) + + LaunchedEffect(appState.value) { // Permissions slideshow - val startPage = AppState.valuesInOrder().indexOf(permissionState) + val startPage = (appState.value.step) val intent = Intent(this@MainActivity, SlideshowActivity::class.java) .putExtra("startPage", startPage) startActivity(intent) } } - - if (permissionState == AppState.AdbConnected) { - MainContent() // Real app content - } - // Otherwise: maybe waiting for the state to be returned. todo } } - // Observers -// viewModel.watchConnectAdb().observe(this) { isConnected -> -// if (!isConnected) { -// setLacksPermissionsCallback?.invoke(true) -// } -// } -// -// viewModel.watchAskPairAdb().observe(this) { resetPairing -> -// if (resetPairing) { -// setLacksPermissionsCallback?.invoke(true) -// } -// } -// -// viewModel.watchCommandOutput().observe(this) { output -> -// // TODO: blibla -// Toast.makeText(this@MainActivity, output.toString(), Toast.LENGTH_SHORT).show() -// Log.d("COMMAND OUTPUT", output.toString()) -// } -// -// // Try auto-connecting -// viewModel.autoConnect() -// -// if (!ConfigurationManager.isNotificationPermissionGranted(this) || !ConfigurationManager.isWirelessDebuggingEnabled( -// this -// ) -// ) { -// setLacksPermissionsCallback?.invoke(true) -// } - -// if (!SlideshowManager.hasSeenHomepage(this)) { -// // On first start, run the SlideshowActivity manually -// SlideshowActivity.start(this) -// } + configViewModel.adbManager.watchCommandOutput().observe(this) { output -> + // TODO + Toast.makeText(applicationContext, output, Toast.LENGTH_SHORT).show() + Log.d(TAG, "Command output: $output") + } } private fun setupIndicatorsUpdates() { @@ -133,26 +111,20 @@ class MainActivity : ComponentActivity() { Log.i("MainActivity", "Scheduled daily indicator update worker") } } - @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun MainContent() { val context = androidx.compose.ui.platform.LocalContext.current val configuration = LocalConfiguration.current val pagerState = rememberPagerState(pageCount = { 2 }) val coroutineScope = rememberCoroutineScope() - var lacksPermissions by remember { mutableStateOf(false) } - + // Detect if we're in landscape mode val isLandscape = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE // Sync tab selection with pager val selectedTabIndex by remember { derivedStateOf { pagerState.currentPage } } - // Function to set lacks permissions state - fun setLacksPermissions(lacks: Boolean) { - lacksPermissions = lacks - } - Scaffold( topBar = { if (isLandscape) { @@ -201,13 +173,11 @@ fun MainContent() { modifier = Modifier.fillMaxSize() ) { pageIndex -> when (pageIndex) { - 0 -> ScanScreen( - lacksPermissions = lacksPermissions, - onLacksPermissionsChange = { setLacksPermissions(it) } - ) + 0 -> ScanScreen() 1 -> AcquisitionsScreen() } } } } -} \ No newline at end of file +} + From 1cfc369c3484674edf3cdcb9f0d5e4964ef31e6a Mon Sep 17 00:00:00 2001 From: Rowen S Date: Fri, 26 Sep 2025 00:39:51 -0400 Subject: [PATCH 13/17] Adjust checkState logic so that only Android 14+ requires adb if wireless is enabled. Catch edge case where user has manually removed prior wireless pairing authorizations. Skip autoconnect if we received a pairing exception. --- .../bugbane/utils/ConfigurationViewModel.kt | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt index c8b4af6..1ba031c 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt @@ -61,11 +61,7 @@ class ConfigurationViewModel private constructor( // UI listeners collect AppState private val _configurationState = MutableStateFlow(AppState.NeedWelcomeScreen) val configurationState: StateFlow = _configurationState.asStateFlow() - - // Some states take a while to broadcast a result; don't confuse the user while waiting - val _needsAsyncStateResult: MutableStateFlow = MutableStateFlow(false) - val needsAsyncStateResult: StateFlow = _needsAsyncStateResult.asStateFlow() - + private val autoConnectAttempts = AtomicInt(0) private val _MAX_AUTOCONNECT_ATTEMPTS = 2 @@ -101,6 +97,11 @@ class ConfigurationViewModel private constructor( SettingsState(notifications, devOpts, adbEnabled, wirelessDebug, wifiConnected) } combine(settingsFlow, adbManager.adbState, appManager.appProgress) { settings, adbState, appProgress -> + if (adbState == AdbState.RequisitesMissing) { + // AdbPairingRequiredException, there's no point in autoconnecting til we fix it + autoConnectAttempts.store(_MAX_AUTOCONNECT_ATTEMPTS) + } + checkState( settings.notificationsEnabled, settings.developerOptionsEnabled, @@ -140,7 +141,10 @@ class ConfigurationViewModel private constructor( if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return AppState.DeviceUnsupported if (!appProgress.hasSeenWelcomeScreen) return AppState.NeedWelcomeScreen - if (wirelessDebuggingEnabled && adbEnabled) { + // Wireless debug + usb debug were separate settings from Android 11-14 + val needAdb = (!adbEnabled && Build.VERSION.SDK_INT > Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + + if (wirelessDebuggingEnabled && !needAdb) { if (adbState == AdbState.ConnectedIdle && !appProgress.hasCompletedOnboarding) return AppState.AdbConnectedFinishOnboarding if (adbState == AdbState.ConnectedIdle) return AppState.AdbConnected if (adbState == AdbState.ConnectedAcquiring) return AppState.AdbScanning @@ -157,16 +161,16 @@ class ConfigurationViewModel private constructor( if (!notificationsEnabled) return AppState.NeedNotificationPermission if (!isConnectedToWifi) return AppState.NeedWifi if (!developerOptionsEnabled) return AppState.NeedDeveloperOptions - if ((!wirelessDebuggingEnabled || !adbEnabled) && !appProgress.hasCompletedOnboarding) return AppState.NeedWirelessDebuggingAndPair + if ((!wirelessDebuggingEnabled || needAdb) && !appProgress.hasCompletedOnboarding) return AppState.NeedWirelessDebuggingAndPair - if ((!wirelessDebuggingEnabled || !adbEnabled)) { + if ((!wirelessDebuggingEnabled || needAdb)) { // Wireless adb or adb are off, but we've connected before. Enable wireless adb. Then autoconnect will be attempted. Log.d(TAG, "Wireless adb is disabled, but we have previously connected successfully.") return AppState.NeedWirelessDebugging } // We don't have a past connection, but nothing else is wrong. We can try autoconnect. - if (autoConnectAttempts.load() < _MAX_AUTOCONNECT_ATTEMPTS) return AppState.TryAutoConnect + if (autoConnectAttempts.load() < _MAX_AUTOCONNECT_ATTEMPTS && adbState != AdbState.RequisitesMissing) return AppState.TryAutoConnect // We did not want to get here. That means no other conditions were met and autoconnect failed once. Log.w(TAG, "checkState: default to re-pairing and debug connection logic.") @@ -180,8 +184,6 @@ class ConfigurationViewModel private constructor( private fun observeAppState() { viewModelScope.launch { configurationState.collect { appState -> - // Reset async waiting flag, since we received a new state - _needsAsyncStateResult.value = false if (appState == AppState.TryAutoConnect && autoConnectAttempts.fetchAndAdd(1) < _MAX_AUTOCONNECT_ATTEMPTS) { Log.d(TAG, "Auto-connect to ADB (attempt ${autoConnectAttempts.load()} / $_MAX_AUTOCONNECT_ATTEMPTS)") @@ -213,7 +215,6 @@ class ConfigurationViewModel private constructor( AppState.NeedWifi, AppState.NeedNotificationPermission, AppState.NeedDeveloperOptions -> { - _needsAsyncStateResult.value = true getIntentForAppState(currentState)?.let { it.addFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP) @@ -222,8 +223,6 @@ class ConfigurationViewModel private constructor( } AppState.NeedWirelessDebuggingAndPair -> { - // transitory state - _needsAsyncStateResult.value = true getIntentForAppState(currentState)?.let { it.addFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP) @@ -232,8 +231,6 @@ class ConfigurationViewModel private constructor( adbManager.startAdbPairingService() } AppState.NeedWirelessDebugging -> { - // transitory state - _needsAsyncStateResult.value = true // Just open settings, don't launch a new pairing service. // Once wireless debugging is re-enabled the state will be updated and autoconnect will be attempted @@ -244,7 +241,6 @@ class ConfigurationViewModel private constructor( } AppState.AdbConnectedFinishOnboarding -> { appManager.markHomepageAsSeen() - _needsAsyncStateResult.value = true } else -> { @@ -299,10 +295,10 @@ class ConfigurationViewModel private constructor( fun refreshState() { viewModelScope.launch { - configurationManager.checkAll() - wifiConnectivityMonitor.checkCurrentWifiConnected() adbManager.checkState() appManager.checkState() + wifiConnectivityMonitor.checkCurrentWifiConnected() + configurationManager.checkAll() } } From 59769fd6cf1d0b6011230a6a4095bcec9bafde98 Mon Sep 17 00:00:00 2001 From: Rowen S Date: Fri, 26 Sep 2025 09:01:30 -0400 Subject: [PATCH 14/17] Skip toast message when ADB is in AdbState.READY, since it will proceed to autoconnect --- .../java/org/osservatorionessuno/bugbane/utils/AdbManager.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.kt index b37303f..e0cb269 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.kt @@ -275,7 +275,6 @@ class AdbManager(applicationContext: Context) { return } else if (!adbConnectionManager.isConnected) { Log.i(TAG, "Need to reconnect first") - commandOutput.postValue("Reconnect adb first") _adbState.value = AdbState.Ready return } From e63efa754ef7685aee480d0a69a92335abfe7371 Mon Sep 17 00:00:00 2001 From: Rowen S Date: Fri, 26 Sep 2025 15:58:02 -0400 Subject: [PATCH 15/17] Stop adb pairing service if the slideshow activity is exiting --- .../org/osservatorionessuno/bugbane/SlideshowActivity.kt | 8 ++++++++ .../org/osservatorionessuno/bugbane/utils/AdbManager.kt | 7 +++++-- .../bugbane/utils/AdbPairingService.java | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt b/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt index 9e954ac..1e0a552 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt @@ -86,6 +86,14 @@ class SlideshowActivity : ComponentActivity() { startActivity(intent) finish() } + + // If we leave the SlideShowActivity, stop the pairing service + override fun onDestroy() { + if (isFinishing) { + configViewModel.adbManager.stopAdbPairingService() + } + super.onDestroy() + } } @OptIn(ExperimentalFoundationApi::class, ExperimentalPermissionsApi::class) diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.kt index e0cb269..354439a 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.kt @@ -70,6 +70,10 @@ class AdbManager(applicationContext: Context) { Log.e(TAG, "Error unregistering adbBroadcastreceiver: $e") } } + + // Cancel the notification, if it's still showing + val stopIntent = AdbPairingService.stopIntent(appContext) + appContext?.stopService(stopIntent) } internal fun startAdbPairingService() { @@ -136,14 +140,13 @@ class AdbManager(applicationContext: Context) { } else { // connection isn't null, isConnected (not yet established) if (adbConnectionManager.adbConnection != null && adbConnectionManager.adbConnection!!.isConnected) { - Log.d(TAG, "manager reports ready") _adbState.value = AdbState.Ready } } } catch (e: Exception) { Log.d(TAG, "Couldn't get adbState: ${e.message}") } - Log.d(TAG, "State is unknown") + Log.d(TAG, "AdbState is ${adbState.value}") } @WorkerThread diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbPairingService.java b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbPairingService.java index f8f953d..f0f9f5d 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbPairingService.java +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbPairingService.java @@ -40,7 +40,7 @@ public static Intent startIntent(Context context) { return new Intent(context, AdbPairingService.class).setAction(START_ACTION); } - private static Intent stopIntent(Context context) { + public static Intent stopIntent(Context context) { return new Intent(context, AdbPairingService.class).setAction(STOP_ACTION); } From 5d8cfafc7018d2ff9428d2456aa4af4fe52fcee0 Mon Sep 17 00:00:00 2001 From: Rowen S Date: Sun, 14 Sep 2025 12:39:11 -0400 Subject: [PATCH 16/17] (chore) suppress indicators permissions warning. --- .../bugbane/workers/IndicatorsUpdateWorker.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/workers/IndicatorsUpdateWorker.kt b/app/src/main/java/org/osservatorionessuno/bugbane/workers/IndicatorsUpdateWorker.kt index 6c83d41..f577d19 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/workers/IndicatorsUpdateWorker.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/workers/IndicatorsUpdateWorker.kt @@ -1,10 +1,13 @@ package org.osservatorionessuno.bugbane.workers +import android.Manifest +import android.annotation.SuppressLint import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context import android.os.Build import android.util.Log +import androidx.annotation.RequiresPermission import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.work.CoroutineWorker @@ -24,6 +27,7 @@ class IndicatorsUpdateWorker( workerParams: WorkerParameters ) : CoroutineWorker(appContext, workerParams) { + @SuppressLint("MissingPermission") // TODO override suspend fun doWork(): Result = withContext(Dispatchers.IO) { // Cross-thread/process lock val lockFile = File(applicationContext.filesDir, "indicators_update.lock") @@ -58,6 +62,7 @@ class IndicatorsUpdateWorker( private const val TAG = "IndicatorsUpdateWorker" private const val CHANNEL_ID = "indicator_updates" + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) fun notify(context: Context, newCount: Long) { val manager = NotificationManagerCompat.from(context) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { From 5f3f3566f16bf3f70b22a2be3c0872d816161474 Mon Sep 17 00:00:00 2001 From: Rowen S Date: Sat, 27 Sep 2025 22:51:53 -0400 Subject: [PATCH 17/17] Add "waiting for adb pairing" button text --- .../org/osservatorionessuno/bugbane/components/SlideshowPage.kt | 2 +- .../java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt | 2 +- app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values/strings.xml | 2 ++ 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/components/SlideshowPage.kt b/app/src/main/java/org/osservatorionessuno/bugbane/components/SlideshowPage.kt index 5dc9d04..d84c12f 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/components/SlideshowPage.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/components/SlideshowPage.kt @@ -76,7 +76,7 @@ fun getSlideshowScreenContent(state: AppState): SlideshowPageData { AppState.TryAutoConnect, AppState.AdbConnecting, -> return SlideshowPageData(title = stringResource(R.string.notification_channel_adb_pairing), //todo description = stringResource(R.string.notification_adb_pairing_working_title), icon = Icons.Filled.Build, - buttonText = "please wait", //todo + buttonText = stringResource(R.string.button_working_adb_pairing) ) AppState.NeedWirelessDebugging -> return SlideshowPageData(title = stringResource(R.string.slideshow_wireless_title), description = stringResource(R.string.slideshow_wireless_description), diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt b/app/src/main/java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt index e84891f..6fe705c 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt @@ -365,7 +365,7 @@ fun ScanScreen() { text = when (appState.value) { AppState.AdbScanning -> stringResource(R.string.home_scanning_button) AppState.AdbConnected -> stringResource(R.string.home_scan_button) - AppState.TryAutoConnect, AppState.AdbConnecting -> stringResource(R.string.notification_adb_pairing_working_title) + AppState.TryAutoConnect, AppState.AdbConnecting -> stringResource(R.string.button_working_adb_pairing) else -> stringResource(R.string.home_permissions_button) }, diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 5b76b02..a751dae 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -28,6 +28,7 @@ Pronto per iniziare Tutto pronto. Ora puoi acquisire dati ed eseguire analisi, successivamente condividere i risultati con terze parti fidate. Inizia + In attesa dell\'accoppiamento ADB… Elenco di verifica forense Avvia acquisizione Sto acquisendo… diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cc63c43..e296d32 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -44,6 +44,8 @@ You\'re all set. You can now acquire data and run analyses, then share results with trusted third parties. Get Started + Waiting for ADB Pairing… + Forensic Checklist Start Acquisition