diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b57f5f..e8ad9a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,7 +57,8 @@ jobs: fi if [[ "$tidal_src" == *.apkm ]]; then echo "Merging splits from $tidal_src via APKEditor" - curl -sLo /tmp/APKEditor.jar https://github.com/REAndroid/APKEditor/releases/download/V1.4.3/APKEditor-1.4.3.jar + curl --fail --location --retry 3 --retry-delay 2 -sSo /tmp/APKEditor.jar \ + https://github.com/REAndroid/APKEditor/releases/download/V1.4.3/APKEditor-1.4.3.jar java -jar /tmp/APKEditor.jar m -i "$tidal_src" -o ./dist/tidal-stock.apk echo "Merged tidal-stock.apk:" ls -la ./dist/tidal-stock.apk diff --git a/Manager/app/build.gradle.kts b/Manager/app/build.gradle.kts index 59cbc5f..e7ccb05 100644 --- a/Manager/app/build.gradle.kts +++ b/Manager/app/build.gradle.kts @@ -191,6 +191,9 @@ dependencies { debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.runtime.tracing) + implementation(libs.androidx.paging.compose) + implementation(libs.androidx.work.runtime) + implementation(libs.kotlinx.immutable) implementation(libs.kotlinx.serialization.json) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/MainActivity.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/MainActivity.kt index e5b85d2..21c2dfe 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/MainActivity.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/MainActivity.kt @@ -26,7 +26,6 @@ import com.meowarex.rlmobile.ui.screens.patching.PatchingScreen import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions import com.meowarex.rlmobile.ui.screens.permissions.PermissionsModel import com.meowarex.rlmobile.ui.screens.permissions.PermissionsScreen -import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreen import com.meowarex.rlmobile.ui.theme.ManagerTheme import com.meowarex.rlmobile.ui.widgets.updater.UpdaterDialog import com.meowarex.rlmobile.util.* @@ -123,16 +122,6 @@ class MainActivity : ComponentActivity() { navigator.push(handleReinstall(packageName)) } - INTENT_OPEN_PLUGINS -> { - // TODO: per-install plugins screen - // val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) ?: run { - // Log.w(BuildConfig.TAG, "Missing $EXTRA_PACKAGE_NAME extra for intent $INTENT_REINSTALL") - // return@launchBlock - // } - - navigator.push(PluginsScreen()) - } - INTENT_IMPORT_COMPONENT -> { val path = intent.getStringExtra(EXTRA_FILE_PATH) ?: run { Log.w(BuildConfig.TAG, "Missing $EXTRA_FILE_PATH extra for intent $INTENT_IMPORT_COMPONENT") @@ -200,7 +189,6 @@ class MainActivity : ComponentActivity() { companion object { const val INTENT_REINSTALL = "com.meowarex.rlmobile.REINSTALL" - const val INTENT_OPEN_PLUGINS = "com.meowarex.rlmobile.OPEN_PLUGINS" const val INTENT_IMPORT_COMPONENT = "com.meowarex.rlmobile.IMPORT_COMPONENT" const val EXTRA_PACKAGE_NAME = "rlmobile.packageName" diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ManagerApplication.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ManagerApplication.kt index 5fb732a..a190155 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ManagerApplication.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ManagerApplication.kt @@ -22,10 +22,11 @@ import com.meowarex.rlmobile.ui.screens.logs.LogsListScreenModel import com.meowarex.rlmobile.ui.screens.patching.PatchingScreenModel import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsModel import com.meowarex.rlmobile.ui.screens.permissions.PermissionsModel -import com.meowarex.rlmobile.ui.screens.plugins.PluginsModel import com.meowarex.rlmobile.ui.screens.settings.SettingsModel import com.meowarex.rlmobile.ui.widgets.updater.UpdaterViewModel +import com.meowarex.rlmobile.updatechecker.UpdateCheckWorker import kotlinx.coroutines.Dispatchers +import org.koin.android.ext.android.get import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin import org.koin.core.module.dsl.* @@ -57,7 +58,6 @@ class ManagerApplication : Application() { // UI Models modules(module { factoryOf(::HomeModel) - factoryOf(::PluginsModel) factoryOf(::AboutModel) factoryOf(::PatchingScreenModel) factoryOf(::SettingsModel) @@ -101,5 +101,13 @@ class ManagerApplication : Application() { .fetcherCoroutineContext(Dispatchers.IO.limitedParallelism(5)) .build() } + + // Schedule periodic update check only when the user has opted in, + // so the disabled state survives app restarts instead of being re-enqueued. + if (get().autoUpdateCheck) { + UpdateCheckWorker.schedule(this) + } else { + UpdateCheckWorker.cancel(this) + } } } 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 5fdfab7..73c5e74 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 @@ -14,4 +14,5 @@ class PreferencesManager(preferences: SharedPreferences) : BasePreferenceManager var keepPatchedApks by booleanPreference("keep_patched_apks", false) var showNetworkWarning by booleanPreference("show_network_warning", true) 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/network/models/GithubCommit.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/models/GithubCommit.kt new file mode 100644 index 0000000..723c351 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/models/GithubCommit.kt @@ -0,0 +1,35 @@ +package com.meowarex.rlmobile.network.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GithubCommit( + val sha: String, + @SerialName("html_url") + val htmlUrl: String, + val commit: CommitDetails, + val author: Author? = null, +) { + @Serializable + data class CommitDetails( + val message: String, + val author: AuthorMeta, + ) + + @Serializable + data class AuthorMeta( + val name: String, + val email: String, + val date: String, + ) + + @Serializable + data class Author( + val login: String, + @SerialName("avatar_url") + val avatarUrl: String, + @SerialName("html_url") + val htmlUrl: String, + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/models/GithubContributor.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/models/GithubContributor.kt new file mode 100644 index 0000000..c1661e3 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/models/GithubContributor.kt @@ -0,0 +1,13 @@ +package com.meowarex.rlmobile.network.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GithubContributor( + val login: String, + @SerialName("avatar_url") + val avatarUrl: String, + val contributions: Int, + val type: String? = null, +) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/services/RLMobileGithubService.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/services/RLMobileGithubService.kt index a2eb7df..e8f8998 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/services/RLMobileGithubService.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/services/RLMobileGithubService.kt @@ -45,6 +45,33 @@ class RadiantLyricsGithubService( header(HttpHeaders.CacheControl, "public, max-age=60, s-maxage=60") } + /** + * Fetches the contributors list from GitHub for the repo. + */ + suspend fun getContributors(): ApiResponse> = + http.request { + url("https://api.github.com/repos/${BuildConfig.PATCHES_REPO_OWNER}/${BuildConfig.PATCHES_REPO_NAME}/contributors?per_page=100") + header(HttpHeaders.CacheControl, "public, max-age=600, s-maxage=600") + } + + /** + * Fetches the latest commit on the default branch. + */ + suspend fun getLatestCommit(): ApiResponse = + http.request { + url("https://api.github.com/repos/${BuildConfig.PATCHES_REPO_OWNER}/${BuildConfig.PATCHES_REPO_NAME}/commits/HEAD") + header(HttpHeaders.CacheControl, "public, max-age=120, s-maxage=120") + } + + /** + * Fetches a page of commits (paginated). Used by the Home screen's commit list. + */ + suspend fun getCommits(page: Int, perPage: Int = 30): ApiResponse> = + http.request { + url("https://api.github.com/repos/${BuildConfig.PATCHES_REPO_OWNER}/${BuildConfig.PATCHES_REPO_NAME}/commits?per_page=$perPage&page=${page + 1}") + header(HttpHeaders.CacheControl, "public, max-age=120, s-maxage=120") + } + companion object { const val REPO_OWNER = BuildConfig.PATCHES_REPO_OWNER const val REPO_NAME = BuildConfig.PATCHES_REPO_NAME diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/utils/CommitsPagingSource.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/utils/CommitsPagingSource.kt new file mode 100644 index 0000000..bd9c32b --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/utils/CommitsPagingSource.kt @@ -0,0 +1,30 @@ +package com.meowarex.rlmobile.network.utils + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.meowarex.rlmobile.network.models.GithubCommit +import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService + +class CommitsPagingSource( + private val github: RadiantLyricsGithubService, +) : PagingSource() { + + override fun getRefreshKey(state: PagingState): Int? = + state.anchorPosition?.let { + val page = state.closestPageToPosition(it) ?: return null + page.prevKey?.plus(1) ?: page.nextKey?.minus(1) + } + + override suspend fun load(params: LoadParams): LoadResult { + val page = params.key ?: 0 + return when (val r = github.getCommits(page)) { + is ApiResponse.Success -> LoadResult.Page( + data = r.data, + prevKey = if (page > 0) page - 1 else null, + nextKey = if (r.data.isNotEmpty()) page + 1 else null, + ) + is ApiResponse.Failure -> LoadResult.Error(r.error) + is ApiResponse.Error -> LoadResult.Error(r.error) + } + } +} 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 9892273..e323289 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 @@ -51,8 +51,16 @@ class SmaliPatchStep : Step(), IDexProvider, KoinComponent { if (patchFile.endsWith(".smali") && patchFile.startsWith("extension/")) { val relative = patchFile.removePrefix("extension/") val out = smaliDir.resolve(relative) - out.parentFile?.mkdirs() - out.writeBytes(zip.openEntry(patchFile)!!.read()) + // Guard against zip-slip: a crafted entry could otherwise escape smaliDir. + val baseCanonical = smaliDir.canonicalPath + File.separator + val outCanonical = out.canonicalPath + if (!outCanonical.startsWith(baseCanonical)) { + throw SecurityException("Zip entry escapes target directory: $patchFile") + } + val entry = zip.openEntry(patchFile) + ?: throw FileNotFoundException("Missing zip entry: $patchFile") + out.canonicalFile.parentFile?.mkdirs() + out.writeBytes(entry.read()) container.log("Extracted extension smali: $relative") continue } diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/ManifestPatcher.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/ManifestPatcher.kt index dba3ccd..a727118 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/ManifestPatcher.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/ManifestPatcher.kt @@ -35,7 +35,8 @@ object ManifestPatcher { } } }) - val origPkg = originalPackage ?: "com.aspiro.tidal" + val origPkg = originalPackage + ?: throw IllegalStateException("Manifest has no package attribute; refusing to rewrite authorities/permissions without a known originalPackage") val reader = AxmlReader(manifestBytes) val writer = AxmlWriter() @@ -104,7 +105,7 @@ object ManifestPatcher { super.attr( ns, name, resourceId, type, when (name) { - "name" -> (value as String).replace(origPkg, packageName) + "name" -> (value as? String)?.replace(origPkg, packageName) ?: value else -> value } ) @@ -155,7 +156,7 @@ object ManifestPatcher { super.attr( ns, name, resourceId, type, if (name == "authorities") { - (value as String).replace(origPkg, packageName) + (value as? String)?.replace(origPkg, packageName) ?: value } else { value } diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/UninstallPluginDialogPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/UninstallPluginDialogPreview.kt deleted file mode 100644 index 733015d..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/UninstallPluginDialogPreview.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.meowarex.rlmobile.ui.previews.dialogs - -import android.content.res.Configuration -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview -import com.meowarex.rlmobile.ui.screens.plugins.components.dialogs.UninstallPluginDialog -import com.meowarex.rlmobile.ui.theme.ManagerTheme - -@Composable -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -private fun UninstallPluginDialogPreview() { - ManagerTheme { - UninstallPluginDialog( - pluginName = "FakeNitro", - onConfirm = {}, - onDismiss = {}, - ) - } -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenFailedPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenFailedPreview.kt deleted file mode 100644 index 4fcf8c4..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenFailedPreview.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.meowarex.rlmobile.ui.previews.screens.home - -import android.content.res.Configuration -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview -import com.meowarex.rlmobile.ui.screens.home.HomeScreenFailureContent -import com.meowarex.rlmobile.ui.screens.home.components.HomeAppBar -import com.meowarex.rlmobile.ui.theme.ManagerTheme - -@Composable -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -private fun HomeScreenFailedPreview() { - ManagerTheme { - Scaffold( - topBar = { HomeAppBar() }, - ) { padding -> - HomeScreenFailureContent(padding = padding) - } - } -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenLoadedPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenLoadedPreview.kt deleted file mode 100644 index 0fc1144..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenLoadedPreview.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.meowarex.rlmobile.ui.previews.screens.home - -import android.content.res.Configuration -import android.graphics.BitmapFactory -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.painter.BitmapPainter -import androidx.compose.ui.tooling.preview.* -import com.meowarex.rlmobile.ui.screens.home.* -import com.meowarex.rlmobile.ui.screens.home.components.HomeAppBar -import com.meowarex.rlmobile.ui.theme.ManagerTheme -import com.meowarex.rlmobile.ui.util.TidalVersion -import kotlinx.collections.immutable.persistentListOf -import kotlin.io.encoding.Base64 - -// This preview has animations that cannot be properly viewed from an IDE preview - -@Composable -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -private fun HomeScreenLoadedPreview( - @PreviewParameter(HomeScreenParametersProvider::class) - state: InstallsState.Fetched, -) { - ManagerTheme { - Scaffold( - topBar = { HomeAppBar() }, - ) { padding -> - HomeScreenLoadedContent( - state = state, - padding = padding, - onClickInstall = {}, - onUpdate = {}, - onOpenApp = {}, - onOpenAppInfo = {}, - onOpenPlugins = {}, - ) - } - } -} - -private class HomeScreenParametersProvider : PreviewParameterProvider { - private val stableVersion = TidalVersion.Existing(TidalVersion.Type.STABLE, "126.21", 126021) - private val radiantIconBytes = Base64.decode( - "/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAKBueIx4ZKCMgoy0qqC+8P//8Nzc8P//////////////////////////////////////////////////////////2wBDAaq0tPDS8P//////////////////////////////////////////////////////////////////////////////wAARCAC9AL0DASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAAAAIDAf/EACIQAQEAAgEFAAIDAAAAAAAAAAABAhEhAxIxQVETMiJhcf/EABcBAQEBAQAAAAAAAAAAAAAAAAABAgP/xAAZEQEBAQEBAQAAAAAAAAAAAAAAARECEjH/2gAMAwEAAhEDEQA/AMsce7/Gkkngk1NOoxboAIAAAAAAAAAAAAAAAAAAm4ys7NXVbJyx2LKoAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHZLfRcbJug4AABJugDtxs9OAAAAAAAAAAAAAA7jN0HccN81cxk9OityBZuaAVP459OyKBMifxwxw1dqAyBrYCpuEvhFmq1TnNwZsZgIyAAAAAAAANcJqM8ZutRrkAVoAAAAAAAAABnnNVLTObjNGKACAAAAAAL6c9rcxmsY6rcALdTYrlyk8kzlZeRGPTYThdzSlbC2TyMsruiW4vvimK+nedIkqwFaGN4rZnnP5DPSQEZAAAAAJ5BtPACug5n+tdAYi8sL6cmFqOeO9P2sk1NCtwYtk5Yb5gljNWH7Odt+NMce2IkjoCtiOp6WjqeIJfiAEYAAAACeYANgllnAroA5lbJxAdEzOe+FblDQAAAAcuUntyZW3iCaoAUT1PEUjOy8CX4gBGAAAAAACWzw0mf1mBLjYZS2eFTqfVa9O9TWv7Zu27u3EZtd7r9O/L64Brvdfrm79AHZ55asVY56mhZWjlykRc7Ui3pWWdqQGQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHMbuOsZbLw0xy2LYoAQAAAAAAAAAAAAAAAAAARnlzqGWdnEQNSP/2Q==" - ) - private val radiantIcon = BitmapFactory - .decodeByteArray(radiantIconBytes, 0, radiantIconBytes.size) - .asImageBitmap() - .let(::BitmapPainter) - - override val values = sequenceOf( - InstallsState.Fetched( - persistentListOf( - InstallData( - name = "Radiant Lyrics", - packageName = "com.radiantLyrics", - version = stableVersion, - icon = radiantIcon, - isUpToDate = true, - ) - ) - ), - InstallsState.Fetched( - persistentListOf( - InstallData( - name = "Tidal", - packageName = "com.tidal", - version = stableVersion, - icon = radiantIcon, - isUpToDate = false, - ) - ) - ), - ) -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenLoadingPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenLoadingPreview.kt deleted file mode 100644 index 5ae1a36..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenLoadingPreview.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.meowarex.rlmobile.ui.previews.screens.home - -import android.content.res.Configuration -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview -import com.meowarex.rlmobile.ui.screens.home.HomeScreenLoadingContent -import com.meowarex.rlmobile.ui.screens.home.components.HomeAppBar -import com.meowarex.rlmobile.ui.theme.ManagerTheme - -// This preview cannot be properly viewed from an IDE preview - -@Composable -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -private fun HomeScreenLoadingPreview() { - ManagerTheme { - Scaffold( - topBar = { HomeAppBar() }, - ) { padding -> - HomeScreenLoadingContent(padding = padding) - } - } -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenNonePreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenNonePreview.kt deleted file mode 100644 index 2e4b592..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenNonePreview.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.meowarex.rlmobile.ui.previews.screens.home - -import android.content.res.Configuration -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview -import com.meowarex.rlmobile.ui.screens.home.HomeScreenNoneContent -import com.meowarex.rlmobile.ui.screens.home.components.HomeAppBar -import com.meowarex.rlmobile.ui.theme.ManagerTheme - -@Composable -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -private fun HomeScreenNonePreview() { - ManagerTheme { - Scaffold( - topBar = { HomeAppBar() }, - ) { padding -> - HomeScreenNoneContent( - padding = padding, - onClickInstall = {}, - ) - } - } -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/plugins/PluginsScreenFailedPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/plugins/PluginsScreenFailedPreview.kt deleted file mode 100644 index 96d7fde..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/plugins/PluginsScreenFailedPreview.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.meowarex.rlmobile.ui.previews.screens.plugins - -import android.content.res.Configuration -import androidx.compose.runtime.* -import androidx.compose.ui.tooling.preview.Preview -import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreenContent -import com.meowarex.rlmobile.ui.theme.ManagerTheme -import com.meowarex.rlmobile.ui.util.emptyImmutableList - -// This preview has interactable content that cannot be tested from an IDE preview - -@Composable -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -private fun PluginsScreenFailedPreview() { - val filterState = remember { mutableStateOf("") } - - ManagerTheme { - PluginsScreenContent( - searchText = filterState, - setSearchText = filterState::value::set, - isError = true, - plugins = emptyImmutableList(), - onPluginUninstall = {}, - onPluginChangelog = {}, - onPluginToggle = { name, enabled -> }, - safeMode = false, - setSafeMode = {} - ) - } -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/plugins/PluginsScreenLoadedPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/plugins/PluginsScreenLoadedPreview.kt deleted file mode 100644 index 69d816a..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/plugins/PluginsScreenLoadedPreview.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.meowarex.rlmobile.ui.previews.screens.plugins - -import android.content.res.Configuration -import androidx.compose.runtime.* -import androidx.compose.ui.tooling.preview.Preview -import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreenContent -import com.meowarex.rlmobile.ui.screens.plugins.model.PluginItem -import com.meowarex.rlmobile.ui.screens.plugins.model.PluginManifest -import com.meowarex.rlmobile.ui.theme.ManagerTheme -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import java.util.UUID - -// This preview has scrollable/interactable content that cannot be tested from an IDE preview - -@Composable -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -private fun PluginsScreenLoadedPreview() { - val filterState = remember { mutableStateOf("test") } - - ManagerTheme { - PluginsScreenContent( - searchText = filterState, - setSearchText = filterState::value::set, - isError = false, - plugins = plugins, - onPluginUninstall = {}, - onPluginChangelog = {}, - onPluginToggle = { name, enabled -> }, - safeMode = false, - setSafeMode = {} - ) - } -} - -private val plugins: ImmutableList = persistentListOf( - PluginItem( - path = UUID.randomUUID().toString(), - manifest = PluginManifest( - name = "CloseDMs", - authors = persistentListOf( - PluginManifest.Author(name = "Diamond", id = 0L), - ), - description = "Shortcut to close DMs in the DM context menu.", - version = "1.0.0", - updateUrl = "", - changelog = "", - changelogMedia = null, - ) - ), - PluginItem( - path = UUID.randomUUID().toString(), - manifest = PluginManifest( - name = "ConfigurableStickerSizes", - authors = persistentListOf( - PluginManifest.Author(name = "rushii", id = 0L, hyperlink = false), - ), - description = "Makes sticker sizes configurable.", - version = "1.1.5", - updateUrl = "", - changelog = "", - changelogMedia = null, - ) - ), - PluginItem( - path = UUID.randomUUID().toString(), - manifest = PluginManifest( - name = "AudioPlayer", - authors = persistentListOf( - PluginManifest.Author(name = "rushii", id = 0L, hyperlink = false), - ), - description = "Makes audio files playable.", - version = "0.0.1", - updateUrl = "", - changelog = "", - changelogMedia = null, - ) - ), - PluginItem( - path = UUID.randomUUID().toString(), - manifest = PluginManifest( - name = "TypingIndicators", - authors = persistentListOf( - PluginManifest.Author(name = "rushii", id = 0L, hyperlink = false), - ), - description = "Adds typing indicators to channels that people are currently typing in.", - version = "1.1.0", - updateUrl = "", - changelog = "", - changelogMedia = null, - ) - ), -) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/plugins/PluginsScreenNonePreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/plugins/PluginsScreenNonePreview.kt deleted file mode 100644 index ea6d5e7..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/plugins/PluginsScreenNonePreview.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.meowarex.rlmobile.ui.previews.screens.plugins - -import android.content.res.Configuration -import androidx.compose.runtime.* -import androidx.compose.ui.tooling.preview.Preview -import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreenContent -import com.meowarex.rlmobile.ui.theme.ManagerTheme -import com.meowarex.rlmobile.ui.util.emptyImmutableList - -// This preview has interactable content that cannot be tested from an IDE preview - -@Composable -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -private fun PluginsScreenNonePreview() { - val filterState = remember { mutableStateOf("") } - - ManagerTheme { - PluginsScreenContent( - searchText = filterState, - setSearchText = filterState::value::set, - isError = false, - plugins = emptyImmutableList(), - onPluginUninstall = {}, - onPluginChangelog = {}, - onPluginToggle = { name, enabled -> }, - safeMode = false, - setSafeMode = {} - ) - } -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/about/AboutModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/about/AboutModel.kt index bfb6ef5..e3ac4cd 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/about/AboutModel.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/about/AboutModel.kt @@ -1,14 +1,47 @@ package com.meowarex.rlmobile.ui.screens.about +import android.util.Log import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import com.meowarex.rlmobile.BuildConfig import com.meowarex.rlmobile.network.models.Contributor -import com.meowarex.rlmobile.network.services.HttpService +import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService +import com.meowarex.rlmobile.network.utils.ApiResponse import com.meowarex.rlmobile.ui.util.toUnsafeImmutable +import com.meowarex.rlmobile.util.launchIO +import kotlinx.collections.immutable.persistentListOf class AboutModel( - @Suppress("unused") private val http: HttpService, -) : StateScreenModel( - AboutScreenState.Loaded(emptyList().toUnsafeImmutable()) -) { - fun fetchContributors() = Unit + private val github: RadiantLyricsGithubService, +) : StateScreenModel(AboutScreenState.Loading) { + + init { + fetchContributors() + } + + fun fetchContributors() = screenModelScope.launchIO { + mutableState.value = AboutScreenState.Loading + + when (val result = github.getContributors()) { + is ApiResponse.Success -> { + val list = result.data + .filter { it.type == null || it.type == "User" } + .map { c -> + Contributor( + username = c.login, + avatarUrl = c.avatarUrl, + commits = c.contributions, + repositories = persistentListOf(), + ) + } + .toUnsafeImmutable() + mutableState.value = AboutScreenState.Loaded(list) + } + is ApiResponse.Error, + is ApiResponse.Failure -> { + Log.w(BuildConfig.TAG, "Failed to fetch contributors: $result") + mutableState.value = AboutScreenState.Failure + } + } + } } diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/about/AboutScreen.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/about/AboutScreen.kt index 72c1547..0e0d477 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/about/AboutScreen.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/about/AboutScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.koinScreenModel +import com.meowarex.rlmobile.BuildConfig import com.meowarex.rlmobile.R import com.meowarex.rlmobile.ui.components.* import com.meowarex.rlmobile.ui.screens.about.components.LeadContributor @@ -69,7 +70,7 @@ fun AboutScreenContent(state: State) { verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - LeadContributor("meowarex", "Radiant Lyrics") + LeadContributor(BuildConfig.PATCHES_REPO_OWNER, "Radiant Lyrics") } } @@ -100,7 +101,8 @@ fun AboutScreenContent(state: State) { } is AboutScreenState.Loaded -> { - items(state.contributors, key = { it.username }) { user -> + val others = state.contributors.filter { it.username != BuildConfig.PATCHES_REPO_OWNER } + items(others, key = { it.username }) { user -> ContributorCommitsItem(user) } } 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 f23c4c2..228baa3 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 @@ -11,19 +11,23 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn import cafe.adriel.voyager.core.model.ScreenModel 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.network.models.GithubCommit import com.meowarex.rlmobile.network.models.RLBuildInfo import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService +import com.meowarex.rlmobile.network.utils.CommitsPagingSource import com.meowarex.rlmobile.network.utils.fold import com.meowarex.rlmobile.patcher.InstallMetadata import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen import com.meowarex.rlmobile.ui.util.TidalVersion -import com.meowarex.rlmobile.ui.util.toUnsafeImmutable import com.meowarex.rlmobile.util.* import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex @@ -36,9 +40,14 @@ class HomeModel( private val github: RadiantLyricsGithubService, private val json: Json, ) : ScreenModel { - var installsState by mutableStateOf(InstallsState.Fetching) + + var state by mutableStateOf(HomeState.Loading) private set + val commits = Pager(PagingConfig(pageSize = 30)) { + CommitsPagingSource(github) + }.flow.cachedIn(screenModelScope) + private val refreshingLock = Mutex() private var remoteDataJson: RLBuildInfo? = null @@ -48,37 +57,47 @@ class HomeModel( fun refresh(delay: Boolean = false) = screenModelScope.launchIO { if (refreshingLock.isLocked) return@launchIO - if (delay) { delay(250) - - if (refreshingLock.isLocked) - return@launchIO + if (refreshingLock.isLocked) return@launchIO } refreshingLock.withLock { - val packages = fetchRadiantLyricsPackages() + val pkg = fetchInstalled() + val remote = async(Dispatchers.IO) { if (remoteDataJson == null) fetchRemoteData() } + remote.await() - val jobs = listOf( - screenModelScope.launch(Dispatchers.IO) { - fetchInstallations(packages) - }, - screenModelScope.launch(Dispatchers.IO) { - if (remoteDataJson == null) - fetchRemoteData() - } - ) + val install = pkg?.toInstallData() + val latest = remoteDataJson?.tidalVersionCode - jobs.joinAll() - mainThread { refreshInstallationsUpToDate(packages) } + mainThread { + state = HomeState.Loaded( + install = install, + latestTidalVersionCode = latest, + ) + } } } - fun openApp(packageName: String) { - val launchIntent = application.packageManager - .getLaunchIntentForPackage(packageName) + fun launchInstall() { + val current = (state as? HomeState.Loaded)?.install ?: return + openApp(current.packageName) + } + fun openCurrentAppInfo() { + val current = (state as? HomeState.Loaded)?.install ?: return + openAppInfo(current.packageName) + } + + fun createReinstallScreen(): PatchOptionsScreen? { + val current = (state as? HomeState.Loaded)?.install ?: return null + return createPrefilledPatchOptsScreen(current.packageName) + } + + fun openApp(packageName: String) { + val launchIntent = application.packageManager.getLaunchIntentForPackage(packageName) if (launchIntent != null) { + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) application.startActivity(launchIntent) } else { application.showToast(R.string.launch_app_fail) @@ -89,7 +108,6 @@ class HomeModel( val launchIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .setData("package:$packageName".toUri()) - application.startActivity(launchIntent) } @@ -98,146 +116,74 @@ class HomeModel( val applicationInfo = application.packageManager.getApplicationInfo(packageName, 0) val metadataFile = ZipReader(applicationInfo.publicSourceDir) .use { it.openEntry("rlmobile.json")?.read() } - metadataFile?.let { json.decodeFromStream(it.inputStream()) } } catch (t: Throwable) { - Log.w(BuildConfig.TAG, "Failed to parse Radiant Lyrics install metadata from package $packageName", t) + Log.w(BuildConfig.TAG, "Failed to parse install metadata for $packageName", t) null } - val patchOptions = metadata?.options - ?: PatchOptions.Default.copy(packageName = packageName) - + val patchOptions = metadata?.options ?: PatchOptions.Default.copy(packageName = packageName) return PatchOptionsScreen(prefilledOptions = patchOptions) } - private suspend fun fetchInstallations(packages: List) { - mainThread { - if (installsState !is InstallsState.Fetched) - installsState = InstallsState.Fetching - } + private fun fetchInstalled(): PackageInfo? = application.packageManager + .getInstalledPackages(PackageManager.GET_META_DATA) + .firstOrNull { it.applicationInfo?.metaData?.containsKey("isRadiantLyrics") == true } - try { - val packageManager = application.packageManager - val rlMobileInstallations = packages.mapNotNull { pkg -> - @Suppress("DEPRECATION") - val versionCode = pkg.versionCode - val versionName = pkg.versionName ?: return@mapNotNull null - val applicationInfo = pkg.applicationInfo ?: return@mapNotNull null - - InstallData( - name = packageManager.getApplicationLabel(applicationInfo).toString(), - packageName = pkg.packageName, - isUpToDate = isInstallationUpToDate(pkg), - icon = packageManager - .getApplicationIcon(applicationInfo) - .toBitmap() - .asImageBitmap() - .let(::BitmapPainter), - version = TidalVersion.Existing( - type = TidalVersion.parseVersionType(versionCode), - name = versionName.split("-")[0].trim(), - code = versionCode, - ), - ) - } - - mainThread { - installsState = if (rlMobileInstallations.isNotEmpty()) { - InstallsState.Fetched(data = rlMobileInstallations.toUnsafeImmutable()) - } else { - InstallsState.None - } - } - } catch (t: Throwable) { - Log.e(BuildConfig.TAG, "Failed to query Radiant Lyrics installations", t) - mainThread { installsState = InstallsState.Error } - } - } - - private suspend fun refreshInstallationsUpToDate(packages: List) { - val installations = mainThread { (installsState as? InstallsState.Fetched)?.data } - ?: return - - try { - val newInstallations = installations.map { data -> - val packageInfo = packages.find { it.packageName == data.packageName } - ?: throw IllegalStateException("Checking up-to-date status for package that has not been fetched") - - data.copy(isUpToDate = isInstallationUpToDate(packageInfo)) - } - - mainThread { installsState = InstallsState.Fetched(data = newInstallations.toUnsafeImmutable()) } - } catch (t: Throwable) { - Log.e(BuildConfig.TAG, "Failed to check installations up-to-date", t) - mainThread { installsState = InstallsState.Error } - } + private fun PackageInfo.toInstallData(): InstallData { + val pm = application.packageManager + @Suppress("DEPRECATION") val versionCode = versionCode + val versionName = versionName ?: "" + val info = applicationInfo!! + return InstallData( + name = pm.getApplicationLabel(info).toString(), + packageName = packageName, + isUpToDate = isInstallationUpToDate(this), + icon = pm.getApplicationIcon(info).toBitmap().asImageBitmap().let(::BitmapPainter), + version = TidalVersion.Existing( + type = TidalVersion.parseVersionType(versionCode), + name = versionName.split("-")[0].trim(), + code = versionCode, + ), + ) } private suspend fun fetchRemoteData() { val release = try { - github.getLatestRelease().let { response -> - response.fold( - success = { it }, - fail = { - Log.w(BuildConfig.TAG, "Failed to fetch latest release", it) - return - }, - ) - } + github.getLatestRelease().fold( + success = { it }, + fail = { Log.w(BuildConfig.TAG, "Failed to fetch latest release", it); return }, + ) } catch (t: Throwable) { Log.w(BuildConfig.TAG, "Failed to fetch remote data", t) - mainThread { application.showToast(R.string.home_network_fail) } return } val dataJsonUrl = release.assets .find { it.name == RadiantLyricsGithubService.DATA_JSON_ASSET_NAME } ?.browserDownloadUrl - ?: run { - Log.w(BuildConfig.TAG, "No data.json asset in latest release") - return - } + ?: return github.getBuildInfo(dataJsonUrl).fold( success = { remoteDataJson = it }, - fail = { Log.w(BuildConfig.TAG, "Failed to fetch remote build info", it) }, + fail = { Log.w(BuildConfig.TAG, "Failed to fetch build info", it) }, ) - - if (remoteDataJson == null) { - mainThread { application.showToast(R.string.home_network_fail) } - } - } - - private fun fetchRadiantLyricsPackages(): List { - return application.packageManager - .getInstalledPackages(PackageManager.GET_META_DATA) - .filter { - it.applicationInfo?.metaData?.containsKey("isRadiantLyrics") == true - } } private fun isInstallationUpToDate(pkg: PackageInfo): Boolean? { - val remoteBuildData = remoteDataJson ?: return null - - @Suppress("DEPRECATION") - val versionCode = pkg.versionCode - - if (remoteBuildData.tidalVersionCode != versionCode) return false + val remote = remoteDataJson ?: return null + @Suppress("DEPRECATION") val versionCode = pkg.versionCode + if (remote.tidalVersionCode != versionCode) return false val apkPath = pkg.applicationInfo?.publicSourceDir ?: return false val installMetadata = try { - val metadataFile = ZipReader(apkPath).use { it.openEntry("rlmobile.json")?.read() } - ?: return false - - json.decodeFromStream(metadataFile.inputStream()) + val mf = ZipReader(apkPath).use { it.openEntry("rlmobile.json")?.read() } ?: return false + json.decodeFromStream(mf.inputStream()) } catch (t: Throwable) { - Log.d(BuildConfig.TAG, "Failed to parse Radiant Lyrics InstallMetadata from package ${pkg.packageName}", t) return false } if (installMetadata.options.customPatches != null) return true - - return remoteBuildData.patchesVersion == installMetadata.patchesVersion + return remote.patchesVersion == installMetadata.patchesVersion } } 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 7be285b..d565ab1 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 @@ -1,34 +1,39 @@ - package com.meowarex.rlmobile.ui.screens.home import android.os.Parcelable -import androidx.compose.animation.* -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.tween +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image +import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.paging.compose.collectAsLazyPagingItems import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import com.meowarex.rlmobile.R -import com.meowarex.rlmobile.ui.components.LoadFailure -import com.meowarex.rlmobile.ui.components.ProjectHeader -import com.meowarex.rlmobile.ui.screens.home.components.* +import com.meowarex.rlmobile.ui.components.SegmentedButton +import com.meowarex.rlmobile.ui.screens.about.AboutScreen +import com.meowarex.rlmobile.ui.screens.home.components.CommitList +import com.meowarex.rlmobile.ui.screens.logs.LogsListScreen import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen -import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreen -import com.meowarex.rlmobile.ui.util.paddings.PaddingValuesSides -import com.meowarex.rlmobile.ui.util.paddings.exclude +import com.meowarex.rlmobile.ui.screens.settings.SettingsScreen import com.meowarex.rlmobile.util.* import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @@ -44,180 +49,178 @@ class HomeScreen : Screen, Parcelable { val scope = rememberCoroutineScope() val model = koinScreenModel() - // Refresh installations list when the screen changes or activity resumes LifecycleResumeEffect(Unit) { model.refresh(delay = true) - onPauseOrDispose {} } Scaffold( - topBar = { HomeAppBar() }, - ) { padding -> - when (val state = model.installsState) { - is InstallsState.Fetched -> HomeScreenLoadedContent( - state = state, - padding = padding, - onClickInstall = { navigator.pushOnce(PatchOptionsScreen()) }, - onUpdate = { - scope.launchIO { - val screen = model.createPrefilledPatchOptsScreen(it) - mainThread { navigator.push(screen) } + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.navigation_home)) }, + actions = { + IconButton(onClick = { model.refresh() }) { + Icon( + painterResource(R.drawable.ic_refresh), + contentDescription = stringResource(R.string.navigation_refresh), + ) + } + IconButton(onClick = { navigator.push(AboutScreen()) }) { + Icon( + painterResource(R.drawable.ic_info), + contentDescription = stringResource(R.string.navigation_about), + ) + } + IconButton(onClick = { navigator.push(LogsListScreen()) }) { + Icon( + painterResource(R.drawable.ic_receipt), + contentDescription = stringResource(R.string.navigation_logs), + ) + } + IconButton(onClick = { navigator.push(SettingsScreen()) }) { + Icon( + painterResource(R.drawable.ic_settings), + contentDescription = stringResource(R.string.navigation_settings), + ) } }, - onOpenApp = model::openApp, - onOpenAppInfo = model::openAppInfo, - onOpenPlugins = { navigator.push(PluginsScreen()) }, // TODO: install-specific plugins ) + }, + ) { pv -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .padding(pv) + .padding(16.dp) + .fillMaxSize(), + ) { + val state = model.state + when (state) { + HomeState.Loading -> Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { CircularProgressIndicator() } - InstallsState.Fetching -> HomeScreenLoadingContent(padding = padding) - - InstallsState.None -> HomeScreenNoneContent( - padding = padding, - onClickInstall = { navigator.pushOnce(PatchOptionsScreen()) }, - ) - - InstallsState.Error -> HomeScreenFailureContent(padding = padding) + is HomeState.Loaded -> HomeContent( + state = state, + commits = model.commits, + onInstall = { navigator.pushOnce(PatchOptionsScreen()) }, + onReinstall = { + scope.launchIO { + val screen = model.createReinstallScreen() ?: return@launchIO + mainThread { navigator.push(screen) } + } + }, + onLaunch = model::launchInstall, + onInfo = model::openCurrentAppInfo, + ) + } } } } } @Composable -fun HomeScreenLoadingContent(padding: PaddingValues) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(top = 16.dp, start = 16.dp, end = 16.dp) - ) { - ProjectHeader() - - AnimatedVisibility( - visibleState = remember { MutableTransitionState(false) }.apply { targetState = true }, - enter = fadeIn(animationSpec = tween(durationMillis = 800)), - exit = ExitTransition.None, - ) { - Box( - contentAlignment = Alignment.Center, - content = { CircularProgressIndicator() }, - modifier = Modifier - .fillMaxSize(), - ) - } - } -} - -@Composable -fun HomeScreenLoadedContent( - state: InstallsState.Fetched, - padding: PaddingValues, - onClickInstall: () -> Unit, - onUpdate: (packageName: String) -> Unit, - onOpenApp: (packageName: String) -> Unit, - onOpenAppInfo: (packageName: String) -> Unit, - onOpenPlugins: (packageName: String) -> Unit, +private fun ColumnScope.HomeContent( + state: HomeState.Loaded, + commits: kotlinx.coroutines.flow.Flow>, + onInstall: () -> Unit, + onReinstall: () -> Unit, + onLaunch: () -> Unit, + onInfo: () -> Unit, ) { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(10.dp), - horizontalAlignment = Alignment.CenterHorizontally, - contentPadding = padding - .exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top), - modifier = Modifier - .fillMaxSize() - .padding(padding.exclude(PaddingValuesSides.Bottom)) - .padding(top = 16.dp, start = 16.dp, end = 16.dp), - ) { - item(key = "PROJECT_HEADER") { - ProjectHeader() + val install = state.install + val currentVersionName = install?.version?.let { "v${it.toString()}" } + val latestVersionName = state.latestTidalVersionCode?.let { "build $it" } + + val fallbackPainter = if (install?.icon == null) { + // R.mipmap.ic_launcher is an adaptive-icon XML on API 26+, which painterResource cannot decode. + val context = LocalContext.current + remember { + val drawable = ContextCompat.getDrawable(context, R.mipmap.ic_launcher) + drawable?.toBitmap()?.asImageBitmap()?.let(::BitmapPainter) } + } else null - item(key = "ADD_INSTALL_BUTTON") { - InstallButton( - secondaryInstall = true, - onClick = onClickInstall, - modifier = Modifier - .padding(vertical = 4.dp) - .height(50.dp) - .fillMaxWidth() - ) - } - - items(state.data, key = { it.packageName }) { item -> - InstalledItemCard( - data = item, - onUpdate = { onUpdate(item.packageName) }, - onOpenApp = { onOpenApp(item.packageName) }, - onOpenInfo = { onOpenAppInfo(item.packageName) }, - onOpenPlugins = { onOpenPlugins(item.packageName) }, - modifier = Modifier.fillMaxWidth() - ) - } - } -} - -@Composable -fun HomeScreenNoneContent( - padding: PaddingValues, - onClickInstall: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - verticalArrangement = Arrangement.spacedBy(6.dp), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier - .padding(padding) - .padding(16.dp) - .fillMaxSize(), - ) { - ProjectHeader() - - InstallButton( - secondaryInstall = false, - onClick = onClickInstall, + val iconPainter = install?.icon ?: fallbackPainter + if (iconPainter != null) { + Image( + painter = iconPainter, + contentDescription = null, modifier = Modifier - .padding(12.dp) - .height(height = 50.dp) - .fillMaxWidth() + .size(60.dp) + .clip(CircleShape), ) + } - Column( - verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .alpha(.7f) - .fillMaxSize() - .padding(bottom = 80.dp) - ) { - Text( - text = """ /ᐠﹷ ‸ ﹷ ᐟ\ノ""", - style = MaterialTheme.typography.labelLarge - .copy(fontSize = 38.sp), - ) + Text( + text = install?.name ?: stringResource(R.string.app_name), + style = MaterialTheme.typography.titleLarge, + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + ) { + AnimatedVisibility(visible = currentVersionName != null) { Text( - text = stringResource(R.string.installs_no_installs), + text = "Current: ${currentVersionName ?: "-"}", style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(start = 10.dp), + color = LocalContentColor.current.copy(alpha = 0.5f), + textAlign = TextAlign.Center, + ) + } + AnimatedVisibility(visible = latestVersionName != null) { + Text( + text = "Latest: ${latestVersionName ?: "-"}", + style = MaterialTheme.typography.labelLarge, + color = LocalContentColor.current.copy(alpha = 0.5f), + textAlign = TextAlign.Center, ) } } -} -@Composable -fun HomeScreenFailureContent( - padding: PaddingValues, -) { - Column( - verticalArrangement = Arrangement.spacedBy(6.dp), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .padding(padding) - .padding(16.dp) - .fillMaxSize(), + Button( + onClick = if (install == null) onInstall else onReinstall, + enabled = state.latestTidalVersionCode != null, + modifier = Modifier.fillMaxWidth(), ) { - ProjectHeader() - LoadFailure(modifier = Modifier.fillMaxSize()) + val label = when { + state.latestTidalVersionCode == null -> "Loading…" + install == null -> "Install" + install.isUpToDate == false -> "Update" + else -> "Reinstall" + } + Text( + text = label, + textAlign = TextAlign.Center, + maxLines = 1, + modifier = Modifier + .basicMarquee() + .fillMaxWidth(), + ) + } + + AnimatedVisibility(visible = install != null) { + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.clip(RoundedCornerShape(16.dp)), + ) { + SegmentedButton( + icon = painterResource(R.drawable.ic_launch), + text = stringResource(R.string.action_launch), + onClick = onLaunch, + ) + SegmentedButton( + icon = painterResource(R.drawable.ic_info), + text = stringResource(R.string.action_open_info), + onClick = onInfo, + ) + } + } + + ElevatedCard(modifier = Modifier.fillMaxSize()) { + CommitList(commits = commits.collectAsLazyPagingItems()) } } 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 new file mode 100644 index 0000000..5c3fb46 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeState.kt @@ -0,0 +1,12 @@ +package com.meowarex.rlmobile.ui.screens.home + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface HomeState { + data object Loading : HomeState + data class Loaded( + val install: InstallData?, + val latestTidalVersionCode: Int?, + ) : HomeState +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/InstallsState.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/InstallsState.kt deleted file mode 100644 index c4293c2..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/InstallsState.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.meowarex.rlmobile.ui.screens.home - -import androidx.compose.runtime.Immutable -import kotlinx.collections.immutable.ImmutableList - -@Immutable -sealed interface InstallsState { - data object None : InstallsState - data object Error : InstallsState - data object Fetching : InstallsState - data class Fetched(val data: ImmutableList) : InstallsState -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/CommitList.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/CommitList.kt new file mode 100644 index 0000000..4443cbf --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/CommitList.kt @@ -0,0 +1,115 @@ +package com.meowarex.rlmobile.ui.screens.home.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.itemKey +import coil3.compose.AsyncImage +import com.meowarex.rlmobile.network.models.GithubCommit +import java.text.SimpleDateFormat +import java.time.Instant +import java.util.Date + +@Composable +fun CommitList(commits: LazyPagingItems) { + val loading = commits.loadState.refresh is LoadState.Loading || commits.loadState.append is LoadState.Loading + val failed = commits.loadState.refresh is LoadState.Error || commits.loadState.append is LoadState.Error + + LazyColumn { + items( + count = commits.itemCount, + key = commits.itemKey { it.sha }, + ) { index -> + val commit = commits[index] ?: return@items + CommitRow(commit) + if (index < commits.itemCount - 1) { + HorizontalDivider( + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + + if (loading) item { + Box( + contentAlignment = Alignment.TopCenter, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) { + CircularProgressIndicator(strokeWidth = 3.dp, modifier = Modifier.size(30.dp)) + } + } + + if (failed) item { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) { + Text("Failed to load commits", style = MaterialTheme.typography.labelLarge, textAlign = TextAlign.Center) + Button(onClick = { commits.retry() }) { Text("Retry") } + } + } + } +} + +@Composable +private fun CommitRow(commit: GithubCommit) { + val uriHandler = LocalUriHandler.current + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .fillMaxWidth() + .clickable { uriHandler.openUri(commit.htmlUrl) } + .padding(16.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + model = commit.author?.avatarUrl ?: "https://github.com/ghost.png", + contentDescription = commit.author?.login ?: "ghost", + modifier = Modifier.size(20.dp).clip(CircleShape), + ) + Text( + text = commit.author?.login ?: commit.commit.author.name, + style = MaterialTheme.typography.labelMedium, + ) + Text("•", style = MaterialTheme.typography.labelLarge) + Text( + text = commit.sha.take(7), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontFamily = FontFamily.Monospace, + ) + + Text( + text = runCatching { + SimpleDateFormat.getDateInstance(SimpleDateFormat.SHORT) + .format(Date.from(Instant.parse(commit.commit.author.date))) + }.getOrDefault(""), + style = MaterialTheme.typography.labelMedium, + color = LocalContentColor.current.copy(alpha = 0.5f), + textAlign = TextAlign.End, + modifier = Modifier.weight(1f), + ) + } + Text( + text = commit.commit.message.lineSequence().first(), + style = MaterialTheme.typography.labelLarge, + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/HomeAppBar.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/HomeAppBar.kt deleted file mode 100644 index 1db1241..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/HomeAppBar.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.meowarex.rlmobile.ui.screens.home.components - -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.navigator.LocalNavigator -import com.meowarex.rlmobile.R -import com.meowarex.rlmobile.ui.screens.about.AboutScreen -import com.meowarex.rlmobile.ui.screens.logs.LogsListScreen -import com.meowarex.rlmobile.ui.screens.settings.SettingsScreen - -@Composable -fun HomeAppBar() { - TopAppBar( - title = {}, - actions = { - val navigator = LocalNavigator.current - - IconButton(onClick = { navigator?.push(AboutScreen()) }) { - Icon( - painter = painterResource(R.drawable.ic_info), - contentDescription = stringResource(R.string.navigation_about), - ) - } - - IconButton(onClick = { navigator?.push(LogsListScreen()) }) { - Icon( - painter = painterResource(R.drawable.ic_receipt), - contentDescription = stringResource(R.string.navigation_logs), - ) - } - - IconButton(onClick = { navigator?.push(SettingsScreen()) }) { - Icon( - painter = painterResource(R.drawable.ic_settings), - contentDescription = stringResource(R.string.navigation_settings), - ) - } - } - ) -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/InstallButton.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/InstallButton.kt deleted file mode 100644 index 403f04d..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/InstallButton.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.meowarex.rlmobile.ui.screens.home.components - -import androidx.compose.animation.core.* -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.* -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.meowarex.rlmobile.R -import com.meowarex.rlmobile.ui.util.thenIf -import com.valentinilk.shimmer.* - -private val shimmerTheme = defaultShimmerTheme.copy( - shimmerWidth = 150.dp, - animationSpec = infiniteRepeatable( - animation = shimmerSpec( - durationMillis = 2000, - easing = LinearEasing, - delayMillis = 3500, - ), - repeatMode = RepeatMode.Restart, - initialStartOffset = StartOffset(1000), - ), - blendMode = BlendMode.Lighten, - shaderColors = listOf( - Color.White.copy(alpha = 0.00f), - Color.White.copy(alpha = 0.50f), - Color.White.copy(alpha = 1.00f), - Color.White.copy(alpha = 0.50f), - Color.White.copy(alpha = 0.00f), - ), - shaderColorStops = listOf( - 0.0f, - 0.25f, - 0.5f, - 0.75f, - 1.0f, - ), -) - -@Composable -fun InstallButton( - enabled: Boolean = true, - secondaryInstall: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - CompositionLocalProvider( - LocalShimmerTheme provides shimmerTheme - ) { - FilledTonalIconButton( - shape = RectangleShape, - colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = if (secondaryInstall) { - MaterialTheme.colorScheme.secondaryContainer - } else { - MaterialTheme.colorScheme.primary - }, - ), - enabled = enabled, - onClick = onClick, - modifier = modifier - .clip(MaterialTheme.shapes.medium) - .thenIf(!secondaryInstall) { shimmer() } - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(end = 10.dp), - ) { - Icon( - painter = painterResource(R.drawable.ic_add), - contentDescription = null, - ) - Text( - text = stringResource(R.string.action_add_install), - style = MaterialTheme.typography.labelLarge, - ) - } - } - } -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/InstalledItemCard.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/InstalledItemCard.kt deleted file mode 100644 index 101559c..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/InstalledItemCard.kt +++ /dev/null @@ -1,123 +0,0 @@ -package com.meowarex.rlmobile.ui.screens.home.components - -import androidx.compose.foundation.Image -import androidx.compose.foundation.basicMarquee -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.meowarex.rlmobile.R -import com.meowarex.rlmobile.ui.components.SegmentedButton -import com.meowarex.rlmobile.ui.components.VersionDisplay -import com.meowarex.rlmobile.ui.screens.home.InstallData - -@Composable -fun InstalledItemCard( - data: InstallData, - onUpdate: () -> Unit, - onOpenApp: () -> Unit, - onOpenInfo: () -> Unit, - onOpenPlugins: () -> Unit, - modifier: Modifier = Modifier, -) { - ElevatedCard( - shape = MaterialTheme.shapes.medium, - elevation = CardDefaults.elevatedCardElevation( - defaultElevation = 3.dp, - ), - modifier = modifier - .width(IntrinsicSize.Max), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(24.dp), - modifier = Modifier.padding(20.dp), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Image( - painter = data.icon, - contentDescription = null, - modifier = Modifier - .size(34.dp) - .clip(CircleShape), - ) - - Column { - Text( - text = data.name, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = .94f), - ) - - Text( - text = data.packageName, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .padding(start = 1.dp) - .offset(y = (-2).dp) - .alpha(.7f) - .basicMarquee(), - ) - } - - Spacer(Modifier.weight(1f, fill = true)) - - VersionDisplay( - version = data.version, - prefix = { append("v") }, - modifier = Modifier - .alpha(.6f) - .padding(end = 4.dp), - ) - } - - Row( - horizontalArrangement = Arrangement.spacedBy(2.dp), - modifier = Modifier.clip(MaterialTheme.shapes.large), - ) { - SegmentedButton( - icon = painterResource(R.drawable.ic_extension), - text = stringResource(R.string.plugins_title), - onClick = onOpenPlugins, - ) - SegmentedButton( - icon = painterResource(R.drawable.ic_info), - text = stringResource(R.string.action_open_info), - onClick = onOpenInfo, - ) - - // If the up-to-date status cannot be determined, assume it is up-to-date - if (data.isUpToDate ?: true) { - SegmentedButton( - icon = painterResource(R.drawable.ic_launch), - text = stringResource(R.string.action_launch), - onClick = onOpenApp, - ) - } else { - val warningColor = Color(0xFFFFBB33) - - SegmentedButton( - icon = painterResource(R.drawable.ic_update), - text = stringResource(R.string.action_update), - iconColor = warningColor, - textColor = warningColor, - onClick = onUpdate, - ) - } - } - } - } -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptions.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptions.kt index 624f9f0..9e5bf27 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptions.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptions.kt @@ -38,7 +38,7 @@ data class PatchOptions( companion object { val Default = PatchOptions( appName = "TIDAL", - packageName = "com.tidal.music", + packageName = "com.aspiro.tidal", debuggable = false, customInjector = null, customPatches = null, diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/PluginsModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/PluginsModel.kt deleted file mode 100644 index 3e44bd3..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/PluginsModel.kt +++ /dev/null @@ -1,279 +0,0 @@ -package com.meowarex.rlmobile.ui.screens.plugins - -import android.app.Application -import android.util.Log -import androidx.compose.runtime.* -import cafe.adriel.voyager.core.model.ScreenModel -import cafe.adriel.voyager.core.model.screenModelScope -import com.meowarex.rlmobile.BuildConfig -import com.meowarex.rlmobile.R -import com.meowarex.rlmobile.manager.PathManager -import com.meowarex.rlmobile.ui.screens.plugins.model.PluginItem -import com.meowarex.rlmobile.ui.screens.plugins.model.PluginManifest -import com.meowarex.rlmobile.ui.util.emptyImmutableList -import com.meowarex.rlmobile.ui.util.toUnsafeImmutable -import com.meowarex.rlmobile.util.* -import com.github.diamondminer88.zip.ZipReader -import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.plus -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.* -import java.io.File -import kotlin.time.Duration - -class PluginsModel( - private val context: Application, - private val paths: PathManager, - private val json: Json, -) : ScreenModel { - private val plugins = MutableStateFlow>(emptyImmutableList()) - - var error by mutableStateOf(false) - private set - - var showChangelogDialog by mutableStateOf(null) - private set - - var showUninstallDialog by mutableStateOf(null) - private set - - val searchText: StateFlow - field = MutableStateFlow("") - - var pluginsSafeMode = MutableStateFlow(false) - private set - - val filteredPlugins: StateFlow> = searchText - .combine(plugins) { searchText, plugins -> - if (searchText.isBlank()) { - plugins - } else { - plugins.filter { plugin -> - plugin.manifest.name.contains(searchText, ignoreCase = true) - || plugin.manifest.description.contains(searchText, ignoreCase = true) - || plugin.manifest.authors.any { (name) -> name.contains(searchText, ignoreCase = true) } - }.toUnsafeImmutable() - } - }.stateIn( - scope = screenModelScope + Dispatchers.Default, - started = SharingStarted.WhileSubscribed(replayExpiration = Duration.ZERO), - initialValue = plugins.value, - ) - - // ---- State setters ---- // - - fun setSearchText(search: String) { - searchText.value = search - } - - fun showChangelogDialog(plugin: PluginItem) { - showChangelogDialog = plugin - } - - fun hideChangelogDialog() { - showChangelogDialog = null - } - - fun showUninstallDialog(plugin: PluginItem) { - showUninstallDialog = plugin - } - - fun hideUninstallDialog() { - showUninstallDialog = null - } - - // ---- IO state setters ---- // - - fun uninstallPlugin(plugin: PluginItem) = screenModelScope.launchIO { - if (!plugins.value.any { it.path == plugin.path }) { - mainThread { hideUninstallDialog() } - return@launchIO - } - - val deleteSuccess = try { - File(plugin.path).delete() - } catch (t: Throwable) { - Log.e(BuildConfig.TAG, "Failed to delete plugin", t) - false - } - - if (!deleteSuccess) { - mainThread { - hideUninstallDialog() - context.showToast(R.string.plugins_error) - } - return@launchIO - } - - plugins.update { (it - plugin).toUnsafeImmutable() } - mainThread { hideUninstallDialog() } - } - - fun setPluginEnabled(pluginName: String, enabled: Boolean) = screenModelScope.launchIO { - try { - editTidalSettings { - put(JsonPrimitive("AC_PM_$pluginName"), JsonPrimitive(enabled)) - } - mainThread { - plugins.value.forEach { - if (it.manifest.name == pluginName) - it.enabled = enabled - } - } - } catch (e: Exception) { - Log.e(BuildConfig.TAG, "Failed to toggle plugin", e) - mainThread { context.showToast(R.string.status_failed) } - } - } - - fun setSafeMode(safeMode: Boolean) = screenModelScope.launchIO { - try { - editTidalSettings { - put(JsonPrimitive("RL_safe_mode_enabled"), JsonPrimitive(safeMode)) - } - pluginsSafeMode.value = safeMode - } catch (e: Exception) { - Log.e(BuildConfig.TAG, "Failed to toggle plugin", e) - mainThread { context.showToast(R.string.status_failed) } - } - } - - // ---- State loading ---- // - - // Called by screen to load initial data - fun refreshData() = screenModelScope.launchIO { - try { - loadSafeMode() - loadPlugins() - loadPluginsEnabled() - } catch (e: Exception) { - Log.e(BuildConfig.TAG, "Failed to load plugins state", e) - mainThread { - context.showToast(R.string.plugins_error) - error = true - } - } - } - - private fun loadSafeMode() = screenModelScope.launchIO { - @Serializable - data class SafeModeSettings( - @SerialName("RL_safe_mode_enabled") - val safeMode: Boolean = false, - ) - - pluginsSafeMode.value = readTidalSettings()?.safeMode ?: false - } - - private suspend fun loadPluginsEnabled() { - val pluginToggles = readTidalSettings>() - ?.filterKeys { it.isString && it.content.startsWith("AC_PM_") } - ?.filterValues { (it as? JsonPrimitive)?.booleanOrNull == true } - ?.mapKeys { (key, _) -> key.content.substring("AC_PM_".length) } - ?.mapValues { (_, value) -> value.jsonPrimitive.boolean } - - if (pluginToggles != null) mainThread { - plugins.value.forEach { - if (!pluginToggles.getOrDefault(it.manifest.name, true)) - it.enabled = false - } - } - } - - private fun loadPlugins() { - if (!paths.pluginsDir.exists() && !paths.pluginsDir.mkdirs()) - throw IllegalStateException("Failed to create plugins directory") - - val pluginFiles = paths.pluginsDir.listFiles { file -> file.extension == "zip" } - ?: throw IllegalStateException("Failed to read plugins directory") - - val pluginItems = pluginFiles - .mapNotNull { - try { - PluginItem( - manifest = loadPluginManifest(it), - path = it.absolutePath, - ) - } catch (e: Exception) { - Log.e(BuildConfig.TAG, "Failed to load plugin at ${it.absolutePath}", e) - null - } - } - .sortedBy { it.manifest.name } - - plugins.value = pluginItems.toUnsafeImmutable() - } - - private fun loadPluginManifest(pluginFile: File): PluginManifest { - return ZipReader(pluginFile).use { - val manifest = it.openEntry("manifest.json") - ?: throw Exception("Plugin ${pluginFile.name} has no manifest") - - try { - json.decodeFromStream(manifest.read().inputStream()) - } catch (t: Throwable) { - throw Exception("Failed to parse plugin manifest for ${pluginFile.name}", t) - } - } - } - - // ---- Radiant Lyrics settings ---- // - - /** - * Reads Radiant Lyrics core's settings, applies [block] to it, and writes it back. - */ - private suspend fun editTidalSettings(block: (MutableMap).() -> Unit) { - SETTINGS_MUTEX.withLock { - val settings = try { - if (paths.coreSettingsFile.exists()) { - json.decodeFromStream>(paths.coreSettingsFile.inputStream()) - } else { - mutableMapOf() - } - } catch (e: Exception) { - Log.e(BuildConfig.TAG, "Radiant Lyrics settings are corrupted!", e) - mutableMapOf() - } - - // Apply modifier block - block(settings) - - paths.coreSettingsFile.parentFile!!.mkdirs() - paths.coreSettingsFile.outputStream() - .use { out -> json.encodeToStream(settings, out) } - } - } - - /** - * Reads Radiant Lyrics core's settings and parses it into a specific model. - * This should not be used for future writes. - * - * @return The parsed settings model, or null if settings are missing or corrupt. - */ - private suspend inline fun readTidalSettings(): T? { - return SETTINGS_MUTEX.withLock { - try { - if (paths.coreSettingsFile.exists()) { - json.decodeFromStream(paths.coreSettingsFile.inputStream()) - } else { - null - } - } catch (e: Exception) { - Log.e(BuildConfig.TAG, "Radiant Lyrics settings are corrupted!", e) - null - } - } - } - - private companion object { - /** - * Global lock on the main Radiant Lyrics settings. - */ - private val SETTINGS_MUTEX = Mutex() - } -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/PluginsScreen.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/PluginsScreen.kt deleted file mode 100644 index caff00b..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/PluginsScreen.kt +++ /dev/null @@ -1,152 +0,0 @@ -package com.meowarex.rlmobile.ui.screens.plugins - -import android.os.Parcelable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.LifecycleResumeEffect -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.koin.koinScreenModel -import com.meowarex.rlmobile.R -import com.meowarex.rlmobile.ui.components.BackButton -import com.meowarex.rlmobile.ui.components.settings.SettingsSwitch -import com.meowarex.rlmobile.ui.screens.plugins.components.* -import com.meowarex.rlmobile.ui.screens.plugins.components.dialogs.UninstallPluginDialog -import com.meowarex.rlmobile.ui.screens.plugins.model.PluginItem -import com.meowarex.rlmobile.ui.util.paddings.* -import kotlinx.collections.immutable.ImmutableList -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize - -@Parcelize -class PluginsScreen : Screen, Parcelable { - @IgnoredOnParcel - override val key = "Plugins" - - @Composable - override fun Content() { - val model = koinScreenModel() - - // Refresh plugins list on activity resume or when this initially opens - LifecycleResumeEffect(Unit) { - model.refreshData() - - onPauseOrDispose {} - } - - model.showUninstallDialog?.let { plugin -> - UninstallPluginDialog( - pluginName = plugin.manifest.name, - onConfirm = { model.uninstallPlugin(plugin) }, - onDismiss = model::hideUninstallDialog - ) - } - - model.showChangelogDialog?.let { plugin -> - Changelog( - plugin = plugin, - onDismiss = model::hideChangelogDialog - ) - } - - PluginsScreenContent( - searchText = model.searchText.collectAsState(), - setSearchText = model::setSearchText, - isError = model.error, - plugins = model.filteredPlugins.collectAsState().value, - onPluginUninstall = model::showUninstallDialog, - onPluginChangelog = model::showChangelogDialog, - onPluginToggle = model::setPluginEnabled, - safeMode = model.pluginsSafeMode.collectAsState().value, - setSafeMode = model::setSafeMode, - ) - } -} - -@Composable -fun PluginsScreenContent( - searchText: State, - setSearchText: (String) -> Unit, - isError: Boolean, - plugins: ImmutableList, - onPluginUninstall: (PluginItem) -> Unit, - onPluginChangelog: (PluginItem) -> Unit, - onPluginToggle: (name: String, enabled: Boolean) -> Unit, - safeMode: Boolean, - setSafeMode: (Boolean) -> Unit, -) { - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.plugins_title)) }, - navigationIcon = { BackButton() }, - ) - } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues.exclude(PaddingValuesSides.Bottom)), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - SettingsSwitch( - label = stringResource(R.string.plugins_safe_mode_title), - secondaryLabel = stringResource(R.string.plugins_safe_mode_desc), - icon = { Icon(painterResource(R.drawable.ic_security), null) }, - pref = safeMode, - onPrefChange = setSafeMode - ) - - Column( - modifier = Modifier.padding(horizontal = 20.dp) - ) { - PluginSearch( - currentFilter = searchText, - onFilterChange = setSearchText, - modifier = Modifier - .fillMaxWidth() - ) - - LazyColumn( - verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = paddingValues - .exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top) - .add(PaddingValues(vertical = 12.dp)), - modifier = Modifier.fillMaxSize(), - ) { - when { - isError -> item(key = "ERROR") { - PluginsError(modifier = Modifier.fillParentMaxSize()) - } - - plugins.isNotEmpty() -> { - items( - items = plugins, - contentType = { "PLUGIN" }, - key = { it.path }, - ) { plugin -> - PluginCard( - plugin = plugin, - onClickDelete = { onPluginUninstall(plugin) }, - onClickShowChangelog = { onPluginChangelog(plugin) }, - onSetEnabled = { onPluginToggle(plugin.manifest.name, it) }, - ) - } - - } - - else -> item("PLUGINS_NONE") { - PluginsNone(Modifier.fillParentMaxSize()) - } - } - } - } - } - } -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/Changelog.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/Changelog.kt deleted file mode 100644 index 16729d4..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/Changelog.kt +++ /dev/null @@ -1,154 +0,0 @@ -package com.meowarex.rlmobile.ui.screens.plugins.components - -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.* -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import com.meowarex.rlmobile.R -import com.meowarex.rlmobile.ui.screens.plugins.model.PluginItem - -private val hyperLinkPattern = Regex("\\[(.+?)]\\((.+?\\))") - -@Suppress("RegExpRedundantEscape") // It is very much not redundant and causes a crash lol -private val headerStylePattern = Regex("\\{(improved|added|fixed)( marginTop)?\\}") - -@Composable -private fun AnnotatedString.Builder.MarkdownHyperlink(content: String) { - var idx = 0 - - with(hyperLinkPattern.toPattern().matcher(content)) { - while (find()) { - val start = start() - val end = end() - val title = group(1)!! - val url = group(2)!! - - append(content.substring(idx, start)) - - // @formatter:off - pushLink(LinkAnnotation.Url( - url, - TextLinkStyles(SpanStyle( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline - )) - )) - append(title) - pop() - // @formatter:on - - idx = end - } - } - - if (idx < content.length) append(content.substring(idx)) -} - -@Composable -fun Changelog( - plugin: PluginItem, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - icon = { - Icon( - painter = painterResource(R.drawable.ic_history), - contentDescription = stringResource(R.string.plugins_view_changelog, plugin.manifest.name) - ) - }, - title = { Text(plugin.manifest.name) }, - text = { - Column { - plugin.manifest.changelogMedia?.let { mediaUrl -> - AsyncImage( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 90.dp) - .clip(RoundedCornerShape(14.dp)), - model = mediaUrl, - contentDescription = stringResource(R.string.plugins_changelog_media) - ) - } - - LazyColumn { - items(plugin.manifest.changelog!!.lines()) { - var line = it.trim() - - if (line.isNotEmpty()) { - when (line[0]) { - '#' -> { - do { - line = line.substring(1) - } while (line.startsWith("#")) - - Text( - text = line.trimStart(), - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 16.dp, bottom = 6.dp) - ) - } - - '*' -> { - Text( - modifier = Modifier.padding(bottom = 2.dp), - text = buildAnnotatedString { - withStyle( - SpanStyle( - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ) - ) { - append("● ") - } - - MarkdownHyperlink(line.substring(1)) - } - ) - } - - else -> { - when { - line.endsWith("marginTop}") -> { - val color = MaterialTheme.colorScheme.onSurface - - Text( - text = line, - fontWeight = FontWeight.Bold, - color = color, - modifier = Modifier.padding(top = 16.dp, bottom = 6.dp) - ) - } - - line.all { c -> c == '=' } -> {} // Tidal ignores ======= - else -> { - Text(buildAnnotatedString { - MarkdownHyperlink(line) - }) - } - } - } - } - } - } - } - } - }, - confirmButton = { - Button(onClick = onDismiss) { - Text(stringResource(R.string.action_close)) - } - } - ) -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginCard.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginCard.kt deleted file mode 100644 index 50e1671..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginCard.kt +++ /dev/null @@ -1,150 +0,0 @@ -package com.meowarex.rlmobile.ui.screens.plugins.components - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.* -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.unit.dp -import com.meowarex.rlmobile.R -import com.meowarex.rlmobile.ui.screens.plugins.model.PluginItem - -@Composable -fun PluginCard( - plugin: PluginItem, - onClickDelete: () -> Unit, - onClickShowChangelog: () -> Unit, - onSetEnabled: (Boolean) -> Unit, -) { - val uriHandler = LocalUriHandler.current - - ElevatedCard { - // Header - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable { onSetEnabled(!plugin.enabled) } - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 14.dp), - ) { - Column { - // Name - Text( - text = buildAnnotatedString { - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(plugin.manifest.name) - } - append(" v") - append(plugin.manifest.version) - } - ) - - // Authors - val authors = buildAnnotatedString { - withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) { - for ((idx, author) in plugin.manifest.authors.withIndex()) { - if (idx > 0) append(", ") - - if (author.hyperlink) pushLink( - LinkAnnotation.Url( - url = author.socialUrl, - styles = TextLinkStyles( - SpanStyle( - textDecoration = TextDecoration.Underline, - ) - ), - ) - ) - append(author.name) - if (author.hyperlink) pop() - } - } - } - Text( - text = authors, - style = MaterialTheme.typography.labelLarge, - ) - } - - Spacer(Modifier.weight(1f, true)) - - // Toggle Switch - Switch( - checked = plugin.enabled, - onCheckedChange = { onSetEnabled(!plugin.enabled) } - ) - } - - HorizontalDivider( - modifier = Modifier - .alpha(0.3f) - .padding(horizontal = 16.dp) - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - // Description - Text( - text = plugin.manifest.description, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .heightIn(max = 150.dp, min = 40.dp) - .padding(bottom = 20.dp), - ) - - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - plugin.manifest.repositoryUrl?.let { repositoryUrl -> - IconButton( - onClick = { uriHandler.openUri(repositoryUrl) }, - modifier = Modifier.size(25.dp), - ) { - Icon( - modifier = Modifier.fillMaxSize(), - painter = painterResource(R.drawable.ic_account_github_white_24dp), - contentDescription = stringResource(R.string.github) - ) - } - } - - if (plugin.manifest.changelog != null) { - IconButton( - onClick = onClickShowChangelog, - modifier = Modifier.size(25.dp), - ) { - Icon( - painter = painterResource(R.drawable.ic_history), - contentDescription = stringResource(R.string.plugins_view_changelog), - modifier = Modifier.fillMaxSize(), - ) - } - } - - Spacer(Modifier.weight(1f, true)) - - IconButton( - onClick = onClickDelete, - modifier = Modifier.size(25.dp), - ) { - Icon( - modifier = Modifier.fillMaxSize(), - painter = painterResource(R.drawable.ic_delete_forever), - contentDescription = stringResource(R.string.action_uninstall), - tint = MaterialTheme.colorScheme.error - ) - } - } - } - } -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginSearch.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginSearch.kt deleted file mode 100644 index 07e2a29..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginSearch.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.meowarex.rlmobile.ui.screens.plugins.components - -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.unit.dp -import com.meowarex.rlmobile.R -import com.meowarex.rlmobile.ui.components.ResetToDefaultButton - -@Composable -fun PluginSearch( - currentFilter: State, - onFilterChange: (String) -> Unit, - modifier: Modifier = Modifier.Companion, -) { - val focusManager = LocalFocusManager.current - - OutlinedTextField( - value = currentFilter.value, - onValueChange = onFilterChange, - singleLine = true, - shape = MaterialTheme.shapes.medium, - label = { Text(stringResource(R.string.action_search)) }, - trailingIcon = { - val isFilterBlank by remember { derivedStateOf { currentFilter.value.isEmpty() } } - - ResetToDefaultButton( - enabled = !isFilterBlank, - onClick = { onFilterChange("") }, - modifier = Modifier.padding(end = 4.dp), - ) - }, - keyboardOptions = KeyboardOptions( - autoCorrectEnabled = false, - imeAction = ImeAction.Companion.Search - ), - keyboardActions = KeyboardActions { focusManager.clearFocus() }, - modifier = modifier, - ) -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginsError.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginsError.kt deleted file mode 100644 index 4fd02c9..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginsError.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.meowarex.rlmobile.ui.screens.plugins.components - -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.meowarex.rlmobile.R - -@Composable -fun PluginsError(modifier: Modifier = Modifier) { - Box( - contentAlignment = Alignment.Center, - modifier = modifier, - ) { - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - painter = painterResource(R.drawable.ic_warning), - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) - Text( - text = stringResource(R.string.plugins_error), - color = MaterialTheme.colorScheme.error, - ) - } - } -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginsNone.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginsNone.kt deleted file mode 100644 index 3350f64..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginsNone.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.meowarex.rlmobile.ui.screens.plugins.components - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import com.meowarex.rlmobile.R - -@Composable -fun PluginsNone(modifier: Modifier = Modifier) { - Box( - contentAlignment = Alignment.Center, - modifier = modifier, - ) { - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - painter = painterResource(R.drawable.ic_extension_off), - contentDescription = null - ) - Text(stringResource(R.string.plugins_none_installed)) - } - } -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/dialogs/UninstallPluginDialog.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/dialogs/UninstallPluginDialog.kt deleted file mode 100644 index e391686..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/dialogs/UninstallPluginDialog.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.meowarex.rlmobile.ui.screens.plugins.components.dialogs - -import androidx.compose.foundation.layout.size -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.meowarex.rlmobile.R - -@Composable -fun UninstallPluginDialog( - pluginName: String, - onConfirm: () -> Unit, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - icon = { - Icon( - painter = painterResource(R.drawable.ic_delete_forever), - contentDescription = null, - modifier = Modifier.size(32.dp), - ) - }, - title = { - Text(stringResource(R.string.plugins_delete_plugin, pluginName)) - }, - text = { - Text( - text = stringResource(R.string.plugins_delete_plugin_body, pluginName), - textAlign = TextAlign.Center, - ) - }, - confirmButton = { - Button( - onClick = onConfirm, - ) { - Text(stringResource(R.string.action_confirm)) - } - }, - dismissButton = { - Button( - onClick = onDismiss, - colors = ButtonDefaults.buttonColors( - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - containerColor = MaterialTheme.colorScheme.secondaryContainer - ) - ) { - Text(stringResource(R.string.action_cancel)) - } - } - ) -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/model/PluginItem.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/model/PluginItem.kt deleted file mode 100644 index f92b078..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/model/PluginItem.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.meowarex.rlmobile.ui.screens.plugins.model - -import androidx.compose.runtime.* - -@Stable -data class PluginItem( - val manifest: PluginManifest, - val path: String, -) { - // Plugins are enabled by default unless disabled in Radiant Lyrics settings - var enabled by mutableStateOf(true) -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/model/PluginManifest.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/model/PluginManifest.kt deleted file mode 100644 index e9f84a5..0000000 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/model/PluginManifest.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.meowarex.rlmobile.ui.screens.plugins.model - -import androidx.compose.runtime.Immutable -import com.meowarex.rlmobile.util.serialization.ImmutableListSerializer -import kotlinx.collections.immutable.ImmutableList -import kotlinx.serialization.Serializable - -@Immutable -@Serializable -data class PluginManifest( - val name: String, - @Serializable(with = ImmutableListSerializer::class) - val authors: ImmutableList, - val description: String, - val version: String, - val updateUrl: String?, - val changelog: String?, - val changelogMedia: String?, -) { - val repositoryUrl: String? - get() = updateUrl?.replaceFirst( - "https://(raw\\.githubusercontent\\.com|cdn\\.jsdelivr\\.net/gh)/([^/]+)/([^/@]+).*".toRegex(), - "https://github.com/$2/$3" - ) - - @Immutable - @Serializable - data class Author( - val name: String, - val id: Long, - val hyperlink: Boolean = true, - ) { - val socialUrl: String - get() = "https://tidal.com/users/$id" - } -} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/SettingsModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/SettingsModel.kt index 6cedacc..7bea216 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/SettingsModel.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/SettingsModel.kt @@ -13,6 +13,7 @@ import com.meowarex.rlmobile.R import com.meowarex.rlmobile.di.ActivityProvider import com.meowarex.rlmobile.manager.* import com.meowarex.rlmobile.ui.theme.Theme +import com.meowarex.rlmobile.updatechecker.UpdateCheckWorker import com.meowarex.rlmobile.util.* class SettingsModel( @@ -58,6 +59,11 @@ class SettingsModel( preferences.keepPatchedApks = value } + fun setAutoUpdateCheck(value: Boolean) { + preferences.autoUpdateCheck = value + if (value) UpdateCheckWorker.schedule(application) else UpdateCheckWorker.cancel(application) + } + fun clearCache() = screenModelScope.launchIO { paths.clearCache() diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/SettingsScreen.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/SettingsScreen.kt index 80104cd..d5ef690 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/SettingsScreen.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/SettingsScreen.kt @@ -99,6 +99,16 @@ class SettingsScreen : Screen, Parcelable { } } + item(key = "SETTING_AUTO_UPDATE_CHECK", contentType = "SETTING_SWITCH") { + SettingsSwitch( + label = stringResource(R.string.setting_auto_update_check), + secondaryLabel = stringResource(R.string.setting_auto_update_check_desc), + pref = preferences.autoUpdateCheck, + icon = { Icon(painterResource(R.drawable.ic_update), null) }, + onPrefChange = { model.setAutoUpdateCheck(it) }, + ) + } + item(key = "HEADER_INSTALL", contentType = "DIVIDER") { SettingsHeader(stringResource(R.string.settings_header_installation)) } diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/updatechecker/UpdateCheckWorker.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/updatechecker/UpdateCheckWorker.kt new file mode 100644 index 0000000..cca1163 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/updatechecker/UpdateCheckWorker.kt @@ -0,0 +1,113 @@ +package com.meowarex.rlmobile.updatechecker + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.content.getSystemService +import androidx.work.* +import com.meowarex.rlmobile.BuildConfig +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.PreferencesManager +import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService +import com.meowarex.rlmobile.network.utils.SemVer +import com.meowarex.rlmobile.network.utils.fold +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.util.concurrent.TimeUnit + +private const val CHANNEL_ID = "rl_updates" +private const val NOTIFICATION_ID = 4242 +private const val WORK_NAME = "rl_update_check" + +class UpdateCheckWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params), KoinComponent { + + private val github: RadiantLyricsGithubService by inject() + private val prefs: PreferencesManager by inject() + + override suspend fun doWork(): Result { + if (!prefs.autoUpdateCheck) return Result.success() + + val current = SemVer.parseOrNull(BuildConfig.VERSION_NAME) ?: return Result.success() + + val releases = github.getManagerReleases().fold( + success = { it }, + fail = { return Result.retry() }, + ) + + val latestVersion = releases + .mapNotNull { r -> SemVer.parseOrNull((r.name ?: "").removePrefix("v"))?.let { v -> v to r } } + .maxByOrNull { it.first } + ?: return Result.success() + + val (version, release) = latestVersion + if (current >= version) return Result.success() + + Log.i(BuildConfig.TAG, "Update available: $version (installed $current)") + postUpdateNotification(version.toString(), release.htmlUrl) + return Result.success() + } + + private fun postUpdateNotification(version: String, releaseUrl: String) { + val nm = applicationContext.getSystemService() ?: return + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + applicationContext.getString(R.string.notif_channel_updates), + NotificationManager.IMPORTANCE_DEFAULT, + ) + nm.createNotificationChannel(channel) + } + + if (Build.VERSION.SDK_INT >= 33 && + ActivityCompat.checkSelfPermission(applicationContext, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + Log.w(BuildConfig.TAG, "Notification permission not granted; skipping update notification") + return + } + + val pendingIntent = PendingIntent.getActivity( + applicationContext, + 0, + Intent(Intent.ACTION_VIEW, Uri.parse(releaseUrl)).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val notif = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle(applicationContext.getString(R.string.notif_update_title)) + .setContentText(applicationContext.getString(R.string.notif_update_text, version)) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + + nm.notify(NOTIFICATION_ID, notif) + } + + companion object { + fun schedule(context: Context) { + val request = PeriodicWorkRequestBuilder(6, TimeUnit.HOURS) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request, + ) + } + + fun cancel(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + } + } +} diff --git a/Manager/app/src/main/res/drawable/ic_launcher_foreground.xml b/Manager/app/src/main/res/drawable/ic_launcher_foreground.xml index 528ce9e..b63058a 100644 --- a/Manager/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/Manager/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -9,14 +9,14 @@ android:translateX="16" android:translateY="18"> diff --git a/Manager/app/src/main/res/values/colors.xml b/Manager/app/src/main/res/values/colors.xml index 51f40b8..d35e5b0 100644 --- a/Manager/app/src/main/res/values/colors.xml +++ b/Manager/app/src/main/res/values/colors.xml @@ -1,4 +1,4 @@ - #0A1929 + #B91D6F diff --git a/Manager/app/src/main/res/values/strings.xml b/Manager/app/src/main/res/values/strings.xml index 25800f2..fb4a489 100644 --- a/Manager/app/src/main/res/values/strings.xml +++ b/Manager/app/src/main/res/values/strings.xml @@ -7,6 +7,14 @@ GitHub Support Server Installer + Home + + Manager updates + Manager update available + v%1$s is now available + + Background update check + Show a notification when a new Manager version is released Cancel Retry @@ -109,6 +117,7 @@ About Logs Settings + Refresh Lead Contributors diff --git a/Manager/gradle/libs.versions.toml b/Manager/gradle/libs.versions.toml index 6411730..dde2d0a 100644 --- a/Manager/gradle/libs.versions.toml +++ b/Manager/gradle/libs.versions.toml @@ -4,7 +4,9 @@ agp = "9.0.0" androidx-activity = "1.12.2" androidx-core = "1.17.0" androidx-lifecycle = "2.10.0" +androidx-paging = "3.3.6" androidx-splashscreen = "1.2.0" +androidx-work = "2.10.0" apksig = "9.0.0" axml = "1.0.1" binary-resources = "2.1.0" @@ -43,6 +45,8 @@ androidx-lifecycle = { module = "androidx.lifecycle:lifecycle-runtime-compose", androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" } androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" } +androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" } +androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" } # Coil (image library) coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }