diff --git a/.github/release-notes/v2.2.0.md b/.github/release-notes/v2.2.0.md new file mode 100644 index 0000000..9324213 --- /dev/null +++ b/.github/release-notes/v2.2.0.md @@ -0,0 +1,13 @@ +## Pixel IMS 5G 2.2.0 - Liquid glass, the modem patch up front, and issue fixes + +- **Liquid glass UI:** Translucent glass panels over a lit background, in light and dark, with a System / Light / Dark switch in the top bar and a floating tab bar whose highlight slides between tabs. +- **Modem patch leads Setup:** As soon as root is granted, the 5G regional modem patch is the first card on Setup, with its prerequisites checked and one install button. Every install asks for confirmation first, and results or errors are shown on the card. +- **KernelSU and APatch:** The regional modem patch now installs with KernelSU (with a mounting metamodule such as mountify) and APatch, not only Magisk (#13). +- **Reapply after reboot (Shizuku):** An opt-in switch on SIM Config saves your changes and reapplies them once Shizuku is running after a reboot (#6). +- **Crash fix on Android 17:** Applying a setting no longer crashes the app on Android 17 QPR2 (#7, #14). +- **Fix for "did not accept this change":** The app now waits for Android to finish applying a setting before checking it (#14). +- **Fix for the modem patch on Android 15:** The installer no longer fails with "Unable to find app for caller" (#15). +- **Fix for NSA 5G:** Setup no longer reports 5G as not connected while the phone is on NSA 5G. +- **Other fixes:** Pages no longer get stuck on "no SIM" when SIMs load late, and buttons are no longer left disabled after rotating the screen mid-action. + +Existing installations using `com.nirmala.pixel5gims` can update normally without uninstalling. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1406db3..b447714 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,10 @@ jobs: cache: gradle - uses: android-actions/setup-android@v3 + with: + # The default also installs the legacy 'tools' package, which sdkmanager + # no longer offers, and that failure fails the whole step. + packages: platform-tools - name: Install Android SDK run: sdkmanager "platforms;android-36" "build-tools;36.0.0" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d63c675..d02a86f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,10 @@ jobs: java-version: "17" - uses: android-actions/setup-android@v3 + with: + # The default also installs the legacy 'tools' package, which sdkmanager + # no longer offers, and that failure fails the whole step. + packages: platform-tools - name: Install Android SDK run: sdkmanager "platforms;android-36" "build-tools;36.0.0" diff --git a/README.md b/README.md index f031736..1eaaa86 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,23 @@ An experimental root- or Shizuku-powered IMS and radio configuration app for Goo Android application ID: `com.nirmala.pixel5gims`. -Current public release: `2.1.0` (`v2.1.0`). The app now **opens on the answer**: a Setup screen that says +Current public release: `2.2.0` (`v2.2.0`). The app now **opens on the answer**: a Setup screen that says whether 5G is working on this SIM, which check is blocking it, and offers the fix for that check. The one-time uninstall/reinstall notice applies specifically to version `0.12.6`, where the application ID changed. Existing `0.12.6` installations can update normally to later versions. ## Changelog +### 2.2.0 - liquid glass, the modem patch up front, and issue fixes + +- **Liquid glass UI** in light and dark, with an in-app System / Light / Dark switch in the top bar. +- **The 5G regional modem patch leads Setup** as soon as root is granted, with its prerequisites checked and one install button. Every install goes through the confirmation dialog. +- **KernelSU and APatch** can install the modem patch, not only Magisk ([#13](https://github.com/barrylk/Pixel-IMS-5G/issues/13)). +- **Reapply after reboot** for Shizuku mode, opt-in on SIM Config ([#6](https://github.com/barrylk/Pixel-IMS-5G/issues/6)). +- **Fixed:** the app crashing after applying a setting on Android 17 QPR2 ([#7](https://github.com/barrylk/Pixel-IMS-5G/issues/7)), and settings wrongly reported as "did not accept" ([#14](https://github.com/barrylk/Pixel-IMS-5G/issues/14)). +- **Fixed:** the modem patch failing with "Unable to find app for caller" ([#15](https://github.com/barrylk/Pixel-IMS-5G/issues/15)); install errors now show on the card. +- **Fixed:** NSA 5G reported as not connected; root mode passing the access check through a Shizuku grant; pages stuck on "no SIM" when SIMs loaded late; buttons left disabled after rotating mid-action. + ### [2.1.0](https://github.com/barrylk/Pixel-IMS-5G/releases/tag/v2.1.0) - task-first setup **The app opens on the question you actually have.** Every version until now opened on a settings list, diff --git a/app/build.gradle b/app/build.gradle index 5afd6a3..c4ec67b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,8 +13,8 @@ android { // Every Tensor Pixel (Pixel 6 and newer) launched on Android 12 / API 31 or later. minSdk 31 targetSdk 36 - versionCode 31 - versionName "2.1.0" + versionCode 32 + versionName "2.2.0" vectorDrawables { useSupportLibrary true diff --git a/app/src/main/java/dev/bluehouse/enablevolte/BrokerInstrumentation.kt b/app/src/main/java/dev/bluehouse/enablevolte/BrokerInstrumentation.kt index cdf22d9..54b6cba 100644 --- a/app/src/main/java/dev/bluehouse/enablevolte/BrokerInstrumentation.kt +++ b/app/src/main/java/dev/bluehouse/enablevolte/BrokerInstrumentation.kt @@ -1,6 +1,7 @@ package dev.bluehouse.enablevolte import android.annotation.SuppressLint +import android.app.Activity import android.app.IActivityManager import android.app.Instrumentation import android.content.Context @@ -39,7 +40,7 @@ class BrokerInstrumentation : Instrumentation() { } } finally { Log.i(TAG, "applyConfig done") - am.stopDelegateShellPermissionIdentity() + stopShellIdentity(am) } } @@ -64,28 +65,78 @@ class BrokerInstrumentation : Instrumentation() { } } finally { Log.i(TAG, "clearConfig done") + stopShellIdentity(am) + } + } + + /** + * Drops the delegated shell identity. + * + * Android 17 QPR2 changed this AIDL method, and calling the old signature + * throws NoSuchMethodError — after the override has already been applied — + * which took the whole app down with it (issues #7 and #14). The direct call + * is tried first, then any method by that name, and a failure is only + * logged: the activity manager drops the delegation itself when the + * instrumentation finishes. + */ + private fun stopShellIdentity(am: IActivityManager) { + try { am.stopDelegateShellPermissionIdentity() + return + } catch (e: LinkageError) { + Log.w(TAG, "stopDelegateShellPermissionIdentity() is gone on this build; trying reflection", e) + } catch (e: Exception) { + Log.w(TAG, "stopDelegateShellPermissionIdentity failed", e) + return } + val method = am.javaClass.methods.firstOrNull { it.name == "stopDelegateShellPermissionIdentity" } + if (method == null) { + Log.w(TAG, "No stopDelegateShellPermissionIdentity on this build; relying on instrumentation teardown") + return + } + runCatching { + val args = method.parameterTypes.map { type -> + when (type) { + Int::class.javaPrimitiveType -> Os.getuid() + Long::class.javaPrimitiveType -> 0L + Boolean::class.javaPrimitiveType -> false + else -> null + } + } + method.invoke(am, *args.toTypedArray()) + }.onFailure { Log.w(TAG, "Reflective stopDelegateShellPermissionIdentity failed", it) } } override fun onCreate(arguments: Bundle?) { super.onCreate(arguments) if (arguments == null) { + finish(Activity.RESULT_CANCELED, Bundle()) return } val clear = arguments.getBoolean("moder_clear") val subId = arguments.getInt("moder_subId") - try { + // This runs on the app's own main thread: anything thrown here kills the + // app, so failures are reported back to the caller instead. + val result = Bundle() + val code = try { if (clear) { this.clearConfig(subId) } else { this.applyConfig(subId, arguments) } - } finally { - finish(0, Bundle()) + Activity.RESULT_OK + } catch (t: Throwable) { + Log.e(TAG, "Broker failed to apply the carrier config", t) + result.putString(RESULT_ERROR, t.message ?: t.javaClass.simpleName) + Activity.RESULT_CANCELED } + finish(code, result) + } + + companion object { + const val RESULT_ERROR = "moder_error" } } diff --git a/app/src/main/java/dev/bluehouse/enablevolte/ControlState.kt b/app/src/main/java/dev/bluehouse/enablevolte/ControlState.kt index 7512600..5d9854a 100644 --- a/app/src/main/java/dev/bluehouse/enablevolte/ControlState.kt +++ b/app/src/main/java/dev/bluehouse/enablevolte/ControlState.kt @@ -1,6 +1,7 @@ package dev.bluehouse.enablevolte import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.withContext /** @@ -63,10 +64,19 @@ suspend fun applyAndConfirm( requested: T, write: () -> Unit, readBack: () -> T?, + settleDelaysMs: List = DEFAULT_SETTLE_DELAYS_MS, ): WriteOutcome = withContext(Dispatchers.IO) { try { write() - val actual = readBack() + // CarrierConfig propagates asynchronously (and the Shizuku broker more so), + // so one immediate read can see the old value. Give it a moment to settle + // before calling the change rejected. + var actual = readBack() + for (wait in settleDelaysMs) { + if (actual == requested) break + delay(wait) + actual = readBack() + } if (actual == requested) { WriteOutcome.Confirmed(requested) } else { @@ -77,6 +87,9 @@ suspend fun applyAndConfirm( } } +/** Roughly two seconds in all, front-loaded because most writes land within the first. */ +val DEFAULT_SETTLE_DELAYS_MS = listOf(150L, 250L, 400L, 600L, 800L) + /** * Reads a value off the main thread, returning null rather than throwing when * the privileged service is unavailable. diff --git a/app/src/main/java/dev/bluehouse/enablevolte/HomeActivity.kt b/app/src/main/java/dev/bluehouse/enablevolte/HomeActivity.kt index 653d0ce..d481dab 100644 --- a/app/src/main/java/dev/bluehouse/enablevolte/HomeActivity.kt +++ b/app/src/main/java/dev/bluehouse/enablevolte/HomeActivity.kt @@ -18,11 +18,10 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -31,15 +30,19 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.BrightnessAuto +import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.PowerSettingsNew import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Science @@ -49,17 +52,16 @@ import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -69,12 +71,12 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -86,9 +88,11 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import dev.bluehouse.enablevolte.components.AppBackdrop +import dev.bluehouse.enablevolte.components.GlassDepth import dev.bluehouse.enablevolte.components.InfoDialog import dev.bluehouse.enablevolte.components.OnLifecycleEvent import dev.bluehouse.enablevolte.components.WhatsNewDialog +import dev.bluehouse.enablevolte.components.liquidGlass import dev.bluehouse.enablevolte.pages.About import dev.bluehouse.enablevolte.pages.Bands import dev.bluehouse.enablevolte.pages.Config @@ -103,12 +107,13 @@ import dev.bluehouse.enablevolte.pages.NetworkPage import dev.bluehouse.enablevolte.pages.SetupPage import dev.bluehouse.enablevolte.ui.theme.EnableVoLTETheme import dev.bluehouse.enablevolte.ui.theme.LocalInstrument -import java.lang.IllegalStateException +import dev.bluehouse.enablevolte.ui.theme.ThemeMode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.lsposed.hiddenapibypass.HiddenApiBypass import rikka.shizuku.Shizuku +import java.lang.IllegalStateException data class Screen( val route: String, @@ -142,9 +147,15 @@ class HomeActivity : ComponentActivity() { enableEdgeToEdge() setContent { - EnableVoLTETheme { + var themeMode by remember { mutableStateOf(ThemeMode.load(this@HomeActivity)) } + EnableVoLTETheme(themeMode = themeMode) { AppBackdrop { PixelIMSApp( + themeMode = themeMode, + onThemeModeChange = { + themeMode = it + ThemeMode.save(this@HomeActivity, it) + }, startDestination = if (intent.getBooleanExtra(EXTRA_OPEN_UPDATES, false)) { "home/about" } else { @@ -231,6 +242,8 @@ class HomeActivity : ComponentActivity() { @OptIn(ExperimentalMaterial3Api::class) @Composable fun PixelIMSApp( + themeMode: ThemeMode = ThemeMode.SYSTEM, + onThemeModeChange: (ThemeMode) -> Unit = {}, startDestination: String = "home", navigationRequest: String? = null, onNavigationRequestHandled: () -> Unit = {}, @@ -247,6 +260,10 @@ fun PixelIMSApp( } var privilegeError by rememberSaveable { mutableStateOf(null) } var privilegeConnecting by rememberSaveable { mutableStateOf(false) } + + // Observable, unlike PrivilegeManager.isRootReady(): the regional patch card + // on the home screen appears only once root is detected and granted. + var rootGranted by remember { mutableStateOf(PrivilegeManager.isRootReady()) } val osDistribution = remember { OsDistributionDetector.detect(context) } var showShizukuRegionalWarning by rememberSaveable { mutableStateOf(false) } var whatsNew by remember { mutableStateOf(UpdateManager.changelogToShow(context)) } @@ -281,6 +298,7 @@ fun PixelIMSApp( context.mainExecutor.execute { privilegeConnecting = false privilegeError = error + rootGranted = ready && PrivilegeManager.isRootReady() if (ready) { runCatching { subscriptions = carrierModer.subscriptions @@ -291,26 +309,23 @@ fun PixelIMSApp( return } if (mode == PrivilegeMode.ROOT) { + rootGranted = true runCatching { subscriptions = carrierModer.subscriptions }.onFailure { privilegeError = it.message ?: "Unable to read telephony services as root" } return } - val shizukuStatus = checkShizukuPermission(0) + rootGranted = false + // checkShizukuPermission throws IllegalStateException when the binder dies + // between its own checks, so it belongs inside the try. try { - when (shizukuStatus) { + when (checkShizukuPermission(0)) { ShizukuStatus.GRANTED -> { Log.d(dev.bluehouse.enablevolte.pages.TAG, "Shizuku granted") subscriptions = carrierModer.subscriptions } - ShizukuStatus.NOT_GRANTED -> { - Shizuku.addRequestPermissionResultListener { _, grantResult -> - if (grantResult == PackageManager.PERMISSION_GRANTED) { - Log.d(dev.bluehouse.enablevolte.pages.TAG, "Shizuku granted") - subscriptions = carrierModer.subscriptions - } - } - } + // The grant arrives through shizukuPermissionListener below. + ShizukuStatus.NOT_GRANTED -> Unit else -> { subscriptions = listOf() } @@ -319,6 +334,20 @@ fun PixelIMSApp( } } + // One listener for the life of the screen. It used to be added on every + // load and every refresh tap, and never removed, so each grant re-read the + // subscriptions once per stale listener. + DisposableEffect(Unit) { + val listener = Shizuku.OnRequestPermissionResultListener { _, grantResult -> + if (grantResult == PackageManager.PERMISSION_GRANTED && PrivilegeManager.activeMode == PrivilegeMode.SHIZUKU) { + Log.d(dev.bluehouse.enablevolte.pages.TAG, "Shizuku granted") + runCatching { subscriptions = carrierModer.subscriptions } + } + } + Shizuku.addRequestPermissionResultListener(listener) + onDispose { Shizuku.removeRequestPermissionResultListener(listener) } + } + OnLifecycleEvent { _, event -> if (event == Lifecycle.Event.ON_CREATE) { loadApplication() @@ -465,6 +494,22 @@ fun PixelIMSApp( } }, actions = { + IconButton(onClick = { onThemeModeChange(themeMode.next()) }) { + Icon( + when (themeMode) { + ThemeMode.SYSTEM -> Icons.Filled.BrightnessAuto + ThemeMode.LIGHT -> Icons.Filled.LightMode + ThemeMode.DARK -> Icons.Filled.DarkMode + }, + contentDescription = stringResource( + when (themeMode) { + ThemeMode.SYSTEM -> R.string.appearance_system + ThemeMode.LIGHT -> R.string.appearance_light + ThemeMode.DARK -> R.string.appearance_dark + }, + ), + ) + } if (subscriptions.isNotEmpty()) { IconButton( onClick = { showRecoveryDialog = true }, @@ -492,7 +537,7 @@ fun PixelIMSApp( }, colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, - scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.82f), + scrolledContainerColor = LocalInstrument.current.scrim, ), ) }, @@ -507,34 +552,41 @@ fun PixelIMSApp( Screen("field-test", stringResource(R.string.field_test_short), Icons.Filled.Science), ) val inst = LocalInstrument.current - // The bar floats again in 2.0, but as a frosted plate with a lit - // edge rather than the opaque pill 1.0.6 shipped: it belongs to - // the same glass family as the panels it sits over. - Box(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 14.dp, vertical = 10.dp)) { - Surface( - modifier = Modifier.fillMaxWidth().height(62.dp), - shape = RoundedCornerShape(22.dp), - color = Color.Transparent, - border = BorderStroke(1.dp, inst.edgeBright), - tonalElevation = 0.dp, - shadowElevation = 12.dp, + val barShape = RoundedCornerShape(30.dp) + val selectedIndex = items.indexOfFirst { screen -> + when (screen.route) { + "controls" -> currentRoute in setOf("controls", "config/{subId}", "bands/{subId}") + else -> currentDestination?.hierarchy?.any { it.route == screen.route } == true + } + } + val lensPosition by animateFloatAsState( + targetValue = selectedIndex.coerceAtLeast(0).toFloat(), + animationSpec = spring(dampingRatio = 0.72f, stiffness = 380f), + label = "nav lens", + ) + // A floating pane of thick glass, with a smaller lens of tinted + // glass that slides between tabs rather than jumping. + Box(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 18.dp, vertical = 10.dp)) { + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .height(66.dp) + .liquidGlass(barShape, GlassDepth.THICK) + .padding(5.dp), ) { - Row( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient(listOf(inst.frostHigh, inst.frost)), - RoundedCornerShape(22.dp), - ) - .padding(horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - items.forEach { screen -> - val selected = when { - screen.route == "controls" -> - currentRoute in setOf("controls", "config/{subId}", "bands/{subId}") - else -> currentDestination?.hierarchy?.any { it.route == screen.route } == true - } + val slot = maxWidth / items.size + if (selectedIndex >= 0) { + Box( + Modifier + .offset(x = slot * lensPosition) + .width(slot) + .fillMaxHeight() + .liquidGlass(RoundedCornerShape(25.dp), GlassDepth.TINTED, inst.glow), + ) + } + Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { + items.forEachIndexed { index, screen -> + val selected = index == selectedIndex val tint by animateColorAsState( if (selected) { MaterialTheme.colorScheme.primary @@ -543,32 +595,32 @@ fun PixelIMSApp( }, label = "nav tint", ) - val lift by animateFloatAsState( - if (selected) 1f else 0f, - animationSpec = spring(dampingRatio = 0.6f, stiffness = 420f), - label = "nav lift", - ) Column( modifier = Modifier .weight(1f) .fillMaxHeight() - .clickable { - navController.navigate(screen.route) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true + .clip(RoundedCornerShape(25.dp)) + .selectable( + selected = selected, + role = Role.Tab, + onClick = { + navController.navigate(screen.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true } - launchSingleTop = true - restoreState = true - } - }, + }, + ), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Icon( screen.icon, - contentDescription = screen.title, + contentDescription = null, tint = tint, - modifier = Modifier.size(22.dp).graphicsLayer { translationY = -3f * lift }, + modifier = Modifier.size(22.dp), ) Spacer(Modifier.height(3.dp)) Text( @@ -577,13 +629,6 @@ fun PixelIMSApp( color = tint, maxLines = 1, ) - Spacer(Modifier.height(3.dp)) - Box( - Modifier - .height(2.dp) - .width((16 * lift).dp) - .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(2.dp)), - ) } } } @@ -594,7 +639,7 @@ fun PixelIMSApp( ) { innerPadding -> NavHost(navController, startDestination = startDestination, Modifier.padding(innerPadding)) { composable("home", context.resources.getString(R.string.setup)) { - SetupPage(subscriptions, navController) + SetupPage(subscriptions, navController, rootGranted) } // The old readiness overview is still reachable; Setup answers the // narrower question most people open the app with. @@ -631,7 +676,7 @@ fun PixelIMSApp( entry.arguments?.getString("subId")?.toIntOrNull()?.let { Editor(it) } } composable("bands/{subId}", context.resources.getString(R.string.bands)) { entry -> - entry.arguments?.getString("subId")?.toIntOrNull()?.let { Bands(it, navController) } + entry.arguments?.getString("subId")?.toIntOrNull()?.let { Bands(it, navController, rootGranted) } } } } diff --git a/app/src/main/java/dev/bluehouse/enablevolte/Moder.kt b/app/src/main/java/dev/bluehouse/enablevolte/Moder.kt index 1aaf436..355ed84 100644 --- a/app/src/main/java/dev/bluehouse/enablevolte/Moder.kt +++ b/app/src/main/java/dev/bluehouse/enablevolte/Moder.kt @@ -3,12 +3,14 @@ package dev.bluehouse.enablevolte import android.annotation.SuppressLint import android.app.IActivityManager import android.app.UiAutomationConnection +import android.app.IInstrumentationWatcher import android.content.ComponentName import android.content.Context import android.content.res.Resources import android.os.Build import android.os.Build.VERSION_CODES import android.os.Bundle +import android.os.Looper import android.os.IInterface import android.os.IBinder import android.os.Parcel @@ -139,6 +141,9 @@ open class Moder { */ private fun settlePause(millis: Long) = Thread.sleep(millis) +/** How long a background caller waits for the Shizuku broker to report its result. */ +private const val BROKER_TIMEOUT_SECONDS = 5L + class CarrierModer( private val context: Context, ) : Moder() { @@ -1541,16 +1546,37 @@ class SubscriptionModer( } arg.putInt("moder_subId", subscriptionId) + // startInstrumentation returns before the broker has run, so a read-back + // straight after it raced the write and reported "did not accept" (#14). + // The watcher lets callers off the main thread wait for the real result. + val finished = CountDownLatch(1) + val brokerError = AtomicReference(null) + val watcher = object : IInstrumentationWatcher.Stub() { + override fun instrumentationStatus(name: ComponentName?, resultCode: Int, results: Bundle?) = Unit + + override fun instrumentationFinished(name: ComponentName?, resultCode: Int, results: Bundle?) { + brokerError.set(results?.getString(BrokerInstrumentation.RESULT_ERROR)) + finished.countDown() + } + } + am.startInstrumentation( ComponentName(context, Class.forName("dev.bluehouse.enablevolte.BrokerInstrumentation")), null, 8, arg, - null, + watcher, UiAutomationConnection(), 0, null, ) + // The broker runs on the main thread, so waiting there would deadlock. + if (Looper.myLooper() != Looper.getMainLooper()) { + if (!finished.await(BROKER_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + Log.w(TAG, "Broker did not report back within ${BROKER_TIMEOUT_SECONDS}s") + } + brokerError.get()?.let { throw IllegalStateException(it) } + } } private fun overrideConfig(bundle: Bundle?) { @@ -1575,7 +1601,11 @@ class SubscriptionModer( ) { val delta = Bundle() fn(delta) - if (PrivilegeManager.activeMode == PrivilegeMode.ROOT) { + // Root always keeps a profile; Shizuku only when "reapply after reboot" is on. + if ( + PrivilegeManager.activeMode == PrivilegeMode.ROOT || + (persistForRoot && ShizukuBootReapply.isEnabled(context)) + ) { val store = RootCarrierConfigStore(context) val merged = store.merge(subscriptionId, delta) this.overrideConfig(merged) @@ -1654,9 +1684,9 @@ class SubscriptionModer( RootCarrierConfigStore(context).clear(subscriptionId) } - internal fun reapplyPersistedRootCarrierConfig(): Boolean = + internal fun reapplyPersistedCarrierConfig(): Boolean = runCatching { - check(PrivilegeManager.activeMode == PrivilegeMode.ROOT && PrivilegeManager.isRootReady()) + check(PrivilegeManager.activeMode == PrivilegeMode.SHIZUKU || PrivilegeManager.isRootReady()) val store = RootCarrierConfigStore(context) if (!store.hasProfile(subscriptionId)) return true this.overrideConfig(store.load(subscriptionId)) diff --git a/app/src/main/java/dev/bluehouse/enablevolte/PrivilegedService.kt b/app/src/main/java/dev/bluehouse/enablevolte/PrivilegedService.kt index eb5205b..98a0aba 100644 --- a/app/src/main/java/dev/bluehouse/enablevolte/PrivilegedService.kt +++ b/app/src/main/java/dev/bluehouse/enablevolte/PrivilegedService.kt @@ -491,6 +491,37 @@ class PrivilegedService : RootService() { } } + private fun detectModuleManager(): ModuleManager? = when { + findExecutable(MAGISK_CANDIDATES) != null -> ModuleManager.MAGISK + File(KSUD).canExecute() -> ModuleManager.KERNELSU + File(APD).canExecute() -> ModuleManager.APATCH + else -> null + } + + /** The domain this root service runs in, e.g. `magisk` or `su`. */ + private fun currentSelinuxDomain(): String? = + runCatching { File("/proc/self/attr/current").readText().trim().trimEnd('\u0000') } + .getOrNull() + ?.split(':') + ?.getOrNull(2) + ?.takeIf { it.matches(Regex("[a-z0-9_]+")) } + + /** + * SQLiteDatabase reads a Settings.Global flag through the app's content + * resolver the first time it opens anything. This service has no app + * identity, so on some builds that lookup throws "Unable to find app for + * caller" and the patch never installs (issue #15). Initialising the + * flags with no value marks them read and skips the lookup. + */ + private fun disableSqliteGlobalSettingsLookup() { + val walFlags = "android.database.sqlite.SQLiteCompatibilityWalFlags" + runCatching { + Class.forName(walFlags).getMethod("init", String::class.java).invoke(null, null) + }.recoverCatching { + HiddenApiBypass.invoke(Class.forName(walFlags), null, "init", null) + }.onFailure { Log.w(TAG, "Unable to pre-initialise SQLite compatibility flags", it) } + } + private fun runCommand(vararg command: String): CommandResult = runCatching { val process = ProcessBuilder(*command).redirectErrorStream(true).start() @@ -503,21 +534,21 @@ class PrivilegedService : RootService() { check(device in TENSOR_DEVICES) { "Unsupported device '$device'. This patch is limited to known Google Tensor Pixels." } - check(findExecutable(MAGISK_CANDIDATES) != null) { - "Magisk was not detected. The modem database patch is unavailable with Shizuku." - } + val manager = detectModuleManager() + ?: error("No module manager (Magisk, KernelSU or APatch) was detected. The modem database patch needs one.") val source = File(REGIONAL_SOURCE_DB) check(source.isFile) { "The Tensor carrier database was not found at $REGIONAL_SOURCE_DB." } - val policy = findExecutable(MAGISK_POLICY_CANDIDATES) - ?: error("magiskpolicy was not found; the stock carrier database cannot be read safely.") - val policyResult = runCommand( - policy, - "--live", - "allow magisk vendor_fw_file file { getattr open read map }", - ) - check(policyResult.exitCode == 0) { - "SELinux refused temporary read access to the stock carrier database." + // Each manager runs this service in its own SELinux domain and ships its + // own live-policy tool, so the read rule is written for whichever is here. + val readRule = "allow ${currentSelinuxDomain() ?: manager.defaultDomain} vendor_fw_file file { getattr open read map }" + val policyApplied = when (manager) { + ModuleManager.KERNELSU -> runCommand(KSUD, "sepolicy", "patch", readRule).exitCode == 0 + else -> findExecutable(MAGISK_POLICY_CANDIDATES)?.let { runCommand(it, "--live", readRule).exitCode == 0 } == true + } + if (!policyApplied) Log.w(TAG, "Live SELinux rule not applied for ${manager.label}; trying the read anyway") + check(source.canRead()) { + "SELinux refused read access to the stock carrier database under ${manager.label}." } val workDir = File(cacheDirectory(), "regional-modem-patch").apply { @@ -567,15 +598,19 @@ class PrivilegedService : RootService() { } stagedDatabase.copyTo(targetDatabase, overwrite = true) - check( - runCommand( - "/system/bin/chcon", - "-R", - "u:object_r:magisk_file:s0", - pendingModule.path, - ).exitCode == 0, - ) { - "Unable to assign the Magisk module SELinux label." + // magisk_file only exists in Magisk's policy; KernelSU and APatch label + // their module tree themselves. + if (manager == ModuleManager.MAGISK) { + check( + runCommand( + "/system/bin/chcon", + "-R", + "u:object_r:magisk_file:s0", + pendingModule.path, + ).exitCode == 0, + ) { + "Unable to assign the Magisk module SELinux label." + } } check(runCommand("/system/bin/chmod", "0755", File(pendingModule, "service.sh").path).exitCode == 0) { "Unable to make the module boot service executable." @@ -603,11 +638,17 @@ class PrivilegedService : RootService() { return regionalPatchStatus( rebootRequired = true, - message = "Validated systemless modem patch installed. Reboot to load it.", + message = "Validated systemless modem patch installed. Reboot to load it." + + if (manager == ModuleManager.KERNELSU) { + " KernelSU needs a mounting metamodule (for example mountify or meta-overlayfs) to apply it." + } else { + "" + }, ).toString() } private fun patchAndValidateCarrierDatabase(databaseFile: File) { + disableSqliteGlobalSettingsLookup() val database = SQLiteDatabase.openDatabase( databaseFile.path, null, @@ -679,7 +720,8 @@ class PrivilegedService : RootService() { val pendingReboot = File(module, ".pending_reboot").isFile return JSONObject() .put("supported", device in TENSOR_DEVICES) - .put("magiskAvailable", findExecutable(MAGISK_CANDIDATES) != null) + .put("magiskAvailable", detectModuleManager() != null) + .put("rootManager", detectModuleManager()?.label.orEmpty()) .put("sourceAvailable", File(REGIONAL_SOURCE_DB).isFile) .put("installed", database.isFile) .put("removalPending", removalPending) @@ -695,7 +737,8 @@ class PrivilegedService : RootService() { val module = File(REGIONAL_MODULE_DIR) return when { device !in TENSOR_DEVICES -> "This device is not in the validated Tensor Pixel list." - findExecutable(MAGISK_CANDIDATES) == null -> "Magisk is required; Shizuku cannot overlay vendor firmware." + detectModuleManager() == null -> + "A module manager (Magisk, KernelSU or APatch) is required; Shizuku cannot overlay vendor firmware." !File(REGIONAL_SOURCE_DB).isFile -> "The Tensor carrier database was not found on this firmware." File(module, "remove").isFile -> "Removal is scheduled. Reboot to restore the stock modem database." File(module, ".pending_reboot").isFile -> "Patch installed. Reboot is required before it becomes active." @@ -762,7 +805,10 @@ class PrivilegedService : RootService() { "/system_ext/bin/magiskpolicy", "/sbin/magiskpolicy", "/data/adb/magisk/magiskpolicy", + "/data/adb/ap/bin/magiskpolicy", ) + private const val KSUD = "/data/adb/ksud" + private const val APD = "/data/adb/apd" private val TENSOR_DEVICES = setOf( "oriole", "raven", "bluejay", "panther", "cheetah", "lynx", "tangorpro", "felix", @@ -898,3 +944,10 @@ class PrivilegedService : RootService() { } } } + +/** The root solutions that can mount a systemless module from /data/adb/modules. */ +private enum class ModuleManager(val label: String, val defaultDomain: String) { + MAGISK("Magisk", "magisk"), + KERNELSU("KernelSU", "su"), + APATCH("APatch", "su"), +} diff --git a/app/src/main/java/dev/bluehouse/enablevolte/ReadinessRules.kt b/app/src/main/java/dev/bluehouse/enablevolte/ReadinessRules.kt new file mode 100644 index 0000000..380a627 --- /dev/null +++ b/app/src/main/java/dev/bluehouse/enablevolte/ReadinessRules.kt @@ -0,0 +1,52 @@ +package dev.bluehouse.enablevolte + +/** + * The pure decisions behind the Setup checks, kept apart from the binder calls + * that feed them so they can be unit tested on the JVM. + */ +object ReadinessRules { + /** + * Whether the chosen privilege path is actually usable. + * + * Root mode routes every binder call through the root service, so a + * Shizuku grant cannot stand in for a root service that is not connected. + */ + fun hasAccess(rootMode: Boolean, rootReady: Boolean, shizukuGranted: Boolean): Boolean = + if (rootMode) rootReady else shizukuGranted + + fun patchStatus(patch: RegionalModemPatchStatus?): CheckStatus = when { + patch == null -> CheckStatus.UNKNOWN + !patch.supported -> CheckStatus.WARN + // Installed but not yet loaded by the modem, or on its way out: neither is done. + patch.removalPending || patch.rebootRequired -> CheckStatus.WARN + patch.installed -> CheckStatus.PASS + else -> CheckStatus.FAIL + } + + fun canInstallPatch(patch: RegionalModemPatchStatus): Boolean = + patch.supported && !patch.installed && patch.magiskAvailable && patch.sourceAvailable && !patch.removalPending + + /** + * Re-staging is only safe before the reboot that mounts the overlay: once it + * is live, the "stock" database the installer reads is the patched copy. + */ + fun canRevalidatePatch(patch: RegionalModemPatchStatus): Boolean = + patch.supported && + patch.installed && + patch.rebootRequired && + !patch.removalPending && + patch.magiskAvailable && + patch.sourceAvailable + + fun canRemovePatch(patch: RegionalModemPatchStatus): Boolean = patch.installed && !patch.removalPending + + /** + * On NSA the NR leg is a secondary cell and is often not reported as a + * registered cell, so the data RAT and NR state count as evidence too. + */ + fun is5gConnected(dataRat: String, nrState: Int, registeredNrCell: Boolean): Boolean = + registeredNrCell || dataRat == "NR" || (dataRat == "LTE" && nrState == NR_STATE_CONNECTED) + + /** `NetworkRegistrationInfo.NR_STATE_CONNECTED`. */ + const val NR_STATE_CONNECTED = 3 +} diff --git a/app/src/main/java/dev/bluehouse/enablevolte/RegionalModemPatch.kt b/app/src/main/java/dev/bluehouse/enablevolte/RegionalModemPatch.kt index 84ac1e7..3c7a757 100644 --- a/app/src/main/java/dev/bluehouse/enablevolte/RegionalModemPatch.kt +++ b/app/src/main/java/dev/bluehouse/enablevolte/RegionalModemPatch.kt @@ -13,6 +13,8 @@ data class RegionalModemPatchStatus( val sourceSha256: String, val patchedSha256: String, val message: String, + /** Magisk, KernelSU or APatch; empty when none was found or the service predates 2.2. */ + val rootManager: String = "", ) { companion object { fun fromJson(value: String): RegionalModemPatchStatus { @@ -28,6 +30,7 @@ data class RegionalModemPatchStatus( sourceSha256 = json.optString("sourceSha256"), patchedSha256 = json.optString("patchedSha256"), message = json.optString("message"), + rootManager = json.optString("rootManager"), ) } diff --git a/app/src/main/java/dev/bluehouse/enablevolte/RegionalPatchViewModel.kt b/app/src/main/java/dev/bluehouse/enablevolte/RegionalPatchViewModel.kt new file mode 100644 index 0000000..92b7e40 --- /dev/null +++ b/app/src/main/java/dev/bluehouse/enablevolte/RegionalPatchViewModel.kt @@ -0,0 +1,88 @@ +package dev.bluehouse.enablevolte + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class RegionalPatchUiState( + val status: RegionalModemPatchStatus? = null, + val loading: Boolean = true, + val busy: Boolean = false, + /** The action waiting on the user's confirmation, if any. */ + val confirming: RegionalPatchAction? = null, + /** What the last install or removal reported, kept until dismissed so a failure is never silent. */ + val outcome: String? = null, +) + +enum class RegionalPatchAction { INSTALL, REMOVE } + +/** + * Owns device-wide patch work across navigation, rotation and appearance changes. + * + * The work used to live in a composable's coroutine scope with a saveable busy + * flag, so rotating mid-install cancelled the coroutine but restored `busy = + * true`, and the buttons stayed disabled until the page was left. + */ +class RegionalPatchViewModel : ViewModel() { + private val _state = MutableStateFlow(RegionalPatchUiState()) + val state = _state.asStateFlow() + + private var work: Job? = null + + init { + refresh() + } + + fun refresh() { + if (work?.isActive == true) return + work = viewModelScope.launch { + _state.value = _state.value.copy(loading = _state.value.status == null) + val status = withContext(Dispatchers.IO) { PrivilegeManager.getRegionalModemPatchStatus() } + _state.value = _state.value.copy(status = status, loading = false) + } + } + + fun requestConfirmation(action: RegionalPatchAction) { + if (_state.value.busy) return + _state.value = _state.value.copy(confirming = action) + } + + fun dismissOutcome() { + _state.value = _state.value.copy(outcome = null) + } + + fun cancelConfirmation() { + _state.value = _state.value.copy(confirming = null) + } + + fun confirm() { + val action = _state.value.confirming ?: return + _state.value = _state.value.copy(confirming = null) + apply(action) + } + + private fun apply(action: RegionalPatchAction) { + if (work?.isActive == true) return + _state.value = _state.value.copy(busy = true) + work = viewModelScope.launch { + val result = withContext(Dispatchers.IO) { + // Recheck both access and compatibility after the confirmation dialog. + val current = PrivilegeManager.getRegionalModemPatchStatus() + when { + action == RegionalPatchAction.INSTALL && + (ReadinessRules.canInstallPatch(current) || ReadinessRules.canRevalidatePatch(current)) -> + PrivilegeManager.installRegionalModemPatch() + action == RegionalPatchAction.REMOVE && ReadinessRules.canRemovePatch(current) -> + PrivilegeManager.scheduleRegionalModemPatchRemoval() + else -> current + } + } + _state.value = RegionalPatchUiState(status = result, loading = false, outcome = result.message.ifBlank { null }) + } + } +} diff --git a/app/src/main/java/dev/bluehouse/enablevolte/ReleaseChangelog.kt b/app/src/main/java/dev/bluehouse/enablevolte/ReleaseChangelog.kt index 2ddcdcb..c2daa5e 100644 --- a/app/src/main/java/dev/bluehouse/enablevolte/ReleaseChangelog.kt +++ b/app/src/main/java/dev/bluehouse/enablevolte/ReleaseChangelog.kt @@ -21,6 +21,58 @@ data class InstalledChangelog( object ReleaseChangelogCatalog { fun forVersion(version: String): InstalledChangelog? = when (version.removePrefix("v")) { + "2.2.0" -> + InstalledChangelog( + version = "2.2.0", + items = + listOf( + ChangelogItem( + title = "Liquid glass, light and dark", + detail = "Panels are now translucent glass over a lit background, with a specular rim and highlight. The floating tab bar has a glass lens that slides between tabs. A new button in the top bar switches between System, Light and Dark.", + tone = ChangelogTone.FEATURE, + ), + ChangelogItem( + title = "The modem patch leads Home for root users", + detail = "As soon as root is granted, the 5G regional modem patch is the first card on Setup, with its prerequisites checked and one install button. Once active it shrinks to a single line.", + tone = ChangelogTone.FEATURE, + ), + ChangelogItem( + title = "KernelSU and APatch", + detail = "The regional modem patch now installs with KernelSU (with a mounting metamodule such as mountify) and APatch, not only Magisk.", + tone = ChangelogTone.FEATURE, + ), + ChangelogItem( + title = "Reapply after reboot (Shizuku)", + detail = "An opt-in switch on SIM Config saves your changes and reapplies them once Shizuku is running after a reboot.", + tone = ChangelogTone.FEATURE, + ), + ChangelogItem( + title = "No more crash when applying a setting on Android 17", + detail = "Android 17 QPR2 changed a system call the app made after every change, which crashed the app even though the setting had applied. That call is now handled safely.", + tone = ChangelogTone.FIX, + ), + ChangelogItem( + title = "Settings no longer report 'did not accept' by mistake", + detail = "The app checked the result before Android had finished applying it. It now waits for the change to land.", + tone = ChangelogTone.FIX, + ), + ChangelogItem( + title = "Modem patch installs on Android 15 builds that refused it", + detail = "The installer failed with 'Unable to find app for caller' when opening the modem database as root. Failures are now shown on the card instead of doing nothing.", + tone = ChangelogTone.FIX, + ), + ChangelogItem( + title = "NSA 5G counts as connected", + detail = "Setup no longer reports 5G as failing while the phone is on NSA 5G.", + tone = ChangelogTone.FIX, + ), + ChangelogItem( + title = "The modem patch always asks first", + detail = "Every install, from any screen, now goes through the confirmation dialog.", + tone = ChangelogTone.IMPORTANT, + ), + ), + ) "2.1.0" -> InstalledChangelog( version = "2.1.0", diff --git a/app/src/main/java/dev/bluehouse/enablevolte/RootCarrierConfigPersistence.kt b/app/src/main/java/dev/bluehouse/enablevolte/RootCarrierConfigPersistence.kt index b99b2a4..dd28c16 100644 --- a/app/src/main/java/dev/bluehouse/enablevolte/RootCarrierConfigPersistence.kt +++ b/app/src/main/java/dev/bluehouse/enablevolte/RootCarrierConfigPersistence.kt @@ -12,15 +12,32 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean -internal object RootCarrierConfigPersistence { +/** Reapplies every saved per-SIM profile through whichever privilege mode is active. */ +internal object CarrierConfigPersistence { fun reapplyAll(context: Context): Boolean { - if (PrivilegeManager.activeMode != PrivilegeMode.ROOT || !PrivilegeManager.isRootReady()) return false + val ready = when (PrivilegeManager.activeMode) { + PrivilegeMode.ROOT -> PrivilegeManager.isRootReady() + PrivilegeMode.SHIZUKU -> ShizukuBootReapply.isEnabled(context) && checkShizukuGrantedQuietly() + } + if (!ready) return false val carrierModer = CarrierModer(context) return carrierModer.subscriptions .filter { RootCarrierConfigStore(context).hasProfile(it.subscriptionId) } - .all { SubscriptionModer(context, it.subscriptionId).reapplyPersistedRootCarrierConfig() } + .all { SubscriptionModer(context, it.subscriptionId).reapplyPersistedCarrierConfig() } } + /** Unlike checkShizukuPermission, never raises a permission prompt from the background. */ + private fun checkShizukuGrantedQuietly(): Boolean = + runCatching { + rikka.shizuku.Shizuku.pingBinder() && + rikka.shizuku.Shizuku.checkSelfPermission() == android.content.pm.PackageManager.PERMISSION_GRANTED + }.getOrDefault(false) +} + +internal object RootCarrierConfigPersistence { + fun reapplyAll(context: Context): Boolean = + PrivilegeManager.activeMode == PrivilegeMode.ROOT && CarrierConfigPersistence.reapplyAll(context) + fun schedule(context: Context, delaySeconds: Long) { if (PrivilegeManager.selectedMode(context) != PrivilegeMode.ROOT) return val request = OneTimeWorkRequestBuilder() @@ -42,6 +59,9 @@ class RootCarrierConfigPersistenceWorker( ) : Worker(appContext, workerParams) { override fun doWork(): Result { if (PrivilegeManager.selectedMode(applicationContext) != PrivilegeMode.ROOT) return Result.success() + // Runs without HomeActivity, which is where the exemptions are normally added; + // without them the hidden telephony interfaces fail to load at boot. + org.lsposed.hiddenapibypass.HiddenApiBypass.addHiddenApiExemptions("L", "I") PrivilegeManager.activate(applicationContext, PrivilegeMode.ROOT) val completed = CountDownLatch(1) @@ -59,5 +79,6 @@ class RootCarrierConfigBootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val delay = if (intent.action == Intent.ACTION_MY_PACKAGE_REPLACED) 3L else 20L RootCarrierConfigPersistence.schedule(context.applicationContext, delay) + ShizukuBootReapply.schedule(context.applicationContext, delay) } } diff --git a/app/src/main/java/dev/bluehouse/enablevolte/SetupViewModel.kt b/app/src/main/java/dev/bluehouse/enablevolte/SetupViewModel.kt index aea34b4..a027d89 100644 --- a/app/src/main/java/dev/bluehouse/enablevolte/SetupViewModel.kt +++ b/app/src/main/java/dev/bluehouse/enablevolte/SetupViewModel.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -75,12 +76,16 @@ class SetupViewModel( private val _state = kotlinx.coroutines.flow.MutableStateFlow(SetupState()) val state: kotlinx.coroutines.flow.StateFlow = _state + private var checkJob: Job? = null + init { refresh() } fun refresh() { - viewModelScope.launch { + // A newer refresh supersedes an older one rather than racing it to the state. + checkJob?.cancel() + checkJob = viewModelScope.launch { _state.value = _state.value.copy(loading = _state.value.checks.isEmpty()) val result = withContext(Dispatchers.IO) { runChecks() } _state.value = _state.value.copy(loading = false, checks = result.first, carrier = result.second, busyFix = null) @@ -88,7 +93,7 @@ class SetupViewModel( } fun applyFix(action: FixAction) { - if (action == FixAction.NONE) return + if (action == FixAction.NONE || _state.value.busyFix != null) return _state.value = _state.value.copy(busyFix = action) viewModelScope.launch { val note = withContext(Dispatchers.IO) { @@ -115,14 +120,8 @@ class SetupViewModel( ) "NR set to NSA and SA." } - FixAction.INSTALL_MODEM_PATCH -> { - val status = PrivilegeManager.installRegionalModemPatch() - if (status.rebootRequired) { - "Patch installed. Reboot for the modem to pick it up." - } else { - status.message.ifBlank { "Patch step finished." } - } - } + // The modem patch is never applied from here: it goes through + // RegionalPatchViewModel and its confirmation dialog. FixAction.RESTART_IMS -> { moder.restartIMSRegistration() "IMS registration restarted." @@ -146,7 +145,12 @@ class SetupViewModel( val rootMode = PrivilegeManager.activeMode == PrivilegeMode.ROOT val privileged = runCatching { - (rootMode && PrivilegeManager.isRootReady()) || checkShizukuPermission(0) == ShizukuStatus.GRANTED + ReadinessRules.hasAccess( + rootMode = rootMode, + rootReady = rootMode && PrivilegeManager.isRootReady(), + // Only asked in Shizuku mode: the check can raise a permission prompt. + shizukuGranted = !rootMode && checkShizukuPermission(0) == ShizukuStatus.GRANTED, + ) }.getOrDefault(false) out += ReadinessCheck( @@ -238,21 +242,13 @@ class SetupViewModel( patch.removalPending -> "Removal is pending — reboot to finish" patch.installed && patch.rebootRequired -> "Installed. Reboot for the modem to load it" patch.installed -> "Installed" - !patch.magiskAvailable -> "Magisk was not found, so the systemless patch cannot be installed" + !patch.magiskAvailable -> "No Magisk, KernelSU or APatch was found, so the systemless patch cannot be installed" + !patch.sourceAvailable -> "This firmware's modem database could not be read, so there is nothing to patch" else -> "Not installed. This is the step that makes the modem accept EN-DC" }, - status = when { - patch == null -> CheckStatus.UNKNOWN - !patch.supported -> CheckStatus.WARN - patch.installed -> CheckStatus.PASS - else -> CheckStatus.FAIL - }, - fix = if (patch != null && patch.supported && !patch.installed && patch.magiskAvailable) { - FixAction.INSTALL_MODEM_PATCH - } else { - FixAction.NONE - }, - fixLabel = if (patch != null && patch.supported && !patch.installed && patch.magiskAvailable) "Apply patch" else null, + status = ReadinessRules.patchStatus(patch), + fix = if (patch != null && ReadinessRules.canInstallPatch(patch)) FixAction.INSTALL_MODEM_PATCH else FixAction.NONE, + fixLabel = if (patch != null && ReadinessRules.canInstallPatch(patch)) "Apply patch" else null, ) } else { out += ReadinessCheck( @@ -267,17 +263,20 @@ class SetupViewModel( // The outcome, not a setting: is there actually an NR leg up right now. val radio = runCatching { moder.getRadioDiagnostics() }.getOrNull() val nrCell = radio?.cells?.firstOrNull { it.registered && it.type.contains("NR", ignoreCase = true) } + val connected = radio != null && ReadinessRules.is5gConnected(radio.dataRat, radio.nrState, nrCell != null) out += ReadinessCheck( id = "endc", title = "5G connected", detail = when { radio == null -> "Could not read the radio" nrCell != null -> "Attached on ${nrCell.band} · ${nrCell.channel} · ${nrCell.rsrp ?: nrCell.dbm} dBm" + connected && radio?.dataRat == "NR" -> "Connected on 5G SA" + connected -> "Connected on 5G NSA (the NR leg rides an LTE anchor)" else -> "No NR cell registered. If everything above passes, the modem is refusing EN-DC." }, status = when { radio == null -> CheckStatus.UNKNOWN - nrCell != null -> CheckStatus.PASS + connected -> CheckStatus.PASS else -> CheckStatus.FAIL }, ) diff --git a/app/src/main/java/dev/bluehouse/enablevolte/ShizukuBootReapply.kt b/app/src/main/java/dev/bluehouse/enablevolte/ShizukuBootReapply.kt new file mode 100644 index 0000000..472ecd8 --- /dev/null +++ b/app/src/main/java/dev/bluehouse/enablevolte/ShizukuBootReapply.kt @@ -0,0 +1,80 @@ +package dev.bluehouse.enablevolte + +import android.content.Context +import android.content.pm.PackageManager +import android.util.Log +import androidx.work.BackoffPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters +import org.lsposed.hiddenapibypass.HiddenApiBypass +import rikka.shizuku.Shizuku +import java.util.concurrent.TimeUnit + +/** + * Opt-in "reapply after reboot" for Shizuku mode (issue #6). + * + * Shizuku overrides are session-only: the platform drops them on reboot. With + * this on, SIM Config changes are saved per SIM exactly as Root mode saves + * them, and a worker reapplies them once Shizuku comes back after boot. Shizuku + * (or a fork that starts on boot) has to be running; the worker waits for it + * with backoff rather than failing on the first try. + */ +object ShizukuBootReapply { + private const val PREFS = "pixel_ims_boot" + private const val KEY = "shizuku_reapply_on_boot" + private const val WORK_NAME = "shizuku_carrier_config_reapply" + + fun isEnabled(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY, false) + + fun setEnabled(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(KEY, enabled).apply() + } + + fun schedule(context: Context, delaySeconds: Long) { + if (PrivilegeManager.selectedMode(context) != PrivilegeMode.SHIZUKU || !isEnabled(context)) return + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(delaySeconds, TimeUnit.SECONDS) + .setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.SECONDS) + .build() + WorkManager.getInstance(context).enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request) + } +} + +class ShizukuReapplyWorker( + appContext: Context, + workerParams: WorkerParameters, +) : Worker(appContext, workerParams) { + override fun doWork(): Result { + val context = applicationContext + if (PrivilegeManager.selectedMode(context) != PrivilegeMode.SHIZUKU || !ShizukuBootReapply.isEnabled(context)) { + return Result.success() + } + // This runs without HomeActivity, which is where the exemptions are normally added. + HiddenApiBypass.addHiddenApiExemptions("L", "I") + PrivilegeManager.activate(context, PrivilegeMode.SHIZUKU) + + // Shizuku may still be starting; about ten minutes of linear backoff covers a slow boot. + if (!Shizuku.pingBinder()) { + return if (runAttemptCount < MAX_ATTEMPTS) Result.retry() else Result.failure() + } + if (Shizuku.checkSelfPermission() != PackageManager.PERMISSION_GRANTED) return Result.failure() + + val applied = runCatching { CarrierConfigPersistence.reapplyAll(context) } + .onFailure { Log.w(TAG, "Unable to reapply the saved Shizuku SIM profile", it) } + .getOrDefault(false) + return when { + applied -> Result.success() + runAttemptCount < MAX_ATTEMPTS -> Result.retry() + else -> Result.failure() + } + } + + private companion object { + const val TAG = "ShizukuReapplyWorker" + const val MAX_ATTEMPTS = 6 + } +} diff --git a/app/src/main/java/dev/bluehouse/enablevolte/components/Glass.kt b/app/src/main/java/dev/bluehouse/enablevolte/components/Glass.kt new file mode 100644 index 0000000..1e7648a --- /dev/null +++ b/app/src/main/java/dev/bluehouse/enablevolte/components/Glass.kt @@ -0,0 +1,115 @@ +package dev.bluehouse.enablevolte.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.unit.dp +import dev.bluehouse.enablevolte.ui.theme.InstrumentColors +import dev.bluehouse.enablevolte.ui.theme.LocalInstrument +import kotlin.math.max + +/** How much of the ground a pane lets through. */ +enum class GlassDepth { + /** Content panels: the most translucent. */ + REGULAR, + + /** Bars and controls that sit over scrolling content and must stay legible. */ + THICK, + + /** A tinted pane, used for the one call to action on a screen. */ + TINTED, +} + +/** + * A pane of liquid glass. + * + * Five layers, drawn in the order light meets them: a translucent body running + * slightly brighter at the top, a specular bloom at the top-leading corner + * where light enters, a faint shade at the bottom where it leaves, the content, + * and a rim that is bright at the top, nearly vanishes along the sides and + * picks up a coloured caustic at the lower-trailing corner. No elevation + * shadow: on a translucent surface it shows through the pane as a grey smudge. + */ +@Composable +fun Modifier.liquidGlass( + shape: Shape = MaterialTheme.shapes.large, + depth: GlassDepth = GlassDepth.REGULAR, + tint: Color = Color.Unspecified, +): Modifier { + val inst = LocalInstrument.current + return this + .clip(shape) + .drawWithCache { + val outline = shape.createOutline(size, layoutDirection, this) + val body = glassBody(inst, depth, tint) + val bloom = Brush.radialGradient( + colors = listOf(inst.specular, Color.Transparent), + center = Offset(size.width * 0.12f, -size.height * 0.15f), + radius = max(size.width, size.height) * 0.9f, + ) + val shade = Brush.verticalGradient( + 0.55f to Color.Transparent, + 1f to inst.shade, + ) + val rim = Brush.linearGradient( + 0f to inst.edgeBright, + 0.28f to inst.edge, + 0.72f to inst.edge.copy(alpha = inst.edge.alpha * 0.6f), + 1f to if (tint.isSpecified) tint.copy(alpha = 0.55f) else inst.caustic, + start = Offset(0f, 0f), + end = Offset(size.width, size.height), + ) + val sheen = Brush.horizontalGradient( + 0.08f to Color.Transparent, + 0.35f to inst.sheen, + 0.75f to inst.sheen.copy(alpha = inst.sheen.alpha * 0.3f), + 0.95f to Color.Transparent, + ) + // The stroke is centred on the clipped edge, so half of it shows. + val rimWidth = 1.2.dp.toPx() * 2f + val sheenInset = 1.dp.toPx() + onDrawWithContent { + drawOutline(outline, body) + drawOutline(outline, bloom) + drawOutline(outline, shade) + drawContent() + drawOutline(outline, rim, style = Stroke(rimWidth)) + drawLine( + brush = sheen, + start = Offset(0f, sheenInset), + end = Offset(size.width, sheenInset), + strokeWidth = sheenInset, + ) + } + } +} + +private fun glassBody(inst: InstrumentColors, depth: GlassDepth, tint: Color): Brush { + val top: Color + val bottom: Color + when (depth) { + GlassDepth.REGULAR -> { + top = inst.frostHigh + bottom = inst.frost + } + GlassDepth.THICK -> { + top = inst.scrim + bottom = inst.scrim.copy(alpha = (inst.scrim.alpha - 0.08f).coerceAtLeast(0f)) + } + GlassDepth.TINTED -> { + val base = if (tint.isSpecified) tint else inst.glow + top = base.copy(alpha = if (inst.isDark) 0.30f else 0.20f) + bottom = base.copy(alpha = if (inst.isDark) 0.12f else 0.08f) + } + } + return Brush.verticalGradient(listOf(top, bottom)) +} diff --git a/app/src/main/java/dev/bluehouse/enablevolte/components/RegionalPatchCard.kt b/app/src/main/java/dev/bluehouse/enablevolte/components/RegionalPatchCard.kt new file mode 100644 index 0000000..aab2119 --- /dev/null +++ b/app/src/main/java/dev/bluehouse/enablevolte/components/RegionalPatchCard.kt @@ -0,0 +1,323 @@ +package dev.bluehouse.enablevolte.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Memory +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.bluehouse.enablevolte.R +import dev.bluehouse.enablevolte.ReadinessRules +import dev.bluehouse.enablevolte.RegionalModemPatchStatus +import dev.bluehouse.enablevolte.RegionalPatchAction +import dev.bluehouse.enablevolte.RegionalPatchViewModel +import dev.bluehouse.enablevolte.ui.theme.LocalInstrument +import dev.bluehouse.enablevolte.ui.theme.ReadoutSmall + +private enum class PatchPhase { CHECKING, READY, REBOOT, ACTIVE, REMOVING, UNAVAILABLE } + +private fun phaseOf(status: RegionalModemPatchStatus?, loading: Boolean): PatchPhase = when { + status == null || (loading && status.message.isBlank()) -> PatchPhase.CHECKING + status.removalPending -> PatchPhase.REMOVING + status.installed && status.rebootRequired -> PatchPhase.REBOOT + status.installed -> PatchPhase.ACTIVE + ReadinessRules.canInstallPatch(status) -> PatchPhase.READY + else -> PatchPhase.UNAVAILABLE +} + +/** + * The regional modem patch, as one card. + * + * Root users used to find this four levels down, under Controls → Bands, below + * the band pickers — while it is the one step without which nothing else on + * the Setup screen can turn into a 5G connection. It now leads the home screen + * for root users and shrinks to a single status line once it is active. The + * same card, with the same view model, also stays on the Bands page. + * + * The confirmation dialog is owned here so every route to installing the patch + * — this card's button or the Setup check's fix — passes through it. + */ +@Composable +fun RegionalPatchCard( + viewModel: RegionalPatchViewModel, + modifier: Modifier = Modifier, + compactWhenActive: Boolean = true, +) { + val ui by viewModel.state.collectAsState() + val inst = LocalInstrument.current + val status = ui.status + val phase = phaseOf(status, ui.loading) + var expanded by rememberSaveable { mutableStateOf(false) } + + ui.confirming?.let { action -> PatchConfirmDialog(action, viewModel) } + + val tone = when (phase) { + PatchPhase.ACTIVE -> inst.good + PatchPhase.REBOOT, PatchPhase.REMOVING -> inst.fair + PatchPhase.READY -> inst.ember + PatchPhase.UNAVAILABLE -> inst.poor + PatchPhase.CHECKING -> MaterialTheme.colorScheme.onSurfaceVariant + } + val headline = when (phase) { + PatchPhase.CHECKING -> stringResource(R.string.patch_card_checking) + PatchPhase.READY -> stringResource(R.string.patch_card_ready) + PatchPhase.REBOOT -> stringResource(R.string.patch_card_reboot) + PatchPhase.ACTIVE -> stringResource(R.string.patch_card_active) + PatchPhase.REMOVING -> stringResource(R.string.patch_card_removing) + PatchPhase.UNAVAILABLE -> stringResource(R.string.patch_card_unavailable) + } + + // Once it is working there is nothing to do here: one line, tap for more. + if (compactWhenActive && phase == PatchPhase.ACTIVE && !expanded) { + Panel(modifier.fillMaxWidth(), onClick = { expanded = true }) { + Row(Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) { + StatusGlyph(true, tone) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.patch_card_title), style = MaterialTheme.typography.titleMedium) + Text( + stringResource(R.string.patch_card_active_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + SignalChip(label = headline, tone = tone, dot = true) + } + } + return + } + + val prominent = phase == PatchPhase.READY || phase == PatchPhase.REBOOT + Panel( + modifier.fillMaxWidth(), + depth = if (prominent) GlassDepth.TINTED else GlassDepth.REGULAR, + tint = if (prominent) tone else Color.Unspecified, + ) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(42.dp).background(tone.copy(alpha = 0.16f), RoundedCornerShape(14.dp)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Filled.Memory, contentDescription = null, tint = tone, modifier = Modifier.size(22.dp)) + } + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.patch_card_step).uppercase(), + style = MaterialTheme.typography.labelMedium, + color = tone, + ) + Text(stringResource(R.string.patch_card_title), style = MaterialTheme.typography.titleLarge) + } + if (phase == PatchPhase.CHECKING || ui.busy) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + SignalChip(label = headline, tone = tone, dot = true) + } + } + + Text( + when (phase) { + PatchPhase.REBOOT -> stringResource(R.string.patch_card_reboot_body) + PatchPhase.REMOVING -> stringResource(R.string.patch_card_removing_body) + PatchPhase.ACTIVE -> stringResource(R.string.patch_card_active_body) + PatchPhase.UNAVAILABLE -> status?.message.orEmpty() + else -> stringResource(R.string.patch_card_pitch) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + ui.outcome?.let { outcome -> + Row( + Modifier + .fillMaxWidth() + .background(tone.copy(alpha = 0.10f), RoundedCornerShape(12.dp)) + .padding(start = 12.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(outcome, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) + TextButton(onClick = viewModel::dismissOutcome) { Text(stringResource(R.string.dismiss)) } + } + } + + if (status != null && phase != PatchPhase.CHECKING) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Prerequisite(stringResource(R.string.patch_check_device), status.supported) + Prerequisite(status.rootManager.ifBlank { stringResource(R.string.patch_check_magisk) }, status.magiskAvailable) + Prerequisite(stringResource(R.string.patch_check_firmware), status.sourceAvailable) + } + } + + if (status != null) { + when { + ReadinessRules.canInstallPatch(status) -> + Button( + onClick = { viewModel.requestConfirmation(RegionalPatchAction.INSTALL) }, + enabled = !ui.busy, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + contentPadding = PaddingValues(vertical = 14.dp), + ) { + Text( + stringResource(if (ui.busy) R.string.applying_profile else R.string.install_patch), + style = MaterialTheme.typography.labelLarge, + ) + } + ReadinessRules.canRevalidatePatch(status) -> + OutlinedButton( + onClick = { viewModel.requestConfirmation(RegionalPatchAction.INSTALL) }, + enabled = !ui.busy, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) { Text(stringResource(R.string.reinstall_patch)) } + } + if (ReadinessRules.canRemovePatch(status)) { + TextButton( + onClick = { viewModel.requestConfirmation(RegionalPatchAction.REMOVE) }, + enabled = !ui.busy, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.remove_patch)) + } + } + } + + TextButton(onClick = { expanded = !expanded }, contentPadding = PaddingValues(horizontal = 4.dp)) { + Text(stringResource(if (expanded) R.string.patch_card_hide_details else R.string.patch_card_details)) + } + AnimatedVisibility(visible = expanded) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.regional_patch_description), style = MaterialTheme.typography.bodySmall) + status?.let { patch -> + if (patch.device.isNotBlank()) DetailLine(stringResource(R.string.patch_device), patch.device) + if (patch.message.isNotBlank()) { + Text(patch.message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (patch.sourceSha256.isNotBlank()) { + DetailLine(stringResource(R.string.stock_database_hash), patch.sourceSha256.take(16) + "…") + } + if (patch.patchedSha256.isNotBlank()) { + DetailLine(stringResource(R.string.patched_database_hash), patch.patchedSha256.take(16) + "…") + } + } + Text( + stringResource(R.string.regional_patch_warning), + style = MaterialTheme.typography.bodySmall, + color = inst.fair, + ) + } + } + } + } +} + +@Composable +private fun PatchConfirmDialog(action: RegionalPatchAction, viewModel: RegionalPatchViewModel) { + val install = action == RegionalPatchAction.INSTALL + AlertDialog( + onDismissRequest = viewModel::cancelConfirmation, + icon = { Icon(Icons.Filled.Memory, contentDescription = null) }, + title = { + Text(stringResource(if (install) R.string.regional_patch_confirm_title else R.string.regional_patch_remove_confirm_title)) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + stringResource( + if (install) R.string.regional_patch_confirm_message else R.string.regional_patch_remove_confirm_message, + ), + ) + if (install) { + Text( + stringResource(R.string.regional_patch_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + confirmButton = { + Button(onClick = viewModel::confirm) { + Text(stringResource(if (install) R.string.install_patch else R.string.schedule_removal)) + } + }, + dismissButton = { + OutlinedButton(onClick = viewModel::cancelConfirmation) { Text(stringResource(R.string.dismiss)) } + }, + ) +} + +@Composable +private fun StatusGlyph(ok: Boolean, tone: Color) { + Box( + Modifier.size(24.dp).background(tone.copy(alpha = 0.16f), CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon(if (ok) Icons.Filled.Check else Icons.Filled.Close, contentDescription = null, tint = tone, modifier = Modifier.size(14.dp)) + } +} + +@Composable +private fun Prerequisite(label: String, ok: Boolean) { + val inst = LocalInstrument.current + val tone = if (ok) inst.good else inst.poor + Row( + Modifier + .background(tone.copy(alpha = 0.10f), RoundedCornerShape(10.dp)) + .padding(horizontal = 8.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(if (ok) Icons.Filled.Check else Icons.Filled.Close, contentDescription = null, tint = tone, modifier = Modifier.size(12.dp)) + Spacer(Modifier.width(4.dp)) + Text(label, style = MaterialTheme.typography.labelMedium, color = tone) + } +} + +@Composable +private fun DetailLine(label: String, value: String) { + Row(Modifier.fillMaxWidth()) { + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Text(value, style = ReadoutSmall) + } +} diff --git a/app/src/main/java/dev/bluehouse/enablevolte/components/Surfaces.kt b/app/src/main/java/dev/bluehouse/enablevolte/components/Surfaces.kt index 72f0a46..58b740c 100644 --- a/app/src/main/java/dev/bluehouse/enablevolte/components/Surfaces.kt +++ b/app/src/main/java/dev/bluehouse/enablevolte/components/Surfaces.kt @@ -1,13 +1,11 @@ package dev.bluehouse.enablevolte.components -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LocalContentColor @@ -15,38 +13,58 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import dev.bluehouse.enablevolte.ui.theme.LocalInstrument /** - * The page ground, lit from above. + * The page ground, lit by three soft pools of light. * - * The gradient is not decoration. Panels are translucent, so whatever is behind - * them decides whether they read as objects or as nothing at all — a flat - * near-black ground is what made the first attempt at this look like outlines - * on a void. + * The pools are not decoration. Panels are translucent, so whatever is behind + * them decides whether they read as glass or as nothing at all — a flat ground + * is what made earlier attempts look like outlines on a void. They are large + * and smooth because Compose has no backdrop blur: a blurred smooth gradient + * looks like the gradient, so the panes read as frosted without sampling. */ @Composable fun AppBackdrop(content: @Composable BoxScope.() -> Unit) { val colors = MaterialTheme.colorScheme val inst = LocalInstrument.current + val strength = if (inst.isDark) 0.34f else 0.55f Box( modifier = Modifier .fillMaxSize() .background(colors.background) - .background( - Brush.verticalGradient( - 0.0f to inst.glow.copy(alpha = if (inst.isDark) 0.20f else 0.10f), - 0.28f to inst.glow.copy(alpha = if (inst.isDark) 0.05f else 0.025f), - 0.60f to Color.Transparent, - 1.0f to Color.Black.copy(alpha = if (inst.isDark) 0.30f else 0f), - ), - ), + .drawWithCache { + val w = size.width + val h = size.height + val reach = maxOf(w, h) + val pools = listOf( + Triple(inst.pools[0], Offset(w * 0.05f, h * 0.06f), reach * 0.62f), + Triple(inst.pools[1], Offset(w * 1.02f, h * 0.38f), reach * 0.55f), + Triple(inst.pools[2], Offset(w * 0.20f, h * 0.92f), reach * 0.50f), + ).map { (color, center, radius) -> + Brush.radialGradient( + 0f to color.copy(alpha = strength), + 0.45f to color.copy(alpha = strength * 0.35f), + 1f to Color.Transparent, + center = center, + radius = radius, + ) + } + val vignette = Brush.verticalGradient( + 0.7f to Color.Transparent, + 1f to Color.Black.copy(alpha = if (inst.isDark) 0.35f else 0f), + ) + onDrawBehind { + pools.forEach { drawRect(it) } + drawRect(vignette) + } + }, ) { CompositionLocalProvider(LocalContentColor provides colors.onBackground) { content() @@ -55,41 +73,24 @@ fun AppBackdrop(content: @Composable BoxScope.() -> Unit) { } /** - * A frosted panel. + * A pane of liquid glass holding one block of content. * - * Three things make this read as glass without a backdrop blur, which Compose - * cannot do: a fill bright enough to separate from the ground on its own, a - * gradient running top-to-bottom so the surface has a direction, and a one-pixel - * sheen along the top edge where a real pane would catch the light. + * See [liquidGlass] for how the pane is drawn. */ @Composable fun Panel( modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, + depth: GlassDepth = GlassDepth.REGULAR, + tint: Color = Color.Unspecified, content: @Composable () -> Unit, ) { - val inst = LocalInstrument.current val colors = MaterialTheme.colorScheme val shape = MaterialTheme.shapes.large - val fill = Brush.verticalGradient(listOf(inst.frostHigh, inst.frost)) - val border = BorderStroke(1.dp, inst.edge) @Composable fun Body() { - Box(Modifier.background(fill, shape).clip(shape)) { - content() - Box( - Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .height(1.dp) - .background( - Brush.horizontalGradient( - listOf(Color.Transparent, inst.sheen, Color.Transparent), - ), - ), - ) - } + Box(Modifier.liquidGlass(shape, depth, tint)) { content() } } if (onClick == null) { @@ -98,9 +99,7 @@ fun Panel( shape = shape, color = Color.Transparent, contentColor = colors.onSurface, - border = border, tonalElevation = 0.dp, - shadowElevation = if (inst.isDark) 0.dp else 2.dp, ) { Body() } } else { Surface( @@ -109,9 +108,7 @@ fun Panel( shape = shape, color = Color.Transparent, contentColor = colors.onSurface, - border = border, tonalElevation = 0.dp, - shadowElevation = if (inst.isDark) 0.dp else 2.dp, ) { Body() } } } diff --git a/app/src/main/java/dev/bluehouse/enablevolte/pages/Bands.kt b/app/src/main/java/dev/bluehouse/enablevolte/pages/Bands.kt index fcfedaa..d4a23c9 100644 --- a/app/src/main/java/dev/bluehouse/enablevolte/pages/Bands.kt +++ b/app/src/main/java/dev/bluehouse/enablevolte/pages/Bands.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults @@ -38,6 +37,7 @@ 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 androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import dev.bluehouse.enablevolte.ui.theme.NumericSmallTextStyle import dev.bluehouse.enablevolte.components.statusToneColor @@ -45,11 +45,11 @@ import dev.bluehouse.enablevolte.components.StatusTone import dev.bluehouse.enablevolte.R import dev.bluehouse.enablevolte.PrivilegeManager import dev.bluehouse.enablevolte.PrivilegeMode -import dev.bluehouse.enablevolte.RegionalModemPatchStatus import dev.bluehouse.enablevolte.SubscriptionModer import dev.bluehouse.enablevolte.components.ClickablePropertyView import dev.bluehouse.enablevolte.components.Panel import dev.bluehouse.enablevolte.components.HeaderText +import dev.bluehouse.enablevolte.components.RegionalPatchCard import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -145,6 +145,7 @@ private fun BandPicker( fun Bands( subId: Int, navController: NavController, + rootGranted: Boolean = false, ) { val context = LocalContext.current val moder = remember(subId) { SubscriptionModer(context, subId) } @@ -156,19 +157,16 @@ fun Bands( var currentSelection by rememberSaveable { mutableStateOf(context.getString(R.string.band_automatic)) } var radioMode by rememberSaveable { mutableStateOf(0) } var easyMode by rememberSaveable { mutableStateOf(false) } - var easyModeBusy by rememberSaveable { mutableStateOf(false) } + var easyModeBusy by remember { mutableStateOf(false) } var caStatus by rememberSaveable { mutableStateOf(context.getString(R.string.checking)) } var servingBands by rememberSaveable { mutableStateOf(context.getString(R.string.checking)) } var visibleBands by rememberSaveable { mutableStateOf(context.getString(R.string.checking)) } var nrAttachStatus by rememberSaveable { mutableStateOf(context.getString(R.string.checking)) } var noServiceChange by rememberSaveable { mutableStateOf(null) } var rootForceReport by remember { mutableStateOf(null) } - var rootForceBusy by rememberSaveable { mutableStateOf(false) } - var shizukuRegionalBusy by rememberSaveable { mutableStateOf(false) } + var rootForceBusy by remember { mutableStateOf(false) } + var shizukuRegionalBusy by remember { mutableStateOf(false) } var shizukuRegionalResult by remember { mutableStateOf(null) } - var regionalPatch by remember { mutableStateOf(null) } - var regionalPatchBusy by rememberSaveable { mutableStateOf(false) } - var regionalConfirmation by rememberSaveable { mutableStateOf(null) } var bandLoadError by rememberSaveable { mutableStateOf(null) } fun loadSelection() { @@ -229,15 +227,6 @@ fun Bands( } else { null } - loadStage = "Regional modem patch" - regionalPatch = if ( - PrivilegeManager.activeMode == PrivilegeMode.ROOT && - PrivilegeManager.isRootReady() - ) { - withContext(Dispatchers.IO) { PrivilegeManager.getRegionalModemPatchStatus() } - } else { - RegionalModemPatchStatus.unavailable(context.getString(R.string.regional_patch_root_only)) - } loadStage = "Service state" noServiceChange = withContext(Dispatchers.IO) { if (!moder.hasCellularService()) moder.lastChangeDescription else null @@ -366,67 +355,8 @@ fun Bands( } } - fun runRegionalPatchAction(action: String) { - if (regionalPatchBusy) return - scope.launch { - regionalPatchBusy = true - regionalPatch = withContext(Dispatchers.IO) { - when (action) { - "install" -> PrivilegeManager.installRegionalModemPatch() - else -> PrivilegeManager.scheduleRegionalModemPatchRemoval() - } - } - regionalPatchBusy = false - } - } - LaunchedEffect(subId) { loadSelection() } - regionalConfirmation?.let { action -> - AlertDialog( - onDismissRequest = { regionalConfirmation = null }, - title = { - Text( - stringResource( - if (action == "install") { - R.string.regional_patch_confirm_title - } else { - R.string.regional_patch_remove_confirm_title - }, - ), - ) - }, - text = { - Text( - stringResource( - if (action == "install") { - R.string.regional_patch_confirm_message - } else { - R.string.regional_patch_remove_confirm_message - }, - ), - ) - }, - confirmButton = { - Button(onClick = { - regionalConfirmation = null - runRegionalPatchAction(action) - }) { - Text( - stringResource( - if (action == "install") R.string.install_patch else R.string.schedule_removal, - ), - ) - } - }, - dismissButton = { - OutlinedButton(onClick = { regionalConfirmation = null }) { - Text(stringResource(R.string.dismiss)) - } - }, - ) - } - Column( modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp), @@ -634,100 +564,9 @@ fun Bands( } } - if (PrivilegeManager.activeMode == PrivilegeMode.ROOT) { + if (rootGranted) { HeaderText(text = stringResource(R.string.regional_modem_patch)) - Panel(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - stringResource(R.string.regional_patch_description), - style = MaterialTheme.typography.bodyMedium, - ) - regionalPatch?.let { patch -> - ClickablePropertyView( - label = stringResource(R.string.patch_device), - value = patch.device.ifBlank { stringResource(R.string.status_unknown) }, - ) - ClickablePropertyView( - label = stringResource(R.string.patch_compatibility), - value = stringResource( - if (patch.supported && patch.magiskAvailable && patch.sourceAvailable) { - R.string.patch_compatible - } else { - R.string.patch_unavailable - }, - ), - ) - ClickablePropertyView( - label = stringResource(R.string.patch_status), - value = stringResource( - when { - patch.removalPending -> R.string.patch_removal_pending - patch.installed -> R.string.patch_installed - else -> R.string.patch_not_installed - }, - ), - ) - Text(patch.message) - if (patch.sourceSha256.isNotBlank()) { - ClickablePropertyView( - label = stringResource(R.string.stock_database_hash), - value = patch.sourceSha256.take(16) + "…", - ) - } - if (patch.patchedSha256.isNotBlank()) { - ClickablePropertyView( - label = stringResource(R.string.patched_database_hash), - value = patch.patchedSha256.take(16) + "…", - ) - } - if (!patch.installed) { - Button( - enabled = !regionalPatchBusy && - patch.supported && - patch.magiskAvailable && - patch.sourceAvailable, - onClick = { regionalConfirmation = "install" }, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - stringResource( - if (regionalPatchBusy) R.string.applying_profile else R.string.install_patch, - ), - ) - } - } else { - if (patch.rebootRequired && !patch.removalPending) { - Button( - enabled = !regionalPatchBusy && - patch.supported && - patch.magiskAvailable && - patch.sourceAvailable, - onClick = { regionalConfirmation = "install" }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.reinstall_patch)) - } - } - if (!patch.removalPending) { - OutlinedButton( - enabled = !regionalPatchBusy, - onClick = { regionalConfirmation = "remove" }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.remove_patch)) - } - } - } - } - Text( - stringResource(R.string.regional_patch_warning), - color = MaterialTheme.colorScheme.error, - ) - } - } + RegionalPatchCard(viewModel(key = "regional-patch"), compactWhenActive = false) } HeaderText(text = stringResource(R.string.band_selection)) diff --git a/app/src/main/java/dev/bluehouse/enablevolte/pages/Config.kt b/app/src/main/java/dev/bluehouse/enablevolte/pages/Config.kt index 7c063f8..ae38741 100644 --- a/app/src/main/java/dev/bluehouse/enablevolte/pages/Config.kt +++ b/app/src/main/java/dev/bluehouse/enablevolte/pages/Config.kt @@ -13,16 +13,22 @@ 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.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState 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.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -36,6 +42,7 @@ import dev.bluehouse.enablevolte.ConfigViewModel import dev.bluehouse.enablevolte.PrivilegeManager import dev.bluehouse.enablevolte.PrivilegeMode import dev.bluehouse.enablevolte.R +import dev.bluehouse.enablevolte.ShizukuBootReapply import dev.bluehouse.enablevolte.components.BooleanPropertyView import dev.bluehouse.enablevolte.components.ClickablePropertyView import dev.bluehouse.enablevolte.components.Panel @@ -229,6 +236,30 @@ fun Config( modifier = Modifier.padding(top = 12.dp), ) + if (PrivilegeManager.activeMode == PrivilegeMode.SHIZUKU) { + var reapplyOnBoot by remember { mutableStateOf(ShizukuBootReapply.isEnabled(context)) } + Panel(Modifier.fillMaxWidth().padding(top = 12.dp)) { + Row(Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.shizuku_reapply_on_boot), style = MaterialTheme.typography.titleMedium) + Text( + stringResource(R.string.shizuku_reapply_on_boot_summary), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(12.dp)) + Switch( + checked = reapplyOnBoot, + onCheckedChange = { + reapplyOnBoot = it + ShizukuBootReapply.setEnabled(context, it) + }, + ) + } + } + } + SectionPanel(title = stringResource(R.string.config_section_network), rows = networkRows) SectionPanel(title = stringResource(R.string.config_section_calling), rows = callingRows) diff --git a/app/src/main/java/dev/bluehouse/enablevolte/pages/FieldTestPage.kt b/app/src/main/java/dev/bluehouse/enablevolte/pages/FieldTestPage.kt index 773305c..b3c92f7 100644 --- a/app/src/main/java/dev/bluehouse/enablevolte/pages/FieldTestPage.kt +++ b/app/src/main/java/dev/bluehouse/enablevolte/pages/FieldTestPage.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -64,9 +65,18 @@ fun FieldTestPage(subscriptions: List) { val context = LocalContext.current val scope = rememberCoroutineScope() var selectedSubId by rememberSaveable { mutableStateOf(subscriptions.firstOrNull()?.subscriptionId ?: -1) } - val selectedSubscription = subscriptions.firstOrNull { it.subscriptionId == selectedSubId } - var runningMode by rememberSaveable { mutableStateOf(null) } - var progress by rememberSaveable { mutableStateOf(0) } + // Subscriptions can arrive (or change) after this page opens; never stay pinned to a missing SIM. + LaunchedEffect(subscriptions) { + if (subscriptions.none { it.subscriptionId == selectedSubId }) { + selectedSubId = subscriptions.firstOrNull()?.subscriptionId ?: -1 + } + } + // Subscriptions arrive after the first frame, so a saved -1 must not pin the page to "no SIM". + val selectedSubscription = subscriptions.firstOrNull { it.subscriptionId == selectedSubId } ?: subscriptions.firstOrNull() + // Not saveable: the test's coroutine does not survive the page being recreated, and a + // restored "running" flag would leave both start buttons disabled with nothing running. + var runningMode by remember { mutableStateOf(null) } + var progress by remember { mutableStateOf(0) } var result by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } var status by remember { mutableStateOf(null) } @@ -218,7 +228,7 @@ fun FieldTestPage(subscriptions: List) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { subscriptions.forEach { subscription -> FilterChip( - selected = selectedSubId == subscription.subscriptionId, + selected = selectedSubscription?.subscriptionId == subscription.subscriptionId, onClick = { if (runningMode == null) selectedSubId = subscription.subscriptionId }, label = { Text(subscription.uniqueName) }, ) diff --git a/app/src/main/java/dev/bluehouse/enablevolte/pages/Home.kt b/app/src/main/java/dev/bluehouse/enablevolte/pages/Home.kt index 96b5f2a..8609d8b 100644 --- a/app/src/main/java/dev/bluehouse/enablevolte/pages/Home.kt +++ b/app/src/main/java/dev/bluehouse/enablevolte/pages/Home.kt @@ -49,6 +49,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlin.coroutines.resume const val TAG = "HomeActivity:Home" @@ -68,16 +69,26 @@ fun Home(navController: NavController) { var imsIssues by remember { mutableStateOf(listOf()) } val scope = rememberCoroutineScope() - fun loadFlags() { + // Every value here is a binder round trip (diagnoseIms is several per SIM), + // so it is read off the main thread and published in one step. + suspend fun loadFlags() { + val loaded = withContext(Dispatchers.IO) { + runCatching { + val subs = carrierModer.subscriptions + val ims = carrierModer.deviceSupportsIMS + val diagnoses = if (subs.isNotEmpty() && ims) { + subs.map { SubscriptionModer(context, it.subscriptionId).diagnoseIms() } + } else { + emptyList() + } + Triple(subs, ims, diagnoses) + }.getOrNull() + } ?: return shizukuGranted = true - subscriptions = carrierModer.subscriptions - deviceIMSEnabled = carrierModer.deviceSupportsIMS - - if (subscriptions.isNotEmpty() && deviceIMSEnabled) { - val diagnoses = subscriptions.map { SubscriptionModer(context, it.subscriptionId).diagnoseIms() } - isIMSRegistered = diagnoses.map { it.registered } - imsIssues = diagnoses.map { it.issue } - } + subscriptions = loaded.first + deviceIMSEnabled = loaded.second + isIMSRegistered = loaded.third.map { it.registered } + imsIssues = loaded.third.map { it.issue } } LaunchedEffect(Unit) { @@ -101,11 +112,19 @@ fun Home(navController: NavController) { } ShizukuStatus.NOT_GRANTED -> { shizukuEnabled = true - Shizuku.addRequestPermissionResultListener { _, grantResult -> - if (grantResult == PackageManager.PERMISSION_GRANTED) { - loadFlags() + // Wait for the answer here instead of registering a listener that + // outlives the page; leaving the page cancels the wait. + val granted = kotlinx.coroutines.suspendCancellableCoroutine { cont -> + val listener = object : Shizuku.OnRequestPermissionResultListener { + override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) { + Shizuku.removeRequestPermissionResultListener(this) + if (cont.isActive) cont.resume(grantResult == PackageManager.PERMISSION_GRANTED) + } } + Shizuku.addRequestPermissionResultListener(listener) + cont.invokeOnCancellation { Shizuku.removeRequestPermissionResultListener(listener) } } + if (granted) loadFlags() } else -> { shizukuEnabled = false diff --git a/app/src/main/java/dev/bluehouse/enablevolte/pages/Monitor.kt b/app/src/main/java/dev/bluehouse/enablevolte/pages/Monitor.kt index 0e26c52..08e8971 100644 --- a/app/src/main/java/dev/bluehouse/enablevolte/pages/Monitor.kt +++ b/app/src/main/java/dev/bluehouse/enablevolte/pages/Monitor.kt @@ -61,6 +61,12 @@ fun Monitor(subscriptions: List) { val context = LocalContext.current val scope = rememberCoroutineScope() var selectedSubId by rememberSaveable { mutableStateOf(subscriptions.firstOrNull()?.subscriptionId ?: -1) } + // Subscriptions can arrive (or change) after this page opens; never stay pinned to a missing SIM. + LaunchedEffect(subscriptions) { + if (subscriptions.none { it.subscriptionId == selectedSubId }) { + selectedSubId = subscriptions.firstOrNull()?.subscriptionId ?: -1 + } + } val selectedSubscription = subscriptions.firstOrNull { it.subscriptionId == selectedSubId } var snapshot by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } diff --git a/app/src/main/java/dev/bluehouse/enablevolte/pages/MonitoringHub.kt b/app/src/main/java/dev/bluehouse/enablevolte/pages/MonitoringHub.kt index 8948a7c..f6773c1 100644 --- a/app/src/main/java/dev/bluehouse/enablevolte/pages/MonitoringHub.kt +++ b/app/src/main/java/dev/bluehouse/enablevolte/pages/MonitoringHub.kt @@ -318,6 +318,12 @@ private fun AttachTracePage(subscriptions: List) { val context = LocalContext.current val scope = rememberCoroutineScope() var selectedSubId by rememberSaveable { mutableStateOf(subscriptions.firstOrNull()?.subscriptionId ?: -1) } + // Subscriptions can arrive (or change) after this page opens; never stay pinned to a missing SIM. + LaunchedEffect(subscriptions) { + if (subscriptions.none { it.subscriptionId == selectedSubId }) { + selectedSubId = subscriptions.firstOrNull()?.subscriptionId ?: -1 + } + } var radio by remember { mutableStateOf(null) } var report by remember { mutableStateOf(null) } var registryState by remember { mutableStateOf("Waiting for monitoring permission") } @@ -555,6 +561,12 @@ private data class ConfigGate( private fun CarrierConfigDiffPage(subscriptions: List) { val context = LocalContext.current var selectedSubId by rememberSaveable { mutableStateOf(subscriptions.firstOrNull()?.subscriptionId ?: -1) } + // Subscriptions can arrive (or change) after this page opens; never stay pinned to a missing SIM. + LaunchedEffect(subscriptions) { + if (subscriptions.none { it.subscriptionId == selectedSubId }) { + selectedSubId = subscriptions.firstOrNull()?.subscriptionId ?: -1 + } + } var rows by remember { mutableStateOf(emptyList()) } var error by remember { mutableStateOf(null) } @@ -667,6 +679,12 @@ private fun carrierConfigRows(context: Context, subId: Int): List { private fun NrCapabilitiesPage(subscriptions: List) { val context = LocalContext.current var selectedSubId by rememberSaveable { mutableStateOf(subscriptions.firstOrNull()?.subscriptionId ?: -1) } + // Subscriptions can arrive (or change) after this page opens; never stay pinned to a missing SIM. + LaunchedEffect(subscriptions) { + if (subscriptions.none { it.subscriptionId == selectedSubId }) { + selectedSubId = subscriptions.firstOrNull()?.subscriptionId ?: -1 + } + } var radio by remember { mutableStateOf(null) } var gates by remember { mutableStateOf(null) } var endcControl by remember { mutableStateOf(null) } diff --git a/app/src/main/java/dev/bluehouse/enablevolte/pages/NetworkPage.kt b/app/src/main/java/dev/bluehouse/enablevolte/pages/NetworkPage.kt index 68f06df..3b4a066 100644 --- a/app/src/main/java/dev/bluehouse/enablevolte/pages/NetworkPage.kt +++ b/app/src/main/java/dev/bluehouse/enablevolte/pages/NetworkPage.kt @@ -73,7 +73,7 @@ fun NetworkPage( ) { val context = LocalContext.current var slot by rememberSaveable { mutableIntStateOf(0) } - val subscription = subscriptions.getOrNull(slot) + val subscription = subscriptions.getOrNull(slot) ?: subscriptions.firstOrNull() val scrollState = rememberScrollState() if (subscription == null) { @@ -128,7 +128,7 @@ fun NetworkPage( Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { subscriptions.forEachIndexed { index, sub -> FilterChip( - selected = index == slot, + selected = sub.subscriptionId == subscription?.subscriptionId, onClick = { slot = index }, label = { Text(sub.uniqueName, maxLines = 1) }, ) diff --git a/app/src/main/java/dev/bluehouse/enablevolte/pages/SetupPage.kt b/app/src/main/java/dev/bluehouse/enablevolte/pages/SetupPage.kt index 8655829..c87d9a6 100644 --- a/app/src/main/java/dev/bluehouse/enablevolte/pages/SetupPage.kt +++ b/app/src/main/java/dev/bluehouse/enablevolte/pages/SetupPage.kt @@ -32,9 +32,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -50,10 +53,15 @@ import dev.bluehouse.enablevolte.CheckStatus import dev.bluehouse.enablevolte.FixAction import dev.bluehouse.enablevolte.R import dev.bluehouse.enablevolte.ReadinessCheck +import dev.bluehouse.enablevolte.RegionalModemPatchStatus +import dev.bluehouse.enablevolte.RegionalPatchAction +import dev.bluehouse.enablevolte.RegionalPatchViewModel import dev.bluehouse.enablevolte.SetupState import dev.bluehouse.enablevolte.SetupViewModel +import dev.bluehouse.enablevolte.components.GlassDepth import dev.bluehouse.enablevolte.components.Panel import dev.bluehouse.enablevolte.components.PanelGroup +import dev.bluehouse.enablevolte.components.RegionalPatchCard import dev.bluehouse.enablevolte.components.RowDivider import dev.bluehouse.enablevolte.components.SignalChip import dev.bluehouse.enablevolte.ui.theme.LocalInstrument @@ -73,14 +81,28 @@ import dev.bluehouse.enablevolte.uniqueName fun SetupPage( subscriptions: List, navController: NavController, + rootGranted: Boolean, ) { val context = LocalContext.current var slot by rememberSaveable { mutableIntStateOf(0) } - val subscription = subscriptions.getOrNull(slot) + // A saved slot can outlive the SIM it pointed at (eSIM switched off, tray + // pulled); fall back to the first SIM rather than claiming there is none. + val subscription = subscriptions.getOrNull(slot) ?: subscriptions.firstOrNull() val scrollState = rememberScrollState() + // The patch is device-wide and root-only, so it leads the page for root + // users whether or not a SIM has been read yet. + val patchViewModel: RegionalPatchViewModel? = + if (rootGranted) viewModel(key = "regional-patch") else null + val patchState = patchViewModel?.state?.collectAsState()?.value + if (subscription == null) { - Column(Modifier.fillMaxWidth().padding(20.dp)) { + Column( + Modifier.fillMaxWidth().padding(horizontal = 16.dp).verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Spacer(Modifier.height(4.dp)) + patchViewModel?.let { RegionalPatchCard(it) } Panel(Modifier.fillMaxWidth()) { Text( stringResource(R.string.controls_no_sim), @@ -99,17 +121,27 @@ fun SetupPage( ) val ui by viewModel.state.collectAsState() + // Installing or removing the patch changes what the checks below report. + var seenPatch by remember { mutableStateOf(null) } + LaunchedEffect(patchState?.status) { + val now = patchState?.status ?: return@LaunchedEffect + if (seenPatch != null && seenPatch != now) viewModel.refresh() + seenPatch = now + } + Column( modifier = Modifier.padding(horizontal = 16.dp).verticalScroll(scrollState), verticalArrangement = Arrangement.spacedBy(12.dp), ) { Spacer(Modifier.height(4.dp)) + patchViewModel?.let { RegionalPatchCard(it) } + if (subscriptions.size > 1) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { subscriptions.forEachIndexed { index, sub -> FilterChip( - selected = index == slot, + selected = sub.subscriptionId == subscription.subscriptionId, onClick = { slot = index }, label = { Text(sub.uniqueName, maxLines = 1) }, ) @@ -147,6 +179,9 @@ fun SetupPage( when (check.fix) { FixAction.OPEN_BANDS -> navController.navigate("bands/${subscription.subscriptionId}") FixAction.OPEN_EXPERT -> navController.navigate("config/${subscription.subscriptionId}") + // Through the card's confirmation dialog, never straight to the installer. + FixAction.INSTALL_MODEM_PATCH -> + patchViewModel?.requestConfirmation(RegionalPatchAction.INSTALL) else -> viewModel.applyFix(check.fix) } }, @@ -193,7 +228,11 @@ private fun VerdictPanel( else -> inst.fair } - Panel(Modifier.fillMaxWidth()) { + Panel( + Modifier.fillMaxWidth(), + depth = if (ui.loading) GlassDepth.REGULAR else GlassDepth.TINTED, + tint = if (ui.loading) Color.Unspecified else tone, + ) { Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Box(contentAlignment = Alignment.Center) { diff --git a/app/src/main/java/dev/bluehouse/enablevolte/ui/theme/Color.kt b/app/src/main/java/dev/bluehouse/enablevolte/ui/theme/Color.kt index 582b553..924ff57 100644 --- a/app/src/main/java/dev/bluehouse/enablevolte/ui/theme/Color.kt +++ b/app/src/main/java/dev/bluehouse/enablevolte/ui/theme/Color.kt @@ -3,71 +3,81 @@ package dev.bluehouse.enablevolte.ui.theme import androidx.compose.ui.graphics.Color /** - * The 2.0 palette. + * The 2.2 palette: liquid glass. * - * The ground is a navy-teal rather than a neutral grey, because the panels are - * translucent and translucency needs something to be translucent over. Grey - * frosted on grey was the failure of the original theme: it read as haze, not - * as layers. + * Glass is only visible against something. The ground is therefore lit by + * three soft pools of colour, and every panel is a translucent pane over them + * with a specular rim, a highlight where the light enters and a faint shade + * where it leaves. Without a backdrop blur in Compose, the pools are kept large + * and smooth on purpose: a blurred smooth gradient looks the same as the + * gradient, so the pane reads as frosted without sampling what is behind it. * - * Colour is spent in three places and nowhere else — one accent for anything - * interactive, one warm counterweight so the accent is not a lone pop on a dark - * ground, and a three-stop ramp reserved strictly for signal quality. If a - * number is green in this app, the measurement is good. + * Colour outside the ground is still spent in three places only — one accent + * for anything interactive, one warm counterweight, and a three-stop ramp + * reserved strictly for signal quality. If a number is green, the measurement + * is good. */ // Ground — dark. -val Void = Color(0xFF04070C) -val Deck = Color(0xFF081220) -val Hull = Color(0xFF0A1420) -val HullHigh = Color(0xFF102030) +val Void = Color(0xFF03060C) +val Deck = Color(0xFF070C16) +val Hull = Color(0xFF0C1422) +val HullHigh = Color(0xFF142034) // Ground — light. -val Paper = Color(0xFFEDF2F7) -val PaperHull = Color(0xFFFFFFFF) -val PaperHullHigh = Color(0xFFE3EBF3) +val Paper = Color(0xFFE8EEF6) +val PaperHull = Color(0xFFF7FAFD) +val PaperHullHigh = Color(0xFFE2EAF4) + +// Ambient light pools behind the glass. +val PoolCyanDark = Color(0xFF00B4FF) +val PoolVioletDark = Color(0xFF7457FF) +val PoolEmberDark = Color(0xFFFF7A3D) +val PoolCyanLight = Color(0xFF6FD3FF) +val PoolVioletLight = Color(0xFFB9A4FF) +val PoolEmberLight = Color(0xFFFFC3A0) // Accents. -val Signal = Color(0xFF00E0FF) -val SignalDeep = Color(0xFF0077A8) +val Signal = Color(0xFF3CE0FF) +val SignalDeep = Color(0xFF0068A8) val Ember = Color(0xFFFFA23A) -val EmberDeep = Color(0xFFB2660C) +val EmberDeep = Color(0xFFA85A06) val Pulse = Color(0xFFFF3A5E) // Signal quality ramp, per theme. -val Good = Color(0xFF25E08C) +val Good = Color(0xFF2EE59A) val Fair = Color(0xFFFFC247) val Poor = Color(0xFFFF5A6E) -val GoodDeep = Color(0xFF0F9D63) -val FairDeep = Color(0xFFB87400) -val PoorDeep = Color(0xFFD3303F) +val GoodDeep = Color(0xFF0B8A55) +val FairDeep = Color(0xFF9A6200) +val PoorDeep = Color(0xFFC82434) // Text. -val Ink = Color(0xFFE9F4FA) -val InkDim = Color(0xFFA9BFD0) -val InkFaint = Color(0xFF6F8698) +val Ink = Color(0xFFF0F6FC) +val InkDim = Color(0xFFADBED0) +val InkFaint = Color(0xFF718599) val PaperInk = Color(0xFF0A1622) -val PaperInkDim = Color(0xFF445668) -val PaperInkFaint = Color(0xFF74889B) +val PaperInkDim = Color(0xFF3F5164) +val PaperInkFaint = Color(0xFF6D8196) -/* - * Panel fills and hairlines. - * - * These were ported straight from a CSS prototype where the panels also had - * `backdrop-filter: blur()` behind them. Compose has no backdrop blur, and - * without it a 7% white fill over a near-black ground lands about ten levels - * above the background — outlines, not glass. The fill does all the work here, - * so it carries roughly three times the alpha the prototype used, with a - * brighter top edge standing in for the highlight the blur used to give. - */ -val FrostDark = Color(0x2E92C4E0) -val FrostDarkHigh = Color(0x4592C4E0) -val EdgeDark = Color(0x3DA8D6F0) -val EdgeDarkBright = Color(0x73C4E8FF) -val SheenDark = Color(0x59DCF0FF) +// Glass — dark. White light on a dark pane, rim brightest where the light enters. +val FrostDark = Color(0x14FFFFFF) +val FrostDarkHigh = Color(0x26FFFFFF) +val EdgeDark = Color(0x24FFFFFF) +val EdgeDarkBright = Color(0x66FFFFFF) +val SheenDark = Color(0x8CFFFFFF) +val SpecularDark = Color(0x2EFFFFFF) +val CausticDark = Color(0x664FE3FF) +val ShadeDark = Color(0x33000000) +val ScrimDark = Color(0xB3070C16) -val FrostLight = Color(0xFFFFFFFF) -val FrostLightHigh = Color(0xFFFFFFFF) -val EdgeLight = Color(0x2E0A1622) -val EdgeLightBright = Color(0x520A1622) -val SheenLight = Color(0xB3FFFFFF) +// Glass — light. Milk-white panes, with a darker lower rim so they lift off the paper. +val FrostLight = Color(0x80FFFFFF) +val FrostLightHigh = Color(0xC7FFFFFF) +val EdgeLight = Color(0x1A0A1622) +val EdgeLightBright = Color(0xF2FFFFFF) +val SheenLight = Color(0xFFFFFFFF) +val SpecularLight = Color(0x99FFFFFF) +val CausticLight = Color(0x2E0A2A4A) +val ShadeLight = Color(0x0F0A1622) +val ScrimLight = Color(0xCCF2F6FB) diff --git a/app/src/main/java/dev/bluehouse/enablevolte/ui/theme/Theme.kt b/app/src/main/java/dev/bluehouse/enablevolte/ui/theme/Theme.kt index cf96062..93697a9 100644 --- a/app/src/main/java/dev/bluehouse/enablevolte/ui/theme/Theme.kt +++ b/app/src/main/java/dev/bluehouse/enablevolte/ui/theme/Theme.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.dp -import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat /** * Tokens Material's own scheme has no slot for. @@ -37,6 +37,15 @@ data class InstrumentColors( val pulse: Color, val glow: Color, val sheen: Color, + /** The highlight where light enters a pane, at its top-leading corner. */ + val specular: Color, + /** The coloured edge light at a pane's lower-trailing rim. */ + val caustic: Color, + /** The faint shade where light leaves a pane. */ + val shade: Color, + /** Fill for bars that content scrolls beneath, where more opacity keeps text legible. */ + val scrim: Color, + val pools: List, val isDark: Boolean, ) @@ -53,6 +62,11 @@ private val DarkInstrument = pulse = Pulse, glow = Signal, sheen = SheenDark, + specular = SpecularDark, + caustic = CausticDark, + shade = ShadeDark, + scrim = ScrimDark, + pools = listOf(PoolCyanDark, PoolVioletDark, PoolEmberDark), isDark = true, ) @@ -71,6 +85,11 @@ private val LightInstrument = pulse = Pulse, glow = SignalDeep, sheen = SheenLight, + specular = SpecularLight, + caustic = CausticLight, + shade = ShadeLight, + scrim = ScrimLight, + pools = listOf(PoolCyanLight, PoolVioletLight, PoolEmberLight), isDark = false, ) @@ -92,6 +111,8 @@ private val DarkColorScheme = surfaceContainerHighest = HullHigh, surfaceVariant = HullHigh, onSurfaceVariant = InkDim, + secondaryContainer = Color(0x333CE0FF), + onSecondaryContainer = Ink, outline = EdgeDark, outlineVariant = EdgeDark, error = Poor, @@ -116,6 +137,8 @@ private val LightColorScheme = surfaceContainerHighest = PaperHullHigh, surfaceVariant = PaperHullHigh, onSurfaceVariant = PaperInkDim, + secondaryContainer = Color(0x290068A8), + onSecondaryContainer = PaperInk, outline = EdgeLight, outlineVariant = EdgeLight, error = PoorDeep, @@ -130,19 +153,24 @@ private val LightColorScheme = */ private val InstrumentShapes = Shapes( - extraSmall = RoundedCornerShape(10.dp), - small = RoundedCornerShape(14.dp), - medium = RoundedCornerShape(18.dp), - large = RoundedCornerShape(22.dp), - extraLarge = RoundedCornerShape(26.dp), + extraSmall = RoundedCornerShape(12.dp), + small = RoundedCornerShape(16.dp), + medium = RoundedCornerShape(20.dp), + large = RoundedCornerShape(26.dp), + extraLarge = RoundedCornerShape(32.dp), ) @Suppress("ktlint:standard:function-naming") @Composable fun EnableVoLTETheme( - darkTheme: Boolean = isSystemInDarkTheme(), + themeMode: ThemeMode = ThemeMode.SYSTEM, content: @Composable () -> Unit, ) { + val darkTheme = when (themeMode) { + ThemeMode.SYSTEM -> isSystemInDarkTheme() + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true + } // Dynamic colour stays off: the ramp means something, and a wallpaper does // not know what a good RSRP looks like. val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme @@ -154,8 +182,11 @@ fun EnableVoLTETheme( window.statusBarColor = Color.Transparent.toArgb() window.navigationBarColor = Color.Transparent.toArgb() window.isNavigationBarContrastEnforced = false - ViewCompat.getWindowInsetsController(view)?.isAppearanceLightStatusBars = !darkTheme - ViewCompat.getWindowInsetsController(view)?.isAppearanceLightNavigationBars = !darkTheme + // Re-run on every in-app theme change, not only on system changes. + WindowCompat.getInsetsController(window, view).apply { + isAppearanceLightStatusBars = !darkTheme + isAppearanceLightNavigationBars = !darkTheme + } } } diff --git a/app/src/main/java/dev/bluehouse/enablevolte/ui/theme/ThemeMode.kt b/app/src/main/java/dev/bluehouse/enablevolte/ui/theme/ThemeMode.kt new file mode 100644 index 0000000..8d0c2e2 --- /dev/null +++ b/app/src/main/java/dev/bluehouse/enablevolte/ui/theme/ThemeMode.kt @@ -0,0 +1,29 @@ +package dev.bluehouse.enablevolte.ui.theme + +import android.content.Context + +/** The appearance the user picked, independent of the system setting. */ +enum class ThemeMode { + SYSTEM, + LIGHT, + DARK, + ; + + /** System → Light → Dark → System, the order the top-bar toggle steps through. */ + fun next(): ThemeMode = entries[(ordinal + 1) % entries.size] + + companion object { + private const val PREFS = "pixel_ims_appearance" + private const val KEY = "theme_mode" + + fun load(context: Context): ThemeMode = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY, null) + ?.let { runCatching { valueOf(it) }.getOrNull() } + ?: SYSTEM + + fun save(context: Context, mode: ThemeMode) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY, mode.name).apply() + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bc1f6c4..5a9c293 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -201,7 +201,7 @@ Follow the path for your selected access mode. Start with NSA and Automatic bands for Sri Lankan networks, then confirm the result in Monitor or Field Test. ROOT • STRONGEST LOCAL FIX Enable 5G with Root - 1. On Home, choose Root and grant superuser access. The regional modem patch specifically requires Magisk.\n\n2. Open Controls → Band selection & LTE+. Keep bands on Automatic, select Force NSA, and tap Force all local 5G gates.\n\n3. Under Regional modem compatibility (root), confirm the device is compatible, tap Install patch, approve the warning, and reboot.\n\n4. Return near a known 5G site with mobile data enabled. Open Monitor or run the standard Field Test and confirm EN-DC/NR attachment. Use SA only when the carrier actually offers SA. + 1. On Home, choose Root and grant superuser access. The regional modem patch specifically requires Magisk.\n\n2. Open Controls → Band selection & LTE+. Keep bands on Automatic, select Force NSA, and tap Force all local 5G gates.\n\n3. The 5G regional modem patch card sits at the top of Setup as soon as root is granted. Confirm the Tensor, Magisk and Modem DB checks are green, tap Validate and install patch, approve the warning, and reboot.\n\n4. Return near a known 5G site with mobile data enabled. Open Monitor or run the standard Field Test and confirm EN-DC/NR attachment. Use SA only when the carrier actually offers SA. After a Pixel firmware update, remove the old regional patch before updating, reboot, then validate and install a fresh patch for the new baseband. Root cannot create tower coverage or bypass carrier/SIM authorization. SHIZUKU • ANDROID-SIDE GATES Try 5G with Shizuku @@ -326,8 +326,8 @@ Root Force snapshots the current state, removes band restrictions, enables NSA + SA in CarrierConfig, opens every readable allowed-network reason, enables IMS debug gates, and restarts IMS. It does not write modem NV/EFS. Device-specific NV profiles must match the exact Pixel model and baseband. Regional modem compatibility (root) Experimental systemless Tensor modem patch. It copies this firmware’s own cfg.db, validates the SQLite schema and integrity, and maps only wildcard/PTCRB carrier entries to the firmware’s permissive profile. It does not fake the SIM country, registered PLMN, or Wi-Fi regulatory country. - Root and Magisk only. A reboot is required. Before applying a firmware update, remove this patch and reboot; after the update, validate and install a fresh copy. The installer refuses unknown layouts. This can open a local modem profile but cannot bypass Dialog authentication, tower EN-DC configuration, subscriber entitlement, or RF hardware limits. - Root mode with Magisk is required. This operation is unavailable through Shizuku. + Root with Magisk, KernelSU (plus a mounting metamodule) or APatch only. A reboot is required. Before applying a firmware update, remove this patch and reboot; after the update, validate and install a fresh copy. The installer refuses unknown layouts. This can open a local modem profile but cannot bypass Dialog authentication, tower EN-DC configuration, subscriber entitlement, or RF hardware limits. + Root mode with Magisk, KernelSU or APatch is required. This operation is unavailable through Shizuku. Regional 5G compatibility (Shizuku) Applies every reversible Android-side regional gate available to the Shizuku shell: LTE + NR network policy, NSA + SA CarrierConfig, VoLTE, VoWiFi, VoNR, LTE+ and IMS visibility. It requests Automatic bands where the modem API supports it, then reads the effective regional gates back. Apply Shizuku regional profile @@ -439,4 +439,27 @@ Wildcard/PTCRB Tensor cfg.db mapping research credited to the Pixel Modem Fix and Displax modem-fix projects. Pixel IMS 5G independently validates and patches the current firmware database. Open-source license Pixel IMS 5G is an unofficial community project. It is not affiliated with Google, mobile carriers, Facebook, or GitHub. + + Appearance: follow system + Appearance: light + Appearance: dark + Step 1 · Root + 5G regional modem patch + Lets the Tensor modem accept 5G (EN-DC) on carriers its firmware does not list. Do this first, then reboot. + Checking the modem patch… + Patch active + The modem is using the regional compatibility profile. + Reboot to activate + The patch is staged. It takes effect on the next reboot. + Removal pending + The stock modem database comes back on the next reboot. + Not available on this device + Not installed + Details + Hide details + Magisk / KSU + Tensor + Modem DB + Reapply after reboot + Saves SIM Config changes made from now on and reapplies them once Shizuku is running after a reboot. Shizuku has to start on boot (for example a fork that does). diff --git a/app/src/test/java/dev/bluehouse/enablevolte/ControlStateTest.kt b/app/src/test/java/dev/bluehouse/enablevolte/ControlStateTest.kt index 82aa2fb..44830a1 100644 --- a/app/src/test/java/dev/bluehouse/enablevolte/ControlStateTest.kt +++ b/app/src/test/java/dev/bluehouse/enablevolte/ControlStateTest.kt @@ -30,12 +30,30 @@ class ControlStateTest { requested = true, write = { }, readBack = { false }, + settleDelaysMs = listOf(1L, 1L), ) assertTrue(outcome is WriteOutcome.Rejected) assertEquals(false, (outcome as WriteOutcome.Rejected).actual) } + @Test + fun waitsForAValueThatLandsLate() = + runBlocking { + var reads = 0 + val outcome = + applyAndConfirm( + requested = true, + write = { }, + // The broker applies the override a beat after the write returns. + readBack = { ++reads >= 3 }, + settleDelaysMs = listOf(1L, 1L, 1L, 1L), + ) + + assertTrue(outcome is WriteOutcome.Confirmed) + assertEquals(3, reads) + } + @Test fun failsWhenTheWriteThrows() = runBlocking { diff --git a/app/src/test/java/dev/bluehouse/enablevolte/ReadinessRulesTest.kt b/app/src/test/java/dev/bluehouse/enablevolte/ReadinessRulesTest.kt new file mode 100644 index 0000000..130c91d --- /dev/null +++ b/app/src/test/java/dev/bluehouse/enablevolte/ReadinessRulesTest.kt @@ -0,0 +1,79 @@ +package dev.bluehouse.enablevolte + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReadinessRulesTest { + private fun patch( + supported: Boolean = true, + magisk: Boolean = true, + source: Boolean = true, + installed: Boolean = false, + removalPending: Boolean = false, + rebootRequired: Boolean = false, + ) = RegionalModemPatchStatus( + supported = supported, + magiskAvailable = magisk, + sourceAvailable = source, + installed = installed, + removalPending = removalPending, + rebootRequired = rebootRequired, + device = "cheetah", + sourceSha256 = "", + patchedSha256 = "", + message = "", + ) + + @Test + fun rootModeDoesNotFallBackToShizuku() { + assertFalse(ReadinessRules.hasAccess(rootMode = true, rootReady = false, shizukuGranted = true)) + assertTrue(ReadinessRules.hasAccess(rootMode = true, rootReady = true, shizukuGranted = false)) + assertTrue(ReadinessRules.hasAccess(rootMode = false, rootReady = false, shizukuGranted = true)) + assertFalse(ReadinessRules.hasAccess(rootMode = false, rootReady = true, shizukuGranted = false)) + } + + @Test + fun patchStatusTreatsPendingStatesAsUnfinished() { + assertEquals(CheckStatus.UNKNOWN, ReadinessRules.patchStatus(null)) + assertEquals(CheckStatus.WARN, ReadinessRules.patchStatus(patch(supported = false))) + assertEquals(CheckStatus.WARN, ReadinessRules.patchStatus(patch(installed = true, rebootRequired = true))) + assertEquals(CheckStatus.WARN, ReadinessRules.patchStatus(patch(installed = true, removalPending = true))) + assertEquals(CheckStatus.PASS, ReadinessRules.patchStatus(patch(installed = true))) + assertEquals(CheckStatus.FAIL, ReadinessRules.patchStatus(patch())) + } + + @Test + fun installNeedsEveryPrerequisite() { + assertTrue(ReadinessRules.canInstallPatch(patch())) + assertFalse(ReadinessRules.canInstallPatch(patch(source = false))) + assertFalse(ReadinessRules.canInstallPatch(patch(magisk = false))) + assertFalse(ReadinessRules.canInstallPatch(patch(supported = false))) + assertFalse(ReadinessRules.canInstallPatch(patch(installed = true))) + assertFalse(ReadinessRules.canInstallPatch(patch(removalPending = true))) + } + + @Test + fun revalidateOnlyBeforeTheOverlayIsLive() { + assertTrue(ReadinessRules.canRevalidatePatch(patch(installed = true, rebootRequired = true))) + assertFalse(ReadinessRules.canRevalidatePatch(patch(installed = true))) + assertFalse(ReadinessRules.canRevalidatePatch(patch(installed = true, rebootRequired = true, removalPending = true))) + assertFalse(ReadinessRules.canRevalidatePatch(patch(installed = true, rebootRequired = true, source = false))) + } + + @Test + fun removeOnlyWhenInstalledAndNotAlreadyPending() { + assertTrue(ReadinessRules.canRemovePatch(patch(installed = true))) + assertFalse(ReadinessRules.canRemovePatch(patch(installed = true, removalPending = true))) + assertFalse(ReadinessRules.canRemovePatch(patch())) + } + + @Test + fun nsaCountsAsConnected() { + assertTrue(ReadinessRules.is5gConnected("LTE", ReadinessRules.NR_STATE_CONNECTED, registeredNrCell = false)) + assertTrue(ReadinessRules.is5gConnected("NR", 0, registeredNrCell = false)) + assertTrue(ReadinessRules.is5gConnected("LTE", 0, registeredNrCell = true)) + assertFalse(ReadinessRules.is5gConnected("LTE", 2, registeredNrCell = false)) + } +}