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" }