├── fastlane └── metadata │ └── android │ ├── ar │ ├── title.txt │ ├── short_description.txt │ └── full_description.txt │ ├── en-US │ ├── title.txt │ ├── short_description.txt │ ├── images │ │ ├── icon.png │ │ └── phoneScreenshots │ │ │ ├── 1.png │ │ │ ├── 2.png │ │ │ ├── 3.png │ │ │ ├── 4.png │ │ │ ├── 5.png │ │ │ └── 6.png │ └── full_description.txt │ └── ru-RU │ ├── title.txt │ ├── short_description.txt │ └── full_description.txt ├── app ├── proguard-rules.pro └── src │ └── main │ ├── res │ ├── values-fil │ │ └── strings.xml │ ├── values-night │ │ ├── themes.xml │ │ └── colors.xml │ ├── drawable │ │ ├── launcher_splash.xml │ │ ├── check.xml │ │ ├── arrow_badge_up.xml │ │ ├── arrow_badge_down.xml │ │ ├── rotate.xml │ │ ├── brand_open_source.xml │ │ ├── command.xml │ │ ├── search.xml │ │ ├── cloud.xml │ │ ├── refresh.xml │ │ ├── reload.xml │ │ ├── currency_dollar.xml │ │ ├── notification.xml │ │ ├── circle_check_filled.xml │ │ ├── sun.xml │ │ ├── alert_circle_filled.xml │ │ ├── circle_x_filled.xml │ │ ├── brightness_2.xml │ │ ├── menu_2.xml │ │ ├── arrow_left.xml │ │ ├── hexagons.xml │ │ ├── info_circle_filled.xml │ │ ├── cloud_download.xml │ │ ├── home.xml │ │ ├── device_floppy.xml │ │ ├── notification_off.xml │ │ ├── award.xml │ │ ├── alert_triangle.xml │ │ ├── moon_stars.xml │ │ ├── files.xml │ │ ├── github.xml │ │ ├── box.xml │ │ ├── pencil_plus.xml │ │ ├── telegram.xml │ │ ├── users.xml │ │ ├── file_download.xml │ │ ├── file_certificate.xml │ │ ├── settings.xml │ │ ├── color_swatch.xml │ │ ├── heart_handshake.xml │ │ ├── launcher_outline.xml │ │ ├── trash.xml │ │ ├── file_type_zip.xml │ │ ├── device_mobile_down.xml │ │ ├── share.xml │ │ ├── world.xml │ │ ├── package_import.xml │ │ ├── git_pull_request.xml │ │ ├── launcher_foreground.xml │ │ ├── brand_git.xml │ │ ├── ci_label.xml │ │ └── weblate.xml │ ├── values │ │ ├── colors.xml │ │ ├── strings_untranslatable.xml │ │ └── themes.xml │ ├── values-v31 │ │ └── colors.xml │ ├── values-night-v31 │ │ └── colors.xml │ ├── mipmap-anydpi-v26 │ │ └── launcher.xml │ └── xml │ │ └── locales_config.xml │ ├── kotlin │ └── dev │ │ └── sanmer │ │ └── mrepo │ │ ├── ui │ │ ├── theme │ │ │ ├── Shape.kt │ │ │ ├── Type.kt │ │ │ └── Theme.kt │ │ ├── providable │ │ │ └── LocalUserPreferences.kt │ │ ├── utils │ │ │ ├── BottomSheetExt.kt │ │ │ ├── MenuExt.kt │ │ │ ├── NavControllerExt.kt │ │ │ └── LazyListStateExt.kt │ │ ├── navigation │ │ │ ├── Main.kt │ │ │ └── graphs │ │ │ │ ├── Modules.kt │ │ │ │ ├── Repository.kt │ │ │ │ └── Settings.kt │ │ ├── screens │ │ │ ├── repository │ │ │ │ ├── view │ │ │ │ │ ├── items │ │ │ │ │ │ ├── TagItem.kt │ │ │ │ │ │ └── LicenseItem.kt │ │ │ │ │ ├── pages │ │ │ │ │ │ └── AboutPage.kt │ │ │ │ │ └── ViewScreen.kt │ │ │ │ └── ModulesList.kt │ │ │ └── settings │ │ │ │ ├── items │ │ │ │ ├── NonRootItem.kt │ │ │ │ └── RootItem.kt │ │ │ │ ├── workingmode │ │ │ │ ├── WorkingModeItem.kt │ │ │ │ └── WorkingModeScreen.kt │ │ │ │ ├── repositories │ │ │ │ └── RepositoriesList.kt │ │ │ │ └── app │ │ │ │ └── items │ │ │ │ └── AppThemeItem.kt │ │ ├── component │ │ │ ├── Logo.kt │ │ │ ├── AppBar.kt │ │ │ ├── LabelItem.kt │ │ │ ├── DropdownMenu.kt │ │ │ ├── TextFieldDialog.kt │ │ │ ├── MenuChip.kt │ │ │ ├── Text.kt │ │ │ └── scrollbar │ │ │ │ └── ThumbExt.kt │ │ └── activity │ │ │ ├── SetupScreen.kt │ │ │ └── InstallActivity.kt │ │ ├── datastore │ │ ├── model │ │ │ ├── Option.kt │ │ │ ├── Homepage.kt │ │ │ ├── DarkMode.kt │ │ │ ├── ModulesMenu.kt │ │ │ ├── WorkingMode.kt │ │ │ ├── RepositoryMenu.kt │ │ │ └── UserPreferences.kt │ │ ├── UserPreferencesSerializer.kt │ │ ├── di │ │ │ └── DataStoreModule.kt │ │ └── UserPreferencesDataSource.kt │ │ ├── model │ │ ├── local │ │ │ ├── State.kt │ │ │ └── LocalModule.kt │ │ ├── json │ │ │ ├── License.kt │ │ │ └── UpdateJson.kt │ │ └── online │ │ │ ├── ModulesJson.kt │ │ │ ├── VersionItem.kt │ │ │ └── OnlineModule.kt │ │ ├── utils │ │ ├── extensions │ │ │ ├── LocaleListCompatExt.kt │ │ │ ├── ParcelableExt.kt │ │ │ ├── LocalDateTimeExt.kt │ │ │ └── ContextExt.kt │ │ ├── timber │ │ │ ├── ReleaseTree.kt │ │ │ └── DebugTree.kt │ │ └── StrUtil.kt │ │ ├── stub │ │ └── IRepoManager.kt │ │ ├── app │ │ ├── Const.kt │ │ └── utils │ │ │ └── NotificationUtils.kt │ │ ├── compat │ │ ├── BuildCompat.kt │ │ └── PermissionCompat.kt │ │ ├── App.kt │ │ ├── database │ │ ├── di │ │ │ └── DatabaseModule.kt │ │ ├── AppDatabase.kt │ │ ├── entity │ │ │ ├── online │ │ │ │ ├── RepoEntity.kt │ │ │ │ ├── VersionItemEntity.kt │ │ │ │ └── OnlineModuleEntity.kt │ │ │ └── local │ │ │ │ └── LocalModuleEntity.kt │ │ └── dao │ │ │ ├── LocalDao.kt │ │ │ └── RepoDao.kt │ │ ├── repository │ │ ├── UserPreferencesRepository.kt │ │ └── ModulesRepository.kt │ │ ├── viewmodel │ │ ├── SettingsViewModel.kt │ │ └── RepositoriesViewModel.kt │ │ └── Compat.kt │ └── AndroidManifest.xml ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── fr.yml │ └── bug.yml ├── dependabot.yml └── workflows │ └── android.yml ├── core ├── src │ └── main │ │ ├── aidl │ │ └── dev │ │ │ └── sanmer │ │ │ └── mrepo │ │ │ ├── content │ │ │ └── Module.aidl │ │ │ └── stub │ │ │ ├── IModuleOpsCallback.aidl │ │ │ ├── IInstallCallback.aidl │ │ │ └── IModuleManager.aidl │ │ └── kotlin │ │ └── dev │ │ └── sanmer │ │ └── mrepo │ │ ├── Platform.kt │ │ ├── content │ │ ├── State.kt │ │ └── Module.kt │ │ ├── ModuleManager.kt │ │ └── impl │ │ ├── Shell.kt │ │ ├── APatchModuleManagerImpl.kt │ │ ├── KernelSUModuleManagerImpl.kt │ │ └── MagiskModuleManagerImpl.kt └── build.gradle.kts ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── .gitignore ├── settings.gradle.kts ├── README.md └── gradlew.bat /fastlane/metadata/android/ar/title.txt: -------------------------------------------------------------------------------- 1 | MRepo 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/title.txt: -------------------------------------------------------------------------------- 1 | MRepo -------------------------------------------------------------------------------- /fastlane/metadata/android/ru-RU/title.txt: -------------------------------------------------------------------------------- 1 | MRepo 2 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | -repackageclasses dev.sanmer.mrepo -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/ar/short_description.txt: -------------------------------------------------------------------------------- 1 | مدير إضافات لـMagisk وKernelSU 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | A modules manager for Magisk & KernelSU 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/ru-RU/short_description.txt: -------------------------------------------------------------------------------- 1 | Менеджер модулей для Magisk и KernelSU 2 | -------------------------------------------------------------------------------- /app/src/main/res/values-fil/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /core/src/main/aidl/dev/sanmer/mrepo/content/Module.aidl: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.content; 2 | 3 | parcelable Module; -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MRepoApp/MRepo/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MRepoApp/MRepo/HEAD/fastlane/metadata/android/en-US/images/icon.png -------------------------------------------------------------------------------- /core/src/main/kotlin/dev/sanmer/mrepo/Platform.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo 2 | 3 | enum class Platform { 4 | Magisk, 5 | KernelSU, 6 | APatch 7 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.theme 2 | 3 | import androidx.compose.material3.Shapes 4 | 5 | val Shapes = Shapes() -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/datastore/model/Option.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.datastore.model 2 | 3 | enum class Option { 4 | Name, 5 | UpdatedTime 6 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/model/local/State.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.model.local 2 | 3 | import dev.sanmer.mrepo.content.State 4 | 5 | typealias State = State -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MRepoApp/MRepo/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MRepoApp/MRepo/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MRepoApp/MRepo/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MRepoApp/MRepo/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MRepoApp/MRepo/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MRepoApp/MRepo/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/datastore/model/Homepage.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.datastore.model 2 | 3 | enum class Homepage { 4 | Modules, 5 | Repository 6 | } -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 14 | 15 | 23 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/screens/repository/view/items/TagItem.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.screens.repository.view.items 2 | 3 | import androidx.annotation.DrawableRes 4 | import androidx.compose.foundation.layout.size 5 | import androidx.compose.material3.FilledTonalIconButton 6 | import androidx.compose.material3.Icon 7 | import androidx.compose.material3.IconButtonDefaults 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.res.painterResource 12 | import androidx.compose.ui.unit.dp 13 | 14 | @Composable 15 | internal fun TagItem( 16 | @DrawableRes icon: Int, 17 | onClick: () -> Unit 18 | ) = FilledTonalIconButton( 19 | onClick = onClick, 20 | colors = IconButtonDefaults.filledTonalIconButtonColors( 21 | containerColor = MaterialTheme.colorScheme.surfaceVariant 22 | ), 23 | modifier = Modifier.size(35.dp), 24 | ) { 25 | Icon( 26 | painter = painterResource(id = icon), 27 | contentDescription = null 28 | ) 29 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/github.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/box.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/pencil_plus.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/telegram.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/datastore/di/DataStoreModule.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.datastore.di 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.core.DataStoreFactory 6 | import androidx.datastore.dataStoreFile 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.android.qualifiers.ApplicationContext 11 | import dagger.hilt.components.SingletonComponent 12 | import dev.sanmer.mrepo.datastore.UserPreferencesSerializer 13 | import dev.sanmer.mrepo.datastore.model.UserPreferences 14 | import javax.inject.Singleton 15 | 16 | @Module 17 | @InstallIn(SingletonComponent::class) 18 | object DataStoreModule { 19 | @Provides 20 | @Singleton 21 | fun providesUserPreferencesDataStore( 22 | @ApplicationContext context: Context, 23 | userPreferencesSerializer: UserPreferencesSerializer 24 | ): DataStore = 25 | DataStoreFactory.create( 26 | serializer = userPreferencesSerializer 27 | ) { 28 | context.dataStoreFile("user_preferences.pb") 29 | } 30 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/users.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/file_download.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/file_certificate.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/settings.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/database/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.database 2 | 3 | import android.content.Context 4 | import androidx.room.Database 5 | import androidx.room.Room 6 | import androidx.room.RoomDatabase 7 | import dev.sanmer.mrepo.database.dao.LocalDao 8 | import dev.sanmer.mrepo.database.dao.RepoDao 9 | import dev.sanmer.mrepo.database.entity.local.LocalModuleEntity 10 | import dev.sanmer.mrepo.database.entity.online.OnlineModuleEntity 11 | import dev.sanmer.mrepo.database.entity.online.RepoEntity 12 | import dev.sanmer.mrepo.database.entity.online.VersionItemEntity 13 | 14 | @Database( 15 | entities = [ 16 | RepoEntity::class, 17 | OnlineModuleEntity::class, 18 | VersionItemEntity::class, 19 | LocalModuleEntity::class, 20 | LocalModuleEntity.Updatable::class 21 | ], 22 | version = 1 23 | ) 24 | abstract class AppDatabase : RoomDatabase() { 25 | abstract fun repoDao(): RepoDao 26 | abstract fun localDao(): LocalDao 27 | 28 | companion object { 29 | fun build(context: Context) = 30 | Room.databaseBuilder(context, 31 | AppDatabase::class.java, "mrepo") 32 | .build() 33 | } 34 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/color_swatch.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/utils/LazyListStateExt.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.utils 2 | 3 | import androidx.compose.foundation.lazy.LazyListState 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.State 6 | import androidx.compose.runtime.derivedStateOf 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.runtime.mutableIntStateOf 9 | import androidx.compose.runtime.remember 10 | import androidx.compose.runtime.setValue 11 | 12 | @Composable 13 | fun LazyListState.isScrollingUp(): State { 14 | var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) } 15 | var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) } 16 | return remember(this) { 17 | derivedStateOf { 18 | if (previousIndex != firstVisibleItemIndex) { 19 | previousIndex > firstVisibleItemIndex 20 | } else { 21 | previousScrollOffset >= firstVisibleItemScrollOffset 22 | }.also { 23 | previousIndex = firstVisibleItemIndex 24 | previousScrollOffset = firstVisibleItemScrollOffset 25 | } 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/fr.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | labels: [ "enhancement" ] 4 | title: "[FR] " 5 | body: 6 | - type: checkboxes 7 | id: checklist 8 | attributes: 9 | label: Checklist 10 | description: Ensure that our bug report form is appropriate for you 11 | options: 12 | - label: No one has submitted a similar or identical feature request before 13 | required: true 14 | - label: This suggestion does not depart from the original intention of MRepo 15 | required: true 16 | - type: textarea 17 | id: propose 18 | attributes: 19 | label: Enhancement propose 20 | description: Propose of the enhancement 21 | placeholder: | 22 | Show your idea here 23 | validations: 24 | required: true 25 | - type: textarea 26 | id: solution 27 | attributes: 28 | label: Solution 29 | description: What's your solution for this enhancement 30 | placeholder: | 31 | How to do it on your opinion 32 | - type: textarea 33 | id: addition 34 | attributes: 35 | label: Additional info 36 | description: Everything else you consider worthy that we didn't ask for -------------------------------------------------------------------------------- /app/src/main/res/drawable/heart_handshake.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/launcher_outline.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/trash.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/file_type_zip.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/device_mobile_down.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/share.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/database/entity/online/RepoEntity.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.database.entity.online 2 | 3 | import androidx.room.Embedded 4 | import androidx.room.Entity 5 | import dev.sanmer.mrepo.model.online.ModulesJson 6 | 7 | @Entity( 8 | tableName = "repo", 9 | primaryKeys = ["url"] 10 | ) 11 | data class RepoEntity( 12 | val url: String, 13 | val disable: Boolean, 14 | val size: Int, 15 | val name: String, 16 | val timestamp: Long, 17 | @Embedded val metadata: Metadata 18 | ) { 19 | constructor(url: String) : this( 20 | url = url, 21 | disable = false, 22 | size = 0, 23 | name = url, 24 | timestamp = 0L, 25 | metadata = Metadata() 26 | ) 27 | 28 | fun copy(modulesJson: ModulesJson) = copy( 29 | size = modulesJson.modules.size, 30 | name = modulesJson.name, 31 | timestamp = modulesJson.timestamp, 32 | metadata = Metadata(modulesJson.metadata) 33 | ) 34 | 35 | data class Metadata( 36 | val homepage: String = "", 37 | val donate: String = "", 38 | val support: String = "" 39 | ) { 40 | constructor(original: ModulesJson.Metadata) : this( 41 | homepage = original.homepage, 42 | donate = original.donate, 43 | support = original.support 44 | ) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/database/entity/online/VersionItemEntity.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.database.entity.online 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import dev.sanmer.mrepo.model.online.VersionItem 6 | 7 | @Entity( 8 | tableName = "version", 9 | primaryKeys = ["id", "repo_url", "version_code"] 10 | ) 11 | data class VersionItemEntity( 12 | @ColumnInfo(name = "repo_url") 13 | val repoUrl: String, 14 | val id: String, 15 | val timestamp: Long, 16 | val version: String, 17 | @ColumnInfo(name = "version_code") 18 | val versionCode: Int, 19 | @ColumnInfo(name = "zip_url") 20 | val zipUrl: String, 21 | val changelog: String 22 | ) { 23 | constructor( 24 | repoUrl: String, 25 | id: String, 26 | original: VersionItem, 27 | ) : this( 28 | repoUrl = repoUrl, 29 | id = id, 30 | timestamp = original.timestamp, 31 | version = original.version, 32 | versionCode = original.versionCode, 33 | zipUrl = original.zipUrl, 34 | changelog = original.changelog 35 | ) 36 | 37 | fun toJson() = VersionItem( 38 | repoUrl = repoUrl, 39 | timestamp = timestamp, 40 | version = version, 41 | versionCode = versionCode, 42 | zipUrl = zipUrl, 43 | changelog = changelog 44 | ) 45 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/world.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 36 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/model/online/OnlineModule.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.model.online 2 | 3 | import dev.sanmer.mrepo.utils.StrUtil 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class OnlineModule( 9 | val id: String, 10 | val name: String, 11 | val version: String, 12 | @SerialName("version_code") 13 | val versionCode: Int, 14 | val author: String, 15 | val description: String, 16 | val metadata: Metadata = Metadata(), 17 | val versions: List, 18 | ) { 19 | val versionDisplay by lazy { 20 | StrUtil.getVersionDisplay(version, versionCode) 21 | } 22 | 23 | @Serializable 24 | data class Metadata( 25 | val license: String = "", 26 | val homepage: String = "", 27 | val source: String = "", 28 | val donate: String = "", 29 | val support: String = "" 30 | ) 31 | 32 | companion object { 33 | fun example() = OnlineModule( 34 | id = "online_example", 35 | name = "Example", 36 | version = "2022.08.16", 37 | versionCode = 1703, 38 | author = "Sanmer", 39 | description = "This is an example!", 40 | metadata = Metadata( 41 | license = "GPL-3.0" 42 | ), 43 | versions = emptyList() 44 | ) 45 | } 46 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MRepo 2 | [![release](https://img.shields.io/github/v/release/MRepoApp/MRepo?label=release&color=red)](https://github.com/MRepoApp/MRepo/releases) [![download](https://shields.io/github/downloads/MRepoApp/MRepo/total?label=download)](https://github.com/MRepoApp/MRepo/releases/latest) 3 | 4 | MRepo is an Android app that helps manage your own modules repository. 5 | 6 | ## Preview 7 |

8 |

9 | 10 | ## Supported Versions 11 | - Android 8.0 ~ 14 12 | - Magisk 24.0 ~ latest 13 | - KernelSU 0.5.1 ~ latest 14 | - APatch 10253 ~ latest 15 | 16 | ## Modules Repository 17 | - [mrepo-rs](https://github.com/MRepoApp/mrepo-rs): A manager for building modules repository 18 | - [demo-modules-repo](https://github.com/MRepoApp/demo-modules-repo): A demo of Magisk Modules Repo 19 | - [magisk-modules-alt-repo](https://github.com/MRepoApp/magisk-modules-alt-repo): A mirror of Magisk-Modules-Alt-Repo 20 | 21 | ## Credits 22 | - [tabler/tabler-icons](https://github.com/tabler/tabler-icons.git) 23 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/package_import.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 36 | 42 | 43 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/component/Logo.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.component 2 | 3 | import androidx.annotation.DrawableRes 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.shape.CircleShape 7 | import androidx.compose.material3.Icon 8 | import androidx.compose.material3.LocalContentColor 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Surface 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.graphics.Shape 16 | import androidx.compose.ui.res.painterResource 17 | 18 | @Composable 19 | fun Logo( 20 | @DrawableRes icon: Int, 21 | modifier: Modifier = Modifier, 22 | shape: Shape = CircleShape, 23 | contentColor: Color = MaterialTheme.colorScheme.onPrimary, 24 | containerColor: Color = MaterialTheme.colorScheme.primary, 25 | fraction: Float = 0.6f 26 | ) = Surface( 27 | modifier = modifier, 28 | shape = shape, 29 | color = containerColor, 30 | contentColor = contentColor 31 | ) { 32 | Box( 33 | contentAlignment = Alignment.Center 34 | ) { 35 | Icon( 36 | modifier = Modifier.fillMaxSize(fraction), 37 | painter = painterResource(id = icon), 38 | contentDescription = null, 39 | tint = LocalContentColor.current 40 | ) 41 | } 42 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/repository/UserPreferencesRepository.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.repository 2 | 3 | import dev.sanmer.mrepo.datastore.UserPreferencesDataSource 4 | import dev.sanmer.mrepo.datastore.model.DarkMode 5 | import dev.sanmer.mrepo.datastore.model.Homepage 6 | import dev.sanmer.mrepo.datastore.model.ModulesMenu 7 | import dev.sanmer.mrepo.datastore.model.RepositoryMenu 8 | import dev.sanmer.mrepo.datastore.model.WorkingMode 9 | import javax.inject.Inject 10 | import javax.inject.Singleton 11 | 12 | @Singleton 13 | class UserPreferencesRepository @Inject constructor( 14 | private val userPreferencesDataSource: UserPreferencesDataSource 15 | ) { 16 | val data get() = userPreferencesDataSource.data 17 | 18 | suspend fun setWorkingMode(value: WorkingMode) = userPreferencesDataSource.setWorkingMode(value) 19 | 20 | suspend fun setDarkTheme(value: DarkMode) = userPreferencesDataSource.setDarkTheme(value) 21 | 22 | suspend fun setThemeColor(value: Int) = userPreferencesDataSource.setThemeColor(value) 23 | 24 | suspend fun setDeleteZipFile(value: Boolean) = userPreferencesDataSource.setDeleteZipFile(value) 25 | 26 | suspend fun setDownloadPath(value: String) = userPreferencesDataSource.setDownloadPath(value) 27 | 28 | suspend fun setHomepage(value: Homepage) = userPreferencesDataSource.setHomepage(value) 29 | 30 | suspend fun setRepositoryMenu(value: RepositoryMenu) = userPreferencesDataSource.setRepositoryMenu(value) 31 | 32 | suspend fun setModulesMenu(value: ModulesMenu) = userPreferencesDataSource.setModulesMenu(value) 33 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/git_pull_request.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 36 | 42 | 43 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/navigation/graphs/Repository.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.navigation.graphs 2 | 3 | import androidx.compose.animation.fadeIn 4 | import androidx.compose.animation.fadeOut 5 | import androidx.navigation.NavController 6 | import androidx.navigation.NavGraphBuilder 7 | import androidx.navigation.NavType 8 | import androidx.navigation.compose.composable 9 | import androidx.navigation.navArgument 10 | import androidx.navigation.navigation 11 | import dev.sanmer.mrepo.ui.navigation.MainScreen 12 | import dev.sanmer.mrepo.ui.screens.repository.RepositoryScreen 13 | import dev.sanmer.mrepo.ui.screens.repository.view.ViewScreen 14 | 15 | enum class RepositoryScreen(val route: String) { 16 | Home("Repository"), 17 | View("View/{moduleId}") 18 | } 19 | 20 | fun NavGraphBuilder.repositoryScreen( 21 | navController: NavController 22 | ) = navigation( 23 | startDestination = RepositoryScreen.Home.route, 24 | route = MainScreen.Repository.route 25 | ) { 26 | composable( 27 | route = RepositoryScreen.Home.route, 28 | enterTransition = { fadeIn() }, 29 | exitTransition = { fadeOut() } 30 | ) { 31 | RepositoryScreen( 32 | navController = navController 33 | ) 34 | } 35 | 36 | composable( 37 | route = RepositoryScreen.View.route, 38 | arguments = listOf(navArgument("moduleId") { type = NavType.StringType }), 39 | enterTransition = { fadeIn() }, 40 | exitTransition = { fadeOut() } 41 | ) { 42 | ViewScreen( 43 | navController = navController 44 | ) 45 | } 46 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/component/AppBar.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.component 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.width 7 | import androidx.compose.material3.Icon 8 | import androidx.compose.material3.LocalContentColor 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.res.painterResource 15 | import androidx.compose.ui.text.style.TextOverflow 16 | import androidx.compose.ui.unit.dp 17 | import dev.sanmer.mrepo.BuildConfig 18 | import dev.sanmer.mrepo.R 19 | 20 | @Composable 21 | fun TopAppBarTitle( 22 | text: String, 23 | modifier: Modifier = Modifier 24 | ) = Row( 25 | modifier = modifier, 26 | horizontalArrangement = Arrangement.Start, 27 | verticalAlignment = Alignment.CenterVertically 28 | ) { 29 | Text( 30 | text = text, 31 | style = MaterialTheme.typography.titleLarge, 32 | maxLines = 1, 33 | overflow = TextOverflow.Ellipsis, 34 | color = LocalContentColor.current 35 | ) 36 | 37 | if (BuildConfig.IS_DEV_VERSION) { 38 | Spacer(modifier = Modifier.width(10.dp)) 39 | Icon( 40 | painter = painterResource(id = R.drawable.ci_label), 41 | contentDescription = null, 42 | tint = LocalContentColor.current 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /core/src/main/kotlin/dev/sanmer/mrepo/impl/Shell.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.impl 2 | 3 | import android.util.Log 4 | 5 | internal object Shell { 6 | private const val TAG = "Shell" 7 | 8 | fun String.exec(): Result = 9 | runCatching { 10 | Log.d(TAG, "exec: $this") 11 | val process = ProcessBuilder("sh", "-c", this).start() 12 | val output = process.inputStream.bufferedReader().readText() 13 | .removeSurrounding("", "\n") 14 | 15 | val error = process.errorStream.bufferedReader().readText() 16 | .removeSurrounding("", "\n") 17 | 18 | require(process.waitFor().ok()) { error } 19 | Log.d(TAG, "output: $output") 20 | 21 | output 22 | }.onFailure { 23 | Log.e(TAG, Log.getStackTraceString(it)) 24 | } 25 | 26 | fun String.exec( 27 | stdout: (String) -> Unit, 28 | stderr: (String) -> Unit 29 | ) = runCatching { 30 | Log.d(TAG, "exec: $this") 31 | val process = ProcessBuilder("sh", "-c", this).start() 32 | val output = process.inputStream.bufferedReader() 33 | val error = process.errorStream.bufferedReader() 34 | 35 | output.forEachLine { 36 | Log.d(TAG, "output: $it") 37 | stdout(it) 38 | } 39 | 40 | error.forEachLine { 41 | Log.d(TAG, "error: $it") 42 | stderr(it) 43 | } 44 | 45 | require(process.waitFor().ok()) 46 | }.onFailure { 47 | Log.e(TAG, Log.getStackTraceString(it)) 48 | } 49 | 50 | private fun Int.ok() = this == 0 51 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 16 | 22 | 28 | 34 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/model/json/UpdateJson.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.model.json 2 | 3 | import dev.sanmer.mrepo.compat.NetworkCompat 4 | import dev.sanmer.mrepo.model.online.VersionItem 5 | import dev.sanmer.mrepo.utils.StrUtil 6 | import kotlinx.serialization.Serializable 7 | import kotlinx.serialization.json.decodeFromStream 8 | 9 | @Serializable 10 | data class UpdateJson( 11 | val version: String, 12 | val versionCode: Int, 13 | val zipUrl: String, 14 | val changelog: String 15 | ) { 16 | fun toItemOrNull(timestamp: Long): VersionItem? { 17 | if (!NetworkCompat.isUrl(zipUrl)) return null 18 | 19 | val changelog = when { 20 | !NetworkCompat.isUrl(changelog) -> "" 21 | NetworkCompat.isBlobUrl(changelog) -> "" 22 | else -> changelog 23 | } 24 | 25 | return VersionItem( 26 | timestamp = timestamp, 27 | version = StrUtil.getVersionDisplay(version, versionCode), 28 | versionCode = versionCode, 29 | zipUrl = zipUrl, 30 | changelog = changelog 31 | ) 32 | } 33 | 34 | companion object { 35 | suspend fun load(url: String): VersionItem? { 36 | if (!NetworkCompat.isUrl(url)) return null 37 | 38 | val result = NetworkCompat.request(url) { body, headers -> 39 | val json = NetworkCompat.defaultJson.decodeFromStream(body.byteStream()) 40 | val lastModified = headers.getInstant("Last-Modified")?.toEpochMilli() 41 | 42 | json.toItemOrNull(lastModified ?: System.currentTimeMillis()) 43 | } 44 | 45 | return result.getOrNull() 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/component/LabelItem.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.component 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.shape.RoundedCornerShape 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.graphics.Shape 14 | import androidx.compose.ui.text.intl.Locale 15 | import androidx.compose.ui.text.toUpperCase 16 | import androidx.compose.ui.unit.dp 17 | import androidx.compose.ui.unit.sp 18 | 19 | @Composable 20 | fun LabelItem( 21 | text: String, 22 | containerColor: Color = MaterialTheme.colorScheme.primary, 23 | contentColor: Color = MaterialTheme.colorScheme.onPrimary, 24 | shape: Shape = RoundedCornerShape(3.dp), 25 | upperCase: Boolean = true 26 | ) { 27 | if (text.isBlank()) return 28 | 29 | Box( 30 | modifier = Modifier 31 | .background( 32 | color = containerColor, 33 | shape = shape 34 | ), 35 | contentAlignment = Alignment.Center 36 | ) { 37 | Text( 38 | text = when { 39 | upperCase -> text.toUpperCase(Locale.current) 40 | else -> text 41 | }, 42 | style = MaterialTheme.typography.labelSmall.copy(fontSize = 8.sp), 43 | color = contentColor, 44 | modifier = Modifier.padding(horizontal = 4.dp) 45 | ) 46 | } 47 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/database/entity/local/LocalModuleEntity.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.database.entity.local 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import dev.sanmer.mrepo.model.local.LocalModule 6 | import dev.sanmer.mrepo.model.local.State 7 | 8 | @Entity( 9 | tableName = "local", 10 | primaryKeys = ["id"] 11 | ) 12 | data class LocalModuleEntity( 13 | val id: String, 14 | val name: String, 15 | val version: String, 16 | @ColumnInfo(name = "version_code") 17 | val versionCode: Int, 18 | val author: String, 19 | val description: String, 20 | val state: String, 21 | @ColumnInfo(name = "update_json") 22 | val updateJson: String, 23 | @ColumnInfo(name = "last_updated") 24 | val lastUpdated: Long 25 | ) { 26 | constructor(original: LocalModule) : this( 27 | id = original.id, 28 | name = original.name, 29 | version = original.version, 30 | versionCode = original.versionCode, 31 | author = original.author, 32 | description = original.description, 33 | state = original.state.name, 34 | updateJson = original.updateJson, 35 | lastUpdated = original.lastUpdated 36 | ) 37 | 38 | fun toModule() = LocalModule( 39 | id = id, 40 | name = name, 41 | version = version, 42 | versionCode = versionCode, 43 | author = author, 44 | description = description, 45 | updateJson = updateJson, 46 | state = State.valueOf(state), 47 | lastUpdated = lastUpdated 48 | ) 49 | 50 | @Entity( 51 | tableName = "local_updatable", 52 | primaryKeys = ["id"] 53 | ) 54 | data class Updatable( 55 | val id: String, 56 | val updatable: Boolean 57 | ) 58 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/screens/repository/ModulesList.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.screens.repository 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.lazy.LazyColumn 7 | import androidx.compose.foundation.lazy.LazyListState 8 | import androidx.compose.foundation.lazy.items 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.unit.dp 13 | import androidx.navigation.NavController 14 | import dev.sanmer.mrepo.ui.component.scrollbar.VerticalFastScrollbar 15 | import dev.sanmer.mrepo.ui.utils.navigateSingleTopTo 16 | import dev.sanmer.mrepo.viewmodel.ModuleViewModel 17 | import dev.sanmer.mrepo.viewmodel.RepositoryViewModel 18 | 19 | @Composable 20 | internal fun ModulesList( 21 | state: LazyListState, 22 | navController: NavController, 23 | list: List, 24 | ) = Box( 25 | modifier = Modifier.fillMaxSize() 26 | ) { 27 | LazyColumn( 28 | state = state, 29 | modifier = Modifier.fillMaxSize(), 30 | verticalArrangement = Arrangement.spacedBy(5.dp) 31 | ) { 32 | items( 33 | items = list, 34 | key = { it.original.id } 35 | ) { module -> 36 | ModuleItem( 37 | module = module, 38 | onClick = { 39 | navController.navigateSingleTopTo( 40 | ModuleViewModel.putModuleId(module.original) 41 | ) 42 | } 43 | ) 44 | } 45 | } 46 | 47 | VerticalFastScrollbar( 48 | state = state, 49 | modifier = Modifier.align(Alignment.CenterEnd) 50 | ) 51 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/viewmodel/SettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import dev.sanmer.mrepo.Compat 7 | import dev.sanmer.mrepo.datastore.model.DarkMode 8 | import dev.sanmer.mrepo.datastore.model.WorkingMode 9 | import dev.sanmer.mrepo.repository.UserPreferencesRepository 10 | import kotlinx.coroutines.launch 11 | import timber.log.Timber 12 | import javax.inject.Inject 13 | 14 | @HiltViewModel 15 | class SettingsViewModel @Inject constructor( 16 | private val userPreferencesRepository: UserPreferencesRepository 17 | ) : ViewModel() { 18 | private val mm get() = Compat.moduleManager 19 | val isProviderAlive get() = Compat.isAlive 20 | 21 | val version get() = Compat.get("") { 22 | with(mm) { "$version (${versionCode})" } 23 | } 24 | 25 | init { 26 | Timber.d("SettingsViewModel init") 27 | } 28 | 29 | fun setWorkingMode(value: WorkingMode) { 30 | viewModelScope.launch { 31 | userPreferencesRepository.setWorkingMode(value) 32 | } 33 | } 34 | 35 | fun setDarkTheme(value: DarkMode) { 36 | viewModelScope.launch { 37 | userPreferencesRepository.setDarkTheme(value) 38 | } 39 | } 40 | 41 | fun setThemeColor(value: Int) { 42 | viewModelScope.launch { 43 | userPreferencesRepository.setThemeColor(value) 44 | } 45 | } 46 | 47 | fun setDeleteZipFile(value: Boolean) { 48 | viewModelScope.launch { 49 | userPreferencesRepository.setDeleteZipFile(value) 50 | } 51 | } 52 | 53 | fun setDownloadPath(value: String) { 54 | viewModelScope.launch { 55 | userPreferencesRepository.setDownloadPath(value) 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/brand_git.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 36 | 42 | 48 | 49 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/repository/ModulesRepository.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.repository 2 | 3 | import dev.sanmer.mrepo.Compat 4 | import dev.sanmer.mrepo.compat.NetworkCompat.runRequest 5 | import dev.sanmer.mrepo.database.entity.online.RepoEntity 6 | import dev.sanmer.mrepo.stub.IRepoManager 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.withContext 9 | import timber.log.Timber 10 | import javax.inject.Inject 11 | import javax.inject.Singleton 12 | 13 | @Singleton 14 | class ModulesRepository @Inject constructor( 15 | private val localRepository: LocalRepository, 16 | ) { 17 | private val mm get() = Compat.moduleManager 18 | 19 | suspend fun getLocalAll() = withContext(Dispatchers.IO) { 20 | runCatching { 21 | mm.modules.toList() 22 | }.onSuccess { modules -> 23 | localRepository.updateLocal(modules) 24 | }.onFailure { 25 | Timber.e(it, "getLocalAll") 26 | } 27 | } 28 | 29 | suspend fun getLocal(id: String) = withContext(Dispatchers.IO) { 30 | runCatching { 31 | mm.getModuleById(id) 32 | }.onSuccess { 33 | localRepository.insertLocal(it) 34 | }.onFailure { 35 | Timber.e(it, "getLocal: $id") 36 | } 37 | } 38 | 39 | suspend fun getRepoAll(onlyEnable: Boolean = true) = 40 | localRepository.getRepoAll().filter { 41 | if (onlyEnable) !it.disable else true 42 | }.map { 43 | getRepo(it) 44 | } 45 | 46 | suspend fun getRepo(repo: RepoEntity) = 47 | runRequest { 48 | val api = IRepoManager.create(repo.url) 49 | api.modules.execute() 50 | }.onSuccess { 51 | localRepository.updateRepo(repo, it) 52 | }.onFailure { 53 | Timber.e(it, "getRepo: ${repo.url}") 54 | } 55 | 56 | suspend fun getRepo(repoUrl: String) = getRepo(RepoEntity(repoUrl)) 57 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/dev/sanmer/mrepo/impl/APatchModuleManagerImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.impl 2 | 3 | import dev.sanmer.mrepo.Platform 4 | import dev.sanmer.mrepo.impl.Shell.exec 5 | import dev.sanmer.mrepo.stub.IInstallCallback 6 | import dev.sanmer.mrepo.stub.IModuleOpsCallback 7 | import dev.sanmer.su.wrap.ThrowableWrapper.Companion.wrap 8 | import java.io.File 9 | import java.io.FileNotFoundException 10 | 11 | internal class APatchModuleManagerImpl : BaseModuleManagerImpl() { 12 | override fun getPlatform(): String { 13 | return Platform.APatch.name 14 | } 15 | 16 | override fun enable(id: String, callback: IModuleOpsCallback?) { 17 | moduleOps( 18 | cmd = "apd module enable $id", 19 | id = id, 20 | callback = callback 21 | ) 22 | } 23 | 24 | override fun disable(id: String, callback: IModuleOpsCallback?) { 25 | moduleOps( 26 | cmd = "apd module disable $id", 27 | id = id, 28 | callback = callback 29 | ) 30 | } 31 | 32 | override fun remove(id: String, callback: IModuleOpsCallback?) { 33 | moduleOps( 34 | cmd = "apd module uninstall $id", 35 | id = id, 36 | callback = callback 37 | ) 38 | } 39 | 40 | override fun install(path: String, callback: IInstallCallback?) { 41 | install( 42 | cmd = "apd module install '${path}'", 43 | path = path, 44 | callback = callback 45 | ) 46 | } 47 | 48 | private fun moduleOps(cmd: String, id: String, callback: IModuleOpsCallback?) { 49 | val moduleDir = File(modulesDir, id) 50 | if (!moduleDir.exists()) { 51 | callback?.onFailure(id, FileNotFoundException(moduleDir.path).wrap()) 52 | return 53 | } 54 | 55 | cmd.exec().onSuccess { 56 | callback?.onSuccess(id) 57 | }.onFailure { 58 | callback?.onFailure(id, it.wrap()) 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/dev/sanmer/mrepo/impl/KernelSUModuleManagerImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.impl 2 | 3 | import dev.sanmer.mrepo.Platform 4 | import dev.sanmer.mrepo.impl.Shell.exec 5 | import dev.sanmer.mrepo.stub.IInstallCallback 6 | import dev.sanmer.mrepo.stub.IModuleOpsCallback 7 | import dev.sanmer.su.wrap.ThrowableWrapper.Companion.wrap 8 | import java.io.File 9 | import java.io.FileNotFoundException 10 | 11 | internal class KernelSUModuleManagerImpl : BaseModuleManagerImpl() { 12 | override fun getPlatform(): String { 13 | return Platform.KernelSU.name 14 | } 15 | 16 | override fun enable(id: String, callback: IModuleOpsCallback?) { 17 | moduleOps( 18 | cmd = "ksud module enable $id", 19 | id = id, 20 | callback = callback 21 | ) 22 | } 23 | 24 | override fun disable(id: String, callback: IModuleOpsCallback?) { 25 | moduleOps( 26 | cmd = "ksud module disable $id", 27 | id = id, 28 | callback = callback 29 | ) 30 | } 31 | 32 | override fun remove(id: String, callback: IModuleOpsCallback?) { 33 | moduleOps( 34 | cmd = "ksud module uninstall $id", 35 | id = id, 36 | callback = callback 37 | ) 38 | } 39 | 40 | override fun install(path: String, callback: IInstallCallback?) { 41 | install( 42 | cmd = "ksud module install '${path}'", 43 | path = path, 44 | callback = callback 45 | ) 46 | } 47 | 48 | private fun moduleOps(cmd: String, id: String, callback: IModuleOpsCallback?) { 49 | val moduleDir = File(modulesDir, id) 50 | if (!moduleDir.exists()) { 51 | callback?.onFailure(id, FileNotFoundException(moduleDir.path).wrap()) 52 | return 53 | } 54 | 55 | cmd.exec().onSuccess { 56 | callback?.onSuccess(id) 57 | }.onFailure { 58 | callback?.onFailure(id, it.wrap()) 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/datastore/model/UserPreferences.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.datastore.model 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.runtime.Composable 5 | import dev.sanmer.mrepo.app.Const 6 | import dev.sanmer.mrepo.compat.BuildCompat 7 | import dev.sanmer.mrepo.datastore.model.WorkingMode.Companion.isNonRoot 8 | import dev.sanmer.mrepo.ui.theme.Colors 9 | import kotlinx.serialization.Serializable 10 | import kotlinx.serialization.decodeFromByteArray 11 | import kotlinx.serialization.encodeToByteArray 12 | import kotlinx.serialization.protobuf.ProtoBuf 13 | import kotlinx.serialization.protobuf.ProtoNumber 14 | import java.io.InputStream 15 | import java.io.OutputStream 16 | 17 | @Serializable 18 | data class UserPreferences( 19 | val workingMode: WorkingMode = WorkingMode.Setup, 20 | val darkMode: DarkMode = DarkMode.FollowSystem, 21 | val themeColor: Int = if (BuildCompat.atLeastS) Colors.Dynamic.id else Colors.Pourville.id, 22 | val deleteZipFile: Boolean = false, 23 | val downloadPath: String = Const.PUBLIC_DOWNLOADS.path, 24 | val homepage: Homepage = Homepage.Repository, 25 | @ProtoNumber(20) 26 | val repositoryMenu: RepositoryMenu = RepositoryMenu(), 27 | @ProtoNumber(30) 28 | val modulesMenu: ModulesMenu = ModulesMenu() 29 | ) { 30 | val currentHomepage by lazy { 31 | when { 32 | workingMode.isNonRoot -> Homepage.Repository 33 | else -> homepage 34 | } 35 | } 36 | 37 | @Composable 38 | fun isDarkMode() = when (darkMode) { 39 | DarkMode.AlwaysOff -> false 40 | DarkMode.AlwaysOn -> true 41 | DarkMode.FollowSystem -> isSystemInDarkTheme() 42 | } 43 | 44 | fun encodeTo(output: OutputStream) = output.write( 45 | ProtoBuf.encodeToByteArray(this) 46 | ) 47 | 48 | companion object { 49 | fun decodeFrom(input: InputStream): UserPreferences = 50 | ProtoBuf.decodeFromByteArray(input.readBytes()) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ci_label.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 11 | 14 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.theme 2 | 3 | import androidx.activity.ComponentActivity 4 | import androidx.activity.SystemBarStyle 5 | import androidx.activity.enableEdgeToEdge 6 | import androidx.compose.foundation.isSystemInDarkTheme 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.SideEffect 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.graphics.toArgb 12 | import androidx.compose.ui.platform.LocalContext 13 | 14 | @Composable 15 | fun AppTheme( 16 | themeColor: Int, 17 | darkMode: Boolean = isSystemInDarkTheme(), 18 | content: @Composable () -> Unit 19 | ) { 20 | val color = Colors.getColor(id = themeColor) 21 | val colorScheme = when { 22 | darkMode -> color.darkColorScheme 23 | else -> color.lightColorScheme 24 | } 25 | 26 | SystemBarStyle( 27 | darkMode = darkMode 28 | ) 29 | 30 | MaterialTheme( 31 | colorScheme = colorScheme, 32 | shapes = Shapes, 33 | typography = Typography, 34 | content = content 35 | ) 36 | } 37 | 38 | @Composable 39 | private fun SystemBarStyle( 40 | darkMode: Boolean, 41 | statusBarScrim: Color = Color.Transparent, 42 | navigationBarScrim: Color = Color.Transparent 43 | ) { 44 | val context = LocalContext.current 45 | val activity = context as ComponentActivity 46 | 47 | SideEffect { 48 | activity.enableEdgeToEdge( 49 | statusBarStyle = SystemBarStyle.auto( 50 | statusBarScrim.toArgb(), 51 | statusBarScrim.toArgb(), 52 | ) { darkMode }, 53 | navigationBarStyle = when { 54 | darkMode -> SystemBarStyle.dark( 55 | navigationBarScrim.toArgb() 56 | ) 57 | else -> SystemBarStyle.light( 58 | navigationBarScrim.toArgb(), 59 | navigationBarScrim.toArgb(), 60 | ) 61 | } 62 | ) 63 | } 64 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a bug 3 | labels: [ "bug" ] 4 | title: "[BUG] " 5 | body: 6 | - type: checkboxes 7 | id: checklist 8 | attributes: 9 | label: Checklist 10 | description: Ensure that our bug report form is appropriate for you 11 | options: 12 | - label: No one has submitted a similar or identical bug report before 13 | required: true 14 | - label: I'm using the latest version of MRepo 15 | required: true 16 | - type: textarea 17 | id: bug 18 | attributes: 19 | label: Bug description 20 | description: Please describe the bug 21 | placeholder: | 22 | e.g. Crashed when installing module 23 | validations: 24 | required: true 25 | - type: textarea 26 | id: expected 27 | attributes: 28 | label: Expected behavior 29 | description: What did you expect to happen 30 | placeholder: | 31 | e.g. Install a module 32 | validations: 33 | required: true 34 | - type: textarea 35 | id: actual 36 | attributes: 37 | label: Actual behavior 38 | description: What happened instead 39 | placeholder: | 40 | e.g. Crashed 41 | validations: 42 | required: true 43 | - type: textarea 44 | id: steps 45 | attributes: 46 | label: Steps to reproduce 47 | description: How to reproduce the bug 48 | placeholder: | 49 | 1. Open the app 50 | 2. Crashed 51 | 52 | What an app 53 | - type: input 54 | id: ui 55 | attributes: 56 | label: UI / OS 57 | description: Your system UI or OS 58 | placeholder: MIUI / OneUI / etc. 59 | validations: 60 | required: true 61 | - type: input 62 | id: android 63 | attributes: 64 | label: Android Version 65 | description: Your Android Version 66 | placeholder: "14" 67 | validations: 68 | required: true 69 | - type: textarea 70 | id: additional 71 | attributes: 72 | label: Additional info 73 | description: Everything else you consider worthy that we didn't ask for -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/Compat.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import dev.sanmer.mrepo.datastore.model.WorkingMode 7 | import dev.sanmer.mrepo.stub.IModuleManager 8 | import dev.sanmer.su.IServiceManager 9 | import dev.sanmer.su.ServiceManagerCompat 10 | import dev.sanmer.su.ServiceManagerCompat.addService 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | import kotlinx.coroutines.flow.asStateFlow 13 | import timber.log.Timber 14 | 15 | object Compat { 16 | private var mServiceOrNull: IServiceManager? = null 17 | private val mService get() = checkNotNull(mServiceOrNull) { 18 | "IServiceManager haven't been received" 19 | } 20 | 21 | var isAlive by mutableStateOf(false) 22 | private set 23 | 24 | private val _isAliveFlow = MutableStateFlow(false) 25 | val isAliveFlow get() = _isAliveFlow.asStateFlow() 26 | 27 | val moduleManager: IModuleManager by lazy { 28 | IModuleManager.Stub.asInterface( 29 | mService.addService( 30 | ModuleManager::class.java 31 | ) 32 | ) 33 | } 34 | 35 | private fun state(): Boolean { 36 | isAlive = mServiceOrNull != null 37 | _isAliveFlow.value = isAlive 38 | 39 | return isAlive 40 | } 41 | 42 | suspend fun init(mode: WorkingMode) = when { 43 | isAlive -> true 44 | else -> try { 45 | mServiceOrNull = when (mode) { 46 | WorkingMode.Shizuku -> ServiceManagerCompat.fromShizuku() 47 | WorkingMode.Superuser -> ServiceManagerCompat.fromLibSu() 48 | else -> null 49 | } 50 | 51 | state() 52 | } catch (e: Throwable) { 53 | Timber.e(e) 54 | 55 | mServiceOrNull = null 56 | state() 57 | } 58 | } 59 | 60 | fun get(fallback: T, block: Compat.() -> T): T { 61 | return when { 62 | isAlive -> block(this) 63 | else -> fallback 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/database/dao/LocalDao.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.database.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import androidx.room.Transaction 8 | import dev.sanmer.mrepo.database.entity.local.LocalModuleEntity 9 | import dev.sanmer.mrepo.model.local.LocalModule 10 | import kotlinx.coroutines.flow.Flow 11 | 12 | @Dao 13 | interface LocalDao { 14 | @Insert(onConflict = OnConflictStrategy.REPLACE) 15 | suspend fun insertLocal(value: LocalModuleEntity) 16 | 17 | @Insert(onConflict = OnConflictStrategy.REPLACE) 18 | suspend fun insertLocal(list: List) 19 | 20 | @Insert(onConflict = OnConflictStrategy.REPLACE) 21 | suspend fun insertUpdatable(value: LocalModuleEntity.Updatable) 22 | 23 | @Query("DELETE FROM local_updatable WHERE id NOT IN (:list)") 24 | suspend fun deleteUpdatableNotIn(list: List) 25 | 26 | @Query("DELETE FROM local WHERE id NOT IN (:list)") 27 | suspend fun deleteLocalNotIn(list: List) 28 | 29 | @Transaction 30 | suspend fun updateLocal(list: List) { 31 | val moduleIds = list.map { it.id } 32 | 33 | deleteUpdatableNotIn(moduleIds) 34 | deleteLocalNotIn(moduleIds) 35 | insertLocal(list.map { LocalModuleEntity(it) }) 36 | } 37 | 38 | @Query("SELECT * FROM local") 39 | fun getLocalAllAsFlow(): Flow> 40 | 41 | @Query( 42 | "SELECT * FROM local " + 43 | "LEFT JOIN local_updatable ON local_updatable.id = local.id " 44 | ) 45 | fun getLocalAndUpdatableAllAsFlow(): Flow> 46 | 47 | @Query( 48 | "SELECT * FROM local " + 49 | "LEFT JOIN local_updatable ON local_updatable.id = local.id " + 50 | "WHERE local.id = :id" 51 | ) 52 | suspend fun getLocalAndUpdatableById(id: String): Map 53 | 54 | @Query("SELECT * FROM local_updatable WHERE id = :id") 55 | suspend fun getUpdatable(id: String): LocalModuleEntity.Updatable? 56 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/screens/repository/view/items/LicenseItem.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.screens.repository.view.items 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.WindowInsets 5 | import androidx.compose.foundation.layout.navigationBars 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material3.BottomSheetDefaults 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.material3.ModalBottomSheet 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.getValue 13 | import androidx.compose.runtime.mutableStateOf 14 | import androidx.compose.runtime.remember 15 | import androidx.compose.runtime.setValue 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.res.stringResource 19 | import androidx.compose.ui.unit.dp 20 | import dev.sanmer.mrepo.R 21 | import dev.sanmer.mrepo.ui.component.LicenseContent 22 | import dev.sanmer.mrepo.ui.utils.expandedShape 23 | 24 | @Composable 25 | internal fun LicenseItem( 26 | licenseId: String 27 | ) = Box { 28 | var open by remember { mutableStateOf(false) } 29 | if (open) { 30 | ModalBottomSheet( 31 | onDismissRequest = { open = false }, 32 | shape = BottomSheetDefaults.expandedShape(15.dp), 33 | windowInsets = WindowInsets.navigationBars, 34 | containerColor = MaterialTheme.colorScheme.surface, 35 | tonalElevation = 0.dp 36 | ) { 37 | Text( 38 | text = stringResource(id = R.string.license_title), 39 | style = MaterialTheme.typography.headlineSmall, 40 | modifier = Modifier.align(Alignment.CenterHorizontally) 41 | ) 42 | 43 | LicenseContent( 44 | licenseId = licenseId, 45 | modifier = Modifier 46 | .padding(top = 16.dp) 47 | .padding(horizontal = 16.dp) 48 | ) 49 | } 50 | } 51 | 52 | TagItem( 53 | icon = R.drawable.file_certificate, 54 | onClick = { open = true } 55 | ) 56 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/activity/SetupScreen.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.activity 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.compose.ui.unit.dp 16 | import dev.sanmer.mrepo.R 17 | import dev.sanmer.mrepo.datastore.model.WorkingMode 18 | import dev.sanmer.mrepo.ui.screens.settings.workingmode.WorkingModeItem 19 | 20 | @Composable 21 | fun SetupScreen( 22 | setMode: (WorkingMode) -> Unit 23 | ) = Column( 24 | modifier = Modifier 25 | .background(color = MaterialTheme.colorScheme.background) 26 | .fillMaxSize(), 27 | verticalArrangement = Arrangement.Center, 28 | horizontalAlignment = Alignment.CenterHorizontally 29 | ) { 30 | Text( 31 | text = stringResource(id = R.string.setup_mode), 32 | style = MaterialTheme.typography.titleLarge, 33 | color = MaterialTheme.colorScheme.onBackground 34 | ) 35 | 36 | Spacer(modifier = Modifier.height(30.dp)) 37 | WorkingModeItem( 38 | title = stringResource(id = R.string.setup_root_title), 39 | desc = stringResource(id = R.string.setup_root_desc), 40 | onClick = { setMode(WorkingMode.Superuser) } 41 | ) 42 | 43 | Spacer(modifier = Modifier.height(20.dp)) 44 | WorkingModeItem( 45 | title = stringResource(id = R.string.setup_shizuku_title), 46 | desc = stringResource(id = R.string.setup_shizuku_desc), 47 | onClick = { setMode(WorkingMode.Shizuku) } 48 | ) 49 | 50 | Spacer(modifier = Modifier.height(20.dp)) 51 | WorkingModeItem( 52 | title = stringResource(id = R.string.setup_non_root_title), 53 | desc = stringResource(id = R.string.setup_non_root_desc), 54 | onClick = { setMode(WorkingMode.None) } 55 | ) 56 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/compat/PermissionCompat.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.compat 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.ContextWrapper 6 | import android.content.pm.PackageManager 7 | import androidx.activity.result.ActivityResultRegistryOwner 8 | import androidx.activity.result.contract.ActivityResultContracts 9 | import androidx.core.content.ContextCompat 10 | import java.util.UUID 11 | 12 | object PermissionCompat { 13 | data class PermissionState( 14 | private val results: Map 15 | ) { 16 | val allGranted = results.all { it.value } 17 | 18 | override fun toString(): String { 19 | return results.toString() 20 | } 21 | } 22 | 23 | private fun Context.findActivity(): Activity? { 24 | var context = this 25 | while (context is ContextWrapper) { 26 | if (context is Activity) return context 27 | context = context.baseContext 28 | } 29 | 30 | return null 31 | } 32 | 33 | fun checkPermissions( 34 | context: Context, 35 | permissions: List 36 | ): PermissionState { 37 | val results = permissions.associateWith { 38 | ContextCompat.checkSelfPermission( 39 | context, it 40 | ) == PackageManager.PERMISSION_GRANTED 41 | } 42 | 43 | return PermissionState(results) 44 | } 45 | 46 | fun requestPermissions( 47 | context: Context, 48 | permissions: List, 49 | callback: (PermissionState) -> Unit 50 | ) { 51 | val state = checkPermissions(context, permissions) 52 | if (state.allGranted) { 53 | callback(state) 54 | return 55 | } 56 | 57 | val activity = context.findActivity() 58 | if (activity !is ActivityResultRegistryOwner) return 59 | 60 | val activityResultRegistry = activity.activityResultRegistry 61 | val key = UUID.randomUUID().toString() 62 | val launcher = activityResultRegistry.register( 63 | key, 64 | ActivityResultContracts.RequestMultiplePermissions() 65 | ) { results -> 66 | callback(PermissionState(results)) 67 | } 68 | 69 | launcher.launch(permissions.toTypedArray()) 70 | } 71 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/database/entity/online/OnlineModuleEntity.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.database.entity.online 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Embedded 5 | import androidx.room.Entity 6 | import dev.sanmer.mrepo.model.online.OnlineModule 7 | import dev.sanmer.mrepo.model.online.VersionItem 8 | 9 | @Entity( 10 | tableName = "online", 11 | primaryKeys = ["id", "repo_url"] 12 | ) 13 | data class OnlineModuleEntity( 14 | @ColumnInfo(name = "repo_url") 15 | val repoUrl: String, 16 | val id: String, 17 | val name: String, 18 | val version: String, 19 | @ColumnInfo(name = "version_code") 20 | val versionCode: Int, 21 | val author: String, 22 | val description: String, 23 | @Embedded val metadata: Metadata 24 | ) { 25 | constructor( 26 | repoUrl: String, 27 | original: OnlineModule 28 | ) : this( 29 | repoUrl = repoUrl, 30 | id = original.id, 31 | name = original.name, 32 | version = original.version, 33 | versionCode = original.versionCode, 34 | author = original.author, 35 | description = original.description, 36 | metadata = Metadata(original.metadata) 37 | ) 38 | 39 | fun toJson(versions: List = emptyList()) = OnlineModule( 40 | id = id, 41 | name = name, 42 | version = version, 43 | versionCode = versionCode, 44 | author = author, 45 | description = description, 46 | metadata = metadata.toJson(), 47 | versions = versions 48 | ) 49 | 50 | data class Metadata( 51 | val license: String = "", 52 | val homepage: String = "", 53 | val source: String = "", 54 | val donate: String = "", 55 | val support: String = "" 56 | ) { 57 | constructor(original: OnlineModule.Metadata) : this( 58 | license = original.license, 59 | homepage = original.homepage, 60 | source = original.source, 61 | support = original.support, 62 | donate = original.donate 63 | ) 64 | 65 | fun toJson() = OnlineModule.Metadata( 66 | license = license, 67 | homepage = homepage, 68 | source = source, 69 | support = support, 70 | donate = donate 71 | ) 72 | } 73 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/component/DropdownMenu.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.component 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.ColumnScope 5 | import androidx.compose.foundation.shape.CornerBasedShape 6 | import androidx.compose.foundation.shape.RoundedCornerShape 7 | import androidx.compose.material3.DropdownMenu 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.unit.DpOffset 12 | import androidx.compose.ui.unit.dp 13 | import androidx.compose.ui.window.PopupProperties 14 | import dev.sanmer.mrepo.ui.utils.ProvideMenuShape 15 | 16 | @Composable 17 | fun DropdownMenu( 18 | expanded: Boolean, 19 | onDismissRequest: () -> Unit, 20 | modifier: Modifier = Modifier, 21 | shape: CornerBasedShape = RoundedCornerShape(8.dp), 22 | offset: DpOffset = DpOffset.Zero, 23 | properties: PopupProperties = PopupProperties(focusable = true), 24 | content: @Composable ColumnScope.() -> Unit 25 | ) { 26 | ProvideMenuShape(shape) { 27 | DropdownMenu( 28 | expanded = expanded, 29 | onDismissRequest = onDismissRequest, 30 | modifier = modifier, 31 | offset = offset, 32 | properties = properties, 33 | content = content 34 | ) 35 | } 36 | } 37 | 38 | @Composable 39 | fun DropdownMenu( 40 | expanded: Boolean, 41 | onDismissRequest: () -> Unit, 42 | modifier: Modifier = Modifier, 43 | shape: CornerBasedShape = RoundedCornerShape(8.dp), 44 | contentAlignment: Alignment = Alignment.TopStart, 45 | offset: DpOffset = DpOffset.Zero, 46 | properties: PopupProperties = PopupProperties(focusable = true), 47 | surface: @Composable () -> Unit, 48 | content: @Composable ColumnScope.() -> Unit 49 | ) = Box { 50 | surface() 51 | 52 | ProvideMenuShape(shape) { 53 | Box( 54 | modifier = Modifier.align(contentAlignment), 55 | contentAlignment = contentAlignment 56 | ) { 57 | DropdownMenu( 58 | expanded = expanded, 59 | onDismissRequest = onDismissRequest, 60 | modifier = modifier, 61 | offset = offset, 62 | properties = properties, 63 | content = content 64 | ) 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /.github/workflows/android.yml: -------------------------------------------------------------------------------- 1 | name: Android 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Checkout compat 20 | uses: actions/checkout@v4 21 | with: 22 | repository: SanmerApps/ServiceManagerCompat 23 | path: compat 24 | 25 | - name: Set up signing key 26 | if: github.ref == 'refs/heads/main' 27 | run: | 28 | if [ ! -z "${{ secrets.KEY_STORE }}" ]; then 29 | echo keyStorePassword='${{ secrets.KEY_STORE_PASSWORD }}' >> signing.properties 30 | echo keyAlias='${{ secrets.KEY_ALIAS }}' >> signing.properties 31 | echo keyPassword='${{ secrets.KEY_PASSWORD }}' >> signing.properties 32 | echo keyStore='${{ github.workspace }}/key.jks' >> signing.properties 33 | echo ${{ secrets.KEY_STORE }} | base64 --decode > ${{ github.workspace }}/key.jks 34 | fi 35 | 36 | - name: Set up JDK 37 | uses: actions/setup-java@v4 38 | with: 39 | distribution: 'zulu' 40 | java-version: 21 41 | 42 | - name: Set up Gradle 43 | uses: gradle/actions/setup-gradle@v3 44 | with: 45 | validate-wrappers: true 46 | gradle-home-cache-cleanup: true 47 | 48 | - name: Build dependencies 49 | working-directory: compat 50 | run: ./gradlew publishToMavenLocal 51 | 52 | - name: Build with Gradle 53 | run: ./gradlew assembleRelease 54 | 55 | - name: Get release name 56 | if: success() && github.ref == 'refs/heads/main' 57 | id: release-name 58 | run: | 59 | name=`ls app/build/outputs/apk/release/*.apk | awk -F '(/|.apk)' '{print $6}'` && echo "name=${name}" >> $GITHUB_OUTPUT 60 | 61 | - name: Upload built apk 62 | if: success() && github.ref == 'refs/heads/main' 63 | uses: actions/upload-artifact@v4 64 | with: 65 | name: ${{ steps.release-name.outputs.name }} 66 | path: app/build/outputs/apk/release/*.apk 67 | 68 | - name: Upload mappings 69 | if: success() && github.ref == 'refs/heads/main' 70 | uses: actions/upload-artifact@v4 71 | with: 72 | name: mappings 73 | path: app/build/outputs/mapping/release 74 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/component/TextFieldDialog.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.component 2 | 3 | import androidx.compose.material3.AlertDialog 4 | import androidx.compose.material3.AlertDialogDefaults 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.LaunchedEffect 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.focus.FocusRequester 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.graphics.Shape 12 | import androidx.compose.ui.platform.LocalSoftwareKeyboardController 13 | import androidx.compose.ui.unit.Dp 14 | import androidx.compose.ui.window.DialogProperties 15 | 16 | @Composable 17 | fun TextFieldDialog( 18 | onDismissRequest: () -> Unit, 19 | confirmButton: @Composable () -> Unit, 20 | modifier: Modifier = Modifier, 21 | dismissButton: @Composable (() -> Unit)? = null, 22 | icon: @Composable (() -> Unit)? = null, 23 | title: @Composable (() -> Unit)? = null, 24 | shape: Shape = AlertDialogDefaults.shape, 25 | containerColor: Color = AlertDialogDefaults.containerColor, 26 | iconContentColor: Color = AlertDialogDefaults.iconContentColor, 27 | titleContentColor: Color = AlertDialogDefaults.titleContentColor, 28 | textContentColor: Color = AlertDialogDefaults.textContentColor, 29 | tonalElevation: Dp = AlertDialogDefaults.TonalElevation, 30 | properties: DialogProperties = DialogProperties(), 31 | launchKeyboard: Boolean = true, 32 | content: @Composable (FocusRequester) -> Unit 33 | ) { 34 | val focusRequester = remember { FocusRequester() } 35 | val keyboardController = LocalSoftwareKeyboardController.current 36 | 37 | LaunchedEffect(focusRequester) { 38 | if (launchKeyboard) { 39 | focusRequester.requestFocus() 40 | keyboardController?.show() 41 | } 42 | } 43 | 44 | AlertDialog( 45 | onDismissRequest = onDismissRequest, 46 | confirmButton = confirmButton, 47 | modifier = modifier, 48 | dismissButton = dismissButton, 49 | icon = icon, 50 | title = title, 51 | text = { content(focusRequester) }, 52 | shape = shape, 53 | containerColor = containerColor, 54 | iconContentColor = iconContentColor, 55 | titleContentColor = titleContentColor, 56 | textContentColor = textContentColor, 57 | tonalElevation = tonalElevation, 58 | properties = properties 59 | ) 60 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/screens/settings/items/NonRootItem.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.screens.settings.items 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.layout.size 10 | import androidx.compose.foundation.layout.width 11 | import androidx.compose.foundation.shape.RoundedCornerShape 12 | import androidx.compose.material3.Icon 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.material3.Surface 15 | import androidx.compose.material3.Text 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.res.painterResource 20 | import androidx.compose.ui.res.stringResource 21 | import androidx.compose.ui.unit.dp 22 | import dev.sanmer.mrepo.R 23 | 24 | @Composable 25 | internal fun NonRootItem() = Surface( 26 | modifier = Modifier.padding(all = 18.dp), 27 | shape = RoundedCornerShape(15.dp), 28 | color = MaterialTheme.colorScheme.secondaryContainer 29 | ) { 30 | Row( 31 | modifier = Modifier 32 | .padding(all = 20.dp) 33 | .fillMaxWidth(), 34 | verticalAlignment = Alignment.CenterVertically 35 | ) { 36 | Icon( 37 | modifier = Modifier.size(30.dp), 38 | painter = painterResource(id = R.drawable.info_circle_filled), 39 | contentDescription = null, 40 | tint = MaterialTheme.colorScheme.primary 41 | ) 42 | 43 | Spacer(modifier = Modifier.width(16.dp)) 44 | Column( 45 | modifier = Modifier.fillMaxWidth(), 46 | verticalArrangement = Arrangement.spacedBy(4.dp) 47 | ) { 48 | Text( 49 | text = stringResource(id = R.string.settings_non_root), 50 | style = MaterialTheme.typography.titleMedium, 51 | color = MaterialTheme.colorScheme.onSecondaryContainer 52 | ) 53 | 54 | Text( 55 | text = stringResource(id = R.string.settings_non_root_desc), 56 | style = MaterialTheme.typography.bodyMedium, 57 | color = MaterialTheme.colorScheme.onSecondaryContainer 58 | ) 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/navigation/graphs/Settings.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.navigation.graphs 2 | 3 | import androidx.compose.animation.fadeIn 4 | import androidx.compose.animation.fadeOut 5 | import androidx.navigation.NavController 6 | import androidx.navigation.NavGraphBuilder 7 | import androidx.navigation.compose.composable 8 | import androidx.navigation.navigation 9 | import dev.sanmer.mrepo.ui.navigation.MainScreen 10 | import dev.sanmer.mrepo.ui.screens.settings.SettingsScreen 11 | import dev.sanmer.mrepo.ui.screens.settings.about.AboutScreen 12 | import dev.sanmer.mrepo.ui.screens.settings.app.AppScreen 13 | import dev.sanmer.mrepo.ui.screens.settings.repositories.RepositoriesScreen 14 | import dev.sanmer.mrepo.ui.screens.settings.workingmode.WorkingModeScreen 15 | 16 | enum class SettingsScreen(val route: String) { 17 | Home("Settings"), 18 | Repositories("Repositories"), 19 | App("App"), 20 | WorkingMode("WorkingMode"), 21 | About("About") 22 | } 23 | 24 | fun NavGraphBuilder.settingsScreen( 25 | navController: NavController 26 | ) = navigation( 27 | startDestination = SettingsScreen.Home.route, 28 | route = MainScreen.Settings.route 29 | ) { 30 | composable( 31 | route = SettingsScreen.Home.route, 32 | enterTransition = { fadeIn() }, 33 | exitTransition = { fadeOut() } 34 | ) { 35 | SettingsScreen( 36 | navController = navController 37 | ) 38 | } 39 | 40 | composable( 41 | route = SettingsScreen.Repositories.route, 42 | enterTransition = { fadeIn() }, 43 | exitTransition = { fadeOut() } 44 | ) { 45 | RepositoriesScreen( 46 | navController = navController 47 | ) 48 | } 49 | 50 | composable( 51 | route = SettingsScreen.App.route, 52 | enterTransition = { fadeIn() }, 53 | exitTransition = { fadeOut() } 54 | ) { 55 | AppScreen( 56 | navController = navController 57 | ) 58 | } 59 | 60 | composable( 61 | route = SettingsScreen.WorkingMode.route, 62 | enterTransition = { fadeIn() }, 63 | exitTransition = { fadeOut() } 64 | ) { 65 | WorkingModeScreen( 66 | navController = navController 67 | ) 68 | } 69 | 70 | composable( 71 | route = SettingsScreen.About.route, 72 | enterTransition = { fadeIn() }, 73 | exitTransition = { fadeOut() } 74 | ) { 75 | AboutScreen( 76 | navController = navController 77 | ) 78 | } 79 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/component/MenuChip.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.component 2 | 3 | import androidx.compose.foundation.Canvas 4 | import androidx.compose.foundation.layout.height 5 | import androidx.compose.foundation.layout.size 6 | import androidx.compose.foundation.shape.CircleShape 7 | import androidx.compose.material3.FilterChip 8 | import androidx.compose.material3.FilterChipDefaults 9 | import androidx.compose.material3.Icon 10 | import androidx.compose.material3.LocalContentColor 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.res.painterResource 16 | import androidx.compose.ui.unit.Dp 17 | import androidx.compose.ui.unit.dp 18 | import dev.sanmer.mrepo.R 19 | 20 | @Composable 21 | fun MenuChip( 22 | selected: Boolean, 23 | onClick: () -> Unit, 24 | label: @Composable () -> Unit, 25 | modifier: Modifier = Modifier, 26 | enabled: Boolean = true, 27 | ) = FilterChip( 28 | selected = selected, 29 | onClick = onClick, 30 | label = label, 31 | modifier = modifier.height(FilterChipDefaults.Height), 32 | enabled = enabled, 33 | leadingIcon = { 34 | if (!selected) { 35 | Point(size = 8.dp) 36 | } 37 | }, 38 | trailingIcon = { 39 | if (selected) { 40 | Icon( 41 | painter = painterResource(id = R.drawable.check), 42 | contentDescription = null, 43 | modifier = Modifier.size(FilterChipDefaults.IconSize) 44 | ) 45 | } 46 | }, 47 | shape = CircleShape, 48 | colors = FilterChipDefaults.filterChipColors( 49 | iconColor = MaterialTheme.colorScheme.secondary, 50 | selectedContainerColor = MaterialTheme.colorScheme.secondary, 51 | selectedLabelColor = MaterialTheme.colorScheme.onSecondary, 52 | selectedLeadingIconColor = MaterialTheme.colorScheme.onSecondary, 53 | selectedTrailingIconColor = MaterialTheme.colorScheme.onSecondary 54 | ), 55 | border = FilterChipDefaults.filterChipBorder( 56 | enabled = enabled, 57 | selected = selected, 58 | borderColor = MaterialTheme.colorScheme.secondary, 59 | ) 60 | ) 61 | 62 | @Composable 63 | private fun Point( 64 | size: Dp, 65 | color: Color = LocalContentColor.current 66 | ) = Canvas( 67 | modifier = Modifier.size(size) 68 | ) { 69 | drawCircle( 70 | color = color, 71 | radius = this.size.width / 2, 72 | center = this.center 73 | ) 74 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/datastore/UserPreferencesDataSource.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.datastore 2 | 3 | import androidx.datastore.core.DataStore 4 | import dev.sanmer.mrepo.datastore.model.DarkMode 5 | import dev.sanmer.mrepo.datastore.model.Homepage 6 | import dev.sanmer.mrepo.datastore.model.ModulesMenu 7 | import dev.sanmer.mrepo.datastore.model.RepositoryMenu 8 | import dev.sanmer.mrepo.datastore.model.UserPreferences 9 | import dev.sanmer.mrepo.datastore.model.WorkingMode 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.withContext 12 | import javax.inject.Inject 13 | 14 | class UserPreferencesDataSource @Inject constructor( 15 | private val userPreferences: DataStore 16 | ) { 17 | val data get() = userPreferences.data 18 | 19 | suspend fun setWorkingMode(value: WorkingMode) = withContext(Dispatchers.IO) { 20 | userPreferences.updateData { 21 | it.copy( 22 | workingMode = value 23 | ) 24 | } 25 | } 26 | 27 | suspend fun setDarkTheme(value: DarkMode) = withContext(Dispatchers.IO) { 28 | userPreferences.updateData { 29 | it.copy( 30 | darkMode = value 31 | ) 32 | } 33 | } 34 | 35 | suspend fun setThemeColor(value: Int) = withContext(Dispatchers.IO) { 36 | userPreferences.updateData { 37 | it.copy( 38 | themeColor = value 39 | ) 40 | } 41 | } 42 | 43 | suspend fun setDeleteZipFile(value: Boolean) = withContext(Dispatchers.IO) { 44 | userPreferences.updateData { 45 | it.copy( 46 | deleteZipFile = value 47 | ) 48 | } 49 | } 50 | 51 | suspend fun setDownloadPath(value: String) = withContext(Dispatchers.IO) { 52 | userPreferences.updateData { 53 | it.copy( 54 | downloadPath = value 55 | ) 56 | } 57 | } 58 | 59 | suspend fun setHomepage(value: Homepage) = withContext(Dispatchers.IO) { 60 | userPreferences.updateData { 61 | it.copy( 62 | homepage = value 63 | ) 64 | } 65 | } 66 | 67 | suspend fun setRepositoryMenu(value: RepositoryMenu) = withContext(Dispatchers.IO) { 68 | userPreferences.updateData { 69 | it.copy( 70 | repositoryMenu = value 71 | ) 72 | } 73 | } 74 | 75 | suspend fun setModulesMenu(value: ModulesMenu) = withContext(Dispatchers.IO) { 76 | userPreferences.updateData { 77 | it.copy( 78 | modulesMenu = value 79 | ) 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/dev/sanmer/mrepo/impl/MagiskModuleManagerImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.impl 2 | 3 | import dev.sanmer.mrepo.Platform 4 | import dev.sanmer.mrepo.stub.IInstallCallback 5 | import dev.sanmer.mrepo.stub.IModuleOpsCallback 6 | import dev.sanmer.su.wrap.ThrowableWrapper.Companion.wrap 7 | import java.io.File 8 | import java.io.FileNotFoundException 9 | 10 | internal class MagiskModuleManagerImpl : BaseModuleManagerImpl() { 11 | override fun getPlatform(): String { 12 | return Platform.Magisk.name 13 | } 14 | 15 | override fun enable(id: String, callback: IModuleOpsCallback?) { 16 | moduleOps( 17 | tags = listOf( 18 | Tag("remove", FileOp.Delete), 19 | Tag("disable", FileOp.Delete) 20 | ), 21 | id = id, 22 | callback = callback 23 | ) 24 | } 25 | 26 | override fun disable(id: String, callback: IModuleOpsCallback?) { 27 | moduleOps( 28 | tags = listOf( 29 | Tag("remove", FileOp.Delete), 30 | Tag("disable", FileOp.Create) 31 | ), 32 | id = id, 33 | callback = callback 34 | ) 35 | } 36 | 37 | override fun remove(id: String, callback: IModuleOpsCallback?) { 38 | moduleOps( 39 | tags = listOf( 40 | Tag("disable", FileOp.Delete), 41 | Tag("remove", FileOp.Create) 42 | ), 43 | id = id, 44 | callback = callback 45 | ) 46 | } 47 | 48 | override fun install(path: String, callback: IInstallCallback?) { 49 | install( 50 | cmd = "magisk --install-module '${path}'", 51 | path = path, 52 | callback = callback 53 | ) 54 | } 55 | 56 | private fun moduleOps(tags: List, id: String, callback: IModuleOpsCallback?) { 57 | val moduleDir = File(modulesDir, id) 58 | if (!moduleDir.exists()) { 59 | callback?.onFailure(id, FileNotFoundException(moduleDir.path).wrap()) 60 | return 61 | } 62 | 63 | runCatching { 64 | tags.forEach { 65 | val tag = File(moduleDir, it.name) 66 | when (it.op) { 67 | FileOp.Delete -> if (tag.exists()) tag.delete() 68 | FileOp.Create -> tag.createNewFile() 69 | } 70 | } 71 | }.onSuccess { 72 | callback?.onSuccess(id) 73 | }.onFailure { 74 | callback?.onFailure(id, it.wrap()) 75 | } 76 | } 77 | 78 | private class Tag( 79 | val name: String, 80 | val op: FileOp 81 | ) 82 | 83 | private enum class FileOp { 84 | Delete, 85 | Create 86 | } 87 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 12 | 14 | 15 | 19 | 20 | 23 | 24 | 34 | 35 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/component/Text.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.component 2 | 3 | import android.text.method.LinkMovementMethod 4 | import android.widget.TextView 5 | import androidx.compose.material3.LocalContentColor 6 | import androidx.compose.material3.LocalTextStyle 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.CompositionLocalProvider 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.graphics.Color 12 | import androidx.compose.ui.graphics.toArgb 13 | import androidx.compose.ui.platform.LocalContext 14 | import androidx.compose.ui.text.TextStyle 15 | import androidx.compose.ui.viewinterop.AndroidView 16 | import androidx.core.text.HtmlCompat 17 | import io.noties.markwon.Markwon 18 | 19 | @Composable 20 | internal fun ProvideContentColorTextStyle( 21 | contentColor: Color, 22 | textStyle: TextStyle, 23 | content: @Composable () -> Unit 24 | ) { 25 | val mergedStyle = LocalTextStyle.current.merge(textStyle) 26 | CompositionLocalProvider( 27 | LocalContentColor provides contentColor, 28 | LocalTextStyle provides mergedStyle, 29 | content = content 30 | ) 31 | } 32 | 33 | @Composable 34 | fun HtmlText( 35 | text: String, 36 | modifier: Modifier = Modifier, 37 | style: TextStyle = LocalTextStyle.current, 38 | color: Color = LocalContentColor.current, 39 | ) { 40 | val linkTextColor = MaterialTheme.colorScheme.primary.toArgb() 41 | AndroidView( 42 | modifier = modifier, 43 | factory = { TextView(it) }, 44 | update = { 45 | it.movementMethod = LinkMovementMethod.getInstance() 46 | it.setLinkTextColor(linkTextColor) 47 | it.highlightColor = style.background.toArgb() 48 | 49 | it.textSize = style.fontSize.value 50 | it.setTextColor(color.toArgb()) 51 | it.setBackgroundColor(style.background.toArgb()) 52 | it.text = HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_COMPACT) 53 | } 54 | ) 55 | } 56 | 57 | @Composable 58 | fun MarkdownText( 59 | text: String, 60 | modifier: Modifier = Modifier, 61 | style: TextStyle = LocalTextStyle.current, 62 | color: Color = LocalContentColor.current, 63 | ) { 64 | val context = LocalContext.current 65 | val markdown = Markwon.create(context) 66 | val linkTextColor = MaterialTheme.colorScheme.primary.toArgb() 67 | 68 | AndroidView( 69 | modifier = modifier, 70 | factory = { TextView(it) }, 71 | update = { 72 | it.setLinkTextColor(linkTextColor) 73 | it.highlightColor = style.background.toArgb() 74 | 75 | it.textSize = style.fontSize.value 76 | it.setTextColor(color.toArgb()) 77 | it.setBackgroundColor(style.background.toArgb()) 78 | markdown.setMarkdown(it, text) 79 | } 80 | ) 81 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/screens/settings/workingmode/WorkingModeItem.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.screens.settings.workingmode 2 | 3 | import androidx.compose.foundation.BorderStroke 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.requiredHeightIn 11 | import androidx.compose.foundation.layout.requiredWidth 12 | import androidx.compose.foundation.layout.size 13 | import androidx.compose.foundation.shape.RoundedCornerShape 14 | import androidx.compose.material3.Icon 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.material3.Surface 17 | import androidx.compose.material3.Text 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.res.painterResource 22 | import androidx.compose.ui.unit.dp 23 | import dev.sanmer.mrepo.R 24 | 25 | @Composable 26 | internal fun WorkingModeItem( 27 | title: String, 28 | desc: String, 29 | modifier: Modifier = Modifier, 30 | selected: Boolean = false, 31 | onClick: () -> Unit 32 | ) = Box( 33 | modifier = Modifier 34 | .requiredWidth(240.dp) 35 | .then(modifier) 36 | ) { 37 | Surface( 38 | onClick = onClick, 39 | tonalElevation = if (selected) 4.dp else 0.dp, 40 | border = BorderStroke(1.dp, color = MaterialTheme.colorScheme.outline), 41 | shape = RoundedCornerShape(15.dp) 42 | ) { 43 | Column( 44 | modifier = Modifier 45 | .padding(all = 16.dp) 46 | .requiredHeightIn(min = 120.dp) 47 | .fillMaxWidth() 48 | ) { 49 | Text( 50 | text = title, 51 | style = MaterialTheme.typography.titleMedium, 52 | color = MaterialTheme.colorScheme.primary 53 | ) 54 | 55 | Spacer(modifier = Modifier.height(10.dp)) 56 | 57 | Text( 58 | text = desc, 59 | style = MaterialTheme.typography.bodyMedium, 60 | color = MaterialTheme.colorScheme.onSurfaceVariant 61 | ) 62 | } 63 | } 64 | 65 | if (selected) { 66 | Box( 67 | modifier = Modifier 68 | .padding(top = 8.dp, end = 8.dp) 69 | .align(Alignment.TopEnd) 70 | ) { 71 | Icon( 72 | painter = painterResource(id = R.drawable.circle_check_filled), 73 | contentDescription = null, 74 | tint = MaterialTheme.colorScheme.primary, 75 | modifier = Modifier.size(30.dp) 76 | ) 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/component/scrollbar/ThumbExt.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Sanmer 3 | * Copyright 2023 The Android Open Source Project 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * https://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | package dev.sanmer.mrepo.ui.component.scrollbar 19 | 20 | import androidx.compose.foundation.lazy.LazyListState 21 | import androidx.compose.foundation.lazy.grid.LazyGridState 22 | import androidx.compose.runtime.Composable 23 | import androidx.compose.runtime.LaunchedEffect 24 | import androidx.compose.runtime.getValue 25 | import androidx.compose.runtime.mutableFloatStateOf 26 | import androidx.compose.runtime.remember 27 | import androidx.compose.runtime.rememberUpdatedState 28 | import androidx.compose.runtime.setValue 29 | 30 | /** 31 | * Remembers a function to react to [Scrollbar] thumb position displacements for a [LazyListState] 32 | * @param itemsAvailable the amount of items in the list. 33 | */ 34 | @Composable 35 | fun LazyListState.rememberDraggableScroller( 36 | itemsAvailable: Int = layoutInfo.totalItemsCount 37 | ): (Float) -> Unit = rememberDraggableScroller( 38 | itemsAvailable = itemsAvailable, 39 | scroll = ::scrollToItem 40 | ) 41 | 42 | /** 43 | * Remembers a function to react to [Scrollbar] thumb position displacements for a [LazyGridState] 44 | * @param itemsAvailable the amount of items in the grid. 45 | */ 46 | @Composable 47 | fun LazyGridState.rememberDraggableScroller( 48 | itemsAvailable: Int = layoutInfo.totalItemsCount 49 | ): (Float) -> Unit = rememberDraggableScroller( 50 | itemsAvailable = itemsAvailable, 51 | scroll = ::scrollToItem 52 | ) 53 | 54 | /** 55 | * Generic function to react to [Scrollbar] thumb displacements in a lazy layout. 56 | * @param itemsAvailable the total amount of items available to scroll in the layout. 57 | * @param scroll a function to be invoked when an index has been identified to scroll to. 58 | */ 59 | @Composable 60 | private inline fun rememberDraggableScroller( 61 | itemsAvailable: Int, 62 | crossinline scroll: suspend (index: Int) -> Unit 63 | ): (Float) -> Unit { 64 | var percentage by remember { mutableFloatStateOf(Float.NaN) } 65 | val itemCount by rememberUpdatedState(itemsAvailable) 66 | 67 | LaunchedEffect(percentage) { 68 | if (percentage.isNaN()) return@LaunchedEffect 69 | val indexToFind = (itemCount * percentage).toInt() 70 | scroll(indexToFind) 71 | } 72 | return remember { 73 | { newPercentage -> percentage = newPercentage } 74 | } 75 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/activity/InstallActivity.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.activity 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.os.Bundle 7 | import androidx.activity.ComponentActivity 8 | import androidx.activity.compose.setContent 9 | import androidx.activity.enableEdgeToEdge 10 | import androidx.activity.viewModels 11 | import androidx.compose.runtime.CompositionLocalProvider 12 | import androidx.compose.runtime.getValue 13 | import androidx.core.net.toUri 14 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 15 | import androidx.lifecycle.lifecycleScope 16 | import dagger.hilt.android.AndroidEntryPoint 17 | import dev.sanmer.mrepo.repository.UserPreferencesRepository 18 | import dev.sanmer.mrepo.ui.providable.LocalUserPreferences 19 | import dev.sanmer.mrepo.ui.theme.AppTheme 20 | import dev.sanmer.mrepo.utils.extensions.tmpDir 21 | import dev.sanmer.mrepo.viewmodel.InstallViewModel 22 | import kotlinx.coroutines.launch 23 | import timber.log.Timber 24 | import java.io.File 25 | import javax.inject.Inject 26 | 27 | @AndroidEntryPoint 28 | class InstallActivity : ComponentActivity() { 29 | @Inject lateinit var userPreferencesRepository: UserPreferencesRepository 30 | private val viewModel: InstallViewModel by viewModels() 31 | 32 | override fun onCreate(savedInstanceState: Bundle?) { 33 | Timber.d("onCreate") 34 | super.onCreate(savedInstanceState) 35 | enableEdgeToEdge() 36 | 37 | if (intent.data == null) { 38 | finish() 39 | } else { 40 | initModule(intent) 41 | } 42 | 43 | setContent { 44 | val userPreferences by userPreferencesRepository.data 45 | .collectAsStateWithLifecycle(initialValue = null) 46 | 47 | val preferences = if (userPreferences == null) { 48 | return@setContent 49 | } else { 50 | checkNotNull(userPreferences) 51 | } 52 | 53 | CompositionLocalProvider( 54 | LocalUserPreferences provides preferences 55 | ) { 56 | AppTheme( 57 | darkMode = preferences.isDarkMode(), 58 | themeColor = preferences.themeColor 59 | ) { 60 | InstallScreen() 61 | } 62 | } 63 | } 64 | } 65 | 66 | override fun onDestroy() { 67 | Timber.d("onDestroy") 68 | tmpDir.deleteRecursively() 69 | super.onDestroy() 70 | } 71 | 72 | private fun initModule(intent: Intent) { 73 | lifecycleScope.launch { 74 | viewModel.loadModule( 75 | context = applicationContext, 76 | uri = checkNotNull(intent.data) 77 | ) 78 | } 79 | } 80 | 81 | companion object { 82 | fun start(context: Context, uri: Uri) { 83 | val intent = Intent(context, InstallActivity::class.java) 84 | .apply { 85 | data = uri 86 | } 87 | 88 | context.startActivity(intent) 89 | } 90 | 91 | fun start(context: Context, file: File) = start(context, file.toUri()) 92 | } 93 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/screens/settings/items/RootItem.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.screens.settings.items 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.layout.size 10 | import androidx.compose.foundation.layout.width 11 | import androidx.compose.foundation.shape.RoundedCornerShape 12 | import androidx.compose.material3.Icon 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.material3.Surface 15 | import androidx.compose.material3.Text 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.res.painterResource 20 | import androidx.compose.ui.res.stringResource 21 | import androidx.compose.ui.unit.dp 22 | import dev.sanmer.mrepo.R 23 | 24 | @Composable 25 | internal fun RootItem( 26 | isAlive: Boolean, 27 | version: String 28 | ) = Surface( 29 | modifier = Modifier.padding(all = 18.dp), 30 | shape = RoundedCornerShape(15.dp), 31 | color = MaterialTheme.colorScheme.secondaryContainer 32 | ) { 33 | Row( 34 | modifier = Modifier 35 | .padding(all = 20.dp) 36 | .fillMaxWidth(), 37 | verticalAlignment = Alignment.CenterVertically 38 | ) { 39 | Icon( 40 | modifier = Modifier.size(30.dp), 41 | painter = painterResource(id = if (isAlive) { 42 | R.drawable.circle_check_filled 43 | } else { 44 | R.drawable.alert_circle_filled 45 | }), 46 | contentDescription = null, 47 | tint = MaterialTheme.colorScheme.primary 48 | ) 49 | 50 | Spacer(modifier = Modifier.width(16.dp)) 51 | Column( 52 | modifier = Modifier.fillMaxWidth(), 53 | verticalArrangement = Arrangement.spacedBy(4.dp) 54 | ) { 55 | Text( 56 | text = if (isAlive) { 57 | stringResource(id = R.string.settings_root_access, 58 | stringResource(id = R.string.settings_root_granted)) 59 | } else { 60 | stringResource(id = R.string.settings_root_access, 61 | stringResource(id = R.string.settings_root_none)) 62 | }, 63 | style = MaterialTheme.typography.titleMedium, 64 | color = MaterialTheme.colorScheme.onSecondaryContainer 65 | ) 66 | 67 | Text( 68 | text = if (isAlive) { 69 | stringResource(id = R.string.settings_root_provider, 70 | version) 71 | } else { 72 | stringResource(id = R.string.settings_root_provider, 73 | stringResource(id = R.string.settings_root_not_available)) 74 | }, 75 | style = MaterialTheme.typography.bodyMedium, 76 | color = MaterialTheme.colorScheme.onSecondaryContainer 77 | ) 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/screens/settings/repositories/RepositoriesList.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.screens.settings.repositories 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.lazy.LazyColumn 7 | import androidx.compose.foundation.lazy.LazyListState 8 | import androidx.compose.foundation.lazy.items 9 | import androidx.compose.foundation.shape.RoundedCornerShape 10 | import androidx.compose.material3.AlertDialog 11 | import androidx.compose.material3.Text 12 | import androidx.compose.material3.TextButton 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.getValue 15 | import androidx.compose.runtime.mutableStateOf 16 | import androidx.compose.runtime.remember 17 | import androidx.compose.runtime.setValue 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.res.stringResource 20 | import androidx.compose.ui.unit.dp 21 | import dev.sanmer.mrepo.R 22 | import dev.sanmer.mrepo.database.entity.online.RepoEntity 23 | 24 | @Composable 25 | internal fun RepositoriesList( 26 | list: List, 27 | state: LazyListState, 28 | insert: (RepoEntity) -> Unit, 29 | delete: (RepoEntity) -> Unit, 30 | update: (RepoEntity) -> Unit 31 | ) = LazyColumn( 32 | state = state, 33 | modifier = Modifier.fillMaxSize(), 34 | contentPadding = PaddingValues(10.dp), 35 | verticalArrangement = Arrangement.spacedBy(10.dp), 36 | ) { 37 | items( 38 | items = list, 39 | key = { it.url } 40 | ) { repo -> 41 | RepositoryItem( 42 | repo = repo, 43 | onToggle = { insert(it) }, 44 | onUpdate = update, 45 | onDelete = delete, 46 | ) 47 | } 48 | } 49 | 50 | @Composable 51 | private fun RepositoryItem( 52 | repo: RepoEntity, 53 | onToggle: (RepoEntity) -> Unit, 54 | onUpdate: (RepoEntity) -> Unit, 55 | onDelete: (RepoEntity) -> Unit, 56 | ) { 57 | var delete by remember { mutableStateOf(false) } 58 | if (delete) DeleteDialog( 59 | repo = repo, 60 | onClose = { delete = false }, 61 | onConfirm = { onDelete(repo) } 62 | ) 63 | 64 | RepositoryItem( 65 | repo = repo, 66 | toggle = { onToggle(repo.copy(disable = it)) }, 67 | onUpdate = { onUpdate(repo) }, 68 | onDelete = { delete = true } 69 | ) 70 | } 71 | 72 | @Composable 73 | private fun DeleteDialog( 74 | repo: RepoEntity, 75 | onClose: () -> Unit, 76 | onConfirm: () -> Unit 77 | ) = AlertDialog( 78 | shape = RoundedCornerShape(20.dp), 79 | onDismissRequest = onClose, 80 | title = { Text(text = repo.name) }, 81 | text = { Text(text = stringResource(id = R.string.repo_delete_dialog_desc)) }, 82 | confirmButton = { 83 | TextButton( 84 | onClick = { 85 | onConfirm() 86 | onClose() 87 | } 88 | ) { 89 | Text(text = stringResource(id = R.string.dialog_ok)) 90 | } 91 | }, 92 | dismissButton = { 93 | TextButton( 94 | onClick = onClose 95 | ) { 96 | Text(text = stringResource(id = R.string.dialog_cancel)) 97 | } 98 | } 99 | ) -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/viewmodel/RepositoriesViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.viewmodel 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import dev.sanmer.mrepo.database.entity.online.RepoEntity 10 | import dev.sanmer.mrepo.repository.LocalRepository 11 | import dev.sanmer.mrepo.repository.ModulesRepository 12 | import kotlinx.coroutines.flow.MutableStateFlow 13 | import kotlinx.coroutines.flow.asStateFlow 14 | import kotlinx.coroutines.flow.launchIn 15 | import kotlinx.coroutines.flow.onEach 16 | import kotlinx.coroutines.launch 17 | import timber.log.Timber 18 | import javax.inject.Inject 19 | 20 | @HiltViewModel 21 | class RepositoriesViewModel @Inject constructor( 22 | private val localRepository: LocalRepository, 23 | private val modulesRepository: ModulesRepository 24 | ) : ViewModel() { 25 | private val reposFlow = MutableStateFlow(listOf()) 26 | val repos get() = reposFlow.asStateFlow() 27 | 28 | var isLoading by mutableStateOf(true) 29 | private set 30 | var progress by mutableStateOf(false) 31 | private set 32 | private inline fun T.refreshing(callback: T.() -> Unit) { 33 | progress = true 34 | callback() 35 | progress = false 36 | } 37 | 38 | init { 39 | Timber.d("RepositoriesViewModel init") 40 | dataObserver() 41 | } 42 | 43 | private fun dataObserver() { 44 | localRepository.getRepoAllAsFlow() 45 | .onEach { list -> 46 | reposFlow.value = list.sortedBy { it.name } 47 | 48 | isLoading = false 49 | 50 | }.launchIn(viewModelScope) 51 | } 52 | 53 | fun insert( 54 | url: String, 55 | onFailure: (Throwable) -> Unit 56 | ) { 57 | val repoUrl = if (url.endsWith("/")) url else "${url}/" 58 | viewModelScope.launch { 59 | refreshing { 60 | modulesRepository.getRepo(repoUrl) 61 | .onFailure { 62 | Timber.e(it, "insert: $url") 63 | onFailure(it) 64 | } 65 | } 66 | } 67 | } 68 | 69 | fun insert(repo: RepoEntity) { 70 | viewModelScope.launch { 71 | localRepository.insertRepo(repo) 72 | } 73 | } 74 | 75 | fun delete(repo: RepoEntity) { 76 | viewModelScope.launch { 77 | localRepository.deleteRepo(repo) 78 | } 79 | } 80 | 81 | fun update( 82 | repo: RepoEntity, 83 | onFailure: (Throwable) -> Unit 84 | ) { 85 | viewModelScope.launch { 86 | refreshing { 87 | modulesRepository.getRepo(repo) 88 | .onFailure { 89 | Timber.e(it, "update: ${repo.url}") 90 | onFailure(it) 91 | } 92 | } 93 | } 94 | } 95 | 96 | fun getRepoAll() { 97 | viewModelScope.launch { 98 | refreshing { 99 | modulesRepository.getRepoAll(onlyEnable = false) 100 | } 101 | } 102 | } 103 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/database/dao/RepoDao.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.database.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import androidx.room.Transaction 8 | import dev.sanmer.mrepo.database.entity.online.OnlineModuleEntity 9 | import dev.sanmer.mrepo.database.entity.online.RepoEntity 10 | import dev.sanmer.mrepo.database.entity.online.VersionItemEntity 11 | import dev.sanmer.mrepo.model.online.ModulesJson 12 | import kotlinx.coroutines.flow.Flow 13 | 14 | @Dao 15 | interface RepoDao { 16 | @Insert(onConflict = OnConflictStrategy.REPLACE) 17 | suspend fun insertRepo(value: RepoEntity) 18 | 19 | @Insert(onConflict = OnConflictStrategy.REPLACE) 20 | suspend fun insertOnline(list: List) 21 | 22 | @Insert(onConflict = OnConflictStrategy.REPLACE) 23 | suspend fun insertVersion(list: List) 24 | 25 | @Query("DELETE FROM repo WHERE url = :url") 26 | suspend fun deleteRepoByUrl(url: String) 27 | 28 | @Query("DELETE FROM online WHERE repo_url = :repoUrl") 29 | suspend fun deleteOnlineByUrl(repoUrl: String) 30 | 31 | @Query("DELETE FROM online WHERE repo_url = :repoUrl AND id NOT IN (:list)") 32 | suspend fun deleteOnlineNotIn(repoUrl: String, list: List) 33 | 34 | @Query("DELETE FROM version WHERE repo_url = :repoUrl") 35 | suspend fun deleteVersionByUrl(repoUrl: String) 36 | 37 | @Query("DELETE FROM version WHERE repo_url = :repoUrl AND id NOT IN (:list)") 38 | suspend fun deleteVersionNotIn(repoUrl: String, list: List) 39 | 40 | @Transaction 41 | suspend fun updateRepo(repo: RepoEntity, modulesJson: ModulesJson) { 42 | val moduleIds = modulesJson.modules.map { it.id } 43 | 44 | insertRepo(repo.copy(modulesJson)) 45 | 46 | deleteOnlineNotIn(repo.url, moduleIds) 47 | insertOnline( 48 | modulesJson.modules.map { 49 | OnlineModuleEntity(repo.url, it) 50 | } 51 | ) 52 | 53 | deleteVersionNotIn(repo.url, moduleIds) 54 | insertVersion( 55 | modulesJson.modules.map { module -> 56 | module.versions.map { 57 | VersionItemEntity(repo.url, module.id, it) 58 | } 59 | }.flatten() 60 | ) 61 | } 62 | 63 | @Transaction 64 | suspend fun deleteRepo(url: String) { 65 | deleteRepoByUrl(url) 66 | deleteOnlineByUrl(url) 67 | deleteVersionByUrl(url) 68 | } 69 | 70 | @Query("SELECT * FROM repo") 71 | fun getRepoAllAsFlow(): Flow> 72 | 73 | @Query("SELECT * FROM repo") 74 | suspend fun getRepoAll(): List 75 | 76 | @Query( 77 | "SELECT online.*, version.* FROM repo " + 78 | "INNER JOIN online ON online.repo_url = repo.url " + 79 | "INNER JOIN version ON version.id = online.id " + 80 | "WHERE repo.disable = 0 " 81 | ) 82 | fun getOnlineAndVersionAllAsFlow(): Flow>> 83 | 84 | @Query("SELECT * FROM online WHERE id = :id") 85 | suspend fun getOnlineById(id: String): List 86 | 87 | @Query( 88 | "SELECT * FROM repo " + 89 | "INNER JOIN version ON version.repo_url = repo.url " + 90 | "WHERE repo.disable = 0 AND version.id = :id " 91 | ) 92 | suspend fun getVersionAndRepoById(id: String): Map> 93 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/screens/repository/view/pages/AboutPage.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.screens.repository.view.pages 2 | 3 | import androidx.annotation.DrawableRes 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.size 12 | import androidx.compose.foundation.rememberScrollState 13 | import androidx.compose.foundation.verticalScroll 14 | import androidx.compose.material3.AssistChipDefaults 15 | import androidx.compose.material3.ElevatedAssistChip 16 | import androidx.compose.material3.HorizontalDivider 17 | import androidx.compose.material3.Icon 18 | import androidx.compose.material3.MaterialTheme 19 | import androidx.compose.material3.Text 20 | import androidx.compose.runtime.Composable 21 | import androidx.compose.ui.Alignment 22 | import androidx.compose.ui.Modifier 23 | import androidx.compose.ui.platform.LocalContext 24 | import androidx.compose.ui.res.painterResource 25 | import androidx.compose.ui.res.stringResource 26 | import androidx.compose.ui.unit.dp 27 | import dev.sanmer.mrepo.R 28 | import dev.sanmer.mrepo.model.online.OnlineModule 29 | import dev.sanmer.mrepo.utils.extensions.openUrl 30 | 31 | @Composable 32 | internal fun AboutPage( 33 | online: OnlineModule 34 | ) = Column( 35 | modifier = Modifier 36 | .fillMaxSize() 37 | .verticalScroll(rememberScrollState()) 38 | ) { 39 | ValueItem( 40 | key = stringResource(id = R.string.view_module_homepage), 41 | value = online.metadata.homepage, 42 | icon = R.drawable.home 43 | ) 44 | 45 | ValueItem( 46 | key = stringResource(id = R.string.view_module_source), 47 | value = online.metadata.source, 48 | icon = R.drawable.brand_git 49 | ) 50 | 51 | ValueItem( 52 | key = stringResource(id = R.string.view_module_support), 53 | value = online.metadata.support, 54 | icon = R.drawable.heart_handshake 55 | ) 56 | } 57 | 58 | @Composable 59 | private fun ValueItem( 60 | key: String, 61 | value: String, 62 | @DrawableRes icon: Int 63 | ) { 64 | if (value.isBlank()) return 65 | val context = LocalContext.current 66 | 67 | Row( 68 | modifier = Modifier 69 | .clickable { context.openUrl(value) } 70 | .padding(horizontal = 16.dp, vertical = 8.dp) 71 | .fillMaxWidth(), 72 | verticalAlignment = Alignment.CenterVertically, 73 | horizontalArrangement = Arrangement.spacedBy(16.dp) 74 | ) { 75 | Text( 76 | text = value, 77 | style = MaterialTheme.typography.bodyMedium, 78 | color = MaterialTheme.colorScheme.onSurfaceVariant, 79 | modifier = Modifier.weight(1f) 80 | ) 81 | 82 | ElevatedAssistChip( 83 | onClick = { context.openUrl(value) }, 84 | label = { Text(text = key) }, 85 | leadingIcon = { 86 | Icon( 87 | painter = painterResource(id = icon), 88 | contentDescription = null, 89 | modifier = Modifier.size(AssistChipDefaults.IconSize) 90 | ) 91 | } 92 | ) 93 | } 94 | 95 | HorizontalDivider(thickness = 0.9.dp) 96 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/screens/settings/app/items/AppThemeItem.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.screens.settings.app.items 2 | 3 | import androidx.compose.foundation.layout.WindowInsets 4 | import androidx.compose.foundation.layout.navigationBars 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.BottomSheetDefaults 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.ModalBottomSheet 9 | import androidx.compose.material3.Text 10 | import androidx.compose.material3.rememberModalBottomSheetState 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.getValue 13 | import androidx.compose.runtime.mutableStateOf 14 | import androidx.compose.runtime.remember 15 | import androidx.compose.runtime.setValue 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.res.stringResource 19 | import androidx.compose.ui.unit.dp 20 | import dev.sanmer.mrepo.R 21 | import dev.sanmer.mrepo.datastore.model.DarkMode 22 | import dev.sanmer.mrepo.ui.component.SettingNormalItem 23 | import dev.sanmer.mrepo.ui.utils.expandedShape 24 | 25 | @Composable 26 | internal fun AppThemeItem( 27 | themeColor: Int, 28 | darkMode: DarkMode, 29 | isDarkMode: Boolean, 30 | onThemeColorChange: (Int) -> Unit, 31 | onDarkModeChange: (DarkMode) -> Unit 32 | ) { 33 | var open by remember { mutableStateOf(false) } 34 | if (open) { 35 | BottomSheet( 36 | onClose = { open = false }, 37 | themeColor = themeColor, 38 | darkMode = darkMode, 39 | isDarkMode = isDarkMode, 40 | onThemeColorChange = onThemeColorChange, 41 | onDarkModeChange = onDarkModeChange 42 | ) 43 | } 44 | 45 | SettingNormalItem( 46 | icon = R.drawable.color_swatch, 47 | title = stringResource(id = R.string.settings_app_theme), 48 | desc = stringResource(id = R.string.settings_app_theme_desc), 49 | onClick = { open = true } 50 | ) 51 | } 52 | 53 | @Composable 54 | private fun BottomSheet( 55 | onClose: () -> Unit, 56 | themeColor: Int, 57 | darkMode: DarkMode, 58 | isDarkMode: Boolean, 59 | onThemeColorChange: (Int) -> Unit, 60 | onDarkModeChange: (DarkMode) -> Unit, 61 | ) = ModalBottomSheet( 62 | onDismissRequest = onClose, 63 | sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), 64 | shape = BottomSheetDefaults.expandedShape(15.dp), 65 | windowInsets = WindowInsets.navigationBars, 66 | containerColor = MaterialTheme.colorScheme.surface, 67 | tonalElevation = 0.dp 68 | ) { 69 | Text( 70 | text = stringResource(id = R.string.settings_app_theme), 71 | style = MaterialTheme.typography.headlineSmall, 72 | modifier = Modifier.align(Alignment.CenterHorizontally) 73 | ) 74 | 75 | TitleItem(text = stringResource(id = R.string.app_theme_palette)) 76 | ThemePaletteItem( 77 | themeColor = themeColor, 78 | isDarkMode = isDarkMode, 79 | onChange = onThemeColorChange 80 | ) 81 | 82 | TitleItem(text = stringResource(id = R.string.app_theme_dark_theme)) 83 | DarkModeItem( 84 | darkMode = darkMode, 85 | onChange = onDarkModeChange 86 | ) 87 | } 88 | 89 | @Composable 90 | private fun TitleItem( 91 | text: String 92 | ) = Text( 93 | text = text, 94 | style = MaterialTheme.typography.titleSmall, 95 | modifier = Modifier.padding(start = 18.dp, top = 18.dp) 96 | ) -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/screens/repository/view/ViewScreen.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.screens.repository.view 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.WindowInsets 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.pager.HorizontalPager 8 | import androidx.compose.foundation.pager.rememberPagerState 9 | import androidx.compose.material3.Scaffold 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.input.nestedscroll.nestedScroll 13 | import androidx.compose.ui.unit.dp 14 | import androidx.hilt.navigation.compose.hiltViewModel 15 | import androidx.navigation.NavController 16 | import dev.sanmer.mrepo.ui.component.CollapsingTopAppBarDefaults 17 | import dev.sanmer.mrepo.ui.screens.repository.view.pages.AboutPage 18 | import dev.sanmer.mrepo.ui.screens.repository.view.pages.OverviewPage 19 | import dev.sanmer.mrepo.ui.screens.repository.view.pages.VersionsPage 20 | import dev.sanmer.mrepo.viewmodel.ModuleViewModel 21 | 22 | @Composable 23 | fun ViewScreen( 24 | navController: NavController, 25 | viewModel: ModuleViewModel = hiltViewModel() 26 | ) { 27 | val scrollBehavior = CollapsingTopAppBarDefaults.scrollBehavior() 28 | val pagerState = rememberPagerState { if (viewModel.isEmptyAbout) 2 else 3 } 29 | 30 | Scaffold( 31 | modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), 32 | topBar = { 33 | ViewTopBar( 34 | online = viewModel.online, 35 | scrollBehavior = scrollBehavior, 36 | navController = navController 37 | ) 38 | }, 39 | contentWindowInsets = WindowInsets(0.dp) 40 | ) { innerPadding -> 41 | Column( 42 | modifier = Modifier.padding(innerPadding) 43 | ) { 44 | ViewTab( 45 | state = pagerState, 46 | updatableSize = viewModel.updatableSize, 47 | hasAbout = !viewModel.isEmptyAbout 48 | ) 49 | 50 | HorizontalPager( 51 | state = pagerState, 52 | modifier = Modifier.fillMaxSize() 53 | ) { pageIndex -> 54 | when (pageIndex) { 55 | 0 -> OverviewPage( 56 | online = viewModel.online, 57 | item = viewModel.lastVersionItem, 58 | local = viewModel.local, 59 | updatable = viewModel.isUpdatable, 60 | setUpdatable = viewModel::insertUpdatable, 61 | isProviderAlive = viewModel.isProviderAlive, 62 | onInstall = { context, item -> 63 | viewModel.downloader(context, item, true) 64 | } 65 | ) 66 | 1 -> VersionsPage( 67 | versions = viewModel.versions, 68 | localVersionCode = viewModel.localVersionCode, 69 | isProviderAlive = viewModel.isProviderAlive, 70 | getProgress = viewModel::getProgress, 71 | onDownload = viewModel::downloader 72 | ) 73 | 2 -> AboutPage( 74 | online = viewModel.online 75 | ) 76 | } 77 | } 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/weblate.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | 17 | 18 | 19 | 20 | 21 | 22 | 27 | 28 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 43 | 44 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/mrepo/ui/screens/settings/workingmode/WorkingModeScreen.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.mrepo.ui.screens.settings.workingmode 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.WindowInsets 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.rememberScrollState 9 | import androidx.compose.foundation.verticalScroll 10 | import androidx.compose.material3.Scaffold 11 | import androidx.compose.material3.TopAppBarDefaults 12 | import androidx.compose.material3.TopAppBarScrollBehavior 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.input.nestedscroll.nestedScroll 17 | import androidx.compose.ui.res.stringResource 18 | import androidx.compose.ui.unit.dp 19 | import androidx.hilt.navigation.compose.hiltViewModel 20 | import androidx.navigation.NavController 21 | import dev.sanmer.mrepo.R 22 | import dev.sanmer.mrepo.datastore.model.WorkingMode 23 | import dev.sanmer.mrepo.ui.component.NavigateUpTopBar 24 | import dev.sanmer.mrepo.ui.providable.LocalUserPreferences 25 | import dev.sanmer.mrepo.viewmodel.SettingsViewModel 26 | 27 | @Composable 28 | fun WorkingModeScreen( 29 | navController: NavController, 30 | viewModel: SettingsViewModel = hiltViewModel() 31 | ) { 32 | val userPreferences = LocalUserPreferences.current 33 | val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() 34 | 35 | Scaffold( 36 | modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), 37 | topBar = { 38 | TopBar( 39 | scrollBehavior = scrollBehavior, 40 | navController = navController 41 | ) 42 | }, 43 | contentWindowInsets = WindowInsets(0.dp) 44 | ) { innerPadding -> 45 | Column( 46 | modifier = Modifier 47 | .padding(innerPadding) 48 | .fillMaxSize() 49 | .verticalScroll(rememberScrollState()) 50 | .padding(vertical = 20.dp), 51 | verticalArrangement = Arrangement.spacedBy(20.dp), 52 | horizontalAlignment = Alignment.CenterHorizontally 53 | ) { 54 | WorkingModeItem( 55 | title = stringResource(id = R.string.setup_root_title), 56 | desc = stringResource(id = R.string.setup_root_desc), 57 | selected = userPreferences.workingMode == WorkingMode.Superuser, 58 | onClick = { viewModel.setWorkingMode(WorkingMode.Superuser) } 59 | ) 60 | 61 | WorkingModeItem( 62 | title = stringResource(id = R.string.setup_shizuku_title), 63 | desc = stringResource(id = R.string.setup_shizuku_desc), 64 | selected = userPreferences.workingMode == WorkingMode.Shizuku, 65 | onClick = { viewModel.setWorkingMode(WorkingMode.Shizuku) } 66 | ) 67 | 68 | WorkingModeItem( 69 | title = stringResource(id = R.string.setup_non_root_title), 70 | desc = stringResource(id = R.string.setup_non_root_desc), 71 | selected = userPreferences.workingMode == WorkingMode.None, 72 | onClick = { viewModel.setWorkingMode(WorkingMode.None) } 73 | ) 74 | } 75 | } 76 | } 77 | 78 | @Composable 79 | private fun TopBar( 80 | scrollBehavior: TopAppBarScrollBehavior, 81 | navController: NavController 82 | ) = NavigateUpTopBar( 83 | title = stringResource(id = R.string.setup_mode), 84 | scrollBehavior = scrollBehavior, 85 | navController = navController 86 | ) --------------------------------------------------------------------------------