Skip to content

Commit e136ec6

Browse files
committed
feat: Block patching when bundle requires newer patcher
Reads the Patcher-Version manifest attribute from .mpp bundles and compares it against BuildConfig.PATCHER_VERSION at preflight. If a bundle requires a newer patcher than the manager ships, patching is blocked and a dialog is shown directing the user to morphe.software.
1 parent e322f44 commit e136ec6

8 files changed

Lines changed: 147 additions & 67 deletions

File tree

app/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ android {
145145
val versionCodeOffset = 10010100
146146
versionCode = timestampVersionCode + versionCodeOffset
147147

148+
// Expose the resolved morphe-patcher version so PatcherViewModel can compare it
149+
// against the Patcher-Version declared in .mpp bundle manifests at runtime.
150+
buildConfigField("String", "PATCHER_VERSION", "\"${libs.versions.morphe.patcher.get()}\"")
151+
148152
vectorDrawables.useSupportLibrary = true
149153
}
150154

app/src/main/java/app/morphe/manager/domain/repository/PatchBundleRepository.kt

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -409,11 +409,12 @@ class PatchBundleRepository(
409409
val metadata = map.mapNotNull { (bundle, src) ->
410410
try {
411411
src.uid to PatchBundleInfo.Global(
412-
src.displayTitle,
413-
bundle.manifestAttributes?.version,
414-
src.uid,
415-
src.enabled,
416-
PatchBundle.Loader.metadata(bundle)
412+
name = src.displayTitle,
413+
version = bundle.manifestAttributes?.version,
414+
uid = src.uid,
415+
enabled = src.enabled,
416+
patches = PatchBundle.Loader.metadata(bundle),
417+
patcherVersion = bundle.manifestAttributes?.patcherVersion,
417418
)
418419
} catch (error: Throwable) {
419420
failures += src.uid to error

app/src/main/java/app/morphe/manager/patcher/patch/PatchBundleInfo.kt

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,19 @@ import app.morphe.manager.util.PatchSelection
66
* A base class for storing [PatchBundle] metadata.
77
*/
88
sealed class PatchBundleInfo {
9-
/**
10-
* The name of the bundle.
11-
*/
9+
/** The name of the bundle. */
1210
abstract val name: String
1311

14-
/**
15-
* The version of the bundle.
16-
*/
12+
/** The version of the bundle. */
1713
abstract val version: String?
1814

19-
/**
20-
* The unique ID of the bundle.
21-
*/
15+
/** The unique ID of the bundle. */
2216
abstract val uid: Int
2317

24-
/**
25-
* The state indicating whether the bundle is enabled or disabled.
26-
*/
18+
/** The state indicating whether the bundle is enabled or disabled. */
2719
abstract val enabled: Boolean
2820

29-
/**
30-
* The patch list.
31-
*/
21+
/** The patch list. */
3222
abstract val patches: List<PatchInfo>
3323

3424
/**
@@ -41,7 +31,10 @@ sealed class PatchBundleInfo {
4131
override val version: String?,
4232
override val uid: Int,
4333
override val enabled: Boolean,
44-
override val patches: List<PatchInfo>
34+
override val patches: List<PatchInfo>,
35+
// Version of morphe-patcher required by this bundle, from Patcher-Version manifest attribute.
36+
// Null if the bundle was built before this field was introduced
37+
val patcherVersion: String? = null
4538
) : PatchBundleInfo() {
4639
/**
4740
* Create a [PatchBundleInfo.Scoped] that only contains information about patches that are relevant for a specific [packageName].
@@ -92,6 +85,7 @@ sealed class PatchBundleInfo {
9285
apkFileType = apkFileType,
9386
displayName = displayName,
9487
signatures = signaturesAcc.takeIf { it.isNotEmpty() },
88+
patcherVersion = patcherVersion
9589
)
9690
}
9791
}
@@ -124,6 +118,7 @@ sealed class PatchBundleInfo {
124118
val apkFileType: app.morphe.patcher.patch.ApkFileType? = null,
125119
val displayName: String? = null,
126120
val signatures: Set<String>? = null,
121+
val patcherVersion: String? = null // Propagated from Global.patcherVersion
127122
) : PatchBundleInfo() {
128123
fun patchSequence(allowIncompatible: Boolean) = if (allowIncompatible) {
129124
patches.asSequence()

app/src/main/java/app/morphe/manager/ui/screen/PatcherScreen.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,19 @@ fun PatcherScreen(
327327
)
328328
}
329329

330+
// Patcher version incompatibility pre-flight dialog.
331+
// Shown when a bundle's Patcher-Version is newer than what the manager ships.
332+
patcherViewModel.incompatiblePatcherVersion?.let { state ->
333+
IncompatiblePatcherVersionDialog(
334+
bundleName = state.bundleName,
335+
requiredVersion = state.requiredVersion,
336+
onDismiss = {
337+
patcherViewModel.dismissIncompatiblePatcherVersion()
338+
onBackClick()
339+
}
340+
)
341+
}
342+
330343
// Error dialog
331344
if (state.showErrorDialog) {
332345
PatcherErrorDialog(

app/src/main/java/app/morphe/manager/ui/screen/patcher/PatcherDialogs.kt

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,75 @@ import androidx.compose.ui.text.style.TextAlign
3838
import androidx.compose.ui.text.style.TextOverflow
3939
import androidx.compose.ui.unit.dp
4040
import androidx.compose.ui.unit.sp
41+
import androidx.core.net.toUri
4142
import app.morphe.manager.R
4243
import app.morphe.manager.ui.screen.shared.*
44+
import app.morphe.manager.util.MORPHE_WEBSITE_URL
4345
import app.morphe.manager.util.PathValidationResult
4446
import app.morphe.manager.util.toast
4547

4648

4749
/**
48-
* Cancel patching confirmation dialog
49-
* Warns user about stopping patching process
50+
* Shown when a patch bundle requires a newer version of morphe-patcher than the one
51+
* bundled in this version of the manager. Directs the user to the website to update.
52+
*/
53+
@Composable
54+
fun IncompatiblePatcherVersionDialog(
55+
bundleName: String,
56+
requiredVersion: String,
57+
onDismiss: () -> Unit,
58+
) {
59+
val context = LocalContext.current
60+
61+
MorpheDialog(
62+
onDismissRequest = onDismiss,
63+
title = stringResource(R.string.patcher_incompatible_patcher_title),
64+
footer = {
65+
Column(
66+
modifier = Modifier.fillMaxWidth(),
67+
verticalArrangement = Arrangement.spacedBy(8.dp)
68+
) {
69+
MorpheDialogButton(
70+
text = stringResource(R.string.patcher_incompatible_patcher_update_button),
71+
onClick = {
72+
val intent = Intent(Intent.ACTION_VIEW, MORPHE_WEBSITE_URL.toUri())
73+
context.startActivity(intent)
74+
},
75+
icon = Icons.Outlined.SystemUpdate,
76+
modifier = Modifier.fillMaxWidth()
77+
)
78+
MorpheDialogOutlinedButton(
79+
text = stringResource(R.string.close),
80+
onClick = onDismiss,
81+
modifier = Modifier.fillMaxWidth()
82+
)
83+
}
84+
}
85+
) {
86+
val secondaryColor = LocalDialogSecondaryTextColor.current
87+
88+
Column(
89+
modifier = Modifier.fillMaxWidth(),
90+
verticalArrangement = Arrangement.spacedBy(12.dp)
91+
) {
92+
Text(
93+
text = stringResource(
94+
R.string.patcher_incompatible_patcher_description,
95+
bundleName,
96+
requiredVersion
97+
),
98+
style = MaterialTheme.typography.bodyLarge,
99+
color = secondaryColor,
100+
textAlign = TextAlign.Center,
101+
modifier = Modifier.fillMaxWidth()
102+
)
103+
}
104+
}
105+
}
106+
107+
/**
108+
* Cancel patching confirmation dialog.
109+
* Warns user about stopping patching process.
50110
*/
51111
@Composable
52112
fun CancelPatchingDialog(

app/src/main/java/app/morphe/manager/ui/viewmodel/PatcherViewModel.kt

Lines changed: 47 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
1616
import androidx.lifecycle.viewmodel.compose.saveable
1717
import androidx.work.WorkInfo
1818
import androidx.work.WorkManager
19+
import app.morphe.manager.BuildConfig
1920
import app.morphe.manager.R
2021
import app.morphe.manager.data.platform.Filesystem
2122
import app.morphe.manager.data.room.apps.installed.InstallType
@@ -39,6 +40,7 @@ import app.morphe.manager.ui.screen.patcher.PatcherErrorInfo
3940
import app.morphe.manager.util.*
4041
import app.morphe.manager.util.saver.snapshotStateListSaver
4142
import app.morphe.manager.worker.UpdateCheckWorker
43+
import io.github.z4kn4fein.semver.Version
4244
import kotlinx.coroutines.*
4345
import kotlinx.coroutines.channels.Channel
4446
import kotlinx.coroutines.flow.*
@@ -47,12 +49,7 @@ import kotlinx.coroutines.sync.withLock
4749
import org.koin.core.component.KoinComponent
4850
import org.koin.core.component.get
4951
import org.koin.core.component.inject
50-
import android.content.ContentValues
51-
import android.os.Build
52-
import android.os.Environment
53-
import android.provider.MediaStore
5452
import java.io.File
55-
import java.io.FileOutputStream
5653
import java.io.IOException
5754
import java.nio.file.Files
5855
import java.util.UUID
@@ -137,6 +134,24 @@ class PatcherViewModel(
137134
inaccessibleOptionPaths = null
138135
}
139136

137+
/**
138+
* Non-null when a bundle requires a newer morphe-patcher than the one bundled
139+
* in this version of Morphe.
140+
*
141+
* @param requiredVersion The minimum patcher version declared in the bundle.
142+
* @param bundleName The display name of the offending bundle.
143+
*/
144+
data class IncompatiblePatcherVersionState(
145+
val requiredVersion: String,
146+
val bundleName: String,
147+
)
148+
var incompatiblePatcherVersion by mutableStateOf<IncompatiblePatcherVersionState?>(null)
149+
private set
150+
151+
fun dismissIncompatiblePatcherVersion() {
152+
incompatiblePatcherVersion = null
153+
}
154+
140155
/**
141156
* Called when the user acknowledges the storage permission warning and chooses
142157
* to proceed anyway (e.g. after granting MANAGE_EXTERNAL_STORAGE in settings
@@ -405,7 +420,23 @@ class PatcherViewModel(
405420

406421
bundleVersionsForLog = collectSelectedBundleMetadata().first
407422

408-
// Validate any file-system paths supplied as patch options before handing off to the worker.
423+
// Check that all selected bundles are compatible with the patcher bundled in this
424+
// version of the manager. If a bundle requires a newer patcher, block and show a dialog
425+
// asking the user to update the manager app
426+
val globalBundlesForCheck = patchBundleRepository.bundleInfoFlow.first()
427+
appliedSelection.keys.forEach { uid ->
428+
val bundle = globalBundlesForCheck[uid] ?: return@forEach
429+
val required = bundle.patcherVersion ?: return@forEach
430+
if (isPatcherOutdated(required, BuildConfig.PATCHER_VERSION)) {
431+
incompatiblePatcherVersion = IncompatiblePatcherVersionState(
432+
requiredVersion = required,
433+
bundleName = bundle.name,
434+
)
435+
return
436+
}
437+
}
438+
439+
// Validate any file-system paths supplied as patch options before handing off to the worker
409440
val optionsToValidate = if (prefs.useExpertMode.getBlocking()) {
410441
input.options
411442
} else {
@@ -601,44 +632,6 @@ class PatcherViewModel(
601632
}
602633
}
603634

604-
/**
605-
* Exports the patched APK to the public Downloads folder.
606-
* Used as a fallback on devices without DocumentsUI.
607-
*/
608-
fun exportToDownloads() = viewModelScope.launch {
609-
if (_isSaving.value) return@launch
610-
_isSaving.value = true
611-
try {
612-
ensureExportMetadata()
613-
val fileName = exportFileName
614-
val exportSucceeded = runCatching {
615-
withContext(Dispatchers.IO) {
616-
val stream = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
617-
val values = ContentValues().apply {
618-
put(MediaStore.Downloads.DISPLAY_NAME, fileName)
619-
put(MediaStore.Downloads.MIME_TYPE, APK_MIMETYPE)
620-
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
621-
}
622-
val uri = app.contentResolver.insert(
623-
MediaStore.Downloads.EXTERNAL_CONTENT_URI, values
624-
) ?: throw IOException("Could not create Downloads entry")
625-
app.contentResolver.openOutputStream(uri)
626-
?: throw IOException("Could not open Downloads output stream")
627-
} else {
628-
@Suppress("DEPRECATION")
629-
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
630-
dir.mkdirs()
631-
FileOutputStream(File(dir, fileName))
632-
}
633-
stream.use { Files.copy(outputFile.toPath(), it) }
634-
}
635-
}.isSuccess
636-
finishExport(exportSucceeded)
637-
} finally {
638-
_isSaving.value = false
639-
}
640-
}
641-
642635
/**
643636
* Shared post-export logic: persists the patched app record, shows a toast,
644637
* and triggers the notification prompt on success.
@@ -1051,6 +1044,16 @@ class PatcherViewModel(
10511044
private const val MEMORY_ADJUSTMENT_MB = 200
10521045
private const val MIN_LIMIT_MB = 200
10531046

1047+
/**
1048+
* Returns true if [required] is strictly newer than [current].
1049+
* Uses the semver library already present in the project.
1050+
* Falls back to false (allow patching) if either string cannot be parsed.
1051+
*/
1052+
fun isPatcherOutdated(required: String, current: String): Boolean = runCatching {
1053+
Version.parse(required, strict = false) >
1054+
Version.parse(current, strict = false)
1055+
}.getOrDefault(false)
1056+
10541057
fun LogLevel.androidLog(msg: String) = when (this) {
10551058
LogLevel.TRACE -> Log.v(TAG, msg)
10561059
LogLevel.INFO -> Log.i(TAG, msg)

app/src/main/java/app/morphe/manager/util/Constants.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const val SOURCE_NAME = "Morphe Patches"
1515
const val MANAGER_REPO_URL = "https://github.com/MorpheApp/morphe-manager"
1616
const val SOURCE_REPO_URL = "https://github.com/MorpheApp/morphe-patches"
1717
const val MORPHE_API_URL = "https://api.morphe.software"
18+
const val MORPHE_WEBSITE_URL = "https://morphe.software"
1819

1920
/**
2021
* Delay before showing a manager update notification to the user.

app/src/main/res/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,9 @@ For the best results, this app recommends patching a &lt;b&gt;full APK&lt;/b&gt;
283283
<string name="patcher_ready_to_install_subtitle">Press the button below to install</string>
284284
<string name="patcher_ready_to_mount_subtitle">Press the button below to mount</string>
285285
<string name="patcher_storage_permission_dialog_title">Cannot access storage path</string>
286+
<string name="patcher_incompatible_patcher_title">Update required</string>
287+
<string name="patcher_incompatible_patcher_description">The bundle \"%1$s\" requires patcher version %2$s or newer. Please update Morphe to patch this app</string>
288+
<string name="patcher_incompatible_patcher_update_button">Download and update Morphe</string>
286289
<string name="patcher_storage_permission_description_api30">One or more patch options point to paths Morphe cannot read. Grant storage access and tap the button then patching will start automatically</string>
287290
<string name="patcher_storage_permission_description_legacy">One or more patch options point to paths that cannot be accessed. Update the paths in patch options and try again</string>
288291
<string name="patcher_storage_permission_grant">Grant storage permission</string>

0 commit comments

Comments
 (0)