From d775395efd38761eb12a0f3855fba287680ea820 Mon Sep 17 00:00:00 2001 From: Hisham Aburaqibah Date: Thu, 30 Apr 2026 19:49:10 +0200 Subject: [PATCH] WIP downloading --- .gitignore | 2 - TODO.md | 21 + app/build.gradle.kts | 92 +- .../1.json | 70 + .../2.json | 78 + app/src/main/AndroidManifest.xml | 21 +- .../illusionware/npsbrowser/MainActivity.kt | 89 +- .../com/illusionware/npsbrowser/NPSApp.kt | 7 + .../com/illusionware/npsbrowser/Routes.kt | 9 +- .../data/SettingsPreferencesRepository.kt | 2 +- .../data/download/DownloadManager.kt | 89 + .../data/download/DownloadNotifier.kt | 122 + .../data/download/DownloadRepository.kt | 24 + .../data/download/DownloadService.kt | 130 + .../npsbrowser/data/download/DownloadStore.kt | 159 + .../npsbrowser/data/download/Downloader.kt | 466 +++ .../npsbrowser/data/network/NetworkHelper.kt | 25 + .../data/network/OkHttpExtensions.kt | 131 + .../data/network/ProgressListener.kt | 5 + .../data/network/ProgressResponseBody.kt | 48 + .../npsbrowser/data/network/Requests.kt | 31 + .../data/notification/Notifications.kt | 32 + .../npsbrowser/model/PackageItem.kt | 1 + .../ui/components/NPSAlertDialog.kt | 5 +- .../npsbrowser/ui/components/NPSIconButton.kt | 71 +- .../npsbrowser/ui/screens/Downloads.kt | 269 ++ .../npsbrowser/ui/screens/Onboarding.kt | 45 +- .../npsbrowser/ui/screens/PackageDetails.kt | 121 +- .../npsbrowser/ui/screens/Settings.kt | 7 +- .../illusionware/npsbrowser/ui/theme/Color.kt | 1 + .../illusionware/npsbrowser/ui/theme/Theme.kt | 11 +- .../npsbrowser/util/DisplayExtensions.kt | 10 - .../util/system/ContextExtensions.kt | 29 + .../util/system/CoroutinesExtensions.kt | 7 + .../util/system/DisplayExtensions.kt | 32 + .../{ => system}/InternalResourceHelper.kt | 2 +- .../util/system/NetworkExtensions.kt | 40 + .../util/system/NotificationExtensions.kt | 60 + .../npsbrowser/util/system/OkioExtensions.kt | 62 + .../viewmodels/OnboardingViewModel.kt | 5 + .../viewmodels/PackageDetailsViewModel.kt | 21 +- .../npsbrowser/viewmodels/PackageListData.kt | 31 +- .../drawable/baseline_cloud_download_24.xml | 5 + .../main/res/layout/single_app_fragment.xml | 213 - app/src/main/res/values-zh-rCN/strings.xml | 16 +- app/src/main/res/values-zh-rTW/strings.xml | 16 +- app/src/main/res/values/strings.xml | 16 +- .../main/res/xml/network_security_config.xml | 7 + build.gradle.kts | 12 +- changes.patch | 3699 +++++++++++++++++ gradle.properties | 1 - gradle/libs.versions.toml | 51 + gradle/wrapper/gradle-wrapper.properties | 2 +- 53 files changed, 6075 insertions(+), 446 deletions(-) create mode 100644 TODO.md create mode 100644 app/schemas/com.illusionware.npsbrowser.data.download.DownloadStore/1.json create mode 100644 app/schemas/com.illusionware.npsbrowser.data.download.DownloadStore/2.json create mode 100644 app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadManager.kt create mode 100644 app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadNotifier.kt create mode 100644 app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadRepository.kt create mode 100644 app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadService.kt create mode 100644 app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadStore.kt create mode 100644 app/src/main/java/com/illusionware/npsbrowser/data/download/Downloader.kt create mode 100644 app/src/main/java/com/illusionware/npsbrowser/data/network/NetworkHelper.kt create mode 100644 app/src/main/java/com/illusionware/npsbrowser/data/network/OkHttpExtensions.kt create mode 100644 app/src/main/java/com/illusionware/npsbrowser/data/network/ProgressListener.kt create mode 100644 app/src/main/java/com/illusionware/npsbrowser/data/network/ProgressResponseBody.kt create mode 100644 app/src/main/java/com/illusionware/npsbrowser/data/network/Requests.kt create mode 100644 app/src/main/java/com/illusionware/npsbrowser/data/notification/Notifications.kt create mode 100644 app/src/main/java/com/illusionware/npsbrowser/ui/screens/Downloads.kt delete mode 100644 app/src/main/java/com/illusionware/npsbrowser/util/DisplayExtensions.kt create mode 100644 app/src/main/java/com/illusionware/npsbrowser/util/system/ContextExtensions.kt create mode 100644 app/src/main/java/com/illusionware/npsbrowser/util/system/CoroutinesExtensions.kt create mode 100644 app/src/main/java/com/illusionware/npsbrowser/util/system/DisplayExtensions.kt rename app/src/main/java/com/illusionware/npsbrowser/util/{ => system}/InternalResourceHelper.kt (94%) create mode 100644 app/src/main/java/com/illusionware/npsbrowser/util/system/NetworkExtensions.kt create mode 100644 app/src/main/java/com/illusionware/npsbrowser/util/system/NotificationExtensions.kt create mode 100644 app/src/main/java/com/illusionware/npsbrowser/util/system/OkioExtensions.kt create mode 100644 app/src/main/res/drawable/baseline_cloud_download_24.xml delete mode 100644 app/src/main/res/layout/single_app_fragment.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 changes.patch create mode 100644 gradle/libs.versions.toml diff --git a/.gitignore b/.gitignore index db8ce29..dc9c670 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -*.iml .gradle /local.properties /.idea/caches @@ -7,7 +6,6 @@ /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml -.DS_Store /build /captures .externalNativeBuild diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..8b897cf --- /dev/null +++ b/TODO.md @@ -0,0 +1,21 @@ +Download Manager :- + - Working on :- + - Trying to get OkHttp requests to cancel + + - Key :- + - Show progress on the UI + - Sync download notification with UI + + - Other fixes for the notification + + - Nice To Haves :- + - Show error message in the UI instead of a generic "Failed" + - Simultaneous downloading + - Reordering of queue + + - Testing :- + - Pausing (UI "works" but download doesn't pause) + + - Bugs :- + - Some packages REFUSE to download for some reason and fail with an IO FILE EXISTS (or doesn't?) error + - Notification is persistent even after completion and cancellation (I think, needs testing) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dd9cc31..297e63d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,9 +1,13 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.util.Properties import java.io.FileInputStream plugins { id("com.android.application") kotlin("android") + kotlin("kapt") + id("com.google.devtools.ksp") version "2.2.0-2.0.2" + alias(libs.plugins.compose.compiler) } val keystoreProperties = Properties() @@ -14,12 +18,14 @@ if (keystorePropertiesFile.exists()) { android { namespace = "com.illusionware.npsbrowser" - compileSdk = 33 + compileSdk = 36 defaultConfig { applicationId = "com.illusionware.npsbrowser" + // TODO: Network Security Config needs 24+ API level, don't know if I want to raise the min + // just for that minSdk = 23 - targetSdk = 33 + targetSdk = 36 versionCode = 1 versionName = "0.1.0" @@ -27,6 +33,14 @@ android { vectorDrawables { useSupportLibrary = true } + + ksp { + arg("room.schemaLocation", "$projectDir/schemas") + } + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.2" } buildFeatures { @@ -60,12 +74,12 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - composeOptions { - kotlinCompilerExtensionVersion = "1.4.8" - } - buildToolsVersion = "33.0.1" - kotlinOptions { - jvmTarget = "17" + buildToolsVersion = "36.0.0" + + kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } } } @@ -75,43 +89,43 @@ repositories { } dependencies { - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1") - implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.1") - implementation("androidx.activity:activity-compose:1.7.2") - implementation(platform("androidx.compose:compose-bom:2022.10.00")) - implementation("androidx.compose.ui:ui") - implementation("androidx.compose.ui:ui-graphics") - implementation("androidx.core:core-ktx:1.10.1") - androidTestImplementation(platform("androidx.compose:compose-bom:2022.10.00")) - androidTestImplementation("androidx.compose.ui:ui-test-junit4") + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.core.ktx) // Jetpack Compose - val composeBom = platform("androidx.compose:compose-bom:2023.04.01") - implementation(composeBom) - androidTestImplementation(composeBom) + implementation(platform(libs.androidx.compose.bom)) + + // Room database + implementation(libs.androidx.room.runtime) + // To use Kotlin Symbol Processing (KSP) + ksp(libs.androidx.room.compiler) + + // optional - Kotlin Extensions and Coroutines support for Room + implementation(libs.androidx.room.ktx) + // Material 3 - implementation("androidx.compose.material3:material3:1.1.1") - implementation("androidx.compose.material:material-icons-extended:1.4.3") + implementation(libs.androidx.material3) + implementation(libs.androidx.material.icons.extended) // Android Studio Preview support - implementation("androidx.compose.ui:ui-tooling-preview") - debugImplementation("androidx.compose.ui:ui-tooling") + implementation(libs.androidx.ui.tooling.preview) + debugImplementation(libs.androidx.ui.tooling) // Preferences DataStore (alternative to SharedPreferences) - implementation("androidx.datastore:datastore-preferences:1.0.0") + implementation(libs.androidx.datastore.preferences) - // Compose extras library for controlling system ui and etc. - implementation("com.google.accompanist:accompanist-systemuicontroller:0.31.2-alpha") + implementation(libs.androidx.core.splashscreen) + implementation(libs.androidx.navigation.compose) - implementation("com.github.IllusionMan1212:kotlin-csv:cedb7c5ab2") - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") - implementation("com.google.android.material:material:1.9.0") - implementation("com.github.bumptech.glide:glide:4.14.2") - annotationProcessor("com.github.bumptech.glide:compiler:4.14.2") - debugImplementation("androidx.compose.ui:ui-test-manifest") - implementation("androidx.core:core-splashscreen:1.0.1") - implementation("androidx.navigation:navigation-compose:2.6.0") - implementation("io.coil-kt:coil:2.4.0") - implementation("io.coil-kt:coil-compose:2.4.0") + implementation(libs.coil) + implementation(libs.coil.compose) + implementation(libs.rxjava) + implementation(libs.okio) + implementation(libs.flowreactivenetwork) + + implementation(libs.kotlin.csv) + implementation(libs.material) } diff --git a/app/schemas/com.illusionware.npsbrowser.data.download.DownloadStore/1.json b/app/schemas/com.illusionware.npsbrowser.data.download.DownloadStore/1.json new file mode 100644 index 0000000..26f8bd9 --- /dev/null +++ b/app/schemas/com.illusionware.npsbrowser.data.download.DownloadStore/1.json @@ -0,0 +1,70 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "1560ee26782336728f9f46d05e39072d", + "entities": [ + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `titleId` TEXT NOT NULL, `name` TEXT NOT NULL, `sha256` TEXT, `size` INTEGER NOT NULL, `bytesDownloaded` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "titleId", + "columnName": "titleId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sha256", + "columnName": "sha256", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bytesDownloaded", + "columnName": "bytesDownloaded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "url" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1560ee26782336728f9f46d05e39072d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.illusionware.npsbrowser.data.download.DownloadStore/2.json b/app/schemas/com.illusionware.npsbrowser.data.download.DownloadStore/2.json new file mode 100644 index 0000000..f9c4631 --- /dev/null +++ b/app/schemas/com.illusionware.npsbrowser.data.download.DownloadStore/2.json @@ -0,0 +1,78 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "006b9f2d98a94abb01489e5a0c1837aa", + "entities": [ + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `titleId` TEXT NOT NULL, `name` TEXT NOT NULL, `sha256` TEXT, `size` INTEGER NOT NULL, `bytesDownloaded` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `statusValue` INTEGER NOT NULL DEFAULT 0, `savedFileName` TEXT, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "titleId", + "columnName": "titleId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sha256", + "columnName": "sha256", + "affinity": "TEXT" + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bytesDownloaded", + "columnName": "bytesDownloaded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusValue", + "columnName": "statusValue", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "savedFileName", + "columnName": "savedFileName", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "url" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '006b9f2d98a94abb01489e5a0c1837aa')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 00ebfda..57bfcd6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,9 +2,17 @@ - - + + + + + + + + + + + android:networkSecurityConfig="@xml/network_security_config" + > + + \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/MainActivity.kt b/app/src/main/java/com/illusionware/npsbrowser/MainActivity.kt index a95a908..f9adf81 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/MainActivity.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/MainActivity.kt @@ -3,6 +3,10 @@ package com.illusionware.npsbrowser import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme @@ -26,13 +30,14 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ViewList import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Downloading import androidx.compose.material.icons.filled.GridView import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.ViewList +import androidx.compose.material3.Badge import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -47,7 +52,6 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar -import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -84,20 +88,21 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import coil.compose.AsyncImage import coil.request.ImageRequest -import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.illusionware.npsbrowser.data.ItemLayout import com.illusionware.npsbrowser.data.SettingsPreferences import com.illusionware.npsbrowser.model.ConsoleType import com.illusionware.npsbrowser.model.PackageItem import com.illusionware.npsbrowser.ui.components.* +import com.illusionware.npsbrowser.ui.screens.Downloads import com.illusionware.npsbrowser.ui.screens.OnBoarding import com.illusionware.npsbrowser.ui.screens.PackageDetails import com.illusionware.npsbrowser.ui.screens.SettingsScreen +import com.illusionware.npsbrowser.ui.screens.readexProFamily import com.illusionware.npsbrowser.ui.theme.ColorAccent import com.illusionware.npsbrowser.ui.theme.ColorOnPrimaryLight +import com.illusionware.npsbrowser.ui.theme.ColorTag import com.illusionware.npsbrowser.ui.theme.NPSBrowserTheme import com.illusionware.npsbrowser.ui.theme.Typography -import com.illusionware.npsbrowser.util.isNavigationBarNeedsScrim import com.illusionware.npsbrowser.viewmodels.OnboardingViewModel import com.illusionware.npsbrowser.viewmodels.PackageDetailsViewModel import com.illusionware.npsbrowser.viewmodels.PackageListViewModel @@ -112,8 +117,9 @@ val redHatDisplayFamily = FontFamily( class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() - + enableEdgeToEdge() super.onCreate(savedInstanceState) + setContent { val settingsViewModel: SettingsViewModel = viewModel( factory = SettingsViewModel.Factory @@ -121,13 +127,19 @@ class MainActivity : ComponentActivity() { val onboardingViewModel: OnboardingViewModel = viewModel( factory = OnboardingViewModel.Factory ) - val packageDetailsViewModel: PackageDetailsViewModel = viewModel() + val packageDetailsViewModel: PackageDetailsViewModel = viewModel( + factory = PackageDetailsViewModel.Factory + ) - val onboardingPrefs = onboardingViewModel.uiState.collectAsStateWithLifecycle().value val appTheme = runBlocking { settingsViewModel.getAppTheme() } + var seenOnboarding by remember { mutableStateOf(false) } val settingsPrefs = settingsViewModel.uiState.collectAsStateWithLifecycle().value val navController = rememberNavController() + runBlocking { + seenOnboarding = onboardingViewModel.getOnboardingStatus() + } + val darkTheme = when (appTheme) { 0 -> false 1 -> true @@ -137,11 +149,17 @@ class MainActivity : ComponentActivity() { NPSBrowserTheme(darkTheme = darkTheme) { Surface( modifier = Modifier.fillMaxSize(), - color = if (onboardingPrefs.seenOnboarding) MaterialTheme.colorScheme.background else ColorAccent + color = if (seenOnboarding) MaterialTheme.colorScheme.background else ColorAccent ) { NavHost( navController = navController, - startDestination = if (onboardingPrefs.seenOnboarding) Routes.Home.route else Routes.Onboarding.route, + startDestination = if (seenOnboarding) Routes.Home.route else Routes.Onboarding.route, + enterTransition = { + fadeIn(animationSpec = tween(300)) + }, + exitTransition = { + fadeOut(animationSpec = tween(300)) + }, ) { composable(Routes.Home.route) { HomePage( @@ -152,6 +170,10 @@ class MainActivity : ComponentActivity() { packageDetailsViewModel.setSelectedPackage(item) navController.navigate(Routes.PackageDetails.route) }, + navigateToDownloads = { + navController.navigate(Routes.Downloads.route) + }, + packageDetailsViewModel = packageDetailsViewModel, ) } composable(Routes.Settings.route) { @@ -174,6 +196,12 @@ class MainActivity : ComponentActivity() { packageDetailsViewModel = packageDetailsViewModel, ) } + composable(Routes.Downloads.route) { + Downloads( + navigationGoBack = { navController.popBackStack() }, + packageDetailsViewModel = packageDetailsViewModel, + ) + } } } } @@ -186,32 +214,20 @@ class MainActivity : ComponentActivity() { fun HomePage( navigateToSettings: () -> Unit, navigateToDetailsScreen: (item: PackageItem) -> Unit, + navigateToDownloads: () -> Unit, settingsPrefs: SettingsPreferences, settingsViewModel: SettingsViewModel, packageListViewModel: PackageListViewModel = viewModel( factory = PackageListViewModel.Factory ), + packageDetailsViewModel: PackageDetailsViewModel ) { val searchBar = remember { FocusRequester() } var layoutDialogOpen by remember { mutableStateOf(false) } - val context = LocalContext.current - val systemUiController = rememberSystemUiController() - val navbarScrimColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) - val uiState = packageListViewModel.uiState.collectAsStateWithLifecycle().value val tsvs = settingsViewModel.availableTsvs() var selectedBarItem by rememberSaveable { mutableStateOf(ConsoleType.PSVITA) } - LaunchedEffect(systemUiController) { - systemUiController.setNavigationBarColor( - color = if (context.isNavigationBarNeedsScrim()) { - navbarScrimColor.copy(alpha = 0.7f) - } else { - Color.Transparent - } - ) - } - Scaffold( topBar = { TopAppBar( @@ -228,7 +244,7 @@ fun HomePage( packageListViewModel.closeSearch() } ){ - Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = "Close Search") + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close Search") } Spacer(Modifier.width(12.dp)) TextField( @@ -283,7 +299,7 @@ fun HomePage( onClick = { layoutDialogOpen = true } ) { Icon( - imageVector = if (settingsPrefs.layout == ItemLayout.GRID.ordinal) Icons.Filled.GridView else Icons.Filled.ViewList, + imageVector = if (settingsPrefs.layout == ItemLayout.GRID.ordinal) Icons.Filled.GridView else Icons.AutoMirrored.Filled.ViewList, contentDescription = stringResource( id = R.string.layout ) @@ -291,14 +307,29 @@ fun HomePage( } NPSIconButton( tooltip = "Download queue", - onClick = { /*TODO*/ } + onClick = navigateToDownloads, + badge = { + val count = packageDetailsViewModel.downloadManager.queueState.collectAsStateWithLifecycle().value.count() + if (count > 0) { + Badge( + containerColor = ColorTag, + contentColor = Color.Black, + ) { + Text( + text = count.toString(), + fontWeight = FontWeight.Bold, + fontFamily = readexProFamily + ) + } + } + } ) { Icon(imageVector = Icons.Filled.Downloading, contentDescription = "Download queue") } NPSIconButton( tooltip = stringResource(id = R.string.title_activity_settings), onClick = { navigateToSettings() } - ){ + ) { Icon( imageVector = Icons.Filled.Settings, contentDescription = stringResource( @@ -610,7 +641,7 @@ fun Tag(title: String, small: Boolean = false) { Box( Modifier .clip(RoundedCornerShape(4.dp)) - .background(Color(0xFFEAF654)) + .background(ColorTag) .layoutId("tag") .padding(horizontal = if (small) 6.dp else 8.dp, vertical = if (small) 2.dp else 4.dp) ) { diff --git a/app/src/main/java/com/illusionware/npsbrowser/NPSApp.kt b/app/src/main/java/com/illusionware/npsbrowser/NPSApp.kt index 0bbf593..7713271 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/NPSApp.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/NPSApp.kt @@ -7,6 +7,8 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore import com.illusionware.npsbrowser.data.OnboardingPreferencesRepository import com.illusionware.npsbrowser.data.SettingsPreferencesRepository +import com.illusionware.npsbrowser.data.download.DownloadManager +import com.illusionware.npsbrowser.data.notification.Notifications @OptIn(ExperimentalUnsignedTypes::class) val HMAC_KEY = ubyteArrayOf( @@ -22,10 +24,15 @@ private val Context.dataStore: DataStore by preferencesDataStore( class NPSApp: Application() { lateinit var settingsPreferencesRepository: SettingsPreferencesRepository lateinit var onboardingPreferencesRepository: OnboardingPreferencesRepository + lateinit var downloadManager: DownloadManager override fun onCreate() { super.onCreate() settingsPreferencesRepository = SettingsPreferencesRepository(dataStore) onboardingPreferencesRepository = OnboardingPreferencesRepository(dataStore) +// downloader = Downloader(this) + downloadManager = DownloadManager(this) + + Notifications.initialize(this) } } \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/Routes.kt b/app/src/main/java/com/illusionware/npsbrowser/Routes.kt index 2d6b6f0..281d194 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/Routes.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/Routes.kt @@ -1,8 +1,9 @@ package com.illusionware.npsbrowser sealed class Routes(val route: String) { - object Home : Routes("home") - object Onboarding : Routes("onboarding") - object Settings : Routes("settings") - object PackageDetails : Routes("package_details") + data object Home : Routes("home") + data object Onboarding : Routes("onboarding") + data object Settings : Routes("settings") + data object PackageDetails : Routes("package_details") + data object Downloads : Routes("downloads") } diff --git a/app/src/main/java/com/illusionware/npsbrowser/data/SettingsPreferencesRepository.kt b/app/src/main/java/com/illusionware/npsbrowser/data/SettingsPreferencesRepository.kt index 8151a41..e330e6a 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/data/SettingsPreferencesRepository.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/data/SettingsPreferencesRepository.kt @@ -207,4 +207,4 @@ class SettingsPreferencesRepository( prefs[PKG2ZIP_AUTO_DECRYPT] ?: true, ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadManager.kt b/app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadManager.kt new file mode 100644 index 0000000..58457ed --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadManager.kt @@ -0,0 +1,89 @@ +package com.illusionware.npsbrowser.data.download + +import android.content.Context +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onStart + +class DownloadManager(private val context: Context) { + private val downloader = Downloader(context, this) + + val queueState + get() = downloader.queueState + + fun downloaderStart() = downloader.start() + fun downloaderStop(reason: String? = null) = downloader.stop(reason) + + val isDownloaderRunning + get() = DownloadService.isRunning + val isDownloaderPaused + get() = downloader.isPaused + + fun startDownloads() { + if (DownloadService.isRunning(context)) { + return + } + DownloadService.start(context, this) + } + + fun pauseDownload(url: String) = downloader.pause(url) + fun resumeDownload(url: String) = downloader.resume(url) + + fun pauseDownloads() { + downloader.pauseAll() + downloader.stop() + } + + fun shouldConfirmOverwrite(url: String): Boolean { + return downloader.shouldConfirmOverwrite(url) + } + + fun download(package_: NPSPackageDownload, autoStart: Boolean = true, overwriteExisting: Boolean = false) { + downloader.enqueue(package_, autoStart, overwriteExisting) + } + + fun cancelDownload(url: String) { + val wasRunning = downloader.isRunning +// if (wasRunning) { +// downloader.pauseAll() +// } + + downloader.removeDownload(url) + + if (wasRunning && queueState.value.isEmpty()) { + downloader.stop() + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun statusFlow(): Flow = queueState + .flatMapLatest { downloads -> + downloads + .map { download -> + download.statusFlow.drop(1).map { download } + } + .merge() + } + .onStart { + emitAll(queueState.value.filter { download -> download.status == NPSPackageDownload.State.DOWNLOADING }.asFlow()) + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun progressFlow(): Flow = queueState + .flatMapLatest { downloads -> + downloads + .map { download -> + download.progressFlow.drop(1).map { download } + } + .merge() + } + .onStart { + emitAll(queueState.value.filter { download -> download.status == NPSPackageDownload.State.DOWNLOADING }.asFlow()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadNotifier.kt b/app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadNotifier.kt new file mode 100644 index 0000000..d8f197a --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadNotifier.kt @@ -0,0 +1,122 @@ +package com.illusionware.npsbrowser.data.download + +import android.content.Context +import androidx.core.app.NotificationCompat +import com.illusionware.npsbrowser.R +import com.illusionware.npsbrowser.data.notification.Notifications +import com.illusionware.npsbrowser.util.system.cancelNotification +import com.illusionware.npsbrowser.util.system.notificationBuilder +import com.illusionware.npsbrowser.util.system.notify + +/** + * DownloadNotifier is used to show notifications when downloading one or multiple chapters. + * + * @param context context of application + */ +internal class DownloadNotifier(private val context: Context) { + + private val progressNotificationBuilder by lazy { + context.notificationBuilder(Notifications.CHANNEL_DOWNLOADER) { + setSmallIcon(R.drawable.baseline_cloud_download_24) + setCategory(NotificationCompat.CATEGORY_PROGRESS) + setAutoCancel(false) + setOnlyAlertOnce(true) + setSilent(true) + setOngoing(true) + setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + } + } + + private val errorNotificationBuilder by lazy { + context.notificationBuilder(Notifications.CHANNEL_DOWNLOADER) { + setSmallIcon(R.drawable.baseline_cloud_download_24) + setCategory(NotificationCompat.CATEGORY_STATUS) + setAutoCancel(true) + setSilent(true) + } + } + + /** + * Status of download. Used for correct notification icon. + */ + private var isDownloading = false + + /** + * Shows a notification from this builder. + * + * @param id the id of the notification. + */ + private fun NotificationCompat.Builder.show(id: Int) { + context.notify(id, build()) + } + + /** + * Dismiss the downloader's notification. Downloader error notifications use a different id, so + * those can only be dismissed by the user. + */ + private fun dismissProgress() { + context.cancelNotification(Notifications.ID_DOWNLOADER_GROUP) + } + + private fun dismissError() { + context.cancelNotification(Notifications.ID_DOWNLOADER_ERROR) + } + + /** + * Called when download progress changes. + * + * @param download download object containing download information. + */ + fun onProgressChange(size: Long, progress: Long) { + with(progressNotificationBuilder) { + if (!isDownloading) { + clearActions() + isDownloading = true + } + + setContentTitle("Downloading packages") + setContentText("${progress / 1024 / 1024}MB / ${size / 1024 / 1024}MB") + setProgress((size / 1024).toInt(), (progress / 1024).toInt(), false) + dismissError() + + show(Notifications.ID_DOWNLOADER_GROUP) + } + } + + fun onPaused(reason: String? = null) { + with(progressNotificationBuilder) { + setContentTitle("Downloads paused") + setContentText(reason ?: "Waiting to resume downloads") + setProgress(0, 0, false) + setOngoing(false) + + show(Notifications.ID_DOWNLOADER_GROUP) + } + + isDownloading = false + } + + /** + * Resets the state once downloads are completed. + */ + fun onComplete() { + dismissProgress() + dismissError() + + // Reset states to default + isDownloading = false + } + + fun onError(error: String) { + with(errorNotificationBuilder) { + setContentTitle("Download issue") + setStyle(NotificationCompat.BigTextStyle().bigText(error)) + setContentText(error) + setProgress(0, 0, false) + + show(Notifications.ID_DOWNLOADER_ERROR) + } + + isDownloading = false + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadRepository.kt b/app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadRepository.kt new file mode 100644 index 0000000..50fd8c5 --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadRepository.kt @@ -0,0 +1,24 @@ +package com.illusionware.npsbrowser.data.download + +import android.content.Context +import androidx.annotation.WorkerThread +import kotlinx.coroutines.flow.map + +class DownloadRepository(private val downloadDAO: NPSPackageDownloadDao) { + val allDownloads = downloadDAO.getAll() + + @WorkerThread + suspend fun insert(download: NPSPackageDownload) { + downloadDAO.insert(download) + } + + @WorkerThread + suspend fun delete(url: String) { + downloadDAO.delete(url) + } + + @WorkerThread + suspend fun deleteAll() { + downloadDAO.deleteAll() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadService.kt b/app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadService.kt new file mode 100644 index 0000000..e3a84ba --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadService.kt @@ -0,0 +1,130 @@ +package com.illusionware.npsbrowser.data.download + +import android.app.Notification +import android.app.Service +import android.content.pm.ServiceInfo +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.os.PowerManager.WakeLock +import android.util.Log +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import com.illusionware.npsbrowser.R +import com.illusionware.npsbrowser.data.notification.Notifications +import com.illusionware.npsbrowser.util.system.acquireWakeLock +import com.illusionware.npsbrowser.util.system.isOnline +import com.illusionware.npsbrowser.util.system.isServiceRunning +import com.illusionware.npsbrowser.util.system.notificationBuilder +import com.illusionware.npsbrowser.util.system.toast +import com.illusionware.npsbrowser.util.system.withUIContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import ru.beryukhov.reactivenetwork.ReactiveNetwork + +class DownloadService : Service() { + companion object { + private lateinit var _downloadManager: DownloadManager + private val _isRunning = MutableStateFlow(false) + val isRunning = _isRunning.asStateFlow() + + fun start(context: Context, downloadManager: DownloadManager) { + Log.d("DOWNLOAD", "Starting download service") + val intent = Intent(context, DownloadService::class.java) + _downloadManager = downloadManager + ContextCompat.startForegroundService(context, intent) + } + + fun stop(context: Context) { + context.stopService(Intent(context, DownloadService::class.java)) + } + + fun isRunning(context: Context): Boolean { + return context.isServiceRunning(DownloadService::class.java) + } + } + + private lateinit var wakeLock: WakeLock + + private lateinit var scope: CoroutineScope + + override fun onCreate() { + Log.d("DOWNLOAD", "In onCreate, acquring wakelock") + super.onCreate() + scope = CoroutineScope(Dispatchers.IO) + ServiceCompat.startForeground( + this, + Notifications.ID_DOWNLOADER_GROUP, + getPlaceholderNotification(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + wakeLock = acquireWakeLock(javaClass.name) + _isRunning.value = true + listenNetworkChanges() + } + + override fun onDestroy() { + super.onDestroy() + scope.cancel() + _isRunning.value = false + _downloadManager.downloaderStop() + if (wakeLock.isHeld) { + wakeLock.release() + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return START_NOT_STICKY + } + + override fun onBind(p0: Intent?): IBinder? { + return null + } + + private fun downloaderStop(string: String) { + _downloadManager.downloaderStop(string) + } + + private fun listenNetworkChanges() { + Log.d("DOWNLOAD", "Listening to network changes") + ReactiveNetwork() + .observeNetworkConnectivity(applicationContext) + .onEach { + withUIContext { + if (isOnline()) { + Log.d("DOWNLOAD", "We're online so we start the downloader") + val started = _downloadManager.downloaderStart() + if (!started) stopSelf() + } else { + downloaderStop("Not connected to the internet") + } + } + } + .catch { error -> + withUIContext { +// logcat(LogPriority.ERROR, error) + toast("Download failed: $error") + stopSelf() + } + } + .launchIn(scope) + } + + private fun getPlaceholderNotification(): Notification { + return notificationBuilder(Notifications.CHANNEL_DOWNLOADER) { + setSmallIcon(R.drawable.baseline_cloud_download_24) + setCategory(androidx.core.app.NotificationCompat.CATEGORY_SERVICE) + setOnlyAlertOnce(true) + setOngoing(true) + setSilent(true) + setContentTitle("Preparing downloads") + setContentText("Starting downloader service") + }.build() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadStore.kt b/app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadStore.kt new file mode 100644 index 0000000..0ca3c09 --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadStore.kt @@ -0,0 +1,159 @@ +package com.illusionware.npsbrowser.data.download + +import android.content.Context +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.Update +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.illusionware.npsbrowser.data.network.ProgressListener +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +private const val DOWNLOAD_STORE_NAME = "downloads" +private const val DOWNLOAD_TABLE_NAME = "download" + +@Entity(tableName = DOWNLOAD_TABLE_NAME) +data class NPSPackageDownload( + @PrimaryKey @ColumnInfo(name = "url") + val url: String, + val titleId: String, + val name: String, + val sha256: String?, + val size: Long, + var bytesDownloaded: Long, + val createdAt: Long, + @ColumnInfo(defaultValue = "0") + var statusValue: Int = State.PAUSED.value, + var savedFileName: String? = null, +): ProgressListener { + @Transient + private val _statusFlow = MutableStateFlow(State.PAUSED) + + @Transient + val statusFlow = _statusFlow.asStateFlow() + var status: State + get() = _statusFlow.value + set(status) { + statusValue = status.value + _statusFlow.value = status + } + + @Transient + private val _progressFlow = MutableStateFlow( + if (size > 0) bytesDownloaded.toFloat() / size.toFloat() else 0f + ) + + @Transient + var progressFlow = _progressFlow.asStateFlow() + private var progress: Float + get() = _progressFlow.value + set(value) { + _progressFlow.value = value + } + + @Transient + private val _bytesReadFlow = MutableStateFlow(bytesDownloaded) + + @Transient + var bytesDownloadedFlow = _bytesReadFlow.asStateFlow() + private var bytesRead: Long + get() = _bytesReadFlow.value + set(value) { + _bytesReadFlow.value = value + } + + + enum class State(val value: Int) { + PAUSED(0), + QUEUE(1), + DOWNLOADING(2), + DOWNLOADED(3), + ERROR(4), + + ; + + companion object { + fun fromValue(value: Int): State = entries.firstOrNull { it.value == value } ?: PAUSED + } + } + + fun restoreTransientState() { + _statusFlow.value = State.fromValue(statusValue) + _progressFlow.value = if (size > 0) { + (bytesDownloaded.toFloat() / size.toFloat()).coerceIn(0f, 1f) + } else { + 0f + } + _bytesReadFlow.value = bytesDownloaded + } + + override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { + progress = if (size > 0) { + (bytesRead.toFloat() / size.toFloat()).coerceIn(0f, 1f) + } else { + 0.0f + } + + bytesDownloaded = bytesRead + this.bytesRead = bytesRead + } +} + +@Dao +interface NPSPackageDownloadDao { + @Query("SELECT * FROM download ORDER BY createdAt ASC") + fun getAll(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(download: NPSPackageDownload) + + @Update(onConflict = OnConflictStrategy.REPLACE) + suspend fun update(download: NPSPackageDownload) + + @Query("DELETE FROM download WHERE url = :url") + suspend fun delete(url: String) + + @Query("DELETE FROM download") + suspend fun deleteAll() +} + +@Database(entities = [NPSPackageDownload::class], version = 2) +abstract class DownloadStore : RoomDatabase() { + abstract fun downloadDao(): NPSPackageDownloadDao + + companion object { + private val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE $DOWNLOAD_TABLE_NAME ADD COLUMN statusValue INTEGER NOT NULL DEFAULT 0") + db.execSQL("ALTER TABLE $DOWNLOAD_TABLE_NAME ADD COLUMN savedFileName TEXT") + } + } + + @Volatile + private var INSTANCE: DownloadStore? = null + + fun getDatabase(context: Context): DownloadStore { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + DownloadStore::class.java, + DOWNLOAD_STORE_NAME + ) + .addMigrations(MIGRATION_1_2) + .build() + INSTANCE = instance + instance + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/data/download/Downloader.kt b/app/src/main/java/com/illusionware/npsbrowser/data/download/Downloader.kt new file mode 100644 index 0000000..6f7857c --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/data/download/Downloader.kt @@ -0,0 +1,466 @@ +package com.illusionware.npsbrowser.data.download + +import android.content.Context +import com.illusionware.npsbrowser.data.defaultDownloadDir +import com.illusionware.npsbrowser.data.network.GET +import com.illusionware.npsbrowser.data.network.NetworkHelper +import com.illusionware.npsbrowser.data.network.ProgressListener +import com.illusionware.npsbrowser.data.network.awaitSuccess +import com.illusionware.npsbrowser.data.network.newCachelessCallWithProgress +import com.illusionware.npsbrowser.util.system.saveTo +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okhttp3.Headers +import okio.appendingSink +import okio.buffer +import java.io.File +class Downloader(val context: Context, private val downloadManager: DownloadManager) { + + private val invalidFilenameChars = Regex("[\\\\/:*?\"<>|]") + private val whitespaceRegex = Regex("\\s+") + + private val store = DownloadStore.getDatabase(context) + private val networkHelper = NetworkHelper(context) + private val dao = store.downloadDao() + + private val _queueState = MutableStateFlow>(emptyList()) + val queueState = _queueState.asStateFlow() + + private val queueMutex = Mutex() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private var workerJob: Job? = null + private var currentDownloadJob: Job? = null + + @Volatile + private var currentDownloadUrl: String? = null + + private val notifier by lazy { DownloadNotifier(context) } + + val isRunning: Boolean + get() = workerJob?.isActive ?: false + + @Volatile + var isPaused: Boolean = false + + init { + scope.launch { + val storeItems = dao.getAll().first() + addAllToQueue(storeItems) + } + } + + fun shouldConfirmOverwrite(url: String): Boolean { + val existing = queueState.value.find { it.url == url } ?: return false + return existing.status == NPSPackageDownload.State.DOWNLOADED && downloadedFileFor(existing).exists() + } + + fun enqueue(download: NPSPackageDownload, autoStart: Boolean, overwriteExisting: Boolean) { + scope.launch { + val shouldInsert = queueMutex.withLock { + val queue = _queueState.value.toMutableList() + val existing = queue.find { it.url == download.url } + if (existing != null && existing.status == NPSPackageDownload.State.DOWNLOADED && downloadedFileFor(existing).exists()) { + if (!overwriteExisting) { + _queueState.value = queue + return@withLock false + } + + downloadedFileFor(existing).delete() + } + + queue.removeAll { it.url == download.url } + download.savedFileName = null + download.bytesDownloaded = 0L + download.status = NPSPackageDownload.State.QUEUE + download.restoreTransientState() + queue.add(download) + _queueState.value = queue + true + } + + if (!shouldInsert) { + return@launch + } + + dao.insert(download) + + if (autoStart) { + isPaused = false + launchWorkerIfNeeded() + if (!DownloadService.isRunning(context)) { + DownloadService.start(context, downloadManager) + } + } + } + } + + fun start(): Boolean { + if (isRunning) return true + + val hasPending = queueState.value.any { + it.status == NPSPackageDownload.State.QUEUE || + it.status == NPSPackageDownload.State.PAUSED || + it.status == NPSPackageDownload.State.ERROR + } + if (!hasPending) return false + + scope.launch { + queueMutex.withLock { + val queue = _queueState.value.toMutableList() + queue.forEach { download -> + if (download.status == NPSPackageDownload.State.PAUSED || download.status == NPSPackageDownload.State.ERROR) { + download.status = NPSPackageDownload.State.QUEUE + } + } + _queueState.value = queue + } + isPaused = false + launchWorkerIfNeeded() + } + + return true + } + + fun pauseAll() { + scope.launch { + queueMutex.withLock { + val queue = _queueState.value.toMutableList() + queue.forEach { download -> + if (download.status == NPSPackageDownload.State.DOWNLOADING || download.status == NPSPackageDownload.State.QUEUE) { + download.status = NPSPackageDownload.State.PAUSED + } + } + _queueState.value = queue + } + + currentDownloadJob?.cancel() + workerJob?.cancel() + currentDownloadJob = null + workerJob = null + isPaused = true + notifier.onPaused() + } + } + + fun stop(reason: String? = null) { + scope.launch { + queueMutex.withLock { + val queue = _queueState.value.toMutableList() + queue.forEach { download -> + if (download.status == NPSPackageDownload.State.DOWNLOADING || download.status == NPSPackageDownload.State.QUEUE) { + download.status = NPSPackageDownload.State.PAUSED + } + } + _queueState.value = queue + } + + currentDownloadJob?.cancel() + workerJob?.cancel() + currentDownloadJob = null + workerJob = null + isPaused = queueState.value.isNotEmpty() + if (reason != null) { + notifier.onPaused(reason) + } else if (isPaused) { + notifier.onPaused() + } else { + notifier.onComplete() + } + + if (reason == null && DownloadService.isRunning.value) { + DownloadService.stop(context) + } + } + } + + fun pause(url: String) { + scope.launch { + var pauseCurrent = false + queueMutex.withLock { + val queue = _queueState.value.toMutableList() + queue.forEach { download -> + if (download.url == url) { + if (download.status == NPSPackageDownload.State.DOWNLOADING || download.status == NPSPackageDownload.State.QUEUE) { + download.status = NPSPackageDownload.State.PAUSED + } + pauseCurrent = currentDownloadUrl == url + } + } + _queueState.value = queue + } + + if (pauseCurrent) { + currentDownloadJob?.cancel() + } + + if (areAllPaused()) { + workerJob?.cancel() + workerJob = null + isPaused = true + notifier.onPaused() + if (DownloadService.isRunning.value) { + DownloadService.stop(context) + } + } + } + } + + fun resume(url: String) { + scope.launch { + if (queueState.value.isEmpty()) return@launch + + queueMutex.withLock { + val queue = _queueState.value.toMutableList() + val index = queue.indexOfFirst { it.url == url } + if (index == -1) return@withLock + + val target = queue.removeAt(index) + if (target.status == NPSPackageDownload.State.DOWNLOADED && downloadedFileFor(target).exists()) { + queue.add(index, target) + _queueState.value = queue + return@withLock + } + target.status = NPSPackageDownload.State.QUEUE + // Prioritize explicit resume action so the tapped item starts next. + queue.add(0, target) + _queueState.value = queue + } + + isPaused = false + launchWorkerIfNeeded() + if (!DownloadService.isRunning(context)) { + DownloadService.start(context, downloadManager) + } + } + } + + private fun launchWorkerIfNeeded() { + if (workerJob?.isActive == true) return + + workerJob = scope.launch { + while (true) { + val nextDownload = queueMutex.withLock { + _queueState.value.firstOrNull { it.status == NPSPackageDownload.State.QUEUE } + } ?: break + + currentDownloadUrl = nextDownload.url + + val job = scope.launch { + processDownload(nextDownload) + } + currentDownloadJob = job + + job.join() + currentDownloadJob = null + currentDownloadUrl = null + } + + if (queueState.value.none { it.status == NPSPackageDownload.State.QUEUE || it.status == NPSPackageDownload.State.DOWNLOADING }) { + notifier.onComplete() + isPaused = queueState.value.isNotEmpty() && queueState.value.all { it.status == NPSPackageDownload.State.PAUSED } + if (DownloadService.isRunning.value) { + DownloadService.stop(context) + } + } + + workerJob = null + } + } + + private suspend fun processDownload(download: NPSPackageDownload) { + try { + queueMutex.withLock { + download.status = NPSPackageDownload.State.DOWNLOADING + _queueState.value = _queueState.value.toList() + } + + coroutineScope { + downloadPackage(download, this) + } + + queueMutex.withLock { + download.bytesDownloaded = download.size + download.restoreTransientState() + download.status = NPSPackageDownload.State.DOWNLOADED + _queueState.value = _queueState.value.toList() + } + + dao.update(download) + } catch (_: CancellationException) { + dao.update(download) + } catch (error: Throwable) { + queueMutex.withLock { + download.status = NPSPackageDownload.State.ERROR + _queueState.value = _queueState.value.toList() + } + dao.update(download) + notifier.onError(error.message ?: "Download failed") + } + } + + private suspend fun downloadPackage(download: NPSPackageDownload, scope: CoroutineScope) { + val file = prepareTargetFile(download) + val resumedBytes = if (file.exists()) file.length() else 0L + + if (download.bytesDownloaded != resumedBytes) { + download.bytesDownloaded = resumedBytes + download.restoreTransientState() + dao.update(download) + } + + val startBytes = download.bytesDownloaded + var lastPersist = 0L + + val listener = object : ProgressListener { + override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { + download.update(bytesRead, contentLength, done) + + val totalSize = if (contentLength > 0) { + contentLength + startBytes + } else { + download.size + } + notifier.onProgressChange(totalSize, bytesRead) + + val now = System.currentTimeMillis() + if (done || now - lastPersist >= 1000L) { + lastPersist = now + this@Downloader.scope.launch { + dao.update(download) + } + } + } + } + + val res = networkHelper.client.newCachelessCallWithProgress( + GET(download.url, Headers.headersOf("Range", "bytes=${download.bytesDownloaded}-")), + listener, + download.bytesDownloaded + ).awaitSuccess() + + file.parentFile?.mkdirs() + val fileBufferedSink = file.appendingSink().buffer() + + try { + res.body!!.source().saveTo( + fileBufferedSink, + res.body!!.contentLength(), + download.bytesDownloaded, + scope + ) + dao.update(download) + } catch (e: Exception) { + if (e is CancellationException) { + throw e + } + throw e + } finally { + withContext(Dispatchers.IO) { + fileBufferedSink.close() + } + res.close() + } + } + + private fun areAllPaused(): Boolean { + val unfinished = queueState.value.filter { it.status != NPSPackageDownload.State.DOWNLOADED } + return unfinished.isNotEmpty() && unfinished.all { it.status == NPSPackageDownload.State.PAUSED } + } + + private fun addAllToQueue(downloads: List) { + _queueState.update { + downloads.forEach { download -> + download.restoreTransientState() + when (download.status) { + NPSPackageDownload.State.DOWNLOADING, + NPSPackageDownload.State.QUEUE, + -> download.status = NPSPackageDownload.State.PAUSED + + NPSPackageDownload.State.DOWNLOADED -> { + if (!downloadedFileFor(download).exists()) { + download.savedFileName = null + download.bytesDownloaded = 0L + download.restoreTransientState() + download.status = NPSPackageDownload.State.ERROR + } + } + + else -> Unit + } + } + downloads + } + } + + fun removeDownload(url: String) { + scope.launch { + val removed = queueMutex.withLock { + val queue = _queueState.value.toMutableList() + val item = queue.find { it.url == url } ?: return@withLock null + queue.remove(item) + _queueState.value = queue + item + } ?: return@launch + + if (currentDownloadUrl == removed.url) { + currentDownloadJob?.cancel() + } + + dao.delete(url) + + val file = downloadedFileFor(removed) + file.delete() + + if (queueState.value.isEmpty()) { + workerJob?.cancel() + workerJob = null + currentDownloadJob = null + notifier.onComplete() + if (DownloadService.isRunning.value) { + DownloadService.stop(context) + } + } + } + } + + private fun prepareTargetFile(download: NPSPackageDownload): File { + val existingFileName = download.savedFileName + if (existingFileName != null) { + return File(defaultDownloadDir, existingFileName) + } + + val candidate = File(defaultDownloadDir, download.defaultFileName()) + + download.savedFileName = candidate.name + return candidate + } + + private fun downloadedFileFor(download: NPSPackageDownload): File { + return File(defaultDownloadDir, download.savedFileName ?: download.defaultFileName()) + } + + private fun NPSPackageDownload.defaultFileName(): String { + val normalizedName = name + .trim() + .replace(invalidFilenameChars, "_") + .replace(whitespaceRegex, " ") + .replace(Regex("\\.pkg$", RegexOption.IGNORE_CASE), "") + .trimEnd('.', ' ') + .ifBlank { titleId } + + return "[$titleId] $normalizedName.pkg" + } +} diff --git a/app/src/main/java/com/illusionware/npsbrowser/data/network/NetworkHelper.kt b/app/src/main/java/com/illusionware/npsbrowser/data/network/NetworkHelper.kt new file mode 100644 index 0000000..393c357 --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/data/network/NetworkHelper.kt @@ -0,0 +1,25 @@ +package com.illusionware.npsbrowser.data.network + +import android.content.Context +import okhttp3.Cache +import okhttp3.OkHttpClient +import java.io.File +import java.util.concurrent.TimeUnit + + +class NetworkHelper(context: Context) { + private val cacheDir = File(context.cacheDir, "network_cache") + private val cacheSize = 5L * 1024 * 1024 // 5 MiB + + private val baseClientBuilder: OkHttpClient.Builder + get() { + val builder = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .callTimeout(2, TimeUnit.MINUTES) + + return builder + } + + val client by lazy { baseClientBuilder.cache(Cache(cacheDir, cacheSize)).build() } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/data/network/OkHttpExtensions.kt b/app/src/main/java/com/illusionware/npsbrowser/data/network/OkHttpExtensions.kt new file mode 100644 index 0000000..af06149 --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/data/network/OkHttpExtensions.kt @@ -0,0 +1,131 @@ +package com.illusionware.npsbrowser.data.network + +import android.util.Log +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import rx.Observable +import rx.Producer +import rx.Subscription +import java.io.IOException +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.resumeWithException + +fun Call.asObservable(): Observable { + return Observable.unsafeCreate { subscriber -> + // Since Call is a one-shot type, clone it for each new subscriber. + val call = clone() + + // Wrap the call in a helper which handles both unsubscription and backpressure. + val requestArbiter = object : AtomicBoolean(), Producer, Subscription { + override fun request(n: Long) { + if (n == 0L || !compareAndSet(false, true)) return + + try { + val response = call.execute() + if (!subscriber.isUnsubscribed) { + subscriber.onNext(response) + subscriber.onCompleted() + } + } catch (e: Exception) { + if (!subscriber.isUnsubscribed) { + subscriber.onError(e) + } + } + } + + override fun unsubscribe() { + call.cancel() + } + + override fun isUnsubscribed(): Boolean { + return call.isCanceled() + } + } + + subscriber.add(requestArbiter) + subscriber.setProducer(requestArbiter) + } +} + +fun Call.asObservableSuccess(): Observable { + return asObservable().doOnNext { response -> + if (!response.isSuccessful) { + response.close() + throw HttpException(response.code) + } + } +} + +// Based on https://github.com/gildor/kotlin-coroutines-okhttp +@OptIn(ExperimentalCoroutinesApi::class) +private suspend fun Call.await(callStack: Array): Response { + Log.d("DOWNLOAD", "inside the await we want") + return suspendCancellableCoroutine { continuation -> + val callback = + object : Callback { + override fun onResponse(call: Call, response: Response) { + continuation.resume(response) { + response.body?.close() + } + } + + override fun onFailure(call: Call, e: IOException) { + // Don't bother with resuming the continuation if it is already cancelled. + if (continuation.isCancelled) return + val exception = IOException(e.message, e).apply { stackTrace = callStack } + continuation.resumeWithException(exception) + } + } + + enqueue(callback) + + continuation.invokeOnCancellation { + try { + Log.d("DOWNLOAD", "request is being cancelled") + cancel() + } catch (ex: Throwable) { + // Ignore cancel exception + } + } + } +} + +suspend fun Call.await(): Response { + val callStack = Exception().stackTrace.run { copyOfRange(1, size) } + return await(callStack) +} + +suspend fun Call.awaitSuccess(): Response { + val callStack = Exception().stackTrace.run { copyOfRange(1, size) } + val response = await(callStack) + if (!response.isSuccessful) { + response.close() + throw HttpException(response.code).apply { stackTrace = callStack } + } + return response +} + +fun OkHttpClient.newCachelessCallWithProgress( + request: Request, + listener: ProgressListener, + bytesRead: Long +): Call { + val progressClient = newBuilder() + .cache(null) + .addNetworkInterceptor { chain -> + val originalResponse = chain.proceed(chain.request()) + originalResponse.newBuilder() + .body(ProgressResponseBody(originalResponse.body!!, listener, bytesRead)) + .build() + } + .build() + + return progressClient.newCall(request) +} + +class HttpException(private val code: Int) : IllegalStateException("HTTP error $code") \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/data/network/ProgressListener.kt b/app/src/main/java/com/illusionware/npsbrowser/data/network/ProgressListener.kt new file mode 100644 index 0000000..25c6643 --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/data/network/ProgressListener.kt @@ -0,0 +1,5 @@ +package com.illusionware.npsbrowser.data.network + +interface ProgressListener { + fun update(bytesRead: Long, contentLength: Long, done: Boolean) +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/data/network/ProgressResponseBody.kt b/app/src/main/java/com/illusionware/npsbrowser/data/network/ProgressResponseBody.kt new file mode 100644 index 0000000..cb5b4fd --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/data/network/ProgressResponseBody.kt @@ -0,0 +1,48 @@ +package com.illusionware.npsbrowser.data.network + +import okhttp3.MediaType +import okhttp3.ResponseBody +import okio.Buffer +import okio.BufferedSource +import okio.ForwardingSource +import okio.Source +import okio.buffer +import java.io.IOException + +class ProgressResponseBody( + private val responseBody: ResponseBody, + private val progressListener: ProgressListener, + private val bytesRead: Long +) : ResponseBody() { + + private val bufferedSource: BufferedSource by lazy { + source(responseBody.source()).buffer() + } + + override fun contentType(): MediaType? { + return responseBody.contentType() + } + + override fun contentLength(): Long { + return responseBody.contentLength() + } + + override fun source(): BufferedSource { + return bufferedSource + } + + private fun source(source: Source): Source { + return object : ForwardingSource(source) { + var totalBytesRead = bytesRead + + @Throws(IOException::class) + override fun read(sink: Buffer, byteCount: Long): Long { + val bytesRead = super.read(sink, byteCount) + // read() returns the number of bytes read, or -1 if this source is exhausted. + totalBytesRead += if (bytesRead != -1L) bytesRead else 0 + progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L) + return bytesRead + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/data/network/Requests.kt b/app/src/main/java/com/illusionware/npsbrowser/data/network/Requests.kt new file mode 100644 index 0000000..1fc15af --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/data/network/Requests.kt @@ -0,0 +1,31 @@ +package com.illusionware.npsbrowser.data.network + +import okhttp3.CacheControl +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import java.util.concurrent.TimeUnit + +private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, TimeUnit.MINUTES).build() +private val DEFAULT_HEADERS = Headers.Builder().build() + +fun GET( + url: String, + headers: Headers = DEFAULT_HEADERS, + cache: CacheControl = DEFAULT_CACHE_CONTROL, +): Request { + return GET(url.toHttpUrl(), headers, cache) +} + +fun GET( + url: HttpUrl, + headers: Headers = DEFAULT_HEADERS, + cache: CacheControl = DEFAULT_CACHE_CONTROL, +): Request { + return Request.Builder() + .url(url) + .headers(headers) + .cacheControl(cache) + .build() +} diff --git a/app/src/main/java/com/illusionware/npsbrowser/data/notification/Notifications.kt b/app/src/main/java/com/illusionware/npsbrowser/data/notification/Notifications.kt new file mode 100644 index 0000000..6fa13b8 --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/data/notification/Notifications.kt @@ -0,0 +1,32 @@ +package com.illusionware.npsbrowser.data.notification + +import android.content.Context +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationChannelGroupCompat +import androidx.core.app.NotificationManagerCompat + +object Notifications { + private const val GROUP_DOWNLOADER = "group_downloader" + const val CHANNEL_DOWNLOADER = "channel_downloader" + const val ID_DOWNLOADER_GROUP = 1 + const val ID_DOWNLOADER_ERROR = 2 + + fun initialize(context: Context) { + val notificationManager = NotificationManagerCompat.from(context) + + notificationManager.createNotificationChannelGroup( + NotificationChannelGroupCompat.Builder(GROUP_DOWNLOADER) + .setName("Download") + .build() + ) + + notificationManager.createNotificationChannel( + NotificationChannelCompat.Builder(CHANNEL_DOWNLOADER, NotificationManagerCompat.IMPORTANCE_LOW) + .setName("Progress") + .setDescription("Download progress and downloader status") + .setShowBadge(false) + .setGroup(GROUP_DOWNLOADER) + .build() + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/model/PackageItem.kt b/app/src/main/java/com/illusionware/npsbrowser/model/PackageItem.kt index 291bc59..204959a 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/model/PackageItem.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/model/PackageItem.kt @@ -8,6 +8,7 @@ data class PackageItem( val contentId: String?, val modificationDate: String?, val pkgSize: String, + val pkgSizeInBytes: ULong?, val sha256: String?, val zRif: String?, val rap: String?, diff --git a/app/src/main/java/com/illusionware/npsbrowser/ui/components/NPSAlertDialog.kt b/app/src/main/java/com/illusionware/npsbrowser/ui/components/NPSAlertDialog.kt index 5b264d8..d85bd58 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/ui/components/NPSAlertDialog.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/ui/components/NPSAlertDialog.kt @@ -2,14 +2,13 @@ package com.illusionware.npsbrowser.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -28,7 +27,7 @@ fun NPSAlertDialog( buttons: @Composable () -> Unit, content: @Composable () -> Unit ) { - AlertDialog( + BasicAlertDialog( onDismissRequest = onDismiss, ) { Surface ( diff --git a/app/src/main/java/com/illusionware/npsbrowser/ui/components/NPSIconButton.kt b/app/src/main/java/com/illusionware/npsbrowser/ui/components/NPSIconButton.kt index 18e6d5b..ec5eb43 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/ui/components/NPSIconButton.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/ui/components/NPSIconButton.kt @@ -1,20 +1,77 @@ package com.illusionware.npsbrowser.ui.components +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton -import androidx.compose.material3.PlainTooltipBox import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.layoutId @OptIn(ExperimentalMaterial3Api::class) @Composable -fun NPSIconButton(tooltip: String, onClick: () -> Unit, content: @Composable () -> Unit) { - PlainTooltipBox(tooltip = { - Text(text = tooltip) - }) { - IconButton(onClick = onClick, Modifier.tooltipAnchor()) { - content() +fun NPSIconButton( + tooltip: String, + onClick: () -> Unit, + badge: (@Composable BoxScope.() -> Unit)? = null, + content: @Composable () -> Unit +) { + Layout( + content = { + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { + Text(text = tooltip) + }, + state = rememberTooltipState(), + ) { + IconButton( + onClick = onClick, + ) { + content() + } + } + Box(Modifier.layoutId("badge")) { + badge?.let { it() } + } + } + ) { measurables, constraints -> + val iconPlaceable = measurables[0].measure(constraints) + val badgePlaceable = measurables.firstOrNull { it.layoutId == "badge" }?.measure(constraints) + + layout(iconPlaceable.width, iconPlaceable.height) { + iconPlaceable.placeRelative(0, 0) + badgePlaceable?.placeRelative( + x = iconPlaceable.width - badgePlaceable.width - 16, + y = 16, + ) } } + +// PlainTooltipBox(tooltip = { +// Text(text = tooltip) +// }) { +// if (badge != null) { +// IconButton( +// onClick = onClick, +// Modifier.tooltipAnchor(), +// ) { +// BadgedBox(badge = badge) { +// content() +// } +// } +// } else { +// IconButton( +// onClick = onClick, +// Modifier.tooltipAnchor(), +// ) { +// content() +// } +// } +// } } \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/ui/screens/Downloads.kt b/app/src/main/java/com/illusionware/npsbrowser/ui/screens/Downloads.kt new file mode 100644 index 0000000..cae43e4 --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/ui/screens/Downloads.kt @@ -0,0 +1,269 @@ +package com.illusionware.npsbrowser.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.DownloadDone +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material3.Badge +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.illusionware.npsbrowser.data.download.NPSPackageDownload +import com.illusionware.npsbrowser.ui.components.NPSIconButton +import com.illusionware.npsbrowser.ui.theme.Typography +import com.illusionware.npsbrowser.util.system.prettyByte +import com.illusionware.npsbrowser.viewmodels.PackageDetailsViewModel + +@Composable +fun Downloads(navigationGoBack: () -> Unit, packageDetailsViewModel: PackageDetailsViewModel) { + val queue = packageDetailsViewModel.downloadManager.queueState.collectAsStateWithLifecycle().value + packageDetailsViewModel.downloadManager.statusFlow().collectAsStateWithLifecycle(initialValue = null) + val hasActiveDownloads = queue.any { + it.status == NPSPackageDownload.State.DOWNLOADING || + it.status == NPSPackageDownload.State.QUEUE + } + val hasResumableDownloads = queue.any { + it.status == NPSPackageDownload.State.PAUSED || + it.status == NPSPackageDownload.State.ERROR + } + + Scaffold( + topBar = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .statusBarsPadding() + .padding(vertical = 8.dp, horizontal = 4.dp), + ) { + NPSIconButton(tooltip = "Go Back", onClick = { navigationGoBack() }) { + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Go Back" ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(text = "Downloads", style = Typography.titleLarge) + if (queue.isNotEmpty()) { + Badge(containerColor = MaterialTheme.colorScheme.outlineVariant) { + Text( + text = queue.size.toString(), + style = Typography.titleSmall, + ) + } + } + } + } + }, + floatingActionButton = { + if (hasActiveDownloads) { + ExtendedFloatingActionButton( + text = { Text(text = "Pause All") }, + icon = { Icon(imageVector = Icons.Filled.Pause, contentDescription = "Pause")}, + onClick = { + packageDetailsViewModel.downloadManager.pauseDownloads() + } + ) + } else if (hasResumableDownloads) { + ExtendedFloatingActionButton( + text = { Text(text = "Resume") }, + icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = "Resume")}, + onClick = { + packageDetailsViewModel.downloadManager.startDownloads() + } + ) + } + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .background(MaterialTheme.colorScheme.surface), + ) { + LazyColumn(Modifier.background(MaterialTheme.colorScheme.surface)) { + itemsIndexed( + queue, + key = { _, item -> item.url }, + ) { _, item -> + DownloadItem( + item, + packageDetailsViewModel::removeDownload, + packageDetailsViewModel::pauseDownload, + packageDetailsViewModel::resumeDownload + ) + } + } + } + } +} + +@Composable +fun DownloadItem( + item: NPSPackageDownload, + removeDownload: (String) -> Unit, + pauseDownload: (String) -> Unit, + resumeDownload: (String) -> Unit, +) { + val progress = item.progressFlow.collectAsStateWithLifecycle().value + val bytesDownloaded = item.bytesDownloadedFlow.collectAsStateWithLifecycle().value + val status = item.statusFlow.collectAsStateWithLifecycle().value + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Row( + modifier = Modifier + .clip(shape = RoundedCornerShape(12.dp)) + .clickable { + if (status == NPSPackageDownload.State.DOWNLOADING || status == NPSPackageDownload.State.QUEUE) { + pauseDownload(item.url) + } else if (status != NPSPackageDownload.State.DOWNLOADED) { + resumeDownload(item.url) + } + } + .size(48.dp) + .aspectRatio(1f) + .semantics { + role = Role.Button + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + imageVector = when (status) { + NPSPackageDownload.State.DOWNLOADING, NPSPackageDownload.State.QUEUE -> Icons.Filled.Pause + NPSPackageDownload.State.DOWNLOADED -> Icons.Filled.DownloadDone + NPSPackageDownload.State.ERROR -> Icons.Filled.RestartAlt + else -> Icons.Filled.PlayArrow + }, + contentDescription = when (status) { + NPSPackageDownload.State.DOWNLOADING, NPSPackageDownload.State.QUEUE -> "Pause" + NPSPackageDownload.State.DOWNLOADED -> "Downloaded" + NPSPackageDownload.State.ERROR -> "Retry" + else -> "Resume" + }, + modifier = Modifier.size(32.dp) + ) + } + Column( + modifier = Modifier + .padding(end = 4.dp) + .weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = item.name, + style = Typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text(text = "${bytesDownloaded.prettyByte()} / ${item.size.prettyByte()}", style = Typography.bodySmall, color = MaterialTheme.colorScheme.outline) + } + when (status) { + NPSPackageDownload.State.DOWNLOADING -> { + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.height(2.dp), + strokeCap = StrokeCap.Round, + ) + } + NPSPackageDownload.State.QUEUE -> { + Text("Pending", style = Typography.bodySmall, color = MaterialTheme.colorScheme.outline) + } + NPSPackageDownload.State.ERROR -> { + Text("Failed", style = Typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + NPSPackageDownload.State.DOWNLOADED -> { + Text("Downloaded", style = Typography.bodySmall, color = MaterialTheme.colorScheme.primary) + } + else -> { + Text("Paused", style = Typography.bodySmall, color = MaterialTheme.colorScheme.outline) + } + } + } + Row( + modifier = Modifier + .clip(shape = RoundedCornerShape(12.dp)) + .clickable { removeDownload(item.url) } + .size(48.dp) + .aspectRatio(1f) + .semantics { + role = Role.Button + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = "Remove", + ) + } + } +} + +@Composable +@Preview +fun DownloadPreview() { + val packageDetailsViewModel: PackageDetailsViewModel = viewModel( + factory = PackageDetailsViewModel.Factory + ) + + Downloads({}, packageDetailsViewModel) +} + +@Composable +@Preview(showBackground = true) +fun DownloadItemPreview() { + DownloadItem( + item = NPSPackageDownload( + name = "Test", + url = "https://google.com", + size = 385273850L, + createdAt = 0L, + sha256 = "", + titleId = "BRUH00001", + bytesDownloaded = 0L + ), + removeDownload = {}, + pauseDownload = {}, + resumeDownload = {}, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/ui/screens/Onboarding.kt b/app/src/main/java/com/illusionware/npsbrowser/ui/screens/Onboarding.kt index 7981f03..349b2ba 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/ui/screens/Onboarding.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/ui/screens/Onboarding.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.material3.ButtonDefaults @@ -45,14 +46,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.illusionware.npsbrowser.R import com.illusionware.npsbrowser.ui.theme.ColorAccent import com.illusionware.npsbrowser.ui.theme.Typography @@ -62,6 +64,7 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material3.Button import androidx.compose.material3.FilledTonalButton @@ -85,6 +88,7 @@ import com.illusionware.npsbrowser.model.ConsoleType import com.illusionware.npsbrowser.model.PackageItemType import com.illusionware.npsbrowser.viewmodels.OnboardingViewModel import com.illusionware.npsbrowser.viewmodels.SettingsViewModel +import androidx.core.view.WindowCompat import kotlinx.coroutines.launch const val PAGE_COUNT = 3 @@ -111,13 +115,15 @@ fun OnBoarding( factory = SettingsViewModel.Factory ), ) { - val systemUiController = rememberSystemUiController() val pagerState = rememberPagerState( initialPage = 0, initialPageOffsetFraction = 0f - ) + ) { + PAGE_COUNT + } val scope = rememberCoroutineScope() val context = LocalContext.current + val view = LocalView.current val settingsPrefs = settingsViewModel.uiState.collectAsStateWithLifecycle().value val darkIcons = when (pagerState.currentPage) { @@ -125,23 +131,22 @@ fun OnBoarding( else -> true } - DisposableEffect(systemUiController) { - systemUiController.setStatusBarColor( - color = Color.Transparent, - darkIcons = darkIcons && !darkTheme - ) + DisposableEffect(view, darkIcons, darkTheme) { val activity = context as Activity + val window = activity.window + val insetsController = WindowCompat.getInsetsController(window, view) + + window.statusBarColor = Color.Transparent.toArgb() + insetsController.isAppearanceLightStatusBars = darkIcons && !darkTheme + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT onDispose { - systemUiController.setStatusBarColor( - color = Color.Transparent, - darkIcons = !darkTheme - ) + insetsController.isAppearanceLightStatusBars = !darkTheme activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } } - HorizontalPager(pageCount = PAGE_COUNT, state = pagerState) { page -> + HorizontalPager(state = pagerState) { page -> when (page) { 0 -> OnBoardingInitial( incrementPage = { @@ -782,7 +787,19 @@ fun Toggleable(title: String, checked: Boolean, onCheckedChange: (Boolean) -> Un horizontalArrangement = Arrangement.SpaceBetween, ) { Text(text = title, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1.0f)) - Switch(checked = checked, onCheckedChange = null) + Switch( + checked = checked, + onCheckedChange = null, + thumbContent = if (checked) { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + } else null + ) } } } diff --git a/app/src/main/java/com/illusionware/npsbrowser/ui/screens/PackageDetails.kt b/app/src/main/java/com/illusionware/npsbrowser/ui/screens/PackageDetails.kt index 7cd74fb..05c15d5 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/ui/screens/PackageDetails.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/ui/screens/PackageDetails.kt @@ -1,5 +1,7 @@ package com.illusionware.npsbrowser.ui.screens +import android.annotation.SuppressLint +import android.content.ClipData import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -20,7 +22,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.DownloadDone import androidx.compose.material.icons.filled.Public @@ -50,13 +52,14 @@ import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -65,9 +68,8 @@ import coil.compose.AsyncImage import coil.request.ImageRequest import com.illusionware.npsbrowser.R import com.illusionware.npsbrowser.PlaceholderColor -import com.illusionware.npsbrowser.data.ItemLayout import com.illusionware.npsbrowser.data.SettingsPreferences -import com.illusionware.npsbrowser.data.Theme +import com.illusionware.npsbrowser.data.download.NPSPackageDownload import com.illusionware.npsbrowser.ui.components.NPSAlertDialog import com.illusionware.npsbrowser.ui.components.NPSFloatingActionButton import com.illusionware.npsbrowser.ui.components.NPSIconButton @@ -77,6 +79,7 @@ import com.illusionware.npsbrowser.viewmodels.PackageDetailsViewModel import com.illusionware.npsbrowser.viewmodels.SettingsViewModel import kotlinx.coroutines.launch +@SuppressLint("MissingPermission") @OptIn(ExperimentalMaterial3Api::class) @Composable fun PackageDetails( @@ -86,7 +89,7 @@ fun PackageDetails( ), packageDetailsViewModel: PackageDetailsViewModel, ) { - val clipboard = LocalClipboardManager.current + val clipboard = LocalClipboard.current val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true ) @@ -96,6 +99,29 @@ fun PackageDetails( val item = packageDetailsViewModel.uiState.collectAsStateWithLifecycle().value.item!! var showBottomSheet by remember { mutableStateOf(false) } + var pendingOverwriteDownload by remember { mutableStateOf(null) } + + fun buildDownloadRequest(): NPSPackageDownload { + return NPSPackageDownload( + item.pkgUrl!!, + item.titleId, + item.name, + item.sha256, + item.pkgSizeInBytes?.toLong() ?: 0, + 0L, + System.currentTimeMillis() + ) + } + + fun requestDownload(overwriteExisting: Boolean = false) { + val downloadRequest = buildDownloadRequest() + if (!overwriteExisting && packageDetailsViewModel.downloadManager.shouldConfirmOverwrite(downloadRequest.url)) { + pendingOverwriteDownload = downloadRequest + return + } + + packageDetailsViewModel.downloadManager.download(downloadRequest, overwriteExisting = overwriteExisting) + } Scaffold( snackbarHost = { SnackbarHost(snackbarHostState)}, @@ -107,7 +133,7 @@ fun PackageDetails( .padding(vertical = 8.dp, horizontal = 4.dp), ) { NPSIconButton(tooltip = "Go Back", onClick = navigationGoBack) { - Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = "Go Back" ) + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Go Back" ) } } }, @@ -116,9 +142,13 @@ fun PackageDetails( NPSFloatingActionButton( onClick = { if (!item.zRif.isNullOrEmpty()) { - clipboard.setText(AnnotatedString(item.zRif)) + scope.launch { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(item.zRif, item.zRif))) + } } else if (!item.rap.isNullOrEmpty()) { - clipboard.setText(AnnotatedString(item.rap)) + scope.launch { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(item.rap, item.rap))) + } } scope.launch { snackbarHostState.showSnackbar("License key copied to clipboard") @@ -132,7 +162,9 @@ fun PackageDetails( ) } NPSFloatingActionButton( - onClick = { /* TODO: */ }, + onClick = { + requestDownload() + }, onLongClick = { showBottomSheet = true }, enabled = !item.pkgUrl.isNullOrEmpty() ) { @@ -209,8 +241,30 @@ fun PackageDetails( }, sheetState = sheetState, prefs = prefs, + packageDetailsViewModel = packageDetailsViewModel, + onDownload = { requestDownload() } ) } + + pendingOverwriteDownload?.let { downloadRequest -> + NPSAlertDialog( + onDismiss = { pendingOverwriteDownload = null }, + title = "Overwrite existing package?", + buttons = { + TextButton(onClick = { pendingOverwriteDownload = null }) { + Text(text = "Keep Existing") + } + TextButton(onClick = { + pendingOverwriteDownload = null + packageDetailsViewModel.downloadManager.download(downloadRequest, overwriteExisting = true) + }) { + Text(text = "Overwrite") + } + } + ) { + Text("This package is already downloaded. Choose whether to keep the existing file or overwrite it.") + } + } } @OptIn(ExperimentalMaterial3Api::class) @@ -218,14 +272,16 @@ fun PackageDetails( fun OneTimeConfigBottomSheet( sheetState: SheetState, onDismiss: () -> Unit, - prefs: SettingsPreferences + prefs: SettingsPreferences, + packageDetailsViewModel: PackageDetailsViewModel, + onDownload: () -> Unit, ) { ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - windowInsets = WindowInsets(top = 0.dp), + //windowInsets = WindowInsets(top = 0.dp), ) { - OneTimeConfigSheetContent(prefs) + OneTimeConfigSheetContent(prefs, packageDetailsViewModel, onDownload) } } @@ -290,9 +346,12 @@ fun Option( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun OneTimeConfigSheetContent(prefs: SettingsPreferences) { +fun OneTimeConfigSheetContent( + prefs: SettingsPreferences, + packageDetailsViewModel: PackageDetailsViewModel, + onDownload: () -> Unit, +) { var autoDecrypt by remember { mutableStateOf(prefs.pkg2zipAutoDecrypt) } var unpackInPlace by remember { mutableStateOf(prefs.unpackInDownload) } var deleteAfter by remember { mutableStateOf(prefs.deleteAfterUnpack) } @@ -318,6 +377,8 @@ fun OneTimeConfigSheetContent(prefs: SettingsPreferences) { } } + val item = packageDetailsViewModel.uiState.collectAsStateWithLifecycle().value.item!! + Column( verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -385,7 +446,9 @@ fun OneTimeConfigSheetContent(prefs: SettingsPreferences) { } } Button( - onClick = { /*TODO: do the download*/ }, + onClick = { + onDownload() + }, modifier = Modifier.padding(horizontal = 24.dp), ) { Icon( @@ -425,31 +488,3 @@ fun OneTimeConfigSheetContent(prefs: SettingsPreferences) { } } } - -@Composable -@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) -fun BottomSheetPreview() { - val prefs = SettingsPreferences( - Theme.SYSTEM.ordinal, - ItemLayout.LIST.ordinal, - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - false, - false, - "", - true, - ) - - Column { - OneTimeConfigSheetContent(prefs = prefs) - } -} diff --git a/app/src/main/java/com/illusionware/npsbrowser/ui/screens/Settings.kt b/app/src/main/java/com/illusionware/npsbrowser/ui/screens/Settings.kt index 16231e4..67ff888 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/ui/screens/Settings.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/ui/screens/Settings.kt @@ -23,9 +23,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.outlined.ArrowDropDown import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material.icons.outlined.FileDownload import androidx.compose.material.icons.outlined.Palette @@ -46,7 +45,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext @@ -70,6 +68,7 @@ import com.illusionware.npsbrowser.data.Theme import com.illusionware.npsbrowser.model.ConsoleType import com.illusionware.npsbrowser.model.PackageItemType +// TODO: localize this private val Themes = hashMapOf( Theme.LIGHT.ordinal to "Light", Theme.DARK.ordinal to "Dark", @@ -96,7 +95,7 @@ fun SettingsScreen( .padding(vertical = 8.dp, horizontal = 4.dp), ) { NPSIconButton(tooltip = "Go Back", onClick = { navigationGoBack() }) { - Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = "Go Back" ) + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Go Back" ) } Text(text = stringResource(id = R.string.title_activity_settings), style = Typography.titleLarge) } diff --git a/app/src/main/java/com/illusionware/npsbrowser/ui/theme/Color.kt b/app/src/main/java/com/illusionware/npsbrowser/ui/theme/Color.kt index 1da629b..027834f 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/ui/theme/Color.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/ui/theme/Color.kt @@ -19,6 +19,7 @@ val ColorOnSecondaryDark = Color(0xDFFFFFFF) val ColorAmoledDark = Color(0xFF000000) // Works on both Dark and Light +val ColorTag = Color(0xFFEAF654) val ColorAccent = Color(0xFF545FBF) val ColorError = Color(0xFFF93422) diff --git a/app/src/main/java/com/illusionware/npsbrowser/ui/theme/Theme.kt b/app/src/main/java/com/illusionware/npsbrowser/ui/theme/Theme.kt index 8ee2337..c2f3290 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/ui/theme/Theme.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/ui/theme/Theme.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat -import com.google.accompanist.systemuicontroller.rememberSystemUiController private val LightColorScheme = lightColorScheme( primary = md_theme_light_primary, @@ -88,7 +87,6 @@ fun NPSBrowserTheme( dynamicColor: Boolean = true, content: @Composable () -> Unit ) { - val systemUiController = rememberSystemUiController() val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current @@ -104,13 +102,10 @@ fun NPSBrowserTheme( val window = (view.context as Activity).window window.statusBarColor = Color.Transparent.toArgb() window.navigationBarColor = Color.Transparent.toArgb() - // allows pages to extend to the status bar WindowCompat.setDecorFitsSystemWindows(window, false) - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme - systemUiController.setSystemBarsColor( - color = Color.Transparent, - darkIcons = !darkTheme - ) + val insetsController = WindowCompat.getInsetsController(window, view) + insetsController.isAppearanceLightStatusBars = !darkTheme + insetsController.isAppearanceLightNavigationBars = !darkTheme } } diff --git a/app/src/main/java/com/illusionware/npsbrowser/util/DisplayExtensions.kt b/app/src/main/java/com/illusionware/npsbrowser/util/DisplayExtensions.kt deleted file mode 100644 index a4131e0..0000000 --- a/app/src/main/java/com/illusionware/npsbrowser/util/DisplayExtensions.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.illusionware.npsbrowser.util - -import android.content.Context -import android.os.Build - -// Borrowed from https://github.com/tachiyomiorg/tachiyomi/tree/master -fun Context.isNavigationBarNeedsScrim(): Boolean { - return Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || - InternalResourceHelper.getBoolean(this, "config_navBarNeedsScrim", true) -} diff --git a/app/src/main/java/com/illusionware/npsbrowser/util/system/ContextExtensions.kt b/app/src/main/java/com/illusionware/npsbrowser/util/system/ContextExtensions.kt new file mode 100644 index 0000000..11d00a9 --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/util/system/ContextExtensions.kt @@ -0,0 +1,29 @@ +package com.illusionware.npsbrowser.util.system + +import android.app.ActivityManager +import android.content.Context +import android.os.PowerManager +import android.widget.Toast +import androidx.core.content.getSystemService + +fun Context.isServiceRunning(serviceClass: Class<*>): Boolean { + val className = serviceClass.name + val manager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + return manager.getRunningServices(Integer.MAX_VALUE).any { className == it.service.className } +} + +val Context.powerManager: PowerManager + get() = getSystemService()!! + +fun Context.acquireWakeLock(tag: String): PowerManager.WakeLock { + val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag:WakeLock") + wakeLock.acquire() + return wakeLock +} + +fun Context.toast(text: String?, duration: Int = Toast.LENGTH_SHORT, block: (Toast) -> Unit = {}): Toast { + return Toast.makeText(applicationContext, text.orEmpty(), duration).also { + block(it) + it.show() + } +} diff --git a/app/src/main/java/com/illusionware/npsbrowser/util/system/CoroutinesExtensions.kt b/app/src/main/java/com/illusionware/npsbrowser/util/system/CoroutinesExtensions.kt new file mode 100644 index 0000000..0ed7358 --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/util/system/CoroutinesExtensions.kt @@ -0,0 +1,7 @@ +package com.illusionware.npsbrowser.util.system + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +suspend fun withUIContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block) \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/util/system/DisplayExtensions.kt b/app/src/main/java/com/illusionware/npsbrowser/util/system/DisplayExtensions.kt new file mode 100644 index 0000000..c32133c --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/util/system/DisplayExtensions.kt @@ -0,0 +1,32 @@ +package com.illusionware.npsbrowser.util.system + +import android.content.Context +import android.os.Build +import java.util.Locale.ENGLISH + +// Borrowed from https://github.com/tachiyomiorg/tachiyomi/tree/master +fun Context.isNavigationBarNeedsScrim(): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || + InternalResourceHelper.getBoolean(this, "config_navBarNeedsScrim", true) +} + +fun Long.prettyByte(): String { + val kilobyte: Long = 1024 + val megabyte = kilobyte * 1024 + val gigabyte = megabyte * 1024 + val terabyte = gigabyte * 1024 + + return if (this in 0 until kilobyte) { + "$this" + "B" + } else if (this in kilobyte until megabyte) { + String.format(ENGLISH, "%.2f", (this.toDouble() / kilobyte.toDouble())) + "KB" + } else if (this in megabyte until gigabyte) { + String.format(ENGLISH, "%.2f", (this.toDouble() / megabyte.toDouble())) + "MB" + } else if (this in gigabyte until terabyte) { + String.format(ENGLISH, "%.2f", (this.toDouble() / gigabyte.toDouble())) + "GB" + } else if (this >= terabyte) { + String.format(ENGLISH, "%.2f", (this.toDouble() / terabyte.toDouble())) + "TB" + } else { + "$this" + "B" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/util/InternalResourceHelper.kt b/app/src/main/java/com/illusionware/npsbrowser/util/system/InternalResourceHelper.kt similarity index 94% rename from app/src/main/java/com/illusionware/npsbrowser/util/InternalResourceHelper.kt rename to app/src/main/java/com/illusionware/npsbrowser/util/system/InternalResourceHelper.kt index 96add7d..efd9c8b 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/util/InternalResourceHelper.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/util/system/InternalResourceHelper.kt @@ -1,4 +1,4 @@ -package com.illusionware.npsbrowser.util +package com.illusionware.npsbrowser.util.system import android.annotation.SuppressLint import android.content.Context diff --git a/app/src/main/java/com/illusionware/npsbrowser/util/system/NetworkExtensions.kt b/app/src/main/java/com/illusionware/npsbrowser/util/system/NetworkExtensions.kt new file mode 100644 index 0000000..c3f5cd5 --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/util/system/NetworkExtensions.kt @@ -0,0 +1,40 @@ +package com.illusionware.npsbrowser.util.system + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import android.os.Build +import androidx.core.content.getSystemService + +val Context.connectivityManager: ConnectivityManager + get() = getSystemService()!! + +val Context.wifiManager: WifiManager + get() = getSystemService()!! + +fun Context.isOnline(): Boolean { + val activeNetwork = connectivityManager.activeNetwork ?: return false + val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false + val maxTransport = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 -> NetworkCapabilities.TRANSPORT_LOWPAN + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> NetworkCapabilities.TRANSPORT_WIFI_AWARE + else -> NetworkCapabilities.TRANSPORT_VPN + } + return (NetworkCapabilities.TRANSPORT_CELLULAR..maxTransport).any(networkCapabilities::hasTransport) +} + +fun Context.isConnectedToWifi(): Boolean { + if (!wifiManager.isWifiEnabled) return false + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val activeNetwork = connectivityManager.activeNetwork ?: return false + val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false + + networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && + networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } else { + @Suppress("DEPRECATION") + wifiManager.connectionInfo.bssid != null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/util/system/NotificationExtensions.kt b/app/src/main/java/com/illusionware/npsbrowser/util/system/NotificationExtensions.kt new file mode 100644 index 0000000..a863476 --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/util/system/NotificationExtensions.kt @@ -0,0 +1,60 @@ +package com.illusionware.npsbrowser.util.system + +import android.Manifest +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.compose.ui.graphics.toArgb +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationChannelGroupCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.NotificationManagerCompat.NotificationWithIdAndTag +import androidx.core.content.PermissionChecker +import androidx.core.content.getSystemService +import com.illusionware.npsbrowser.ui.theme.ColorAccent + +val Context.notificationManager: NotificationManager + get() = getSystemService()!! + +fun Context.notify(id: Int, channelId: String, block: (NotificationCompat.Builder.() -> Unit)? = null) { + val notification = notificationBuilder(channelId, block).build() + this.notify(id, notification) +} + +fun Context.notify(id: Int, notification: Notification) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && PermissionChecker.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PermissionChecker.PERMISSION_GRANTED) { + return + } + + NotificationManagerCompat.from(this).notify(id, notification) +} + +fun Context.notify(notificationWithIdAndTags: List) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && PermissionChecker.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PermissionChecker.PERMISSION_GRANTED) { + return + } + + NotificationManagerCompat.from(this).notify(notificationWithIdAndTags) +} + +fun Context.cancelNotification(id: Int) { + NotificationManagerCompat.from(this).cancel(id) +} + +/** + * Helper method to create a notification builder. + * + * @param channelId the channel id. + * @param block the function that will execute inside the builder. + * @return a notification to be displayed or updated. + */ +fun Context.notificationBuilder(channelId: String, block: (NotificationCompat.Builder.() -> Unit)? = null): NotificationCompat.Builder { + val builder = NotificationCompat.Builder(this, channelId) + .setColor(ColorAccent.toArgb()) + if (block != null) { + builder.block() + } + return builder +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/util/system/OkioExtensions.kt b/app/src/main/java/com/illusionware/npsbrowser/util/system/OkioExtensions.kt new file mode 100644 index 0000000..7bb052a --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/util/system/OkioExtensions.kt @@ -0,0 +1,62 @@ +package com.illusionware.npsbrowser.util.system + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ensureActive +import okio.BufferedSink +import okio.BufferedSource + +/** + * Saves the given source to a file and closes it. Directories will be created if needed. + * + * @param file the file where the source is copied. + */ +//fun BufferedSource.saveTo(file: File) { +// try { +// // Create parent dirs if needed +// file.parentFile?.mkdirs() +// +// // Copy to destination +// saveTo(file.outputStream()) +// } catch (e: Exception) { +// close() +// file.delete() +// throw e +// } +//} + +fun BufferedSource.saveTo(sink: BufferedSink, totalBytes: Long, bytesRead: Long, scope: CoroutineScope): Boolean { + var downloaded = bytesRead + + use { input -> + sink.use { out -> + while (true) { + scope.ensureActive() + val read = input.read(out.buffer, DEFAULT_BUFFER_SIZE.toLong()) + if (read == -1L) break + downloaded += read + out.emitCompleteSegments() + } + out.flush() + } + } + + if (totalBytes <= 0L) return true + val expected = bytesRead + totalBytes + return downloaded >= expected +} + +/** + * Saves the given source to an output stream and closes both resources. + * + * @param stream the stream where the source is copied. + */ +//fun BufferedSource.saveTo(stream: OutputStream, scope: CoroutineScope) { +// use { input -> +// stream.sink().buffer().use { +// if (scope.isActive) { +// it.writeAll(input) +// it.flush() +// } +// } +// } +//} diff --git a/app/src/main/java/com/illusionware/npsbrowser/viewmodels/OnboardingViewModel.kt b/app/src/main/java/com/illusionware/npsbrowser/viewmodels/OnboardingViewModel.kt index 468d20c..56703f6 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/viewmodels/OnboardingViewModel.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/viewmodels/OnboardingViewModel.kt @@ -13,6 +13,7 @@ import com.illusionware.npsbrowser.data.OnboardingPreferencesRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.io.File @@ -29,6 +30,10 @@ class OnboardingViewModel( ) ) + suspend fun getOnboardingStatus(): Boolean { + return onboardingPreferencesRepository.getPreferences().first().seenOnboarding + } + fun finishOnboarding(shouldCreateDefaultDir: Boolean) { viewModelScope.launch(Dispatchers.IO) { if (shouldCreateDefaultDir) { diff --git a/app/src/main/java/com/illusionware/npsbrowser/viewmodels/PackageDetailsViewModel.kt b/app/src/main/java/com/illusionware/npsbrowser/viewmodels/PackageDetailsViewModel.kt index 1746b68..68d799d 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/viewmodels/PackageDetailsViewModel.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/viewmodels/PackageDetailsViewModel.kt @@ -1,6 +1,12 @@ package com.illusionware.npsbrowser.viewmodels import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.illusionware.npsbrowser.NPSApp +import com.illusionware.npsbrowser.data.download.DownloadManager import com.illusionware.npsbrowser.model.PackageItem import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -10,11 +16,24 @@ data class SelectedPackageUiState( val item: PackageItem?, ) -class PackageDetailsViewModel: ViewModel() { +class PackageDetailsViewModel(val downloadManager: DownloadManager): ViewModel() { private val _uiState = MutableStateFlow(SelectedPackageUiState(null)) val uiState: StateFlow = _uiState.asStateFlow() fun setSelectedPackage(item: PackageItem) { _uiState.value = SelectedPackageUiState(item) } + + fun removeDownload(url: String) = downloadManager.cancelDownload(url) + fun pauseDownload(url: String) = downloadManager.pauseDownload(url) + fun resumeDownload(url: String) = downloadManager.resumeDownload(url) + + companion object { + val Factory: ViewModelProvider.Factory = viewModelFactory { + initializer { + val application = (this[APPLICATION_KEY] as NPSApp) + PackageDetailsViewModel(application.downloadManager) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/viewmodels/PackageListData.kt b/app/src/main/java/com/illusionware/npsbrowser/viewmodels/PackageListData.kt index bf49359..5709ba3 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/viewmodels/PackageListData.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/viewmodels/PackageListData.kt @@ -15,6 +15,7 @@ import com.illusionware.npsbrowser.data.SettingsPreferencesRepository import com.illusionware.npsbrowser.model.ConsoleType import com.illusionware.npsbrowser.model.PackageItem import com.illusionware.npsbrowser.model.PackageItemType +import com.illusionware.npsbrowser.util.system.prettyByte import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -23,6 +24,8 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import okhttp3.internal.toLongOrDefault import java.io.InputStream +import androidx.core.net.toUri +import java.util.Locale.ENGLISH data class PackageListUiState( val packages: ArrayList = ArrayList(), @@ -125,7 +128,7 @@ class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesReposit val file: InputStream? try { - file = app.contentResolver.openInputStream(Uri.parse(fileUri)) + file = app.contentResolver.openInputStream(fileUri.toUri()) } catch (e: Exception) { throw e } @@ -153,10 +156,10 @@ class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesReposit val tempsha256 = row["SHA256"] val tempDate = row["Last Modification Date"] if (!tempSize.isNullOrEmpty()) { - size = bytesIntoHumanReadable(tempSize.toLongOrDefault(0)) + size = tempSize.toLongOrDefault(0).prettyByte() } if (!tempFW.isNullOrEmpty()) { - minFW = String.format("%.2f", tempFW.toFloat()) + minFW = String.format(ENGLISH, "%.2f", tempFW.toFloat()) } if (!tempUrl.isNullOrEmpty() && tempUrl != "MISSING") { pkgUrl = tempUrl @@ -186,6 +189,7 @@ class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesReposit contentId, modificationDate, size, + tempSize?.toULongOrNull(), sha256, zRif, rap, @@ -201,27 +205,6 @@ class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesReposit return list } - private fun bytesIntoHumanReadable(bytes: Long): String { - val kilobyte: Long = 1024 - val megabyte = kilobyte * 1024 - val gigabyte = megabyte * 1024 - val terabyte = gigabyte * 1024 - - return if (bytes in 0 until kilobyte) { - "$bytes" + "B" - } else if (bytes in kilobyte until megabyte) { - String.format("%.2f", (bytes.toDouble() / kilobyte.toDouble())) + "KB" - } else if (bytes in megabyte until gigabyte) { - String.format("%.2f", (bytes.toDouble() / megabyte.toDouble())) + "MB" - } else if (bytes in gigabyte until terabyte) { - String.format("%.2f", (bytes.toDouble() / gigabyte.toDouble())) + "GB" - } else if (bytes >= terabyte) { - String.format("%.2f", (bytes.toDouble() / terabyte.toDouble())) + "TB" - } else { - "$bytes" + "B" - } - } - companion object { val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { diff --git a/app/src/main/res/drawable/baseline_cloud_download_24.xml b/app/src/main/res/drawable/baseline_cloud_download_24.xml new file mode 100644 index 0000000..1572e34 --- /dev/null +++ b/app/src/main/res/drawable/baseline_cloud_download_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/single_app_fragment.xml b/app/src/main/res/layout/single_app_fragment.xml deleted file mode 100644 index c348cc5..0000000 --- a/app/src/main/res/layout/single_app_fragment.xml +++ /dev/null @@ -1,213 +0,0 @@ - - - - - - - - - - - -