Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build_pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
KEYSTORE_ENTRY_PASSWORD: ${{ secrets.KEYSTORE_ENTRY_PASSWORD }}
run: |
chmod +x ./gradlew
./gradlew assembleRelease --no-daemon --stacktrace
./gradlew assembleRelease --stacktrace
- name: Upload APK
uses: actions/upload-artifact@v4
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/compile_pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,4 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
chmod +x ./gradlew
./gradlew assembleDebug --no-daemon --stacktrace
./gradlew assembleDebug --stacktrace
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEYSTORE_ENTRY_ALIAS: ${{ secrets.KEYSTORE_ENTRY_ALIAS }}
KEYSTORE_ENTRY_PASSWORD: ${{ secrets.KEYSTORE_ENTRY_PASSWORD }}
run: ./gradlew --no-daemon assembleRelease
run: ./gradlew assembleRelease

- name: Setup Node.js
uses: actions/setup-node@v4
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/app/morphe/manager/di/ManagerModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import app.morphe.manager.domain.installer.InstallerManager
import app.morphe.manager.domain.installer.RootInstaller
import app.morphe.manager.domain.installer.ShizukuInstaller
import app.morphe.manager.domain.manager.AppIconManager
import app.morphe.manager.domain.manager.HomeAppButtonPreferences
import app.morphe.manager.domain.manager.KeystoreManager
import app.morphe.manager.domain.manager.PatchOptionsPreferencesManager
import app.morphe.manager.util.PM
Expand All @@ -20,4 +21,5 @@ val managerModule = module {
singleOf(::PatchOptionsPreferencesManager)
singleOf(::AppIconManager)
singleOf(::UpdateNotificationManager)
singleOf(::HomeAppButtonPreferences)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-manager
*/

package app.morphe.manager.domain.manager

import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import androidx.core.content.edit

/**
* Manages user preferences for home screen app buttons.
*
* Ordering is handled automatically:
* 1. Patched (installed) apps first
* 2. Apps with isPinnedByDefault = true
* 3. All other apps — alphabetical
*/
class HomeAppButtonPreferences(context: Context) {

private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)

private val _hiddenPackages = MutableStateFlow(loadHiddenPackages())
val hiddenPackages: StateFlow<Set<String>> = _hiddenPackages.asStateFlow()

private fun loadHiddenPackages(): Set<String> {
return prefs.getStringSet(KEY_HIDDEN, null) ?: emptySet()
}

fun isHidden(packageName: String): Boolean =
_hiddenPackages.value.contains(packageName)

fun hide(packageName: String) {
val current = _hiddenPackages.value.toMutableSet()
current.add(packageName)
saveHiddenPackages(current)
}

fun unhide(packageName: String) {
val current = _hiddenPackages.value.toMutableSet()
current.remove(packageName)
saveHiddenPackages(current)
}

/**
* Get all currently hidden packages (for "show hidden" UI).
*/
fun getHiddenPackages(): Set<String> = _hiddenPackages.value

private fun saveHiddenPackages(packages: Set<String>) {
prefs.edit {
putStringSet(KEY_HIDDEN, packages)
}
_hiddenPackages.value = packages
}

companion object {
private const val PREFS_NAME = "home_app_buttons"
private const val KEY_HIDDEN = "hidden_packages"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,20 @@ package app.morphe.manager.domain.manager

import android.content.Context
import app.morphe.manager.domain.manager.base.BasePreferencesManager
import app.morphe.manager.util.AppPackages
import app.morphe.manager.util.KnownApp

/**
* Manages patch-specific option values that are applied during patching.
* This manager only stores the values - the available options are fetched
* dynamically from the patch bundle repository.
*
* Storage keys follow the pattern: {package}_{patchName}_{optionKey}
* where package is "youtube" or "youtube_music"
*/
class PatchOptionsPreferencesManager(
context: Context
) : BasePreferencesManager(context, "patch_options") {

companion object {
// Package identifiers
const val PACKAGE_YOUTUBE = AppPackages.YOUTUBE
const val PACKAGE_YOUTUBE_MUSIC = AppPackages.YOUTUBE_MUSIC

// Patch names (must match exactly with bundle)
const val PATCH_THEME = "Theme"
const val PATCH_CUSTOM_BRANDING = "Custom branding"
Expand Down Expand Up @@ -97,169 +92,100 @@ The image dimensions must be as follows:
- drawable-xxxhdpi: 512x192 px"""
}

// ==================== YouTube Options ====================

// Theme - Dark
val darkThemeBackgroundColorYouTube = stringPreference(
"${PACKAGE_YOUTUBE}_${PATCH_THEME}_${KEY_DARK_THEME_COLOR}",
fun darkThemeColor(packageName: String) = stringPreference(
"${packageName}_${PATCH_THEME}_${KEY_DARK_THEME_COLOR}",
DEFAULT_DARK_THEME
)

// Theme - Light
val lightThemeBackgroundColorYouTube = stringPreference(
"${PACKAGE_YOUTUBE}_${PATCH_THEME}_${KEY_LIGHT_THEME_COLOR}",
fun lightThemeColor(packageName: String) = stringPreference(
"${packageName}_${PATCH_THEME}_${KEY_LIGHT_THEME_COLOR}",
DEFAULT_LIGHT_THEME
)

// Custom Branding - App Name
val customAppNameYouTube = stringPreference(
"${PACKAGE_YOUTUBE}_${PATCH_CUSTOM_BRANDING}_${KEY_CUSTOM_NAME}",
fun customAppName(packageName: String) = stringPreference(
"${packageName}_${PATCH_CUSTOM_BRANDING}_${KEY_CUSTOM_NAME}",
""
)

// Custom Branding - Icon Path
val customIconPathYouTube = stringPreference(
"${PACKAGE_YOUTUBE}_${PATCH_CUSTOM_BRANDING}_${KEY_CUSTOM_ICON}",
fun customIconPath(packageName: String) = stringPreference(
"${packageName}_${PATCH_CUSTOM_BRANDING}_${KEY_CUSTOM_ICON}",
""
)

// Change Header - Custom Header Path
// Change Header - Custom Header Path (YouTube only)
val customHeaderPath = stringPreference(
"${PACKAGE_YOUTUBE}_${PATCH_CHANGE_HEADER}_${KEY_CUSTOM_HEADER}",
"${KnownApp.YOUTUBE}_${PATCH_CHANGE_HEADER}_${KEY_CUSTOM_HEADER}",
""
)

// Hide Shorts - App Shortcut
// Hide Shorts - App Shortcut (YouTube only)
val hideShortsAppShortcut = booleanPreference(
"${PACKAGE_YOUTUBE}_${PATCH_HIDE_SHORTS}_${KEY_HIDE_SHORTS_APP_SHORTCUT}",
"${KnownApp.YOUTUBE}_${PATCH_HIDE_SHORTS}_${KEY_HIDE_SHORTS_APP_SHORTCUT}",
false
)

// Hide Shorts - Widget
// Hide Shorts - Widget (YouTube only)
val hideShortsWidget = booleanPreference(
"${PACKAGE_YOUTUBE}_${PATCH_HIDE_SHORTS}_${KEY_HIDE_SHORTS_WIDGET}",
"${KnownApp.YOUTUBE}_${PATCH_HIDE_SHORTS}_${KEY_HIDE_SHORTS_WIDGET}",
false
)

// ==================== YouTube Music Options ====================

// Theme - Dark
val darkThemeBackgroundColorYouTubeMusic = stringPreference(
"${PACKAGE_YOUTUBE_MUSIC}_${PATCH_THEME}_${KEY_DARK_THEME_COLOR}",
DEFAULT_DARK_THEME
)

// Custom Branding - App Name
val customAppNameYouTubeMusic = stringPreference(
"${PACKAGE_YOUTUBE_MUSIC}_${PATCH_CUSTOM_BRANDING}_${KEY_CUSTOM_NAME}",
""
)

// Custom Branding - Icon Path
val customIconPathYouTubeMusic = stringPreference(
"${PACKAGE_YOUTUBE_MUSIC}_${PATCH_CUSTOM_BRANDING}_${KEY_CUSTOM_ICON}",
""
)

/**
* Export YouTube specific patch options
* Format: Map<BundleUid, Map<PatchName, Map<OptionKey, Value>>>
*/
suspend fun exportYouTubePatchOptions(): Map<Int, Map<String, Map<String, Any?>>> {
return buildMap {
// Note: Bundle UID 0 is the default Morphe bundle
val bundleOptions = mutableMapOf<String, MutableMap<String, Any?>>()

// Theme patch options for YouTube
val themeOptionsYouTube = mutableMapOf<String, Any?>()
darkThemeBackgroundColorYouTube.get().takeIf { it.isNotBlank() && it != DEFAULT_DARK_THEME }?.let {
themeOptionsYouTube[KEY_DARK_THEME_COLOR] = it
}
lightThemeBackgroundColorYouTube.get().takeIf { it.isNotBlank() && it != DEFAULT_LIGHT_THEME }?.let {
themeOptionsYouTube[KEY_LIGHT_THEME_COLOR] = it
}
if (themeOptionsYouTube.isNotEmpty()) {
bundleOptions[PATCH_THEME] = themeOptionsYouTube
}

// Custom Branding patch options for YouTube
val brandingOptionsYouTube = mutableMapOf<String, Any?>()
customAppNameYouTube.get().takeIf { it.isNotBlank() }?.let {
brandingOptionsYouTube[KEY_CUSTOM_NAME] = it
}
customIconPathYouTube.get().takeIf { it.isNotBlank() }?.let {
brandingOptionsYouTube[KEY_CUSTOM_ICON] = it
}
if (brandingOptionsYouTube.isNotEmpty()) {
bundleOptions[PATCH_CUSTOM_BRANDING] = brandingOptionsYouTube
}

// Change Header patch options
customHeaderPath.get().takeIf { it.isNotBlank() }?.let {
bundleOptions[PATCH_CHANGE_HEADER] = mutableMapOf(
KEY_CUSTOM_HEADER to it
)
}

// Hide Shorts patch options
val shortsOptions = mutableMapOf<String, Any?>()
if (hideShortsAppShortcut.get()) {
shortsOptions[KEY_HIDE_SHORTS_APP_SHORTCUT] = true
}
if (hideShortsWidget.get()) {
shortsOptions[KEY_HIDE_SHORTS_WIDGET] = true
}
if (shortsOptions.isNotEmpty()) {
bundleOptions[PATCH_HIDE_SHORTS] = shortsOptions
}

// Bundle ID 0 = default Morphe bundle
if (bundleOptions.isNotEmpty()) {
put(0, bundleOptions)
}
}
}

/**
* Export YouTube Music specific patch options
* Export patch options for a given package.
* Format: Map<BundleUid, Map<PatchName, Map<OptionKey, Value>>>
*/
suspend fun exportYouTubeMusicPatchOptions(): Map<Int, Map<String, Map<String, Any?>>> {
suspend fun exportPatchOptions(packageName: String): Map<Int, Map<String, Map<String, Any?>>> {
return buildMap {
val bundleOptions = mutableMapOf<String, MutableMap<String, Any?>>()

// Theme patch options for YouTube Music
val themeOptionsYouTubeMusic = mutableMapOf<String, Any?>()
darkThemeBackgroundColorYouTubeMusic.get().takeIf { it.isNotBlank() && it != DEFAULT_DARK_THEME }?.let {
themeOptionsYouTubeMusic[KEY_DARK_THEME_COLOR] = it
}
if (themeOptionsYouTubeMusic.isNotEmpty()) {
bundleOptions[PATCH_THEME] = themeOptionsYouTubeMusic
}

// Custom Branding patch options for YouTube Music
val brandingOptionsYouTubeMusic = mutableMapOf<String, Any?>()
customAppNameYouTubeMusic.get().takeIf { it.isNotBlank() }?.let {
brandingOptionsYouTubeMusic[KEY_CUSTOM_NAME] = it
}
customIconPathYouTubeMusic.get().takeIf { it.isNotBlank() }?.let {
brandingOptionsYouTubeMusic[KEY_CUSTOM_ICON] = it
}
if (brandingOptionsYouTubeMusic.isNotEmpty()) {
bundleOptions[PATCH_CUSTOM_BRANDING] = brandingOptionsYouTubeMusic
// Theme patch options
val themeOptions = mutableMapOf<String, Any?>()
darkThemeColor(packageName).get()
.takeIf { it.isNotBlank() && it != DEFAULT_DARK_THEME }
?.let { themeOptions[KEY_DARK_THEME_COLOR] = it }

// Light theme — YouTube only
if (packageName == KnownApp.YOUTUBE) {
lightThemeColor(packageName).get()
.takeIf { it.isNotBlank() && it != DEFAULT_LIGHT_THEME }
?.let { themeOptions[KEY_LIGHT_THEME_COLOR] = it }
}
if (themeOptions.isNotEmpty()) bundleOptions[PATCH_THEME] = themeOptions

// Custom Branding patch options
val brandingOptions = mutableMapOf<String, Any?>()
customAppName(packageName).get()
.takeIf { it.isNotBlank() }
?.let { brandingOptions[KEY_CUSTOM_NAME] = it }
customIconPath(packageName).get()
.takeIf { it.isNotBlank() }
?.let { brandingOptions[KEY_CUSTOM_ICON] = it }
if (brandingOptions.isNotEmpty()) bundleOptions[PATCH_CUSTOM_BRANDING] = brandingOptions

// Change Header + Hide Shorts — YouTube only
if (packageName == KnownApp.YOUTUBE) {
customHeaderPath.get()
.takeIf { it.isNotBlank() }
?.let { bundleOptions[PATCH_CHANGE_HEADER] = mutableMapOf(KEY_CUSTOM_HEADER to it) }

val shortsOptions = mutableMapOf<String, Any?>()
if (hideShortsAppShortcut.get()) shortsOptions[KEY_HIDE_SHORTS_APP_SHORTCUT] = true
if (hideShortsWidget.get()) shortsOptions[KEY_HIDE_SHORTS_WIDGET] = true
if (shortsOptions.isNotEmpty()) bundleOptions[PATCH_HIDE_SHORTS] = shortsOptions
}

// Bundle ID 0 = default Morphe bundle
if (bundleOptions.isNotEmpty()) {
put(0, bundleOptions)
}
if (bundleOptions.isNotEmpty()) put(0, bundleOptions)
}
}
}

/**
* Gets localized text if it matches original English text,
* otherwise returns the custom text from patch
* Gets localized text if it matches original English text, otherwise returns the custom text from patch.
*/
fun getLocalizedOrCustomText(
context: Context,
Expand Down
23 changes: 23 additions & 0 deletions app/src/main/java/app/morphe/manager/ui/model/HomeAppItem.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package app.morphe.manager.ui.model

import android.content.pm.PackageInfo
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import app.morphe.manager.data.room.apps.installed.InstalledApp

/**
* Represents a single app button on the home screen.
* Built dynamically from patch bundle info and installed app data.
*/
@Immutable
data class HomeAppItem(
val packageName: String,
val displayName: String,
val gradientColors: List<Color>,
val installedApp: InstalledApp?,
val packageInfo: PackageInfo?,
val isPinnedByDefault: Boolean,
val isDeleted: Boolean,
val hasUpdate: Boolean,
val patchCount: Int
)
Loading