diff --git a/Manager/app/build.gradle.kts b/Manager/app/build.gradle.kts index c34fa15..3d32ad9 100644 --- a/Manager/app/build.gradle.kts +++ b/Manager/app/build.gradle.kts @@ -228,3 +228,13 @@ fun ProviderFactory.execIgnoreCode(vararg command: String): String = try { } catch (_: Exception) { "" } + +// Sync Local Cached Assets with Repo Releases to keep them up to date +val syncBundledManifest by tasks.registering(Copy::class) { + val src = rootProject.rootDir.resolve("../patches/manifest.json") + onlyIf { src.exists() } + from(src) + into(layout.projectDirectory.dir("src/main/assets")) + rename { "patches-manifest.json" } +} +tasks.named("preBuild") { dependsOn(syncBundledManifest) } diff --git a/Manager/app/src/main/assets/patches-manifest.json b/Manager/app/src/main/assets/patches-manifest.json new file mode 100644 index 0000000..865d806 --- /dev/null +++ b/Manager/app/src/main/assets/patches-manifest.json @@ -0,0 +1,357 @@ +{ + "version": 1, + "patches": [ + { + "id": "EnableLegacyUi", + "order": 10, + "fileNames": [ + "enable-legacy-ui.patch" + ], + "title": "Enable Legacy UI", + "description": "[This patch will stop working soon] - Replaces the New Compose based UI with the Legacy UI (disables player-market-ui feature flag)", + "default": false, + "requires": [ + "DebugMenuUnlock" + ], + "disables": [ + "LyricsDisableCover", + "LyricsReplaceLyricsButton", + "LyricsReplaceShareButton", + "LyricsRlApi", + "LyricsKeepControlsVisible", + "PlayerBackdrop", + "QualityBadgeColors", + "LyricsProgressPill", + "MiniPlayerRedesign" + ] + }, + { + "id": "LyricsRlApi", + "order": 20, + "fileNames": [ + "lyrics-rl-api.patch", + "lyrics-rl-api-observer.patch" + ], + "title": "Radiant Lyrics API - WIP", + "description": "Use Radiant Lyrics API to fetch Lyrics (Higher quality & More providers)", + "default": false + }, + { + "id": "PlayerBackdrop", + "order": 30, + "fileNames": [ + "player-backdrop.patch" + ], + "title": "Player Backdrop", + "description": "Restores the legacy translucent backdrop blur behind the player", + "default": true, + "advancedOptions": [ + { + "type": "slider", + "key": "blur_strength", + "title": "Blur Strength", + "description": "How strongly the album art behind the player is blurred", + "default": 50, + "min": 0, + "max": 100, + "steps": 19, + "displayAsPercent": true, + "token": "RL_BLUR_BITS", + "encode": { + "kind": "floatBits", + "scale": 1.8 + } + }, + { + "type": "slider", + "key": "dimming", + "title": "Backdrop Dimming", + "description": "Darkens the backdrop so the foreground controls stay legible", + "default": 50, + "min": 0, + "max": 100, + "steps": 19, + "displayAsPercent": true, + "token": "RL_SCRIM_ARGB", + "encode": { + "kind": "argbAlpha" + } + }, + { + "type": "toggle", + "key": "cover_everywhere", + "title": "Cover Everywhere - WIP", + "description": "Applies the blurred backdrop to every Compose based page (Home & Collection)", + "default": false, + "inline": true, + "fileNames": [ + "home-backdrop.patch", + "collection-backdrop.patch", + "cover-capture.patch" + ] + } + ] + }, + { + "id": "QualityBadgeColors", + "order": 36, + "fileNames": [ + "player-quality-badge-colors.patch" + ], + "title": "Quality Badge Colors", + "description": "Brings color back to the player quality badge.", + "default": true + }, + { + "id": "PlayerMovePlayingFrom", + "order": 37, + "fileNames": [ + "player-move-playing-from.patch" + ], + "extensionFiles": [ + "radiant/PlayingFrom.smali" + ], + "title": "Playing From", + "description": "Shows the source name (playlist/album/mix) above the track title", + "default": true + }, + { + "id": "PlayerOneHanded", + "order": 38, + "fileNames": [ + "player-one-handed.patch" + ], + "title": "One-Handed Mode", + "description": "Mirrors the bottom player buttons left-to-right for easier one-handed reach.", + "default": false + }, + { + "id": "LyricsProgressPill", + "order": 40, + "fileNames": [ + "lyrics-progress-pill.patch", + "lyrics-fade-region.patch" + ], + "title": "Lyrics Progress Pill", + "description": "Restores the old Track Progress Pill in the top of the Lyrics screen!", + "default": true, + "requires": [ + "LyricsDisableCover", + "LyricsReplaceLyricsButton", + "LyricsReplaceShareButton" + ] + }, + { + "id": "LyricsDisableCover", + "order": 41, + "fileNames": [ + "lyrics-disable-cover.patch" + ], + "title": "Disable Lyrics Cover", + "description": "Removes the mini track cover from the lyrics screen.", + "default": true + }, + { + "id": "LyricsReplaceLyricsButton", + "order": 42, + "fileNames": [ + "lyrics-replace-lyrics-button.patch", + "lyrics-sparkle-conditional-visibility.patch" + ], + "title": "Replace Lyrics Button", + "description": "Replaces the Lyrics button with the RL Sparkle!", + "default": true + }, + { + "id": "LyricsReplaceShareButton", + "order": 43, + "fileNames": [ + "lyrics-replace-share-button.patch" + ], + "title": "Replace Share Button", + "description": "Replaces the share button with the Tidal Connect button from the top of the screen. (it's in the menu already)", + "default": true + }, + { + "id": "MiniPlayerRedesign", + "order": 50, + "fileNames": [], + "title": "Redesigned Mini-Player", + "description": "Integrates the Seekbar into the control panel at the bottom of the screen.", + "default": false, + "defaultVariantIndex": 2, + "variants": [ + { + "title": "Floating", + "fileNames": [] + }, + { + "title": "Grey", + "fileNames": [ + "mini-player-grey.patch" + ] + }, + { + "title": "Black", + "fileNames": [ + "mini-player-black.patch" + ] + } + ], + "advancedOptions": [ + { + "type": "toggle", + "key": "dynamic_bg", + "title": "Mini-Player Dynamic Background", + "description": "Colorizes the Mini-Player depending on the currently playing track.", + "default": false, + "inline": true, + "fileNames": [ + "mini-player-dynamic-bg.patch" + ], + "extensionFiles": [ + "radiant/MiniPlayerBackground.smali" + ], + "hidesVariants": [ + 2 + ], + "relabelVariants": { + "1": "Legacy" + } + }, + { + "type": "toggle", + "key": "border", + "title": "Progress Border", + "description": "Draws an animated progress ring around the floating mini-player. Requires the Floating style.", + "default": false, + "requiresVariant": 0, + "fileNames": [ + "mini-player-floating-border.patch" + ], + "extensionFiles": [ + "radiant/MiniSeekerFloating.smali" + ] + }, + { + "type": "toggle", + "key": "gestures", + "title": "Mini-Player Gestures", + "description": "Adds swipe up gesture to the mini-player to reopen the player", + "default": true, + "fileNames": [ + "mini-player-gestures.patch" + ], + "extensionFiles": [ + "radiant/MiniPlayerGestures.smali", + "radiant/MiniPlayerGestures$Gesture.smali", + "radiant/MiniPlayerGestures$FeedbackLayer.smali", + "radiant/MiniPlayerGestures$FeedbackResetAnimator.smali", + "radiant/MiniPlayerGestures$RootGesture.smali", + "radiant/MiniPlayerGestures$ApplyPending.smali" + ] + }, + { + "type": "choice", + "key": "swipe_up_drag", + "title": "Swipe Up Style", + "entries": [ + "Static", + "Drag" + ], + "values": [ + "0x0", + "0x1" + ], + "defaultIndex": 1, + "requiresOption": "gestures", + "token": "RL_MINI_PLAYER_SWIPE_UP_DRAG" + }, + { + "type": "toggle", + "key": "next_prev", + "title": "Next/Previous Gestures", + "description": "Swipe left or right on the mini-player to skip tracks.", + "default": false, + "requiresOption": "gestures", + "fileNames": [ + "mini-player-gestures-left-right.patch" + ], + "extensionFiles": [ + "radiant/MiniPlayerTrackGestures.smali", + "radiant/MiniPlayerTrackGestures$Gesture.smali", + "radiant/MiniPlayerTrackGestures$OffsetLayer.smali", + "radiant/MiniPlayerTrackGestures$ResetAnimator.smali", + "radiant/MiniPlayerTrackGestures$TextDraw.smali", + "com/tidal/android/feature/appscaffold/ui/s$c.smali" + ] + } + ] + }, + { + "id": "LyricsKeepControlsVisible", + "order": 60, + "fileNames": [ + "lyrics-keep-controls-visible.patch" + ], + "extensionFiles": [ + "radiant/LyricsReinitGuard.smali" + ], + "title": "Keep Controls Visible", + "description": "Inverts the auto-hide behavior on the lyrics screen, playback controls stay visible by default and only hide when you tap them off. Also stops the open lyrics view from randomly closing when the player re-fetches the same track.", + "default": true + }, + { + "id": "WazeIntegration", + "order": 90, + "category": "integration", + "pathLocked": true, + "fileNames": [ + "integration-waze-playback.patch" + ], + "extensionFiles": [ + "radiant/WazeInitReceiver.smali", + "radiant/WazeServiceConnection.smali" + ], + "title": "Waze Integration", + "description": "Control TIDAL playback from the Waze app", + "default": true, + "advancedOptions": [ + { + "type": "choice", + "key": "browse_root", + "title": "Media Menu", + "entries": [ + "Auto Menu", + "Home", + "Recents" + ], + "values": [ + "ROOT_AUTO", + "HOME_V2::home_page_v2_id", + "RECENTLY_PLAYED::home/pages/CONTINUE_LISTEN_TO/view-all" + ], + "defaultIndex": 0, + "token": "RL_WAZE_ROOT_ID" + }, + { + "type": "color", + "key": "accent_color", + "title": "Accent Color", + "default": -16747037, + "token": "RL_WAZE_THEME_COLOR" + } + ] + }, + { + "id": "DebugMenuUnlock", + "order": 100, + "fileNames": [ + "debug-menu-unlock.patch" + ], + "title": "Unlock Debug Menu", + "description": "Reveals TIDAL's hidden Debug Menu in Settings. (Unlocks Feature Flags & the legacy Debug Options)", + "default": false + } + ] +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/PathManager.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/PathManager.kt index 303a287..86fc8d3 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/PathManager.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/PathManager.kt @@ -22,7 +22,8 @@ class PathManager( val patchingDownloadDir = patchingDir.resolve("downloads") - val cacheDownloadDir = context.cacheDir.resolve("downloads") + // Persistent Stash of assets for Offline Repatching + val cacheDownloadDir = patchingDir.resolve("downloads-cache") val customComponentsDir = patchingDir.resolve("custom") @@ -34,11 +35,15 @@ class PathManager( val patchedApk = patchingWorkingDir.resolve("patched.apk") + // Persistent snapshot of the last successful release fetch (build info + patches asset URL) + val cachedReleaseInfo = patchingDir.resolve("release-info.json") + fun clearCache() { val targets = arrayOf( patchingDownloadDir, patchingWorkingDir, cacheDownloadDir, + cachedReleaseInfo, context.cacheDir, ) for (dir in targets) { @@ -54,6 +59,16 @@ class PathManager( .resolve("patches") .resolve("$version.zip") + fun hasCachedTidalApk(version: Int, split: String = "base"): Boolean { + val rel = "tidal/$version/$split.apk" + return patchingDownloadDir.resolve(rel).exists() || cacheDownloadDir.resolve(rel).exists() + } + + fun hasCachedSmaliPatches(version: SemVer): Boolean { + val rel = "patches/$version.zip" + return patchingDownloadDir.resolve(rel).exists() || cacheDownloadDir.resolve(rel).exists() + } + fun customTidalApks() = customTidalApksDir.listFiles()?.asList() ?: emptyList() fun customSmaliPatches() = customPatchesDir.listFiles()?.asList() ?: emptyList() diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/PreferencesManager.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/PreferencesManager.kt index 5345662..3f94c65 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/PreferencesManager.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/PreferencesManager.kt @@ -12,6 +12,8 @@ class PreferencesManager(preferences: SharedPreferences) : BasePreferenceManager var devMode by booleanPreference("dev_mode", false) var installer by enumPreference("installer", InstallerSetting.PackageInstaller) var keepPatchedApks by booleanPreference("keep_patched_apks", false) + // Dev: force-apply path-gated ("incompatible") patches even under a non-stock package name + var bypassIncompatible by booleanPreference("bypass_incompatible", false) var showPlayProtectWarning by booleanPreference("show_play_protect_warning", true) var autoUpdateCheck by booleanPreference("auto_update_check", true) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/ReleaseInfoCache.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/ReleaseInfoCache.kt new file mode 100644 index 0000000..cd6ac6a --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/ReleaseInfoCache.kt @@ -0,0 +1,32 @@ +package com.meowarex.rlmobile.manager + +import android.util.Log +import com.meowarex.rlmobile.BuildConfig +import com.meowarex.rlmobile.network.models.RLBuildInfo +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +// Snapshot of the last successful release fetch (build info + patches asset URL) +@Serializable +data class CachedReleaseInfo( + val data: RLBuildInfo, + val patchesAssetUrl: String, +) + +object ReleaseInfoCache { + fun load(paths: PathManager, json: Json): CachedReleaseInfo? = try { + paths.cachedReleaseInfo + .takeIf { it.exists() } + ?.let { json.decodeFromString(it.readText()) } + } catch (t: Throwable) { + Log.w(BuildConfig.TAG, "Failed to read cached release info", t) + null + } + + fun save(paths: PathManager, json: Json, info: CachedReleaseInfo) = try { + paths.cachedReleaseInfo.parentFile?.mkdirs() + paths.cachedReleaseInfo.writeText(json.encodeToString(info)) + } catch (t: Throwable) { + Log.w(BuildConfig.TAG, "Failed to cache release info", t) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/PatchManifestStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/PatchManifestStep.kt index e360f2d..b2f70ab 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/PatchManifestStep.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/PatchManifestStep.kt @@ -6,7 +6,6 @@ import com.meowarex.rlmobile.patcher.steps.StepGroup import com.meowarex.rlmobile.patcher.steps.base.Step import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep import com.meowarex.rlmobile.patcher.util.ManifestPatcher -import com.meowarex.rlmobile.ui.screens.patchopts.KnownPatch import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions import com.github.diamondminer88.zip.ZipReader import com.github.diamondminer88.zip.ZipWriter @@ -27,8 +26,9 @@ class PatchManifestStep(private val options: PatchOptions) : Step() { ?: throw IllegalArgumentException("No manifest found in APK") container.log("Patching manifest") - val enableWazeIntegration = - options.patchStates[KnownPatch.WazeIntegration.name] ?: KnownPatch.WazeIntegration.default.isEnabled + val wazeDefault = container.getStep().specs + .firstOrNull { it.id == "WazeIntegration" }?.defaultEnabled ?: false + val enableWazeIntegration = options.patchStates["WazeIntegration"] ?: wazeDefault val patchedManifest = ManifestPatcher.patchManifest( manifestBytes = manifest, diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt index e97e626..f59c3d5 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt @@ -1,5 +1,6 @@ package com.meowarex.rlmobile.patcher.steps.patch +import android.content.Context import com.meowarex.rlmobile.R import com.meowarex.rlmobile.manager.PathManager import com.meowarex.rlmobile.patcher.StepRunner @@ -10,6 +11,7 @@ import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep import com.meowarex.rlmobile.patcher.steps.download.DownloadPatchesStep import com.meowarex.rlmobile.ui.screens.patchopts.PatchManifest import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions +import com.meowarex.rlmobile.ui.screens.patchopts.PatchSpec import com.meowarex.rlmobile.ui.screens.patchopts.builtinPatchSpecs import com.android.tools.smali.baksmali.Baksmali import com.android.tools.smali.baksmali.BaksmaliOptions @@ -31,6 +33,10 @@ class SmaliPatchStep( ) : Step(), IDexProvider, KoinComponent { private val paths: PathManager by inject() private val json: Json by inject() + private val context: Context by inject() + + var specs: List = emptyList() + private set override val group = StepGroup.Patch override val localizedName = R.string.patch_step_patch_smali @@ -55,7 +61,7 @@ class SmaliPatchStep( container.log("Failed to parse manifest.json (${t.message}); using built-in patch specs") null } - val specs = manifestSpecs ?: builtinPatchSpecs { "" } + specs = manifestSpecs ?: builtinPatchSpecs(context, json) container.log( "Loaded ${specs.size} patch specs from " + if (manifestSpecs != null) "manifest.json" else "built-in list" diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/prepare/FetchInfoStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/prepare/FetchInfoStep.kt index e938eee..468dc03 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/prepare/FetchInfoStep.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/prepare/FetchInfoStep.kt @@ -1,19 +1,30 @@ package com.meowarex.rlmobile.patcher.steps.prepare +import android.app.Application +import android.util.Log import androidx.compose.runtime.Stable import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.CachedReleaseInfo +import com.meowarex.rlmobile.manager.PathManager +import com.meowarex.rlmobile.manager.ReleaseInfoCache import com.meowarex.rlmobile.network.models.RLBuildInfo import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService import com.meowarex.rlmobile.network.utils.getOrThrow import com.meowarex.rlmobile.patcher.StepRunner import com.meowarex.rlmobile.patcher.steps.StepGroup import com.meowarex.rlmobile.patcher.steps.base.Step +import com.meowarex.rlmobile.util.isOnline +import kotlinx.serialization.json.Json import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import kotlin.coroutines.cancellation.CancellationException @Stable class FetchInfoStep : Step(), KoinComponent { private val github: RadiantLyricsGithubService by inject() + private val context: Application by inject() + private val paths: PathManager by inject() + private val json: Json by inject() override val group = StepGroup.Prepare override val localizedName = R.string.patch_step_fetch_info @@ -25,6 +36,33 @@ class FetchInfoStep : Step(), KoinComponent { private set override suspend fun execute(container: StepRunner) { + // Fetch the latest release and refresh the cache (when online) + if (context.isOnline()) { + try { + fetchFromRemote(container) + return + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + container.log("Failed to fetch latest release: ${Log.getStackTraceString(t)}") + container.log("Falling back to cached release info for a local re-patch") + } + } else { + container.log("No network connection — using cached release info for a local re-patch") + } + + val cached = ReleaseInfoCache.load(paths, json) + ?: throw IllegalStateException( + "Could not fetch the latest release and no cached release info is available. " + + "Connect to the internet and try again." + ) + data = cached.data + patchesAssetUrl = cached.patchesAssetUrl + container.log("Using cached build info: $data") + container.log("Cached patches asset URL: $patchesAssetUrl") + } + + private suspend fun fetchFromRemote(container: StepRunner) { container.log("Fetching latest release from ${RadiantLyricsGithubService.REPO_OWNER}/${RadiantLyricsGithubService.REPO_NAME}") val release = github.getLatestRelease(force = true).getOrThrow() @@ -33,14 +71,19 @@ class FetchInfoStep : Step(), KoinComponent { ?.browserDownloadUrl ?: throw IllegalStateException("No ${RadiantLyricsGithubService.DATA_JSON_ASSET_NAME} asset found in latest release ${release.tagName}") - patchesAssetUrl = release.assets + val patchesUrl = release.assets .find { it.name == RadiantLyricsGithubService.PATCHES_ASSET_NAME } ?.browserDownloadUrl ?: throw IllegalStateException("No ${RadiantLyricsGithubService.PATCHES_ASSET_NAME} asset found in latest release ${release.tagName}") container.log("Fetching build info from $dataJsonUrl") - data = github.getBuildInfo(dataJsonUrl, force = true).getOrThrow() + val buildInfo = github.getBuildInfo(dataJsonUrl, force = true).getOrThrow() + + data = buildInfo + patchesAssetUrl = patchesUrl container.log("Fetched build info: $data") container.log("Patches asset URL: $patchesAssetUrl") + + ReleaseInfoCache.save(paths, json, CachedReleaseInfo(buildInfo, patchesUrl)) } } diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/PatchOptionsScreenPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/PatchOptionsScreenPreview.kt index 49a95cb..ad441e9 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/PatchOptionsScreenPreview.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/PatchOptionsScreenPreview.kt @@ -9,6 +9,7 @@ import com.meowarex.rlmobile.network.utils.SemVer import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent import com.meowarex.rlmobile.ui.screens.patchopts.* import com.meowarex.rlmobile.ui.theme.ManagerTheme +import kotlinx.serialization.json.Json import kotlin.time.Clock // This preview has scrollable/interactable content that cannot be tested from an IDE preview @@ -21,7 +22,7 @@ private fun PatchOptionsScreenPreview( parameters: PatchOptionsParameters, ) { val context = LocalContext.current - val specs = remember { builtinPatchSpecs { context.getString(it) } } + val specs = remember { builtinPatchSpecs(context, Json { ignoreUnknownKeys = true }) } ManagerTheme { PatchOptionsScreenContent( @@ -29,6 +30,8 @@ private fun PatchOptionsScreenPreview( isDevMode = parameters.isDevMode, debuggable = parameters.debuggable, setDebuggable = {}, + bypassIncompatible = false, + setBypassIncompatible = {}, appName = parameters.appName, appNameIsError = parameters.appNameIsError, setAppName = {}, diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeModel.kt index bb27d59..2eb722b 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeModel.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeModel.kt @@ -19,7 +19,9 @@ import cafe.adriel.voyager.core.model.screenModelScope import com.github.diamondminer88.zip.ZipReader import com.meowarex.rlmobile.BuildConfig import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.PathManager import com.meowarex.rlmobile.manager.PreferencesManager +import com.meowarex.rlmobile.manager.ReleaseInfoCache import com.meowarex.rlmobile.network.models.GithubCommit import com.meowarex.rlmobile.network.models.RLBuildInfo import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService @@ -42,6 +44,7 @@ class HomeModel( private val github: RadiantLyricsGithubService, private val json: Json, private val prefs: PreferencesManager, + private val paths: PathManager, ) : ScreenModel { var state by mutableStateOf(HomeState.Loading) @@ -170,16 +173,23 @@ class HomeModel( refreshingLock.withLock { val pkg = fetchInstalled() - val remote = async(Dispatchers.IO) { if (remoteDataJson == null) fetchRemoteData() } - remote.await() + // Skip the remote fetch entirely when offline + val online = application.isOnline() + if (online) { + val remote = async(Dispatchers.IO) { if (remoteDataJson == null) fetchRemoteData() } + remote.await() + } val install = pkg?.toInstallData() val latest = remoteDataJson?.tidalVersionCode + val offlineRepatchReady = hasOfflineRepatchAssets() mainThread { state = HomeState.Loaded( install = install, latestTidalVersionCode = latest, + offline = !online, + offlineRepatchReady = offlineRepatchReady, ) maybeCheckManagerUpdate(pkg) } @@ -257,6 +267,13 @@ class HomeModel( ) } + // check if an offline "Local Repatch" is possible + private fun hasOfflineRepatchAssets(): Boolean { + val info = ReleaseInfoCache.load(paths, json) ?: return false + return paths.hasCachedTidalApk(info.data.tidalVersionCode) && + paths.hasCachedSmaliPatches(info.data.patchesVersion) + } + private suspend fun fetchRemoteData() { val release = try { github.getLatestRelease().fold( diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeScreen.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeScreen.kt index 4a2aec5..2d75b89 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeScreen.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeScreen.kt @@ -210,14 +210,19 @@ private fun ColumnScope.HomeContent( } val blockedByManagerUpdate = managerUpdateAvailable && (patchesBehind || tidalBehind) + val canLocalRepatch = install != null && state.offlineRepatchReady + val onlineEnabled = state.latestTidalVersionCode != null || install != null + val buttonEnabled = !blockedByManagerUpdate && (if (state.offline) canLocalRepatch else onlineEnabled) Button( onClick = if (install == null) onInstall else onRepatch, - enabled = state.latestTidalVersionCode != null && !blockedByManagerUpdate, + enabled = buttonEnabled, modifier = Modifier.fillMaxWidth(), ) { val label = when { blockedByManagerUpdate -> "Manager Update Required" - state.latestTidalVersionCode == null -> "Loading…" + state.offline && install != null -> "Local Repatch" + state.offline -> "No Network" + install == null && state.latestTidalVersionCode == null -> "Loading…" install == null -> "Install" patchesBehind && tidalBehind -> "Update Patches & TIDAL" patchesBehind -> "Update Patches" @@ -234,6 +239,18 @@ private fun ColumnScope.HomeContent( ) } + if (state.offline && install != null) { + Text( + text = "No Network Connection", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + ) + } + AnimatedVisibility(visible = install != null) { Row( horizontalArrangement = Arrangement.spacedBy(2.dp), diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeState.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeState.kt index 5c3fb46..8aa7c12 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeState.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeState.kt @@ -8,5 +8,7 @@ sealed interface HomeState { data class Loaded( val install: InstallData?, val latestTidalVersionCode: Int?, + val offline: Boolean = false, + val offlineRepatchReady: Boolean = false, ) : HomeState } diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/KnownPatch.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/KnownPatch.kt deleted file mode 100644 index bcdf2e1..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/KnownPatch.kt +++ /dev/null @@ -1,296 +0,0 @@ -package com.meowarex.rlmobile.ui.screens.patchopts - -import androidx.annotation.StringRes -import com.meowarex.rlmobile.R -import com.meowarex.rlmobile.ui.screens.patchopts.PatchDefault.Disabled -import com.meowarex.rlmobile.ui.screens.patchopts.PatchDefault.Enabled - -data class PatchVariant( - @StringRes val titleRes: Int, - val fileNames: List, - val extensionFiles: List = emptyList(), -) - - -enum class KnownPatch( - val order: Int, // Patch order in the UI List (lower = higher up) [Main Patches: multiples of 10 | Sub Patches: multiples of 1] - val fileNames: List, - val extensionFiles: List = emptyList(), - @StringRes val titleRes: Int, - @StringRes val descRes: Int, - val default: PatchDefault, // Default state of the patch in the UI List (enabled/disabled) - val requires: List = emptyList(), - val disables: List = emptyList(), - val variants: List = emptyList(), - val defaultVariantIndex: Int = 0, - val advancedOptions: List = emptyList(), - val category: String = PatchSpec.CATEGORY_PATCH, - val pathLocked: Boolean = false, -) { - LyricsDisableCover( - order = 41, - fileNames = listOf("lyrics-disable-cover.patch"), - titleRes = R.string.patch_lyrics_disable_cover_title, - descRes = R.string.patch_lyrics_disable_cover_desc, - default = Enabled, - ), - LyricsReplaceLyricsButton( - order = 42, - fileNames = listOf( - "lyrics-replace-lyrics-button.patch", - "lyrics-sparkle-conditional-visibility.patch", - ), - titleRes = R.string.patch_lyrics_replace_button_title, - descRes = R.string.patch_lyrics_replace_button_desc, - default = Enabled, - ), - LyricsReplaceShareButton( - order = 43, - fileNames = listOf("lyrics-replace-share-button.patch"), - titleRes = R.string.patch_lyrics_replace_share_button_title, - descRes = R.string.patch_lyrics_replace_share_button_desc, - default = Enabled, - ), - LyricsRlApi( - order = 20, - fileNames = listOf( - "lyrics-rl-api.patch", - "lyrics-rl-api-observer.patch", - ), - titleRes = R.string.patch_lyrics_rl_api_title, - descRes = R.string.patch_lyrics_rl_api_desc, - default = Disabled, - ), - LyricsKeepControlsVisible( - order = 60, - fileNames = listOf("lyrics-keep-controls-visible.patch"), - titleRes = R.string.patch_lyrics_keep_controls_title, - descRes = R.string.patch_lyrics_keep_controls_desc, - default = Enabled, - ), - PlayerBackdrop( - order = 30, - fileNames = listOf("player-backdrop.patch"), - titleRes = R.string.patch_player_backdrop_title, - descRes = R.string.patch_player_backdrop_desc, - default = Enabled, - advancedOptions = listOf( - PatchOption.Slider( - key = "blur_strength", - titleRes = R.string.patch_opt_backdrop_blur_title, - descRes = R.string.patch_opt_backdrop_blur_desc, - default = 50f, - valueRange = 0f..100f, - steps = 19, // dots every 5% (0,5,…,100); snap only when locked - displayAsPercent = true, - token = "RL_BLUR_BITS", - encode = SmaliEncode(EncodeKind.FloatBits, scale = 1.8f), - ), - PatchOption.Slider( - key = "dimming", - titleRes = R.string.patch_opt_backdrop_dimming_title, - descRes = R.string.patch_opt_backdrop_dimming_desc, - default = 50f, // 50% == the original -0x80000000 - valueRange = 0f..100f, - steps = 19, // dots every 5% - displayAsPercent = true, - token = "RL_SCRIM_ARGB", - encode = SmaliEncode(EncodeKind.ArgbAlpha), - ), - PatchOption.Toggle( - key = "cover_everywhere", - titleRes = R.string.patch_cover_everywhere_title, - descRes = R.string.patch_cover_everywhere_desc, - default = false, - inline = true, - fileNames = listOf( - "home-backdrop.patch", - "collection-backdrop.patch", - "cover-capture.patch", - ), - ), - ), - ), - QualityBadgeColors( - order = 36, - fileNames = listOf("player-quality-badge-colors.patch"), - titleRes = R.string.patch_quality_badge_colors_title, - descRes = R.string.patch_quality_badge_colors_desc, - default = Enabled, - ), - PlayerOneHanded( - order = 37, - fileNames = listOf("player-one-handed.patch"), - titleRes = R.string.patch_player_one_handed_title, - descRes = R.string.patch_player_one_handed_desc, - default = Disabled, - ), - DebugMenuUnlock( - order = 100, - fileNames = listOf("debug-menu-unlock.patch"), - titleRes = R.string.patch_debug_menu_unlock_title, - descRes = R.string.patch_debug_menu_unlock_desc, - default = Disabled, - ), - WazeIntegration( - order = 90, - fileNames = listOf("waze-media-browser.patch"), - extensionFiles = listOf( - "radiant/WazeInitReceiver.smali", - "radiant/WazeServiceConnection.smali", - ), - titleRes = R.string.patch_waze_integration_title, - descRes = R.string.patch_waze_integration_desc, - default = Disabled, - category = PatchSpec.CATEGORY_INTEGRATION, - pathLocked = true, - advancedOptions = listOf( - PatchOption.Choice( - key = "browse_root", - titleRes = R.string.patch_waze_browse_root_title, - entries = listOf( - ChoiceEntry(R.string.patch_waze_root_auto, value = "ROOT_AUTO"), - ChoiceEntry(R.string.patch_waze_root_home, value = "HOME_V2::home_page_v2_id"), - ChoiceEntry( - R.string.patch_waze_root_recents, - value = "RECENTLY_PLAYED::home/pages/CONTINUE_LISTEN_TO/view-all", - ), - ), - defaultIndex = 0, - token = "RL_WAZE_ROOT_ID", - ), - PatchOption.Color( - key = "accent_color", - titleRes = R.string.patch_waze_accent_color_title, - default = -16747037, // Waze default #ff0075e3 - token = "RL_WAZE_THEME_COLOR", - ), - ), - ), - LyricsProgressPill( - order = 40, - fileNames = listOf( - "lyrics-progress-pill.patch", - "lyrics-fade-region.patch", - ), - titleRes = R.string.patch_lyrics_progress_pill_title, - descRes = R.string.patch_lyrics_progress_pill_desc, - default = Enabled, - requires = listOf(LyricsDisableCover, LyricsReplaceLyricsButton, LyricsReplaceShareButton), - ), - MiniPlayerRedesign( - order = 50, - fileNames = emptyList(), - titleRes = R.string.patch_mini_player_redesign_title, - descRes = R.string.patch_mini_player_redesign_desc, - default = Disabled, - defaultVariantIndex = 2, - variants = listOf( - PatchVariant( // 0: Floating — stock rounded pill - titleRes = R.string.patch_mini_player_variant_floating_title, - fileNames = emptyList(), - ), - PatchVariant( // 1: Grey — square - titleRes = R.string.patch_mini_player_variant_square_grey_title, - fileNames = listOf("mini-player-grey.patch"), - ), - PatchVariant( // 2: Black — square black background - titleRes = R.string.patch_mini_player_variant_square_black_title, - fileNames = listOf("mini-player-black.patch"), - ), - ), - advancedOptions = listOf( - PatchOption.Toggle( - key = "dynamic_bg", - titleRes = R.string.patch_mini_player_dynamic_bg_title, - descRes = R.string.patch_mini_player_dynamic_bg_desc, - default = false, - inline = true, - fileNames = listOf("mini-player-dynamic-bg.patch"), - extensionFiles = listOf("radiant/MiniPlayerBackground.smali"), - hidesVariants = listOf(2), - relabelVariants = mapOf(1 to R.string.patch_mini_player_variant_legacy_title), - ), - // Animated progress border around the floating pill - PatchOption.Toggle( - key = "border", - titleRes = R.string.patch_mini_player_border_title, - descRes = R.string.patch_mini_player_border_desc, - default = false, - requiresVariant = 0, - fileNames = listOf("mini-player-floating-border.patch"), - extensionFiles = listOf("radiant/MiniSeekerFloating.smali"), - ), - // Swipe up to open the full player - PatchOption.Toggle( - key = "gestures", - titleRes = R.string.patch_mini_player_gestures_title, - descRes = R.string.patch_mini_player_gestures_desc, - default = true, - fileNames = listOf("mini-player-gestures.patch"), - extensionFiles = listOf( - "radiant/MiniPlayerGestures.smali", - "radiant/MiniPlayerGestures\$Gesture.smali", - "radiant/MiniPlayerGestures\$FeedbackLayer.smali", - "radiant/MiniPlayerGestures\$FeedbackResetAnimator.smali", - "radiant/MiniPlayerGestures\$RootGesture.smali", - "radiant/MiniPlayerGestures\$ApplyPending.smali", - ), - ), - PatchOption.Choice( - key = "swipe_up_drag", - titleRes = R.string.patch_mini_player_drag_title, - descRes = R.string.patch_mini_player_drag_desc, - entries = listOf( - ChoiceEntry(R.string.patch_mini_player_drag_static, value = "0x0"), - ChoiceEntry(R.string.patch_mini_player_drag_drag, value = "0x1"), - ), - defaultIndex = 1, - requiresOption = "gestures", - token = "RL_MINI_PLAYER_SWIPE_UP_DRAG", - ), - // Swipe left/right to skip - PatchOption.Toggle( - key = "next_prev", - titleRes = R.string.patch_mini_player_left_right_gestures_title, - descRes = R.string.patch_mini_player_left_right_gestures_desc, - default = false, - requiresOption = "gestures", - fileNames = listOf("mini-player-gestures-left-right.patch"), - extensionFiles = listOf( - "radiant/MiniPlayerTrackGestures.smali", - "radiant/MiniPlayerTrackGestures\$Gesture.smali", - "radiant/MiniPlayerTrackGestures\$OffsetLayer.smali", - "radiant/MiniPlayerTrackGestures\$ResetAnimator.smali", - "radiant/MiniPlayerTrackGestures\$TextDraw.smali", - "com/tidal/android/feature/appscaffold/ui/q\$c.smali", - ), - ), - ), - ), - EnableLegacyUi( - order = 10, - fileNames = listOf("enable-legacy-ui.patch"), - titleRes = R.string.patch_enable_legacy_ui_title, - descRes = R.string.patch_enable_legacy_ui_desc, - default = Disabled, - requires = listOf(DebugMenuUnlock), - disables = listOf( - LyricsDisableCover, - LyricsReplaceLyricsButton, - LyricsReplaceShareButton, - LyricsRlApi, - LyricsKeepControlsVisible, - PlayerBackdrop, - QualityBadgeColors, - LyricsProgressPill, - MiniPlayerRedesign, - ), - ); - - companion object { - val All: List = entries.sortedWith( - compareBy({ it.order }, { it.fileNames.firstOrNull() ?: it.name }) - ) - } -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchDefault.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchDefault.kt deleted file mode 100644 index 4a44372..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchDefault.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.meowarex.rlmobile.ui.screens.patchopts - -enum class PatchDefault(val isEnabled: Boolean) { - Enabled(true), - Disabled(false), -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOption.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOption.kt deleted file mode 100644 index 9fcabf2..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOption.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.meowarex.rlmobile.ui.screens.patchopts - -import androidx.annotation.StringRes - -sealed interface PatchOption { - /** Stable identifier, unique within a single patch. */ - val key: String - - @get:StringRes - val titleRes: Int - - @get:StringRes - val descRes: Int - - /** A simple on/off switch. */ - data class Toggle( - override val key: String, - @StringRes override val titleRes: Int, - @StringRes override val descRes: Int, - val default: Boolean, - /** Patch files applied only while this toggle is on (and its patch is enabled). */ - val fileNames: List = emptyList(), - /** Helper smali extracted only while this toggle is on. */ - val extensionFiles: List = emptyList(), - /** Render inline beneath the variant selector instead of inside the advanced sheet. */ - val inline: Boolean = false, - /** Greyed out (with the lock dialog) unless this variant index is the selected one. */ - val requiresVariant: Int? = null, - /** Greyed out unless the sibling option with this key is currently on. */ - val requiresOption: String? = null, - /** Variant indices hidden from the picker while this toggle is on. */ - val hidesVariants: List = emptyList(), - /** Variant title overrides (index -> @StringRes) applied while this toggle is on. */ - val relabelVariants: Map = emptyMap(), - /** Placeholder name (without the surrounding `__`) baked into the `.patch` files. */ - val token: String? = null, - ) : PatchOption - - /** A continuous (or stepped) numeric value within [valueRange]. */ - data class Slider( - override val key: String, - @StringRes override val titleRes: Int, - @StringRes override val descRes: Int, - val default: Float, - val valueRange: ClosedFloatingPointRange, - /** Number of discrete steps between the range ends (0 = continuous). */ - val steps: Int = 0, - /** Render the value as a rounded percentage (e.g. "50%"). */ - val displayAsPercent: Boolean = false, - /** Optional unit suffix shown after the value (e.g. "dp"). */ - @StringRes val unitRes: Int? = null, - /** Placeholder name (without the surrounding `__`) baked into the `.patch` files. */ - val token: String? = null, - /** How the chosen value becomes the smali literal that replaces the [token]. */ - val encode: SmaliEncode? = null, - ) : PatchOption - - /** A single choice out of a small list, rendered as a segmented control. */ - data class Choice( - override val key: String, - @StringRes override val titleRes: Int, - @StringRes override val descRes: Int = 0, - val entries: List, - val defaultIndex: Int = 0, - val requiresOption: String? = null, - val token: String? = null, - ) : PatchOption - - data class Color( - override val key: String, - @StringRes override val titleRes: Int, - @StringRes override val descRes: Int = 0, - val default: Int, - val token: String? = null, - ) : PatchOption -} - -data class ChoiceEntry( - @StringRes val labelRes: Int, - val value: String? = null, -) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsModel.kt index b545efc..294d933 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsModel.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsModel.kt @@ -45,7 +45,7 @@ class PatchOptionsModel( /** Patches flagged [PatchSpec.pathLocked] only work under the stock package name. */ fun isBlockedByPackageName(spec: PatchSpec): Boolean = - spec.pathLocked && packageName != PatchOptions.Default.packageName + spec.pathLocked && packageName != PatchOptions.Default.packageName && !bypassIncompatible /** The package-name field starts locked & editing requires confirming the unlock dialog */ var packageNameLocked by mutableStateOf(prefilledOptions.packageName == PatchOptions.Default.packageName) @@ -79,10 +79,20 @@ class PatchOptionsModel( debuggable = value } + /** Dev override: force-allow path-gated ("incompatible") patches under a non-stock package name. */ + var bypassIncompatible by mutableStateOf(prefs.bypassIncompatible) + private set + + fun changeBypassIncompatible(value: Boolean) { + bypassIncompatible = value + prefs.bypassIncompatible = value + validatePatchSelection() + } + // The accordion renders from [specs]. It defaults to the compiled-in (localized) list and is // rebuilt from a custom zip's manifest.json whenever a custom patch set is selected. (Rminder for others about the new custom patch selection flow) - private val builtinSpecs: List = builtinPatchSpecs { context.getString(it) } + private val builtinSpecs: List = builtinPatchSpecs(context, json) var specs by mutableStateOf(builtinSpecs) private set @@ -281,6 +291,7 @@ class PatchOptionsModel( val bytes = ZipReader(file).use { it.openEntry(MANIFEST_NAME)?.read() } ?: return null json.decodeFromString(PatchManifest.serializer(), bytes.decodeToString()) .patches + .sortedBy { it.order } // manifest is authoritative for UI order (not KnownPatch) .takeIf { it.isNotEmpty() } } catch (t: Throwable) { Log.w(BuildConfig.TAG, "Failed to parse $MANIFEST_NAME; using built-in list", t) @@ -320,10 +331,19 @@ class PatchOptionsModel( null } - /** Default (non-custom) source: the latest release manifest, falling back to the built-in list. */ + private fun loadCachedReleaseManifestSpecs(): List? = + paths.cacheDownloadDir + .listFiles { f -> f.name.startsWith("manifest-") && f.name.endsWith(".zip") } + ?.maxByOrNull { it.lastModified() } + ?.let { loadManifestSpecs(it) } + ?.also { Log.i(BuildConfig.TAG, "Loaded ${it.size} patches from cached release manifest (offline)") } + + /** Default (non-custom) source: latest release manifest → last cached manifest → built-in list. */ private fun loadDefaultSpecs() = screenModelScope.launchIO { mainThread { specsLoading = true } - val loaded = loadLatestReleaseSpecs() ?: builtinSpecs + // Only hit the network when actually online + val loaded = (if (context.isOnline()) loadLatestReleaseSpecs() else null) + ?: loadCachedReleaseManifestSpecs() ?: builtinSpecs mainThread { specs = loaded validatePatchSelection() diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsScreen.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsScreen.kt index 628d450..2153ea3 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsScreen.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsScreen.kt @@ -48,6 +48,8 @@ class PatchOptionsScreen( debuggable = model.debuggable, setDebuggable = model::changeDebuggable, + bypassIncompatible = model.bypassIncompatible, + setBypassIncompatible = model::changeBypassIncompatible, appName = model.appName, appNameIsError = model.appNameIsError, @@ -89,6 +91,8 @@ fun PatchOptionsScreenContent( debuggable: Boolean, setDebuggable: (Boolean) -> Unit, + bypassIncompatible: Boolean, + setBypassIncompatible: (Boolean) -> Unit, appName: String, appNameIsError: Boolean, @@ -121,7 +125,8 @@ fun PatchOptionsScreenContent( var showPkgUnlockDialog by rememberSaveable { mutableStateOf(false) } if (showPkgUnlockDialog) { PackageNameUnlockDialog( - blockedTitles = specs.filter { it.pathLocked }.map { it.title }, + // With "Bypass Incompatible" on, path-gated patches are force-applied, not disabled. + blockedTitles = if (bypassIncompatible) emptyList() else specs.filter { it.pathLocked }.map { it.title }, onConfirm = { showPkgUnlockDialog = false onUnlockPackageName() @@ -268,6 +273,14 @@ fun PatchOptionsScreenContent( ) } } + + SwitchPatchOption( + icon = painterResource(R.drawable.ic_lock_open), + name = stringResource(R.string.patchopts_bypass_incompatible_title), + description = stringResource(R.string.patchopts_bypass_incompatible_desc), + value = bypassIncompatible, + onValueChange = setBypassIncompatible, + ) } Spacer(Modifier.weight(1f)) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchSpec.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchSpec.kt index 3467ccf..57532a9 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchSpec.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchSpec.kt @@ -1,9 +1,11 @@ package com.meowarex.rlmobile.ui.screens.patchopts +import android.content.Context import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import kotlin.math.roundToInt @Serializable @@ -157,74 +159,17 @@ data class SmaliEncode( } } -fun builtinPatchSpecs(resolve: (Int) -> String): List = - KnownPatch.All.map { patch -> - PatchSpec( - id = patch.name, - order = patch.order, - fileNames = patch.fileNames, - extensionFiles = patch.extensionFiles, - title = resolve(patch.titleRes), - description = resolve(patch.descRes), - defaultEnabled = patch.default.isEnabled, - requires = patch.requires.map { it.name }, - disables = patch.disables.map { it.name }, - variants = patch.variants.map { VariantSpec(resolve(it.titleRes), it.fileNames, it.extensionFiles) }, - defaultVariantIndex = patch.defaultVariantIndex, - advancedOptions = patch.advancedOptions.map { it.toSpec(resolve) }, - category = patch.category, - pathLocked = patch.pathLocked, - ) - } +/** Asset filename of the bundled fallback manifest snapshot (a copy of the release manifest.json). */ +const val BUNDLED_MANIFEST_ASSET = "patches-manifest.json" -private fun PatchOption.toSpec(resolve: (Int) -> String): OptionSpec = when (this) { - is PatchOption.Toggle -> OptionSpec.Toggle( - key = key, - title = resolve(titleRes), - description = resolve(descRes), - default = default, - fileNames = fileNames, - extensionFiles = extensionFiles, - inline = inline, - requiresVariant = requiresVariant, - requiresOption = requiresOption, - hidesVariants = hidesVariants, - relabelVariants = relabelVariants.mapValues { resolve(it.value) }, - token = token, - ) - - is PatchOption.Slider -> OptionSpec.Slider( - key = key, - title = resolve(titleRes), - description = resolve(descRes), - default = default, - min = valueRange.start, - max = valueRange.endInclusive, - steps = steps, - displayAsPercent = displayAsPercent, - unit = unitRes?.let(resolve), - token = token, - encode = encode, - ) - - is PatchOption.Choice -> OptionSpec.Choice( - key = key, - title = resolve(titleRes), - description = if (descRes != 0) resolve(descRes) else "", - entries = entries.map { resolve(it.labelRes) }, - defaultIndex = defaultIndex, - values = entries.map { it.value ?: "" }, - requiresOption = requiresOption, - token = token, - ) - - is PatchOption.Color -> OptionSpec.Color( - key = key, - title = resolve(titleRes), - description = if (descRes != 0) resolve(descRes) else "", - default = default, - token = token, - ) +// Fallback patch list (snapshot of the release manifest bundled in the app's assets) +fun builtinPatchSpecs(context: Context, json: Json): List = try { + context.assets.open(BUNDLED_MANIFEST_ASSET).use { it.readBytes() } + .let { json.decodeFromString(PatchManifest.serializer(), it.decodeToString()) } + .patches + .sortedBy { it.order } +} catch (t: Throwable) { + emptyList() } @Immutable diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Context.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Context.kt index 96aa146..ea149f6 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Context.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Context.kt @@ -5,6 +5,8 @@ import android.app.Activity import android.content.* import android.content.pm.PackageManager import android.content.res.Resources +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import android.net.Uri import android.os.* import android.provider.Settings @@ -63,6 +65,15 @@ fun Context.getPackageVersion(pkg: String): Pair { .let { it.versionName to it.versionCode } } +// Whether the device currently has a validated internet connection +fun Context.isOnline(): Boolean { + val cm = getSystemService() ?: return true + val network = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(network) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) +} + fun Context.isPackageInstalled(packageName: String): Boolean { return try { packageManager.getPackageInfo(packageName, 0) diff --git a/Manager/app/src/main/res/values/strings.xml b/Manager/app/src/main/res/values/strings.xml index 3e92d46..ed6300e 100644 --- a/Manager/app/src/main/res/values/strings.xml +++ b/Manager/app/src/main/res/values/strings.xml @@ -180,7 +180,7 @@ Installer timed out Prepare - Download dependencies + Download Batteries Patch APK Install APK @@ -247,6 +247,8 @@ Provide your own Stock TIDAL APK to patch instead of the version from Github. Custom Patches Provide your own Patches Zip to use instead of the ones from Github. + Bypass Incompatible + Force-allow path-gated patches. This can be VERY DANGEROUS! Basic Advanced Patches diff --git a/patches/waze-media-browser.patch b/patches/integration-waze-playback.patch similarity index 100% rename from patches/waze-media-browser.patch rename to patches/integration-waze-playback.patch diff --git a/patches/manifest.json b/patches/manifest.json index 6a128d7..865d806 100644 --- a/patches/manifest.json +++ b/patches/manifest.json @@ -103,8 +103,21 @@ "default": true }, { - "id": "PlayerOneHanded", + "id": "PlayerMovePlayingFrom", "order": 37, + "fileNames": [ + "player-move-playing-from.patch" + ], + "extensionFiles": [ + "radiant/PlayingFrom.smali" + ], + "title": "Playing From", + "description": "Shows the source name (playlist/album/mix) above the track title", + "default": true + }, + { + "id": "PlayerOneHanded", + "order": 38, "fileNames": [ "player-one-handed.patch" ], @@ -159,19 +172,6 @@ "description": "Replaces the share button with the Tidal Connect button from the top of the screen. (it's in the menu already)", "default": true }, - { - "id": "PlayerMovePlayingFrom", - "order": 44, - "fileNames": [ - "player-move-playing-from.patch" - ], - "extensionFiles": [ - "radiant/PlayingFrom.smali" - ], - "title": "Move Playing From", - "description": "Shows the source name (e.g. the playlist or album you're playing from) just above the track title, in the artist text style, instead of at the bottom of the player.", - "default": false - }, { "id": "MiniPlayerRedesign", "order": 50, @@ -307,7 +307,7 @@ "category": "integration", "pathLocked": true, "fileNames": [ - "waze-media-browser.patch" + "integration-waze-playback.patch" ], "extensionFiles": [ "radiant/WazeInitReceiver.smali", @@ -354,4 +354,4 @@ "default": false } ] -} \ No newline at end of file +} diff --git a/patches/player-hide-playing-from.patch b/patches/player-hide-playing-from.patch new file mode 100644 index 0000000..f200073 --- /dev/null +++ b/patches/player-hide-playing-from.patch @@ -0,0 +1,11 @@ +--- a/com/tidal/android/feature/playerscreen/ui/PlayerScreenKt.smali ++++ b/com/tidal/android/feature/playerscreen/ui/PlayerScreenKt.smali +@@ -5794,6 +5794,8 @@ + .line 336 + iget-object v3, v10, Lcom/tidal/android/feature/playerscreen/ui/s$a;->l:Lcom/tidal/android/feature/playerscreen/ui/p; + ++ const/4 v3, 0x0 # Never show the "Playing from" source at the bottom (relocated above the title, or hidden when off) ++ + if-nez v3, :cond_52 + + const v3, 0x26ba353b diff --git a/patches/player-move-playing-from.patch b/patches/player-move-playing-from.patch index e0b7c19..1678363 100644 --- a/patches/player-move-playing-from.patch +++ b/patches/player-move-playing-from.patch @@ -22,15 +22,6 @@ iget-object v0, v3, Lcom/tidal/android/feature/playerscreen/ui/s$a;->c:Lcom/tidal/android/feature/playerscreen/ui/d; .line 234 -@@ -5794,6 +5814,8 @@ - .line 336 - iget-object v3, v10, Lcom/tidal/android/feature/playerscreen/ui/s$a;->l:Lcom/tidal/android/feature/playerscreen/ui/p; - -+ const/4 v3, 0x0 # RL move-playing-from: force null -> hide source at bottom (rendered above the title instead) -+ - if-nez v3, :cond_52 - - const v3, 0x26ba353b --- a/com/tidal/android/feature/playerscreen/ui/composables/k2.smali +++ b/com/tidal/android/feature/playerscreen/ui/composables/k2.smali @@ -632,6 +632,39 @@ diff --git a/tidal-apk/com.aspiro.tidal_2.192.1-9090_4arch_7dpi_24lang_56762977dcaecf1452892e71fe48d6f0_apkmirror.com.apkm b/tidal-apk/com.aspiro.tidal_2.192.1-9090_4arch_7dpi_24lang_56762977dcaecf1452892e71fe48d6f0_apkmirror.com.apkm deleted file mode 100644 index 72f3c09..0000000 Binary files a/tidal-apk/com.aspiro.tidal_2.192.1-9090_4arch_7dpi_24lang_56762977dcaecf1452892e71fe48d6f0_apkmirror.com.apkm and /dev/null differ