diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1b3132a..701610d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,11 +18,11 @@ android { versionName = "1.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - + vectorDrawables { useSupportLibrary = true } - + resourceConfigurations += listOf("en", "xxhdpi") } @@ -39,7 +39,7 @@ android { isJniDebuggable = false isPseudoLocalesEnabled = false } - + debug { isMinifyEnabled = false isDebuggable = true @@ -47,12 +47,12 @@ android { versionNameSuffix = "-debug" } } - + compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } - + kotlinOptions { jvmTarget = "11" freeCompilerArgs += listOf( @@ -63,17 +63,17 @@ android { "-Xno-receiver-assertions" ) } - + buildFeatures { compose = true aidl = true buildConfig = true } - + composeOptions { kotlinCompilerExtensionVersion = libs.versions.compose.get() } - + testOptions { unitTests { isReturnDefaultValues = true @@ -81,7 +81,7 @@ android { } animationsDisabled = true } - + packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -89,12 +89,16 @@ android { excludes += "/META-INF/maven/**" excludes += "/androidsupportmultidexversion.txt" } - + jniLibs { useLegacyPackaging = false } } - + + lint { + disable += "NullSafeMutableLiveData" + } + // Bundle configuration for APK size optimization bundle { language { @@ -113,18 +117,18 @@ dependencies { implementation(libs.androidx.foundation) compileOnly(project(":hiddenapi")) - + // Root & Shizuku implementation(libs.libsu.core) implementation(libs.libsu.service) implementation(libs.shizuku.api) implementation(libs.shizuku.provider) - + // Core Android implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) - + // Compose implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) @@ -132,15 +136,15 @@ dependencies { implementation(libs.androidx.material3) implementation(libs.androidx.material.icons.extended) implementation(libs.androidx.lifecycle.viewmodel.compose) - + // Dependency Injection implementation(libs.hilt.android) implementation(libs.hilt.navigation.compose) kapt(libs.hilt.compiler) - + // Data Storage implementation(libs.androidx.datastore.preferences) - + // Testing testImplementation(libs.junit) testImplementation(libs.mockk) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3552c09..a3bbb87 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,33 +12,27 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.NetworkSwitch" - tools:targetApi="31"> - + android:enableOnBackInvokedCallback="true" + tools:targetApi="33"> + + + + - - - - - - + - + - + \ No newline at end of file diff --git a/app/src/main/java/com/supernova/networkswitch/NetworkSwitchApplication.kt b/app/src/main/java/com/supernova/networkswitch/NetworkSwitchApplication.kt index 31f5406..7f71998 100644 --- a/app/src/main/java/com/supernova/networkswitch/NetworkSwitchApplication.kt +++ b/app/src/main/java/com/supernova/networkswitch/NetworkSwitchApplication.kt @@ -1,7 +1,34 @@ package com.supernova.networkswitch import android.app.Application +import com.supernova.networkswitch.data.source.PreferencesDataSource +import com.supernova.networkswitch.presentation.LauncherIcon import dagger.hilt.android.HiltAndroidApp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject @HiltAndroidApp -class NetworkSwitchApplication : Application() +class NetworkSwitchApplication : Application() { + + @Inject + lateinit var preferencesDataSource: PreferencesDataSource + + private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + override fun onCreate() { + super.onCreate() + observeLauncherIconState() + } + + private fun observeLauncherIconState() { + preferencesDataSource.observeHideLauncherIcon() + .onEach { hide -> + LauncherIcon.setEnabled(this, !hide) + } + .launchIn(applicationScope) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt b/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt index 3881e56..6ea5f92 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt @@ -56,6 +56,11 @@ class NetworkControlRepositoryImpl @Inject constructor( shizukuDataSource.resetConnection() } + override suspend fun requestPermission(method: ControlMethod): Boolean { + val dataSource = getDataSource(method) + return dataSource.requestPermission() + } + private fun getDataSource(method: ControlMethod): NetworkControlDataSource { return when (method) { ControlMethod.ROOT -> rootDataSource diff --git a/app/src/main/java/com/supernova/networkswitch/data/repository/PreferencesRepositoryImpl.kt b/app/src/main/java/com/supernova/networkswitch/data/repository/PreferencesRepositoryImpl.kt index 7f84c4d..46f26f6 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/repository/PreferencesRepositoryImpl.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/repository/PreferencesRepositoryImpl.kt @@ -15,28 +15,36 @@ import javax.inject.Singleton class PreferencesRepositoryImpl @Inject constructor( private val preferencesDataSource: PreferencesDataSource ) : PreferencesRepository { - + override suspend fun getControlMethod(): ControlMethod { return preferencesDataSource.getControlMethod() } - + override suspend fun setControlMethod(method: ControlMethod) { preferencesDataSource.setControlMethod(method) } - + override fun observeControlMethod(): Flow { return preferencesDataSource.observeControlMethod() } - + override suspend fun getToggleModeConfig(): ToggleModeConfig { return preferencesDataSource.getToggleModeConfig() } - + override suspend fun setToggleModeConfig(config: ToggleModeConfig) { preferencesDataSource.setToggleModeConfig(config) } - + override fun observeToggleModeConfig(): Flow { return preferencesDataSource.observeToggleModeConfig() } + + override fun observeHideLauncherIcon(): Flow { + return preferencesDataSource.observeHideLauncherIcon() + } + + override suspend fun setHideLauncherIcon(hide: Boolean) { + preferencesDataSource.setHideLauncherIcon(hide) + } } diff --git a/app/src/main/java/com/supernova/networkswitch/data/source/NetworkControlDataSource.kt b/app/src/main/java/com/supernova/networkswitch/data/source/NetworkControlDataSource.kt index d6fa53c..7b8ba55 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/source/NetworkControlDataSource.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/source/NetworkControlDataSource.kt @@ -9,4 +9,5 @@ interface NetworkControlDataSource { suspend fun setNetworkMode(subId: Int, mode: NetworkMode) fun isConnected(): Boolean fun resetConnection() + suspend fun requestPermission(): Boolean } diff --git a/app/src/main/java/com/supernova/networkswitch/data/source/PreferencesDataSource.kt b/app/src/main/java/com/supernova/networkswitch/data/source/PreferencesDataSource.kt index e8d8f02..c75bc3e 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/source/PreferencesDataSource.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/source/PreferencesDataSource.kt @@ -19,20 +19,34 @@ import javax.inject.Singleton class PreferencesDataSource @Inject constructor( private val dataStore: DataStore ) { - + companion object { + private val HIDE_LAUNCHER_ICON_KEY = booleanPreferencesKey("hide_launcher_icon") private val CONTROL_METHOD_KEY = stringPreferencesKey("control_method") private val TOGGLE_MODE_A_KEY = intPreferencesKey("toggle_mode_a") private val TOGGLE_MODE_B_KEY = intPreferencesKey("toggle_mode_b") private val TOGGLE_NEXT_IS_B_KEY = booleanPreferencesKey("toggle_next_is_b") - + + private const val DEFAULT_HIDE_LAUNCHER_ICON = false private const val DEFAULT_CONTROL_METHOD = "SHIZUKU" - + private val DEFAULT_MODE_A = NetworkMode.LTE_ONLY private val DEFAULT_MODE_B = NetworkMode.NR_ONLY private const val DEFAULT_NEXT_IS_B = true } - + + suspend fun setHideLauncherIcon(hide: Boolean) { + dataStore.edit { preferences -> + preferences[HIDE_LAUNCHER_ICON_KEY] = hide + } + } + + fun observeHideLauncherIcon(): Flow { + return dataStore.data.map { preferences -> + preferences[HIDE_LAUNCHER_ICON_KEY] ?: DEFAULT_HIDE_LAUNCHER_ICON + } + } + private fun parseControlMethod(methodString: String?): ControlMethod { return try { ControlMethod.valueOf(methodString ?: DEFAULT_CONTROL_METHOD) @@ -40,38 +54,38 @@ class PreferencesDataSource @Inject constructor( ControlMethod.SHIZUKU } } - + suspend fun getControlMethod(): ControlMethod { return dataStore.data.map { preferences -> parseControlMethod(preferences[CONTROL_METHOD_KEY]) }.first() } - + suspend fun setControlMethod(method: ControlMethod) { dataStore.edit { preferences -> preferences[CONTROL_METHOD_KEY] = method.name } } - + fun observeControlMethod(): Flow { return dataStore.data.map { preferences -> parseControlMethod(preferences[CONTROL_METHOD_KEY]) } } - + suspend fun getToggleModeConfig(): ToggleModeConfig { return dataStore.data.map { preferences -> val modeAValue = preferences[TOGGLE_MODE_A_KEY] ?: DEFAULT_MODE_A.value val modeBValue = preferences[TOGGLE_MODE_B_KEY] ?: DEFAULT_MODE_B.value val nextIsB = preferences[TOGGLE_NEXT_IS_B_KEY] ?: DEFAULT_NEXT_IS_B - + val modeA = NetworkMode.fromValue(modeAValue) ?: DEFAULT_MODE_A val modeB = NetworkMode.fromValue(modeBValue) ?: DEFAULT_MODE_B - + ToggleModeConfig(modeA, modeB, nextIsB) }.first() } - + suspend fun setToggleModeConfig(config: ToggleModeConfig) { dataStore.edit { preferences -> preferences[TOGGLE_MODE_A_KEY] = config.modeA.value @@ -79,16 +93,16 @@ class PreferencesDataSource @Inject constructor( preferences[TOGGLE_NEXT_IS_B_KEY] = config.nextModeIsB } } - + fun observeToggleModeConfig(): Flow { return dataStore.data.map { preferences -> val modeAValue = preferences[TOGGLE_MODE_A_KEY] ?: DEFAULT_MODE_A.value val modeBValue = preferences[TOGGLE_MODE_B_KEY] ?: DEFAULT_MODE_B.value val nextIsB = preferences[TOGGLE_NEXT_IS_B_KEY] ?: DEFAULT_NEXT_IS_B - + val modeA = NetworkMode.fromValue(modeAValue) ?: DEFAULT_MODE_A val modeB = NetworkMode.fromValue(modeBValue) ?: DEFAULT_MODE_B - + ToggleModeConfig(modeA, modeB, nextIsB) } } diff --git a/app/src/main/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSource.kt b/app/src/main/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSource.kt index 26b1748..8aff649 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSource.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSource.kt @@ -10,6 +10,7 @@ import com.supernova.networkswitch.service.RootNetworkControllerService import com.supernova.networkswitch.domain.model.CompatibilityState import com.supernova.networkswitch.domain.model.NetworkMode import com.supernova.networkswitch.util.Utils +import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.ipc.RootService import kotlinx.coroutines.suspendCancellableCoroutine import javax.inject.Inject @@ -34,6 +35,17 @@ class RootNetworkControlDataSource @Inject constructor( } } + override suspend fun requestPermission(): Boolean { + return try { + // Request root access by attempting to get a shell + // This will trigger the root permission dialog if not already granted + val shell = Shell.getShell() + Shell.isAppGrantedRoot() == true + } catch (e: Exception) { + false + } + } + override suspend fun getCurrentNetworkMode(subId: Int): NetworkMode? { return try { val controller = getNetworkController() diff --git a/app/src/main/java/com/supernova/networkswitch/data/source/ShizukuNetworkControlDataSource.kt b/app/src/main/java/com/supernova/networkswitch/data/source/ShizukuNetworkControlDataSource.kt index 90e7e8e..07e2406 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/source/ShizukuNetworkControlDataSource.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/source/ShizukuNetworkControlDataSource.kt @@ -157,4 +157,44 @@ class ShizukuNetworkControlDataSource @Inject constructor( false } } + + override suspend fun requestPermission(): Boolean { + return try { + if (!Shizuku.pingBinder()) { + return false + } + if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) { + return true + } + suspendCancellableCoroutine { continuation -> + val permissionListener = object : Shizuku.OnRequestPermissionResultListener { + override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) { + if (requestCode == SHIZUKU_PERMISSION_REQUEST_ID) { + Shizuku.removeRequestPermissionResultListener(this) + val granted = grantResult == PackageManager.PERMISSION_GRANTED + if (continuation.isActive) { + continuation.resume(granted) + } + } + } + } + + continuation.invokeOnCancellation { + Shizuku.removeRequestPermissionResultListener(permissionListener) + } + + try { + Shizuku.addRequestPermissionResultListener(permissionListener) + Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_ID) + } catch (e: Exception) { + Shizuku.removeRequestPermissionResultListener(permissionListener) + if (continuation.isActive) { + continuation.resume(false) + } + } + } + } catch (e: Exception) { + false + } + } } diff --git a/app/src/main/java/com/supernova/networkswitch/domain/repository/Repositories.kt b/app/src/main/java/com/supernova/networkswitch/domain/repository/Repositories.kt index 047b601..937b3f5 100644 --- a/app/src/main/java/com/supernova/networkswitch/domain/repository/Repositories.kt +++ b/app/src/main/java/com/supernova/networkswitch/domain/repository/Repositories.kt @@ -14,21 +14,26 @@ interface NetworkControlRepository { * Check if network control is compatible with current device/method */ suspend fun checkCompatibility(method: ControlMethod): CompatibilityState - + /** * Get current network mode */ suspend fun getCurrentNetworkMode(subId: Int): NetworkMode? - + /** * Set network mode */ suspend fun setNetworkMode(subId: Int, mode: NetworkMode): Result - + /** * Reset connections - useful when switching control methods */ suspend fun resetConnections() + + /** + * Request permission for the specified control method + */ + suspend fun requestPermission(method: ControlMethod): Boolean } /** @@ -39,29 +44,39 @@ interface PreferencesRepository { * Get preferred control method */ suspend fun getControlMethod(): ControlMethod - + /** * Set preferred control method */ suspend fun setControlMethod(method: ControlMethod) - + /** * Observe control method changes */ fun observeControlMethod(): Flow - + /** * Get toggle mode configuration */ suspend fun getToggleModeConfig(): ToggleModeConfig - + /** * Set toggle mode configuration */ suspend fun setToggleModeConfig(config: ToggleModeConfig) - + /** * Observe toggle mode configuration changes */ fun observeToggleModeConfig(): Flow + + /** + * Observe hide launcher icon changes + */ + fun observeHideLauncherIcon(): Flow + + /** + * Set hide launcher icon + */ + suspend fun setHideLauncherIcon(hide: Boolean) } diff --git a/app/src/main/java/com/supernova/networkswitch/domain/usecase/NetworkUseCases.kt b/app/src/main/java/com/supernova/networkswitch/domain/usecase/NetworkUseCases.kt index 2b37404..eae5c0c 100644 --- a/app/src/main/java/com/supernova/networkswitch/domain/usecase/NetworkUseCases.kt +++ b/app/src/main/java/com/supernova/networkswitch/domain/usecase/NetworkUseCases.kt @@ -92,3 +92,16 @@ class UpdateToggleModeConfigUseCase @Inject constructor( preferencesRepository.setToggleModeConfig(config) } } + +/** + * Use case for requesting permission for a specific control method + */ +class RequestPermissionUseCase @Inject constructor( + private val networkControlRepository: NetworkControlRepository, + private val preferencesRepository: PreferencesRepository +) { + suspend operator fun invoke(method: ControlMethod? = null): Boolean { + val controlMethod = method ?: preferencesRepository.getControlMethod() + return networkControlRepository.requestPermission(controlMethod) + } +} diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/LauncherIcon.kt b/app/src/main/java/com/supernova/networkswitch/presentation/LauncherIcon.kt new file mode 100644 index 0000000..afb9cf4 --- /dev/null +++ b/app/src/main/java/com/supernova/networkswitch/presentation/LauncherIcon.kt @@ -0,0 +1,33 @@ +package com.supernova.networkswitch.presentation + +import android.content.Context +import android.content.pm.PackageManager +import com.supernova.networkswitch.BuildConfig + +object LauncherIcon { + + private val LAUNCHER_ACTIVITY_ALIAS = if (BuildConfig.DEBUG) { + "com.supernova.networkswitch.debug.presentation.ui.activity.Launcher" + } else { + "com.supernova.networkswitch.presentation.ui.activity.Launcher" + } + + fun setEnabled(context: Context, enabled: Boolean) { + val componentName = android.content.ComponentName( + context, + LAUNCHER_ACTIVITY_ALIAS + ) + + val newState = if (enabled) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + + context.packageManager.setComponentEnabledSetting( + componentName, + newState, + PackageManager.DONT_KILL_APP + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/theme/Theme.kt b/app/src/main/java/com/supernova/networkswitch/presentation/theme/Theme.kt index fe9f558..f348c89 100644 --- a/app/src/main/java/com/supernova/networkswitch/presentation/theme/Theme.kt +++ b/app/src/main/java/com/supernova/networkswitch/presentation/theme/Theme.kt @@ -3,17 +3,16 @@ package com.supernova.networkswitch.presentation.theme import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.MotionScheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.core.view.WindowCompat + private val DarkColorScheme = darkColorScheme( primary = Purple80, @@ -27,6 +26,7 @@ private val LightColorScheme = lightColorScheme( tertiary = Pink40 ) +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun NetworkSwitchTheme( darkTheme: Boolean = isSystemInDarkTheme(), @@ -43,18 +43,10 @@ fun NetworkSwitchTheme( darkTheme -> DarkColorScheme else -> LightColorScheme } - val view = LocalView.current - if (!view.isInEditMode) { - SideEffect { - val window = (view.context as Activity).window - window.statusBarColor = colorScheme.primary.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme - } - } - - MaterialTheme( + MaterialExpressiveTheme( colorScheme = colorScheme, typography = Typography, + motionScheme = MotionScheme.expressive(), content = content ) } diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/MainActivity.kt b/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/MainActivity.kt index 1c08c2c..18a73cb 100644 --- a/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/MainActivity.kt +++ b/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/MainActivity.kt @@ -1,25 +1,31 @@ package com.supernova.networkswitch.presentation.ui.activity -import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.supernova.networkswitch.BuildConfig import com.supernova.networkswitch.R import com.supernova.networkswitch.domain.model.CompatibilityState import com.supernova.networkswitch.presentation.theme.NetworkSwitchTheme import com.supernova.networkswitch.presentation.viewmodel.MainViewModel +import com.supernova.networkswitch.presentation.viewmodel.NetworkModeConfigViewModel +import com.supernova.networkswitch.presentation.viewmodel.SettingsViewModel +import com.supernova.networkswitch.presentation.ui.composable.AboutCard +import com.supernova.networkswitch.presentation.ui.composable.SettingsBottomSheet import com.supernova.networkswitch.presentation.ui.composable.CompatibilityCard import com.supernova.networkswitch.presentation.ui.composable.NetworkToggleCard import com.supernova.networkswitch.presentation.ui.composable.QuickSettingsHintCard @@ -27,57 +33,112 @@ import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { - + private val viewModel: MainViewModel by viewModels() + private val settingsViewModel: SettingsViewModel by viewModels() + private val networkModeConfigViewModel: NetworkModeConfigViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - + enableEdgeToEdge() setContent { NetworkSwitchTheme { MainScreen( viewModel = viewModel, - onSettingsClick = { - startActivity(Intent(this@MainActivity, SettingsActivity::class.java)) - }, - onNetworkModeConfigClick = { - startActivity(Intent(this@MainActivity, NetworkModeConfigActivity::class.java)) - } + settingsViewModel = settingsViewModel, + networkModeConfigViewModel = networkModeConfigViewModel ) } } } - + override fun onResume() { super.onResume() viewModel.refreshAllData() } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun MainScreen( viewModel: MainViewModel, - onSettingsClick: () -> Unit, - onNetworkModeConfigClick: () -> Unit + settingsViewModel: SettingsViewModel, + networkModeConfigViewModel: NetworkModeConfigViewModel ) { val compatibilityState = viewModel.compatibilityState - + val aboutBottomSheetState = rememberModalBottomSheetState() + val settingsBottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var showAboutBottomSheet by remember { mutableStateOf(false) } + var showSettingsBottomSheet by remember { mutableStateOf(false) } + + // Settings state + val controlMethod by settingsViewModel.controlMethod.collectAsState() + val hideLauncherIcon by settingsViewModel.hideLauncherIcon.collectAsState() + val currentConfig by networkModeConfigViewModel.currentConfig.collectAsState() + + if (showAboutBottomSheet) { + ModalBottomSheet( + onDismissRequest = { showAboutBottomSheet = false }, + sheetState = aboutBottomSheetState + ) { + AboutCard() + Spacer(modifier = Modifier.height(32.dp)) + } + } + + if (showSettingsBottomSheet) { + ModalBottomSheet( + onDismissRequest = { showSettingsBottomSheet = false }, + sheetState = settingsBottomSheetState + ) { + SettingsBottomSheet( + selectedControlMethod = controlMethod, + onControlMethodSelected = { settingsViewModel.updateControlMethod(it) }, + rootCompatibility = settingsViewModel.rootCompatibility, + shizukuCompatibility = settingsViewModel.shizukuCompatibility, + onRetryCompatibilityClick = { settingsViewModel.retryCompatibilityCheck() }, + currentConfig = currentConfig, + onModeASelected = { + networkModeConfigViewModel.updateModeA(it) + networkModeConfigViewModel.saveConfiguration() + }, + onModeBSelected = { + networkModeConfigViewModel.updateModeB(it) + networkModeConfigViewModel.saveConfiguration() + }, + hideLauncherIcon = hideLauncherIcon, + onHideLauncherIconChanged = { settingsViewModel.updateHideLauncherIcon(it) } + ) + } + } + Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.app_name)) }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.app_name)) + if (BuildConfig.DEBUG) { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "DEBUG", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error + ) + } + } + }, actions = { - IconButton(onClick = onNetworkModeConfigClick) { + IconButton(onClick = { showSettingsBottomSheet = true }) { Icon( - imageVector = Icons.Default.Tune, - contentDescription = "Network Mode Configuration" + imageVector = Icons.Default.Settings, + contentDescription = "Settings" ) } - IconButton(onClick = onSettingsClick) { + IconButton(onClick = { showAboutBottomSheet = true }) { Icon( - imageVector = Icons.Default.Settings, - contentDescription = "Settings" + imageVector = Icons.Default.Info, + contentDescription = "About" ) } } @@ -98,7 +159,7 @@ private fun MainScreen( currentControlMethod = viewModel.selectedMethod, onRetryClick = { viewModel.retryCompatibilityCheck() } ) - + // Network Toggle Card (show if compatible) if (compatibilityState is CompatibilityState.Compatible) { NetworkToggleCard( @@ -108,7 +169,7 @@ private fun MainScreen( onToggleClick = { viewModel.toggleNetworkMode() } ) } - + // Quick Settings Tip Card QuickSettingsHintCard() } diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/NetworkModeConfigActivity.kt b/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/NetworkModeConfigActivity.kt deleted file mode 100644 index 859b90e..0000000 --- a/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/NetworkModeConfigActivity.kt +++ /dev/null @@ -1,219 +0,0 @@ -package com.supernova.networkswitch.presentation.ui.activity - -import android.os.Bundle -import android.widget.Toast -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.supernova.networkswitch.R -import com.supernova.networkswitch.domain.model.NetworkMode -import com.supernova.networkswitch.presentation.theme.NetworkSwitchTheme -import com.supernova.networkswitch.presentation.viewmodel.NetworkModeConfigViewModel -import com.supernova.networkswitch.presentation.ui.composable.NetworkModeSelector -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class NetworkModeConfigActivity : ComponentActivity() { - - private val viewModel: NetworkModeConfigViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - NetworkSwitchTheme { - NetworkModeConfigScreen( - viewModel = viewModel, - onBackClick = { finish() } - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun NetworkModeConfigScreen( - viewModel: NetworkModeConfigViewModel, - onBackClick: () -> Unit -) { - val context = LocalContext.current - val currentConfig by viewModel.currentConfig.collectAsState() - val isLoading by viewModel.isLoading.collectAsState() - val configSaved by viewModel.configSaved.collectAsState() - - // Show toast when configuration is saved - LaunchedEffect(configSaved) { - if (configSaved) { - Toast.makeText(context, context.getString(R.string.configuration_saved), Toast.LENGTH_SHORT).show() - viewModel.resetSavedState() - } - } - - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.network_mode_config)) }, - navigationIcon = { - IconButton(onClick = onBackClick) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - } - ) - } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Description Card - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - Text( - text = stringResource(R.string.network_mode_config), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = stringResource(R.string.network_mode_config_desc), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - // Mode A Selection - NetworkModeSelector( - label = stringResource(R.string.mode_a_label), - selectedMode = currentConfig.modeA, - onModeSelected = { mode -> - viewModel.updateModeA(mode) - } - ) - - // Mode B Selection - NetworkModeSelector( - label = stringResource(R.string.mode_b_label), - selectedMode = currentConfig.modeB, - onModeSelected = { mode -> - viewModel.updateModeB(mode) - } - ) - - // Current Configuration Preview - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - Text( - text = "Configuration Preview", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = "Toggle will switch between:", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(4.dp)) - - Text( - text = "• ${currentConfig.modeA.displayName}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - - Text( - text = "• ${currentConfig.modeB.displayName}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - } - } - - // Save Button - Button( - onClick = { viewModel.saveConfiguration() }, - enabled = !isLoading && currentConfig.modeA != currentConfig.modeB, - modifier = Modifier.fillMaxWidth() - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp - ) - Spacer(modifier = Modifier.width(8.dp)) - } - Text(stringResource(R.string.save_configuration)) - } - - // Warning if both modes are the same - if (currentConfig.modeA == currentConfig.modeB) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "⚠️", - style = MaterialTheme.typography.titleMedium - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Mode A and Mode B must be different", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer - ) - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/SettingsActivity.kt b/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/SettingsActivity.kt deleted file mode 100644 index f7d08a7..0000000 --- a/app/src/main/java/com/supernova/networkswitch/presentation/ui/activity/SettingsActivity.kt +++ /dev/null @@ -1,369 +0,0 @@ -package com.supernova.networkswitch.presentation.ui.activity - -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Error -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.supernova.networkswitch.domain.model.CompatibilityState -import com.supernova.networkswitch.domain.model.ControlMethod -import com.supernova.networkswitch.presentation.theme.NetworkSwitchTheme -import com.supernova.networkswitch.presentation.viewmodel.SettingsViewModel -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class SettingsActivity : ComponentActivity() { - - private val viewModel: SettingsViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - NetworkSwitchTheme { - SettingsScreen( - viewModel = viewModel, - onBackClick = { finish() } - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun SettingsScreen( - viewModel: SettingsViewModel, - onBackClick: () -> Unit -) { - val controlMethod by viewModel.controlMethod.collectAsState() - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Settings") }, - navigationIcon = { - IconButton(onClick = onBackClick) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - } - ) - } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Control Method Selection - ControlMethodCard( - selectedMethod = controlMethod, - onMethodSelected = { viewModel.updateControlMethod(it) }, - rootCompatibility = viewModel.rootCompatibility, - shizukuCompatibility = viewModel.shizukuCompatibility, - onRetryClick = { viewModel.retryCompatibilityCheck() } - ) - - // About Section - AboutCard() - } - } -} - -@Composable -private fun ControlMethodCard( - selectedMethod: ControlMethod, - onMethodSelected: (ControlMethod) -> Unit, - rootCompatibility: CompatibilityState, - shizukuCompatibility: CompatibilityState, - onRetryClick: () -> Unit -) { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Control Method", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - - IconButton(onClick = onRetryClick) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = "Refresh compatibility" - ) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = "Choose how the app should control network settings. Root method requires a rooted device, while Shizuku method works with non-rooted devices that have Shizuku installed.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Root Method Option - Row( - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = selectedMethod == ControlMethod.ROOT, - onClick = { onMethodSelected(ControlMethod.ROOT) } - ) - .padding(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = selectedMethod == ControlMethod.ROOT, - onClick = { onMethodSelected(ControlMethod.ROOT) } - ) - Spacer(modifier = Modifier.width(8.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Root Method", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium - ) - Text( - text = "Requires rooted device with root access granted", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - // Compatibility status indicator - when (rootCompatibility) { - is CompatibilityState.Pending -> { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp - ) - } - is CompatibilityState.Compatible -> { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = "Compatible", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) - ) - } - is CompatibilityState.PermissionDenied -> { - Icon( - imageVector = Icons.Default.Error, - contentDescription = "Permission denied", - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(20.dp) - ) - } - is CompatibilityState.Incompatible -> { - Icon( - imageVector = Icons.Default.Error, - contentDescription = "Error", - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(20.dp) - ) - } - } - } - - // Shizuku Method Option - Row( - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = selectedMethod == ControlMethod.SHIZUKU, - onClick = { onMethodSelected(ControlMethod.SHIZUKU) } - ) - .padding(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = selectedMethod == ControlMethod.SHIZUKU, - onClick = { onMethodSelected(ControlMethod.SHIZUKU) } - ) - Spacer(modifier = Modifier.width(8.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Shizuku Method", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium - ) - Text( - text = "Works with non-rooted devices using Shizuku service", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - // Compatibility status indicator - when (shizukuCompatibility) { - is CompatibilityState.Pending -> { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp - ) - } - is CompatibilityState.Compatible -> { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = "Compatible", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) - ) - } - is CompatibilityState.PermissionDenied -> { - Icon( - imageVector = Icons.Default.Error, - contentDescription = "Permission denied", - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(20.dp) - ) - } - is CompatibilityState.Incompatible -> { - Icon( - imageVector = Icons.Default.Error, - contentDescription = "Not available", - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(20.dp) - ) - } - } - } - } - } -} - -@Composable -private fun AboutCard() { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - Text( - text = "About", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = "Source Code", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium - ) - - Spacer(modifier = Modifier.height(8.dp)) - - LinkItem( - title = "NetworkSwitch", - subtitle = "https://github.com/aunchagaonkar/NetworkSwitch", - link = "https://github.com/aunchagaonkar/NetworkSwitch" - ) - - Spacer(modifier = Modifier.height(24.dp)) - - Text( - text = "Open Source Licenses", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium - ) - - Spacer(modifier = Modifier.height(8.dp)) - - LinkItem( - title = "Shizuku", - subtitle = "Apache License 2.0\nhttps://github.com/RikkaApps/Shizuku", - link = "https://github.com/RikkaApps/Shizuku" - ) - - LinkItem( - title = "libsu", - subtitle = "Apache License 2.0\nhttps://github.com/topjohnwu/libsu", - link = "https://github.com/topjohnwu/libsu" - ) - - LinkItem( - title = "Android Jetpack", - subtitle = "Apache License 2.0\nhttps://android.googlesource.com/platform/frameworks/support", - link = "https://android.googlesource.com/platform/frameworks/support" - ) - - LinkItem( - title = "Kotlin", - subtitle = "Apache License 2.0\nhttps://github.com/JetBrains/kotlin", - link = "https://github.com/JetBrains/kotlin" - ) - } - } -} - -@Composable -private fun LinkItem( - title: String, - subtitle: String, - link: String -) { - val context = LocalContext.current - - Column( - modifier = Modifier - .fillMaxWidth() - .clickable { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link))) - } - .padding(vertical = 8.dp) - ) { - Text( - text = title, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary - ) - Text( - text = subtitle, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/AboutCard.kt b/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/AboutCard.kt new file mode 100644 index 0000000..7c4df82 --- /dev/null +++ b/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/AboutCard.kt @@ -0,0 +1,119 @@ +package com.supernova.networkswitch.presentation.ui.composable + +import android.content.Intent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Code +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.Card +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri + +@Composable +fun AboutCard() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Text( + text = "About", + style = MaterialTheme.typography.headlineSmall + ) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Source Code", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp) + ) + Card(modifier = Modifier.fillMaxWidth()) { + LinkItem( + title = "NetworkSwitch", + subtitle = "https://github.com/aunchagaonkar/NetworkSwitch", + link = "https://github.com/aunchagaonkar/NetworkSwitch", + icon = Icons.Outlined.Code + ) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Open Source Licenses", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp) + ) + + val licenses = listOf( + Triple("Shizuku", "Apache License 2.0\nhttps://github.com/RikkaApps/Shizuku", "https://github.com/RikkaApps/Shizuku"), + Triple("libsu", "Apache License 2.0\nhttps://github.com/topjohnwu/libsu", "https://github.com/topjohnwu/libsu"), + Triple("Android Jetpack", "Apache License 2.0\nhttps://android.googlesource.com/platform/frameworks/support", "https://android.googlesource.com/platform/frameworks/support"), + Triple("Kotlin", "Apache License 2.0\nhttps://github.com/JetBrains/kotlin", "https://github.com/JetBrains/kotlin") + ) + + Card(modifier = Modifier.fillMaxWidth()) { + Column { + licenses.forEachIndexed { index, item -> + LinkItem( + title = item.first, + subtitle = item.second, + link = item.third, + icon = Icons.Outlined.Info + ) + if (index < licenses.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + thickness = DividerDefaults.Thickness, + color = DividerDefaults.color + ) + } + } + } + } + } + } +} + +@Composable +private fun LinkItem( + title: String, + subtitle: String, + link: String, + icon: ImageVector +) { + val context = LocalContext.current + ListItem( + headlineContent = { + Text( + text = title, + color = MaterialTheme.colorScheme.primary + ) + }, + supportingContent = { Text(text = subtitle) }, + leadingContent = { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + modifier = Modifier.clickable { + context.startActivity(Intent(Intent.ACTION_VIEW, link.toUri())) + } + ) +} diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/NetworkModeSelector.kt b/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/NetworkModeSelector.kt index b9c525f..8903552 100644 --- a/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/NetworkModeSelector.kt +++ b/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/NetworkModeSelector.kt @@ -1,77 +1,64 @@ package com.supernova.networkswitch.presentation.ui.composable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.supernova.networkswitch.domain.model.NetworkMode -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun NetworkModeSelector( - label: String, selectedMode: NetworkMode, onModeSelected: (NetworkMode) -> Unit, - modifier: Modifier = Modifier, - availableModes: List = NetworkMode.values().toList() + availableModes: List = NetworkMode.entries, ) { var expanded by remember { mutableStateOf(false) } - - Card(modifier = modifier.fillMaxWidth()) { - Column( + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + value = selectedMode.displayName, + onValueChange = { }, + readOnly = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), modifier = Modifier .fillMaxWidth() - .padding(16.dp) + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } ) { - Text( - text = label, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.height(8.dp)) - - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded } + Column( + modifier = Modifier + .heightIn(max = 300.dp) + .verticalScroll(rememberScrollState()) ) { - OutlinedTextField( - value = selectedMode.displayName, - onValueChange = { }, - readOnly = true, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) - }, - colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), - modifier = Modifier - .fillMaxWidth() - .menuAnchor() - ) - - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - availableModes.forEach { mode -> - DropdownMenuItem( - text = { - Column { - Text( - text = mode.displayName, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium - ) - } - }, - onClick = { - onModeSelected(mode) - expanded = false - } - ) - } + availableModes.forEach { mode -> + DropdownMenuItem( + text = { + Text( + text = mode.displayName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + }, + onClick = { + onModeSelected(mode) + expanded = false + } + ) } } } diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/NetworkSwitchComponents.kt b/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/NetworkSwitchComponents.kt index 36c23b3..6009c52 100644 --- a/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/NetworkSwitchComponents.kt +++ b/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/NetworkSwitchComponents.kt @@ -1,14 +1,22 @@ package com.supernova.networkswitch.presentation.ui.composable +import android.app.StatusBarManager +import android.content.ComponentName +import android.content.Context +import android.graphics.drawable.Icon +import android.os.Build +import androidx.annotation.RequiresApi import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -16,9 +24,11 @@ import com.supernova.networkswitch.domain.model.CompatibilityState import com.supernova.networkswitch.domain.model.ControlMethod import com.supernova.networkswitch.domain.model.NetworkMode import com.supernova.networkswitch.presentation.ui.components.CardSection +import com.supernova.networkswitch.service.NetworkTileService private fun ControlMethod.displayName() = if (this == ControlMethod.SHIZUKU) "Shizuku" else "Root" +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun CompatibilityCard( compatibilityState: CompatibilityState, @@ -43,7 +53,7 @@ fun CompatibilityCard( textAlign = TextAlign.Center ) } - + is CompatibilityState.Compatible -> { Icon( imageVector = Icons.Default.CheckCircle, @@ -66,7 +76,7 @@ fun CompatibilityCard( fontWeight = FontWeight.Medium ) } - + is CompatibilityState.PermissionDenied -> { Icon( imageVector = Icons.Default.Warning, @@ -81,18 +91,18 @@ fun CompatibilityCard( textAlign = TextAlign.Center ) Text( - text = if (compatibilityState.method == ControlMethod.ROOT) - "Please grant root access to use this app" - else + text = if (compatibilityState.method == ControlMethod.ROOT) + "Please grant root access to use this app" + else "Please grant Shizuku permission or install Shizuku", style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = onRetryClick) { Text("Retry") } + Button(onClick = onRetryClick,shapes = ButtonDefaults.shapes()) { Text("Retry") } } - + is CompatibilityState.Incompatible -> { Icon( imageVector = Icons.Default.Error, @@ -113,13 +123,15 @@ fun CompatibilityCard( color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = onRetryClick) { Text("Retry") } + Button(onClick = onRetryClick, shapes = ButtonDefaults.shapes()) { Text("Retry") } + } } } } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun NetworkToggleCard( currentMode: NetworkMode?, @@ -133,15 +145,15 @@ fun NetworkToggleCard( modifier = modifier ) { Spacer(modifier = Modifier.height(16.dp)) - + Text( text = if (currentMode != null) "Current: ${currentMode.displayName}" else "Network mode unavailable", style = MaterialTheme.typography.titleMedium, color = if (currentMode != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant ) - + Spacer(modifier = Modifier.height(8.dp)) - + Text( text = if (currentMode != null) { "Tap to switch to the configured alternate network mode" @@ -152,13 +164,14 @@ fun NetworkToggleCard( textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant ) - + Spacer(modifier = Modifier.height(16.dp)) - + Button( onClick = onToggleClick, enabled = !isLoading && currentMode != null, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + shapes = ButtonDefaults.shapes() ) { if (isLoading) { CircularProgressIndicator( @@ -172,32 +185,118 @@ fun NetworkToggleCard( } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun QuickSettingsHintCard(modifier: Modifier = Modifier) { - Card( - modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - Text( - text = "💡 Pro Tip", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = "Add the \"Network Switch Toggle\" tile to your Quick Settings for instant network switching. Pull down your notification panel, tap the pencil icon, and add the tile.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant +fun QuickSettingsHintCard( + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val isTileAdded by NetworkTileService.isTileAdded.collectAsState() + var hasTriedAutoAdd by remember { mutableStateOf(false) } + var showAddButton by remember { mutableStateOf(false) } + + // Auto-add tile on first composition + LaunchedEffect(Unit) { + if (!hasTriedAutoAdd && !isTileAdded) { + hasTriedAutoAdd = true + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val success = requestAddTileToQuickSettings(context) + showAddButton = !success + } else { + showAddButton = true + } + } + } + + // Update showAddButton when tile status changes + LaunchedEffect(isTileAdded) { + if (isTileAdded) { + showAddButton = false + } + } + + if (isTileAdded || showAddButton) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = if (isTileAdded) "✅ Quick Settings" else "💡 Quick Settings", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = if (isTileAdded) { + "Great! You can now switch network modes directly from your Quick Settings panel. Just pull down your notification panel and tap the \"Network Switch\" tile." + } else { + "Add the \"Network Switch\" tile to your Quick Settings for instant network switching from anywhere on your device." + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (!isTileAdded && showAddButton) { + Spacer(modifier = Modifier.height(16.dp)) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Button( + onClick = { + val success = requestAddTileToQuickSettings(context) + if (success) showAddButton = false + }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Add to Quick Settings") + } + } else { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Pull down your notification panel → Tap the pencil/edit icon → Find \"Network Switch\" → Drag it to your Quick Settings", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + } } } } + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +private fun requestAddTileToQuickSettings(context: Context): Boolean { + return try { + val statusBarManager = context.getSystemService(StatusBarManager::class.java) + val componentName = ComponentName(context, NetworkTileService::class.java) + + statusBarManager?.requestAddTileService( + componentName, + "Network Switch", + Icon.createWithResource(context, com.supernova.networkswitch.R.drawable.ic_5g_big), + { runnable -> runnable.run() }, + { result -> + // -> STATUS_BAR_MANAGER_TILE_ADDED or STATUS_BAR_MANAGER_TILE_NOT_ADDED + } + ) + true + } catch (e: Exception) { + e.printStackTrace() + false + } +} \ No newline at end of file diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/SettingsBottomSheet.kt b/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/SettingsBottomSheet.kt new file mode 100644 index 0000000..87fdde2 --- /dev/null +++ b/app/src/main/java/com/supernova/networkswitch/presentation/ui/composable/SettingsBottomSheet.kt @@ -0,0 +1,477 @@ +package com.supernova.networkswitch.presentation.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowCircleDown +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.supernova.networkswitch.domain.model.CompatibilityState +import com.supernova.networkswitch.domain.model.ControlMethod +import com.supernova.networkswitch.domain.model.NetworkMode +import com.supernova.networkswitch.domain.model.ToggleModeConfig + +@Composable +fun SettingsBottomSheet( + // Settings properties + selectedControlMethod: ControlMethod, + onControlMethodSelected: (ControlMethod) -> Unit, + rootCompatibility: CompatibilityState, + shizukuCompatibility: CompatibilityState, + onRetryCompatibilityClick: () -> Unit, + + // Network Mode Config properties + currentConfig: ToggleModeConfig, + onModeASelected: (NetworkMode) -> Unit, + onModeBSelected: (NetworkMode) -> Unit, + + // App settings + hideLauncherIcon: Boolean, + onHideLauncherIconChanged: (Boolean) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + ControlMethodCard( + selectedMethod = selectedControlMethod, + onMethodSelected = onControlMethodSelected, + rootCompatibility = rootCompatibility, + shizukuCompatibility = shizukuCompatibility, + onRetryClick = onRetryCompatibilityClick + ) + + NetworkModeConfigurationCard( + currentConfig = currentConfig, + onModeASelected = onModeASelected, + onModeBSelected = onModeBSelected + ) + + AppSettingsCard( + hideLauncherIcon = hideLauncherIcon, + onHideLauncherIconChanged = onHideLauncherIconChanged + ) + Spacer(modifier = Modifier.height(8.dp)) + } +} + +@Composable +private fun AppSettingsCard( + hideLauncherIcon: Boolean, + onHideLauncherIconChanged: (Boolean) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "App Settings", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 16.dp) + ) + Card( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Hide launcher icon", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = "Relaunch the app for changes to take effect", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Switch( + checked = hideLauncherIcon, + onCheckedChange = onHideLauncherIconChanged + ) + } + } + } +} + +@Composable +private fun ControlMethodCard( + selectedMethod: ControlMethod, + onMethodSelected: (ControlMethod) -> Unit, + rootCompatibility: CompatibilityState, + shizukuCompatibility: CompatibilityState, + onRetryClick: () -> Unit +) { + var showTooltip by remember { mutableStateOf(false) } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Control Method", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 16.dp) + ) + Box { + IconButton(onClick = { showTooltip = true }) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = "More info", + modifier = Modifier.size(16.dp) + ) + } + DropdownMenu( + expanded = showTooltip, + onDismissRequest = { showTooltip = false } + ) { + Text( + text = "Choose how the app should control network settings. Root method requires a rooted device, while Shizuku method works with non-rooted devices that have Shizuku installed.", + modifier = Modifier + .padding(8.dp) + .width(300.dp) + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + IconButton(onClick = onRetryClick) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Refresh compatibility" + ) + } + } + + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + // Root Method Option + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = selectedMethod == ControlMethod.ROOT, + onClick = { onMethodSelected(ControlMethod.ROOT) } + ) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Root Method", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = "Requires rooted device with root access granted", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Compatibility status indicator + when (rootCompatibility) { + is CompatibilityState.Pending -> { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } + + is CompatibilityState.Compatible -> { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Compatible", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + + is CompatibilityState.PermissionDenied -> { + Icon( + imageVector = Icons.Default.Error, + contentDescription = "Permission denied", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + } + + is CompatibilityState.Incompatible -> { + Icon( + imageVector = Icons.Default.Error, + contentDescription = "Error", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + } + } + + RadioButton( + selected = selectedMethod == ControlMethod.ROOT, + onClick = { onMethodSelected(ControlMethod.ROOT) } + ) + } + + // Shizuku Method Option + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = selectedMethod == ControlMethod.SHIZUKU, + onClick = { onMethodSelected(ControlMethod.SHIZUKU) } + ) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Shizuku Method", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = "Works with non-rooted devices using Shizuku service", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.width(8.dp)) + // Compatibility status indicator + when (shizukuCompatibility) { + is CompatibilityState.Pending -> { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } + + is CompatibilityState.Compatible -> { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Compatible", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + + is CompatibilityState.PermissionDenied -> { + Icon( + imageVector = Icons.Default.Error, + contentDescription = "Permission denied", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + } + + is CompatibilityState.Incompatible -> { + Icon( + imageVector = Icons.Default.Error, + contentDescription = "Not available", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + } + } + RadioButton( + selected = selectedMethod == ControlMethod.SHIZUKU, + onClick = { onMethodSelected(ControlMethod.SHIZUKU) } + ) + } + } + } + } +} + +@Composable +private fun NetworkModeConfigurationCard( + currentConfig: ToggleModeConfig, + onModeASelected: (NetworkMode) -> Unit, + onModeBSelected: (NetworkMode) -> Unit +) { + var showTooltip by remember { mutableStateOf(false) } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Configure Network Modes", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 16.dp) + ) + Box { + IconButton(onClick = { showTooltip = true }) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = "More info", + modifier = Modifier.size(16.dp) + ) + } + DropdownMenu( + expanded = showTooltip, + onDismissRequest = { showTooltip = false } + ) { + Text( + text = "Set up the two network modes that the toggle will switch between. Changes are saved automatically.", + modifier = Modifier + .padding(8.dp) + .width(300.dp) + ) + } + } + } + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + NetworkModeSelector( + selectedMode = currentConfig.modeA, + onModeSelected = onModeASelected + ) + + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + ) + + Icon( + painter = rememberVectorPainter(image = Icons.Default.ArrowCircleDown), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + contentDescription = null, + modifier = Modifier.align( + Alignment.CenterHorizontally + ) + ) + + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + ) + + NetworkModeSelector( + selectedMode = currentConfig.modeB, + onModeSelected = onModeBSelected + ) + } + } + + // Current Configuration Preview + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Configuration Preview", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Toggle will switch between:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "• ${currentConfig.modeA.displayName}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + + Text( + text = "• ${currentConfig.modeB.displayName}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } + + // Warning if both modes are the same + if (currentConfig.modeA == currentConfig.modeB) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "⚠️", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Mode A and Mode B must be different", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + } +} diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/MainViewModel.kt b/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/MainViewModel.kt index f59b820..9e56c19 100644 --- a/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/MainViewModel.kt @@ -15,6 +15,7 @@ import com.supernova.networkswitch.domain.usecase.GetCurrentNetworkModeUseCase import com.supernova.networkswitch.domain.usecase.ToggleNetworkModeUseCase import com.supernova.networkswitch.domain.usecase.UpdateControlMethodUseCase import com.supernova.networkswitch.domain.usecase.GetToggleModeConfigUseCase +import com.supernova.networkswitch.domain.usecase.RequestPermissionUseCase import com.supernova.networkswitch.domain.repository.PreferencesRepository import kotlinx.coroutines.launch import kotlinx.coroutines.flow.collectLatest @@ -28,6 +29,7 @@ class MainViewModel @Inject constructor( private val toggleNetworkModeUseCase: ToggleNetworkModeUseCase, private val updateControlMethodUseCase: UpdateControlMethodUseCase, private val getToggleModeConfigUseCase: GetToggleModeConfigUseCase, + private val requestPermissionUseCase: RequestPermissionUseCase, private val preferencesRepository: PreferencesRepository ) : ViewModel() { @@ -102,12 +104,26 @@ class MainViewModel @Inject constructor( } /** - * Retry compatibility check + * Retry compatibility check with request permissions if denied */ fun retryCompatibilityCheck() { - checkCompatibility() + viewModelScope.launch { + val currentState = compatibilityState + if (currentState is CompatibilityState.PermissionDenied) { + compatibilityState = CompatibilityState.Pending + val permissionGranted = requestPermissionUseCase(currentState.method) + compatibilityState = if (permissionGranted) { + checkCompatibilityUseCase() + } else { + // denied :( + currentState + } + } else { + checkCompatibility() + } + } } - + /** * Refresh all data when app resumes */ diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModel.kt index 147885b..d0196c6 100644 --- a/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModel.kt @@ -24,44 +24,57 @@ class SettingsViewModel @Inject constructor( private val preferencesRepository: PreferencesRepository, private val networkControlRepository: NetworkControlRepository ) : ViewModel() { - + val controlMethod: StateFlow = preferencesRepository.observeControlMethod() .stateIn( scope = viewModelScope, started = kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), initialValue = ControlMethod.SHIZUKU ) - + + val hideLauncherIcon: StateFlow = preferencesRepository.observeHideLauncherIcon() + .stateIn( + scope = viewModelScope, + started = kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), + initialValue = false + ) + // Compatibility status for each method var rootCompatibility by mutableStateOf(CompatibilityState.Pending) private set - + var shizukuCompatibility by mutableStateOf(CompatibilityState.Pending) private set - + init { checkAllCompatibility() } - + fun updateControlMethod(method: ControlMethod) { viewModelScope.launch { preferencesRepository.setControlMethod(method) } } - + + fun updateHideLauncherIcon(hide: Boolean) { + viewModelScope.launch { + preferencesRepository.setHideLauncherIcon(hide) + } + } + fun retryCompatibilityCheck() { checkAllCompatibility() } - + private fun checkAllCompatibility() { viewModelScope.launch { rootCompatibility = CompatibilityState.Pending shizukuCompatibility = CompatibilityState.Pending - + // Check both methods in parallel val rootResult = async { networkControlRepository.checkCompatibility(ControlMethod.ROOT) } val shizukuResult = async { networkControlRepository.checkCompatibility(ControlMethod.SHIZUKU) } - + rootCompatibility = rootResult.await() shizukuCompatibility = shizukuResult.await() } diff --git a/app/src/main/java/com/supernova/networkswitch/service/NetworkTileService.kt b/app/src/main/java/com/supernova/networkswitch/service/NetworkTileService.kt index 0fa943d..c1bfd0a 100644 --- a/app/src/main/java/com/supernova/networkswitch/service/NetworkTileService.kt +++ b/app/src/main/java/com/supernova/networkswitch/service/NetworkTileService.kt @@ -3,7 +3,6 @@ package com.supernova.networkswitch.service import android.service.quicksettings.Tile import android.service.quicksettings.TileService import android.telephony.SubscriptionManager -import com.supernova.networkswitch.domain.model.ControlMethod import com.supernova.networkswitch.domain.model.NetworkMode import com.supernova.networkswitch.domain.model.ToggleModeConfig import com.supernova.networkswitch.domain.usecase.GetCurrentNetworkModeUseCase @@ -12,6 +11,9 @@ import com.supernova.networkswitch.domain.usecase.GetToggleModeConfigUseCase import com.supernova.networkswitch.domain.repository.PreferencesRepository import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject @AndroidEntryPoint @@ -34,8 +36,14 @@ class NetworkTileService : TileService() { private var currentNetworkMode: NetworkMode? = null private var toggleConfig: ToggleModeConfig? = null + companion object { + private val _isTileAdded = MutableStateFlow(false) + val isTileAdded: StateFlow = _isTileAdded.asStateFlow() + } + override fun onStartListening() { super.onStartListening() + _isTileAdded.value = true serviceScope.launch { try { // Observe toggle configuration changes @@ -51,6 +59,7 @@ class NetworkTileService : TileService() { override fun onStopListening() { super.onStopListening() + _isTileAdded.value = false // Clean up any ongoing operations when tile becomes inactive } @@ -105,7 +114,7 @@ class NetworkTileService : TileService() { val currentMode = config.getCurrentMode() val nextMode = config.getNextMode() label = currentMode.displayName - subtitle = "${nextMode.displayName}" + subtitle = nextMode.displayName } else { state = Tile.STATE_INACTIVE label = "Network Mode" @@ -120,6 +129,7 @@ class NetworkTileService : TileService() { override fun onDestroy() { super.onDestroy() + _isTileAdded.value = false serviceScope.cancel() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a1b1ffd..fe132bb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ hilt = "2.48" datastore = "1.1.1" mockk = "1.13.10" coroutinesTest = "1.8.0" +material3Expressive = "1.5.0-alpha04" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -31,7 +32,7 @@ androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } -androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3Expressive" } androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } libsu-core = { group = "com.github.topjohnwu.libsu", name = "core", version.ref = "libsu" } libsu-service = { group = "com.github.topjohnwu.libsu", name = "service", version.ref = "libsu" }