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/app/src/main/java/org/osservatorionessuno/bugbane/MainActivity.kt b/app/src/main/java/org/osservatorionessuno/bugbane/MainActivity.kt index f6670e1..43d6414 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/MainActivity.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/MainActivity.kt @@ -7,7 +7,6 @@ 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 @@ -16,11 +15,10 @@ 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.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 +28,53 @@ 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.AppState +import org.osservatorionessuno.bugbane.utils.ConfigurationViewModel 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.ViewModelFactory +private const val TAG = "MainActivity" class MainActivity : ComponentActivity() { - private val viewModel: AdbViewModel by viewModels() - private var setLacksPermissionsCallback: ((Boolean) -> Unit)? = null + + private val configViewModel : ConfigurationViewModel by lazy { + ViewModelFactory.get(application) + } 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 appState = configViewModel.configurationState.collectAsStateWithLifecycle() + val appProgress: State = configViewModel.appManager.appProgress.collectAsStateWithLifecycle() + + 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.value.step) + val intent = Intent(this@MainActivity, SlideshowActivity::class.java) + .putExtra("startPage", startPage) + startActivity(intent) + } } } } + + configViewModel.adbManager.watchCommandOutput().observe(this) { output -> + // TODO + Toast.makeText(applicationContext, output, Toast.LENGTH_SHORT).show() + Log.d(TAG, "Command output: $output") + } } private fun setupIndicatorsUpdates() { @@ -124,34 +111,20 @@ class MainActivity : ComponentActivity() { Log.i("MainActivity", "Scheduled daily indicator update worker") } } - @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 }) 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 - } - - // Provide the callback to the parent - LaunchedEffect(Unit) { - onSetLacksPermissionsCallback { lacks -> - setLacksPermissions(lacks) - } - } - Scaffold( topBar = { if (isLandscape) { @@ -200,13 +173,11 @@ fun MainContent(onSetLacksPermissionsCallback: ((Boolean) -> Unit) -> Unit) { modifier = Modifier.fillMaxSize() ) { pageIndex -> when (pageIndex) { - 0 -> ScanScreen( - lacksPermissions = lacksPermissions, - onLacksPermissionsChange = { setLacksPermissions(it) } - ) + 0 -> ScanScreen() 1 -> AcquisitionsScreen() } } } } -} \ No newline at end of file +} + diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt b/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt index f5bcc03..1e0a552 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/SlideshowActivity.kt @@ -1,13 +1,15 @@ package org.osservatorionessuno.bugbane +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.* @@ -17,50 +19,62 @@ 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.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.pages.* import org.osservatorionessuno.bugbane.ui.theme.Theme -import org.osservatorionessuno.bugbane.utils.AdbViewModel +import org.osservatorionessuno.bugbane.utils.AppState +import org.osservatorionessuno.bugbane.utils.ConfigurationViewModel +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: AdbViewModel by viewModels() - private val totalPages = 6 + + 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 { SlideshowScreen( + configViewModel, onSlideshowComplete = { restartMainActivity() - }, - totalPages = totalPages + } ) } } @@ -73,58 +87,78 @@ class SlideshowActivity : ComponentActivity() { finish() } - companion object { - fun start(context: Context) { - val intent = Intent(context, SlideshowActivity::class.java) - context.startActivity(intent) + // If we leave the SlideShowActivity, stop the pairing service + override fun onDestroy() { + if (isFinishing) { + configViewModel.adbManager.stopAdbPairingService() } + super.onDestroy() } } -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalPermissionsApi::class) @Composable fun SlideshowScreen( + viewModel: ConfigurationViewModel, onSlideshowComplete: () -> Unit, - totalPages: Int ) { - val pagerState = rememberPagerState(pageCount = { totalPages }) + val totalSteps = AppState.distinctSteps() + val state = viewModel.configurationState.collectAsStateWithLifecycle() + + // 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() - - val goToNextPage = { - coroutineScope.launch { - pagerState.animateScrollToPage(currentPage + 1) + + 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})") } } - 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() + // 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 + ) + } } } - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner, currentPage) { + // Skip screens already satisfied + LaunchedEffect(state.value) { + Log.d(TAG, "SlideShowActivity got new state ${state.value}") + updatePager(state.value) + } + + // Re-check state when the user resumes the app + DisposableEffect(Unit) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { - val currentPageData = slideshowPages.getOrNull(currentPage) - if (currentPageData?.shouldSkip?.invoke() == true) { - goToNextPage() - } + Log.d(TAG, "onResume ($state)") + viewModel.refreshState() } } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } @@ -144,7 +178,7 @@ fun SlideshowScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { - slideshowPages.forEachIndexed { index, _ -> + repeat(totalSteps) { index -> Box( modifier = Modifier .padding(4.dp) @@ -169,9 +203,16 @@ fun SlideshowScreen( modifier = Modifier.weight(1f), userScrollEnabled = false ) { pageIndex -> - SlideshowPage(page = slideshowPages[pageIndex]) + SlideshowPage( + state = state.value, + onClickContinue = { + Log.d(TAG, "onClickContinue with state $state") + viewModel.onChangeStateRequest(state.value) + } + ) } } } + \ No newline at end of file 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..d84c12f 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/components/SlideshowPage.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/components/SlideshowPage.kt @@ -1,34 +1,102 @@ 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 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 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, - val shouldSkip: (() -> Boolean)? = null, - val shouldContinue: Boolean = true + val buttonText: String? = null ) @Composable -fun SlideshowPage(page: SlideshowPageData) { - val context = LocalContext.current - +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 = stringResource(R.string.slideshow_welcome_button), + ) + 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.NeedNotificationPermission -> 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_button_text_enable), + ) + 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) + ) + 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 = 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), + 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), + ) + } +} + + +@Composable +fun SlideshowPage(state: AppState, onClickContinue: (() -> Unit)) { + val page = getSlideshowScreenContent(state) + Column( modifier = Modifier .fillMaxWidth() @@ -60,48 +128,34 @@ fun SlideshowPage(page: SlideshowPageData) { 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 ) @@ -109,4 +163,4 @@ fun SlideshowPage(page: SlideshowPageData) { } } } -} \ No newline at end of file +} \ No newline at end of file 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 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..6fe705c 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,6 @@ 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.app.Application import android.content.Intent import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* @@ -23,27 +16,37 @@ 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 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.INTENT_EXIT_BACKPRESS +import org.osservatorionessuno.bugbane.utils.AdbState +import org.osservatorionessuno.bugbane.utils.AppState +import org.osservatorionessuno.bugbane.utils.ViewModelFactory import java.io.File +private const val TAG = "ScanScreen" @Composable -fun ScanScreen( - lacksPermissions: Boolean = false, - onLacksPermissionsChange: (Boolean) -> Unit = {} -) { +fun ScanScreen() { val coroutineScope = rememberCoroutineScope() val context = LocalContext.current - val viewModel = androidx.lifecycle.viewmodel.compose.viewModel() - var isScanning by remember { mutableStateOf(false) } + + val application = LocalContext.current.applicationContext as Application + val viewModel = remember { ViewModelFactory.get(application) } + + val appState = viewModel.configurationState.collectAsStateWithLifecycle() + val adbManager = viewModel.adbManager + val adbState = adbManager.adbState.collectAsStateWithLifecycle() + var showDisableDialog by remember { mutableStateOf(false) } var completedModules by remember { mutableStateOf(0) } var totalModules by remember { mutableStateOf(0) } @@ -53,19 +56,12 @@ 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) - } - Column( modifier = Modifier .fillMaxSize() .padding(8.dp) ) { - if (isScanning) { + if (adbState.value == AdbState.ConnectedAcquiring) { Column(modifier = Modifier.fillMaxSize()) { if (isLandscape) { Row(modifier = Modifier.weight(1f)) { @@ -135,7 +131,7 @@ fun ScanScreen( } } Button( - onClick = { viewModel.cancelQuickForensics() }, + onClick = { adbManager.cancelQuickForensics() }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.error @@ -277,81 +273,86 @@ 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 - viewModel.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 (appState.value) { + AppState.AdbConnected -> { + 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 = viewModel.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 } - } - }) + }) + } + AppState.AdbConnecting, AppState.TryAutoConnect -> { + // No-op and button is disabled below + } + else -> { + // 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 = !isScanning, + enabled = (appState.value !in arrayOf(AppState.AdbScanning, AppState.TryAutoConnect, AppState.AdbConnecting)), 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 (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) + } ) ) { Icon( @@ -361,12 +362,13 @@ 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 (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.button_working_adb_pairing) + else + -> 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 new file mode 100644 index 0000000..354439a --- /dev/null +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbManager.kt @@ -0,0 +1,310 @@ +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 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.nio.charset.StandardCharsets +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.Volatile + +private const val TAG = "AdbManager" + +class AdbManager(applicationContext: Context) { + private val executor: ExecutorService = Executors.newFixedThreadPool(3) + 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 var qfFuture: Future<*>? = null + private val qfCancelled = AtomicBoolean(false) + + private var appContext: Context? = null + private var adbConnectionManager: AdbConnectionManager + + private var adbShellStream: AdbStream? = null + + fun watchCommandOutput(): LiveData { + 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") + } + } + + // Cancel the notification, if it's still showing + val stopIntent = AdbPairingService.stopIntent(appContext) + appContext?.stopService(stopIntent) + } + + 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 autoConnect() { + 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 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 + } + } else { + // connection isn't null, isConnected (not yet established) + if (adbConnectionManager.adbConnection != null && adbConnectionManager.adbConnection!!.isConnected) { + _adbState.value = AdbState.Ready + } + } + } catch (e: Exception) { + Log.d(TAG, "Couldn't get adbState: ${e.message}") + } + Log.d(TAG, "AdbState is ${adbState.value}") + } + + @WorkerThread + private fun autoConnectInternal() { + try { + 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 (th: Throwable) { + Log.e(TAG, "Error retrieving AdbConnectionManager instance") + 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) { + 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) { + adbShellStream = adbConnectionManager.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() + Log.w(TAG, "adbShelLStream error ${e.message}") + _adbState.value = AdbState.Cancelled + } + }) + } + + @get:Synchronized + val isQuickForensicsRunning: Boolean + get() = qfFuture != null && !qfFuture!!.isDone() + + @get:Synchronized + val isQuickForensicsCancelled: Boolean + get() = qfCancelled.get() + + @Synchronized + fun cancelQuickForensics() { + if (_adbState.value == AdbState.ConnectedAcquiring) { + _adbState.value = AdbState.ConnectedIdle + } + qfCancelled.set(true) + } + + fun runQuickForensics( + baseDir: File, + 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") + _adbState.value = AdbState.Ready + return + } + qfCancelled.set(false) + _adbState.value = AdbState.ConnectedAcquiring + qfFuture = executor.submit(Runnable { + try { + val out = QuickForensics() + .run(this.appContext!!, adbConnectionManager, baseDir, listener) + if (qfCancelled.get()) { + commandOutput.postValue("QuickForensics cancelled") + _adbState.value = AdbState.Cancelled + } else { + commandOutput.postValue("QuickForensics completed: " + out.getAbsolutePath()) + _adbState.value = AdbState.ConnectedIdle + } + } 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/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); } 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..fb4006e --- /dev/null +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbState.kt @@ -0,0 +1,33 @@ +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.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( + ErrorAcquisition, + ErrorConnect + ) + + // 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/AdbViewModel.java b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbViewModel.java deleted file mode 100644 index 2d6dc97..0000000 --- a/app/src/main/java/org/osservatorionessuno/bugbane/utils/AdbViewModel.java +++ /dev/null @@ -1,274 +0,0 @@ -package org.osservatorionessuno.bugbane.utils; - -import android.app.Application; -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; - -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 AdbViewModel extends AndroidViewModel { - 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; - - @Nullable - private AdbStream adbShellStream; - - public AdbViewModel(@NonNull Application application) { - super(application); - } - - public LiveData watchConnectAdb() { - return connectAdb; - } - - public LiveData watchPairAdb() { - return pairAdb; - } - - public LiveData watchAskPairAdb() { - return askPairAdb; - } - - public LiveData watchCommandOutput() { - return commandOutput; - } - - public LiveData watchPairingPort() { - return pairingPort; - } - - @Override - protected void onCleared() { - super.onCleared(); - 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(getApplication()); - boolean connectionStatus; - try { - connectionStatus = manager.connect(AndroidUtils.getHostIpAddress(getApplication()), 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(getApplication()); - 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(getApplication(), 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(getApplication()); - String host = mPairingHost != null ? mPairingHost : AndroidUtils.getHostIpAddress(getApplication()); - 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(getApplication()); - boolean connected = false; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - try { - connected = manager.connectTls(getApplication(), 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(getApplication()); - 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(getApplication()); - File out = new org.osservatorionessuno.bugbane.qf.QuickForensics() - .run(getApplication(), 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/AppState.kt b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AppState.kt new file mode 100644 index 0000000..a378e99 --- /dev/null +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/AppState.kt @@ -0,0 +1,41 @@ +package org.osservatorionessuno.bugbane.utils + +// AppStates represent the high-level device configuration and permissions status; +// state requisites are defined in the ViewModel (see ConfigurationViewModel). +// An AppState requires user interaction to change to a different state. +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), + + // 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 { + // Error states have different UI implications + fun isErrorState(state: AppState): Boolean { + 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 342043a..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,106 +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.os.Bundle +import android.database.ContentObserver +import android.os.Handler +import android.os.Looper import android.provider.Settings -import android.net.ConnectivityManager -import android.net.NetworkCapabilities +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" +/** + * 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 openDeviceSettings(context: Context) { - // Open the developer options settings - val intent = Intent(Settings.ACTION_DEVICE_INFO_SETTINGS) - try { - context.startActivity(intent) - } catch (ignored: 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? } } - fun openDeveloperOptions(context: Context) { - // Open the developer options settings - val intent = Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS) - try { - context.startActivity(intent) - } catch (e: Exception) { + /** + * 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 + ) } - } - fun openWifiSettings(context: Context) { - // Open Wi-Fi settings - val intent = Intent(Settings.ACTION_WIFI_SETTINGS) - try { - context.startActivity(intent) - } catch (e: Exception) { + wirelessDebugObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) = wirelessDebugCheck() + }.also { + contentResolver.registerContentObserver( + Settings.Global.getUriFor("adb_wifi_enabled"), + false, + it + ) } - } - 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) + adbObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) = adbObserverCheck() + }.also { + contentResolver.registerContentObserver( + Settings.Global.getUriFor(Settings.Global.ADB_ENABLED), + false, + it + ) } - try { - context.startActivity(settingsIntent) - } catch (e: Exception) { + } + + // 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) } } - 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 + // 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) } - 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) + private fun adbObserverCheck() { + scope.launch { + val enabled = + Settings.Global.getInt(contentResolver, Settings.Global.ADB_ENABLED, 0) == 1 + _adbEnabled.emit(enabled) + } } - fun isWirelessDebuggingEnabled(context: Context): Boolean { - return try { - val developerOptionsEnabled = isDeveloperOptionsEnabled(context) - val adbEnabled = isAdbEnabled(context) + fun notificationsCheck() { + val enabled = NotificationManagerCompat.from(appContext).areNotificationsEnabled() + _notificationsEnabled.value = enabled + } - developerOptionsEnabled && adbEnabled + 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 wireless debugging status", e) - false } } - - fun isDeveloperOptionsEnabled(context: Context): Boolean { - return try { - Settings.Global.getInt(context.contentResolver, Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, 0) == 1 - } catch (e: Exception) { - Log.e("ConfigurationManager", "Error checking developer options status", e) - false + + fun cleanup() { + developerOptsObserver?.let { + contentResolver.unregisterContentObserver(it) + developerOptsObserver = null } - } - - 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 + + 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 new file mode 100644 index 0000000..1ba031c --- /dev/null +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/ConfigurationViewModel.kt @@ -0,0 +1,331 @@ +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.os.Build +import android.os.Bundle +import android.provider.Settings +import android.util.Log +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.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +private const val TAG = "ConfigurationViewModel" + +/** 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). + */ +@OptIn(ExperimentalAtomicApi::class) +class ConfigurationViewModel private constructor( + val appContext: Context, + val adbManager: AdbManager, + 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( + appContext, + AdbManager(appContext) + ) + } + } + + // UI listeners collect AppState + private val _configurationState = MutableStateFlow(AppState.NeedWelcomeScreen) + val configurationState: StateFlow = _configurationState.asStateFlow() + + private val autoConnectAttempts = AtomicInt(0) + private val _MAX_AUTOCONNECT_ATTEMPTS = 2 + + init { + observeCombinedState() + 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 { + 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 -> + 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, + settings.adbEnabled, + settings.wirelessDebuggingEnabled, + settings.wifiConnected, + adbState, + appProgress) + }.distinctUntilChanged() + .collect { newState -> + Log.d(TAG, "New appState $newState") + _configurationState.value = newState + } + } + } + + /** + * 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 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return AppState.DeviceUnsupported + if (!appProgress.hasSeenWelcomeScreen) return AppState.NeedWelcomeScreen + + // 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 + if (adbState == 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()}") + } + + // 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 (!developerOptionsEnabled) return AppState.NeedDeveloperOptions + if ((!wirelessDebuggingEnabled || needAdb) && !appProgress.hasCompletedOnboarding) return AppState.NeedWirelessDebuggingAndPair + + 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 && 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.") + Log.d(TAG, "adbState=$adbState, notifications=$notificationsEnabled, wifi=$isConnectedToWifi, devOpts=$developerOptionsEnabled, has past adb connection=${appProgress.hasCompletedOnboarding}, tried autoconnect=${autoConnectAttempts.load()}") + return AppState.NeedWirelessDebuggingAndPair + } + + /** + * Observe AppState and manage automatic state transitions here. + */ + private fun observeAppState() { + viewModelScope.launch { + configurationState.collect { appState -> + + 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) + } + } + } + } + + fun onChangeStateRequest(currentState: AppState) { + Log.d(TAG, "onChangeRequest from $currentState") + when (currentState) { + AppState.DeviceUnsupported -> { + (appContext as? Activity)?.finishAffinity() + } + + AppState.NeedWelcomeScreen -> { + appManager.setHasSeenWelcomeScreen() + } + + AppState.NeedWifi, + AppState.NeedNotificationPermission, + AppState.NeedDeveloperOptions -> { + + getIntentForAppState(currentState)?.let { + it.addFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP) + appContext.startActivity(it) + } + } + + AppState.NeedWirelessDebuggingAndPair -> { + + getIntentForAppState(currentState)?.let { + it.addFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP) + appContext.startActivity(it) + } + adbManager.startAdbPairingService() + } + AppState.NeedWirelessDebugging -> { + + // 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 -> { + appManager.markHomepageAsSeen() + } + + else -> { + Log.w(TAG, "$currentState not handled by onChangeStateRequest") + } + } + } + + private fun getIntentForAppState(state: AppState): Intent? { + return when (state) { + AppState.NeedWifi -> Intent(Settings.ACTION_WIFI_SETTINGS) + + AppState.NeedNotificationPermission -> Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, appContext.packageName) + } + + AppState.NeedDeveloperOptions -> developerOptionsIntent() + + AppState.NeedWirelessDebuggingAndPair, AppState.NeedWirelessDebugging -> wirelessDebuggingIntent() + + AppState.AdbConnectedFinishOnboarding -> Intent(appContext, MainActivity::class.java).apply { + addFlags(FLAG_ACTIVITY_CLEAR_TOP) + } + + else -> null + } + } + + private fun wirelessDebuggingIntent(): Intent { + val EXTRA_FRAGMENT_ARG_KEY = ":settings:fragment_args_key" + val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args" + + return Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS).apply { + putExtra(EXTRA_FRAGMENT_ARG_KEY, "toggle_adb_wireless") + putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, Bundle().apply { + putString(EXTRA_FRAGMENT_ARG_KEY, "toggle_adb_wireless") + }) + } + } + + 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") + }) + } + } + + fun refreshState() { + viewModelScope.launch { + adbManager.checkState() + appManager.checkState() + wifiConnectivityMonitor.checkCurrentWifiConnected() + configurationManager.checkAll() + } + } + + + + override fun onCleared() { + super.onCleared() + 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 +// - 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 8f7227d..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,25 +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 + object SlideshowManager { - private const val PREFS_NAME = "app_prefs" - 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) { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit().putBoolean(KEY_HAS_SEEN_HOMEPAGE, true).apply() - } - - 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() + 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() + } + } + } + + fun initialize(context: Context) { + if (!::appContext.isInitialized) { + appContext = context.applicationContext + sharedPrefs = + appContext.getSharedPreferences(Keys.PREFS_NAME, Context.MODE_PRIVATE) + // initial values + checkState() + registerListener() + } + } + + + 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 hasSeenHomepage(): Boolean { + return sharedPrefs.getBoolean(Keys.KEY_HAS_SEEN_HOMEPAGE, false) + } + + fun markHomepageAsSeen() { + sharedPrefs.edit { putBoolean(Keys.KEY_HAS_SEEN_HOMEPAGE, true) } + } + + fun resetHomepageState(force: Boolean = false) { + if (!hasSeenHomepage() || force) { + sharedPrefs.edit { putBoolean(Keys.KEY_HAS_SEEN_HOMEPAGE, false) } } - } -} \ No newline at end of file + } + + fun canSkipWelcomeScreen(): Boolean { + return sharedPrefs.getBoolean(Keys.KEY_HAS_SEEN_WELCOME_SCREEN, false) + } + + 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 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..72ef916 --- /dev/null +++ b/app/src/main/java/org/osservatorionessuno/bugbane/utils/WifiConnectivityMonitor.kt @@ -0,0 +1,69 @@ +package org.osservatorionessuno.bugbane.utils + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.util.Log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlin.context + +private const val TAG = "WifiConnectivityMonitor" + +object WifiConnectivityMonitor { + + private lateinit var connectivityManager: ConnectivityManager + + private val _wifiState = MutableStateFlow(false) + val wifiState: StateFlow = _wifiState.asStateFlow() + + 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 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 + } + } + + override fun onLost(network: Network) { + if (_wifiState.value) { + Log.d(TAG, "wifi lost") + _wifiState.value = false + } + } + } + + fun cleanup() { + try { + connectivityManager.unregisterNetworkCallback(networkCallback) + } catch (ex: IllegalArgumentException) { + Log.i(TAG, "NetworkCallback already unregistered") + } + } + + 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) + } +} 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) { diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 81449c2..a751dae 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -7,22 +7,28 @@ 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 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 + In attesa dell\'accoppiamento ADB… Elenco di verifica forense Avvia acquisizione Sto acquisendo… @@ -86,4 +92,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 d37981b..e296d32 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. @@ -29,16 +31,21 @@ 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. Get Started + Waiting for ADB Pairing… + Forensic Checklist Start Acquisition 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" }