Skip to content

feat: Switch to patch-first user flow#2902

Open
CnC-Robert wants to merge 14 commits intodevfrom
feat/improve-user-flow
Open

feat: Switch to patch-first user flow#2902
CnC-Robert wants to merge 14 commits intodevfrom
feat/improve-user-flow

Conversation

@CnC-Robert
Copy link
Member

@CnC-Robert CnC-Robert commented Dec 30, 2025

Currently, the app determines a default version at the start of the flow by calculating the most compatible version across all available patches. Patch selection is then restricted to this version. Overriding the version requires manually selecting an APK with a specific version, even though it's not clear to the user which versions are supported.

Instead, the flow should be patch-first. Users explicitly select the patches they want, and the manager automatically calculates the most compatible version based on that patch selection. The calculated version can still be manually overridden to a specific version. Once a version is chosen or calculated, the app automatically determines the best APK source corresponding to that version.

Motivation

  • Enable version changes directly within the app
  • Avoid restricting patch selection based on a preselected version
  • Reduce cognitive load by clearly separating patch, version and source selection

Should close #2660, #2504, #2620, #2535

@CnC-Robert CnC-Robert force-pushed the feat/improve-user-flow branch from 4c055f4 to b28e9a1 Compare January 8, 2026 21:51
@planshim planshim linked an issue Mar 11, 2026 that may be closed by this pull request
3 tasks
@planshim planshim marked this pull request as ready for review March 11, 2026 12:23
@Ushie Ushie requested a review from Copilot March 11, 2026 12:25
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the patching setup flow to be “patch-first”: users choose patches first, then the app derives the most compatible app version and selects an appropriate APK source (with manual overrides for both).

Changes:

  • Introduces explicit Version and Source selector screens/models (SelectedVersion, SelectedSource) and wires them into navigation.
  • Refactors SelectedAppInfoViewModel/PatcherWorker/PatcherViewModel to operate on package/version/source instead of the removed SelectedApp model.
  • Updates UI strings/plurals to reflect the new flow and selection counts.

Reviewed changes

Copilot reviewed 28 out of 28 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
app/src/main/res/values/strings.xml Adds version-selector strings and updates selection labels to fit the new flow.
app/src/main/res/values/plurals.xml Adds plurals for available patch count + incompatible patch count messaging.
app/src/main/java/app/revanced/manager/util/Util.kt Adds PatchSelection.patchCount helper used across the new flow.
app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt New VM to compute and present version options from selected patches.
app/src/main/java/app/revanced/manager/ui/viewmodel/SourceSelectorViewModel.kt New VM to present source options (installed/local/downloaded/plugin) for a chosen version.
app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt Central refactor: patch selection → resolve version → resolve source → patcher params.
app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt Updates patch selection VM to use new navigation params (package/version/selection).
app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt Refactors storage selection event payload to (packageName, localPath).
app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt Removes legacy “select app then constrain by suggested version” channel flow.
app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt New UI for selecting Auto/Any/Specific versions with incompatibility info.
app/src/main/java/app/revanced/manager/ui/screen/SourceSelectorScreen.kt New UI for selecting Auto/Downloader/Installed/Local/Downloaded sources.
app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt Replaces old source dialog with Version + Source page items and derived descriptions.
app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt Adjusts patch checkbox behavior for the updated selection UX.
app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt Updates list metadata display (available patch count, not-installed indicator).
app/src/main/java/app/revanced/manager/ui/model/navigation/Nav.kt Adds new routes/params for VersionSelector and SourceSelector; refactors SelectedAppInfo params.
app/src/main/java/app/revanced/manager/ui/model/SelectedVersion.kt Adds SelectedVersion sealed model for Auto/Any/Specific.
app/src/main/java/app/revanced/manager/ui/model/SelectedSource.kt Adds SelectedSource sealed model for Auto/Installed/Downloaded/Local/Plugin.
app/src/main/java/app/revanced/manager/ui/model/SelectedApp.kt Removes legacy SelectedApp abstraction in favor of version+source separation.
app/src/main/java/app/revanced/manager/ui/component/AppLabel.kt Ensures app labels truncate cleanly (ellipsis).
app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt Updates worker args and input selection logic to use SelectedSource + resolved version.
app/src/main/java/app/revanced/manager/ui/viewmodel/PatcherViewModel.kt Updates patching step generation + worker args to use SelectedSource.
app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt Fixes compatibility check to treat null version as compatible (package-level match).
app/src/main/java/app/revanced/manager/domain/repository/PatchSelectionRepository.kt Uses the shared PatchSelection typealias return type.
app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt Adds suggestedVersions(packageName, patchSelection) to drive patch-first version resolution.
app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt Adds a flow getter for downloaded apps by package.
app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedAppDao.kt Adds query for downloaded apps by package.
app/src/main/java/app/revanced/manager/di/ViewModelModule.kt Registers the new selector view models in DI.
app/src/main/java/app/revanced/manager/MainActivity.kt Wires new screens/routes into navigation graph.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@planshim planshim force-pushed the feat/improve-user-flow branch 5 times, most recently from fd5c431 to a20ebff Compare March 12, 2026 03:47
@planshim planshim force-pushed the feat/improve-user-flow branch 2 times, most recently from 3911e95 to b28e9a1 Compare March 12, 2026 13:00
@Ushie Ushie requested a review from Copilot March 12, 2026 18:03
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 27 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

private val unscopedBundles = bundleRepository.scopedBundleInfoFlow(packageName, null)

val versionPatchSelection = combine(selectionFlow, unscopedBundles) { selection, bundleInfo ->
selection.patches(bundleInfo, allowIncompatible = true)
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

versionPatchSelection (and downstream patchSelection/customSelection) are computed with allowIncompatible = true unconditionally. This can cause the default selection (based on patch.include) and the patch count shown in the main screen to include version-incompatible patches even when the user has version compatibility checks enabled; later getPatcherParams() recomputes patches using prefs.disablePatchVersionCompatCheck, so the actual patched set may differ from what the UI shows. Consider threading the preference value into these flows (similar to PatchesSelectorViewModel’s allowIncompatiblePatches) so selection/counts stay consistent with what will actually be applied.

Suggested change
selection.patches(bundleInfo, allowIncompatible = true)
selection.patches(
bundleInfo,
allowIncompatible = prefs.disablePatchVersionCompatCheck
)

Copilot uses AI. Check for mistakes.
Comment on lines +116 to +119
installedApp?.installType == InstallType.DEFAULT -> DisableReason.ALREADY_PATCHED
installedApp?.installType == InstallType.MOUNT && !rootInstaller.hasRootAccess() ->
DisableReason.NO_ROOT
input.version != null && packageInfo.versionName != input.version -> DisableReason.VERSION_NOT_MATCHING
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The installed-source option does not account for split APK installs. SelectedAppInfoViewModel.resolveAutoSource() explicitly avoids choosing Installed when splitSourceDirs is non-empty, but here Installed can still be selected manually, and PatcherWorker will only use applicationInfo.sourceDir (base APK). Consider disabling Installed in this case (and aligning isSourceValid accordingly), or adding a dedicated disable reason/message for split installs.

Suggested change
installedApp?.installType == InstallType.DEFAULT -> DisableReason.ALREADY_PATCHED
installedApp?.installType == InstallType.MOUNT && !rootInstaller.hasRootAccess() ->
DisableReason.NO_ROOT
input.version != null && packageInfo.versionName != input.version -> DisableReason.VERSION_NOT_MATCHING
packageInfo.applicationInfo.splitSourceDirs?.isNotEmpty() == true ->
DisableReason.FAILED_TO_LOAD
installedApp?.installType == InstallType.DEFAULT -> DisableReason.ALREADY_PATCHED
installedApp?.installType == InstallType.MOUNT && !rootInstaller.hasRootAccess() ->
DisableReason.NO_ROOT
input.version != null && packageInfo.versionName != input.version ->
DisableReason.VERSION_NOT_MATCHING

Copilot uses AI. Check for mistakes.
SourceOption(
isSelected = viewModel.selectedSource == SelectedSource.Auto,
onSelect = { viewModel.selectSource(SelectedSource.Auto) },
headlineContent = { Text(stringResource(R.string.version_selector_auto_title)) },
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Auto option in the source selector uses version_selector_auto_title ("Auto (Recommended)") as its label. This appears version-specific and may be confusing on the source screen; consider using app_source_dialog_option_auto ("Auto") or introducing a dedicated source_selector_auto_title string so the terminology is consistent.

Suggested change
headlineContent = { Text(stringResource(R.string.version_selector_auto_title)) },
headlineContent = { Text(stringResource(R.string.app_source_dialog_option_auto)) },

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 26 out of 26 changed files in this pull request and generated 9 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

},
headlineContent = headlineContent,
supportingContent = supportingContent,
trailingContent = overlineContent,
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VersionOption passes overlineContent into ListItem as trailingContent, so the state label (Local/Downloaded/Installed) will render on the right instead of the overline slot. Use overlineContent = overlineContent (and keep trailingContent for any true trailing UI) to match the parameter naming and expected layout.

Suggested change
trailingContent = overlineContent,
overlineContent = overlineContent,

Copilot uses AI. Check for mistakes.
Comment on lines +114 to +124
installedSource = SourceOption(
source = SelectedSource.Installed,
title = packageInfo.versionName.toString(),
key = input.packageName,
disableReason = when {
installedApp?.installType == InstallType.DEFAULT -> DisableReason.ALREADY_PATCHED
installedApp?.installType == InstallType.MOUNT && !rootInstaller.hasRootAccess() ->
DisableReason.NO_ROOT

else -> versionMismatchReason(packageInfo.versionName)
}
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

installedSource uses packageInfo.versionName.toString(), which will display the literal string "null" if versionName is absent. Prefer a null-safe fallback (e.g., an empty string or a dedicated "Unknown version" label) and pass the nullable value through to versionMismatchReason consistently.

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +60
val downloadedApps = downloadedAppRepository.get(input.packageName)
.map { apps ->
apps.sortedByDescending { it.version }.map { app ->
SourceOption(
source = SelectedSource.Downloaded(
path = downloadedAppRepository.getApkFileForApp(app).path,
version = app.version
),
title = app.version,
key = app.version,
disableReason = versionMismatchReason(app.version)
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Downloaded apps are sorted with sortedByDescending { it.version }, which is lexicographic and can misorder versions (e.g., "10.0" vs "9.9"). If versions are dot-separated numbers, consider sorting with a semantic/version-aware comparator to ensure the newest versions appear first.

Copilot uses AI. Check for mistakes.
Comment on lines +250 to +260
private fun selectedVersionDescription(
selectedVersion: SelectedVersion,
resolvedVersion: String?,
): String {
val resolvedText = resolvedVersion ?: stringResource(R.string.selected_app_meta_any_version)
return if (selectedVersion is SelectedVersion.Auto) {
stringResource(R.string.selected_app_meta_auto, stringResource(R.string.app_source_dialog_option_auto), resolvedText)
} else {
resolvedText
}
}
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

selectedVersionDescription formats the Auto label using app_source_dialog_option_auto ("Auto" for sources) rather than the version-specific Auto label (version_selector_auto_title). This makes the Version row text inconsistent with the Version selector screen and can be confusing for translators/users; use a version-specific Auto label here.

Copilot uses AI. Check for mistakes.
Comment on lines +128 to +135
input.localPath?.let { local ->
pm.getPackageInfo(File(local))?.let { packageInfo ->
localApp = SourceOption(
source = SelectedSource.Local(local),
title = packageInfo.versionName.toString(),
key = "local",
disableReason = versionMismatchReason(packageInfo.versionName)
)
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

localApp uses packageInfo.versionName.toString(), which can render as "null" if the APK has no versionName. Consider handling the null case explicitly (and keep the disable-reason comparison working with nullable version names).

Copilot uses AI. Check for mistakes.
Comment on lines +59 to +62
allVersions
.map { (version, supported) -> SelectedVersion.Specific(version) to patchCount - supported }
.sortedWith(compareBy<Pair<SelectedVersion.Specific, Int>> { it.second }.thenByDescending { it.first.version })
}
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

availableVersions is sorted using thenByDescending { it.first.version }, which compares version strings lexicographically and can produce an incorrect ordering for numeric/dot-separated versions. Consider using a version-aware comparator so the list order matches user expectations for "newest" versions.

Copilot uses AI. Check for mistakes.
Comment on lines +94 to 101
when (selected) {
is SelectedVersion.Specific -> selected.version
is SelectedVersion.Any -> null
is SelectedVersion.Auto -> mostCompatible?.maxWithOrNull(
compareBy<Map.Entry<String, Int>> { it.value }
.thenBy { it.key }
)?.key
}
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Auto mode, resolvedVersion breaks ties with .thenBy { it.key }, which compares version names lexicographically. For typical numeric/dot-separated versions this can pick an unexpected "highest" version; consider using a version-aware comparator for the tiebreaker (or reuse the same ordering logic used elsewhere for suggested versions).

Copilot uses AI. Check for mistakes.
Comment on lines +296 to +303
SelectedSource.Installed -> {
val installedPackage = pm.getPackageInfo(packageName) ?: return false
val installedApp = installedAppRepository.get(packageName)

installedApp?.installType != InstallType.DEFAULT &&
!(installedApp?.installType == InstallType.MOUNT && !rootInstaller.hasRootAccess()) &&
(version == null || installedPackage.versionName == version)
}
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveAutoSource avoids using the installed APK when the installed app is a split APK (splitSourceDirs not empty), but isSourceValid(SelectedSource.Installed, …) does not include the same check. This allows users to manually select an installed split APK as the patch source, which will likely fail later since patching uses applicationInfo.sourceDir (base APK only). Mirror the split-APK check in isSourceValid (and ideally also hide/disable the Installed option in SourceSelectorViewModel).

Copilot uses AI. Check for mistakes.
Comment on lines +111 to +125
pm.getPackageInfo(input.packageName)?.let { packageInfo ->
val installedApp = installedAppRepository.get(input.packageName)

installedSource = SourceOption(
source = SelectedSource.Installed,
title = packageInfo.versionName.toString(),
key = input.packageName,
disableReason = when {
installedApp?.installType == InstallType.DEFAULT -> DisableReason.ALREADY_PATCHED
installedApp?.installType == InstallType.MOUNT && !rootInstaller.hasRootAccess() ->
DisableReason.NO_ROOT

else -> versionMismatchReason(packageInfo.versionName)
}
)
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Installed source option is created without checking whether the installed app is a split APK (i.e., applicationInfo.splitSourceDirs is non-empty). Since patching later reads applicationInfo.sourceDir (base APK), selecting an installed split APK is likely to fail; consider hiding this option or disabling it with a clear reason when the installed app is split.

Copilot uses AI. Check for mistakes.
# Conflicts:
#	app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt
#	app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment