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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 10683c4..4360acf 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -21,15 +21,9 @@
外观
主题
布局
-
-
- - 明亮
- - 黑暗
- - 使用系统默认
-
-
-
- - 列表
- - 网格
-
+ 明亮
+ 黑暗
+ 使用系统默认
+ 列表
+ 网格
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 964b6ae..7bf95b9 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -21,15 +21,9 @@
外觀
主題
佈局
-
-
- - 明亮
- - 黑暗
- - 使用系統默認
-
-
-
- - 列表
- - 網格
-
+ 明亮
+ 黑暗
+ 使用系統默認
+ 列表
+ 網格
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index dc380b1..e21fd27 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -21,15 +21,9 @@
Appearance
Theme
Layout
-
-
- - Light
- - Dark
- - Use System Default
-
-
-
- - List
- - Grid
-
+ Light
+ Dark
+ Use System Default
+ List
+ Grid
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..af26fa3
--- /dev/null
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,7 @@
+
+
+
+ zeus.dl.playstation.net
+ ares.dl.playstation.net
+
+
diff --git a/build.gradle.kts b/build.gradle.kts
index 8462ad7..f93f06f 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,21 +1,23 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
- val kotlin_version = "1.8.22"
-
repositories {
google()
mavenCentral()
}
dependencies {
- classpath("com.android.tools.build:gradle:8.0.2")
- classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
+ classpath(libs.gradle)
+ classpath(libs.kotlin.gradle.plugin)
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
+plugins {
+ alias(libs.plugins.compose.compiler) apply false
+}
+
allprojects {
repositories {
google()
@@ -26,6 +28,6 @@ allprojects {
tasks {
register("clean", Delete::class) {
- delete(rootProject.buildDir)
+ delete(rootProject.layout.buildDirectory)
}
}
diff --git a/changes.patch b/changes.patch
new file mode 100644
index 0000000..8f66b44
--- /dev/null
+++ b/changes.patch
@@ -0,0 +1,3699 @@
+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")
+-
+- // Compose extras library for controlling system ui and etc.
+- implementation("com.google.accompanist:accompanist-systemuicontroller:0.31.2-alpha")
+-
+- 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.androidx.datastore.preferences)
++
++ implementation(libs.androidx.core.splashscreen)
++ implementation(libs.androidx.navigation.compose)
++
++ 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/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..01c0a59
+--- /dev/null
++++ b/app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadManager.kt
+@@ -0,0 +1,85 @@
++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 download(package_: NPSPackageDownload, autoStart: Boolean = true) {
++ downloader.enqueue(package_, autoStart)
++ }
++
++ 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..2e11521
+--- /dev/null
++++ b/app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadNotifier.kt
+@@ -0,0 +1,187 @@
++package com.illusionware.npsbrowser.data.download
++
++import android.content.Context
++import android.util.Log
++import androidx.core.app.NotificationCompat
++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) {
++// setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher))
++ setAutoCancel(false)
++ setOnlyAlertOnce(true)
++ }
++ }
++
++ private val errorNotificationBuilder by lazy {
++ context.notificationBuilder(Notifications.CHANNEL_DOWNLOADER) {
++ setAutoCancel(false)
++ }
++ }
++
++ /**
++ * 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)
++ }
++
++ /**
++ * Called when download progress changes.
++ *
++ * @param download download object containing download information.
++ */
++ fun onProgressChange(size: Long, progress: Long) {
++ with(progressNotificationBuilder) {
++ if (!isDownloading) {
++ setSmallIcon(android.R.drawable.stat_sys_download)
++ clearActions()
++ // Open download manager when clicked
++// setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
++ isDownloading = true
++ // Pause action
++// addAction(
++// R.drawable.ic_pause_24dp,
++// context.getString(R.string.action_pause),
++// NotificationReceiver.pauseDownloadsPendingBroadcast(context),
++// )
++ }
++
++// val downloadingProgressText = context.getString(
++// R.string.chapter_downloading_progress,
++// download.downloadedImages,
++// download.pages!!.size,
++// )
++
++// setContentTitle("Downloading ${download.name}")
++ setContentText("${progress / 1024 / 1024}MB / ${size / 1024 / 1024}MB")
++
++ setProgress((size / 1024).toInt(), (progress / 1024).toInt(), false)
++ setOngoing(true)
++
++// Log.d("DOWNLOAD", progress.toString())
++
++ show(Notifications.ID_DOWNLOADER_GROUP)
++ }
++ }
++
++ /**
++ * Show notification when download is paused.
++ */
++// fun onPaused() {
++// with(progressNotificationBuilder) {
++// setContentTitle(context.getString(R.string.chapter_paused))
++// setContentText(context.getString(R.string.download_notifier_download_paused))
++// setSmallIcon(R.drawable.ic_pause_24dp)
++// setProgress(0, 0, false)
++// setOngoing(false)
++// clearActions()
++// // Open download manager when clicked
++// setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
++// // Resume action
++// addAction(
++// R.drawable.ic_play_arrow_24dp,
++// context.getString(R.string.action_resume),
++// NotificationReceiver.resumeDownloadsPendingBroadcast(context),
++// )
++// // Clear action
++// addAction(
++// R.drawable.ic_close_24dp,
++// context.getString(R.string.action_cancel_all),
++// NotificationReceiver.clearDownloadsPendingBroadcast(context),
++// )
++//
++// show(Notifications.ID_DOWNLOAD_CHAPTER_PROGRESS)
++// }
++//
++// // Reset initial values
++// isDownloading = false
++// }
++
++ /**
++ * Resets the state once downloads are completed.
++ */
++ fun onComplete() {
++ dismissProgress()
++
++ // Reset states to default
++ isDownloading = false
++ }
++
++ /**
++ * Called when the downloader receives a warning.
++ *
++ * @param reason the text to show.
++ * @param timeout duration after which to automatically dismiss the notification.
++ * Only works on Android 8+.
++ */
++// fun onWarning(reason: String, timeout: Long? = null, contentIntent: PendingIntent? = null) {
++// with(errorNotificationBuilder) {
++// setContentTitle(context.getString(R.string.download_notifier_downloader_title))
++// setStyle(NotificationCompat.BigTextStyle().bigText(reason))
++// setSmallIcon(R.drawable.ic_warning_white_24dp)
++// setAutoCancel(true)
++// clearActions()
++// setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
++// setProgress(0, 0, false)
++// timeout?.let { setTimeoutAfter(it) }
++// contentIntent?.let { setContentIntent(it) }
++//
++// show(Notifications.ID_DOWNLOAD_CHAPTER_ERROR)
++// }
++//
++// // Reset download information
++// isDownloading = false
++// }
++
++ /**
++ * Called when the downloader receives an error. It's shown as a separate notification to avoid
++ * being overwritten.
++ *
++ * @param error string containing error information.
++ * @param chapter string containing chapter title.
++ */
++// fun onError(error: String? = null, chapter: String? = null, mangaTitle: String? = null) {
++// // Create notification
++// with(errorNotificationBuilder) {
++// setContentTitle(
++// mangaTitle?.plus(": $chapter") ?: context.getString(R.string.download_notifier_downloader_title),
++// )
++// setContentText(error ?: context.getString(R.string.download_notifier_unknown_error))
++// setSmallIcon(R.drawable.ic_warning_white_24dp)
++// clearActions()
++// setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
++// setProgress(0, 0, false)
++//
++// show(Notifications.ID_DOWNLOAD_CHAPTER_ERROR)
++// }
++//
++// // Reset download information
++// 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..0e05cbd
+--- /dev/null
++++ b/app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadService.kt
+@@ -0,0 +1,116 @@
++package com.illusionware.npsbrowser.data.download
++
++import android.app.Notification
++import android.app.Service
++import android.content.Context
++import android.content.Intent
++import android.os.IBinder
++import android.os.PowerManager.WakeLock
++import android.util.Log
++import androidx.core.content.ContextCompat
++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)
++ startForeground(Notifications.ID_DOWNLOADER_GROUP, getPlaceholderNotification())
++ 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) {
++ setContentTitle("Starting download")
++ }.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..b26b504
+--- /dev/null
++++ b/app/src/main/java/com/illusionware/npsbrowser/data/download/DownloadStore.kt
+@@ -0,0 +1,130 @@
++package com.illusionware.npsbrowser.data.download
++
++import android.content.Context
++import android.util.Log
++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 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"
++private const val DOWNLOADED_TABLE_NAME = "downloaded"
++
++// TODO: We need to implement a downloaded table
++
++@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,
++): ProgressListener {
++ @Transient
++ private val _statusFlow = MutableStateFlow(State.PAUSED)
++
++ @Transient
++ val statusFlow = _statusFlow.asStateFlow()
++ var status: State
++ get() = _statusFlow.value
++ set(status) {
++ _statusFlow.value = status
++ }
++
++ @Transient
++ private val _progressFlow = MutableStateFlow(bytesDownloaded.toFloat() / size.toFloat())
++
++ @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),
++ }
++
++ override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
++ progress = if (contentLength > 0) {
++ (bytesRead.toFloat() / size.toFloat())
++ } 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 = 1)
++abstract class DownloadStore : RoomDatabase() {
++ abstract fun downloadDao(): NPSPackageDownloadDao
++
++ companion object {
++ @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
++ ).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..0cb4041
+--- /dev/null
++++ b/app/src/main/java/com/illusionware/npsbrowser/data/download/Downloader.kt
+@@ -0,0 +1,448 @@
++package com.illusionware.npsbrowser.data.download
++
++import android.content.Context
++import android.util.Log
++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.CoroutineStart
++import kotlinx.coroutines.DelicateCoroutinesApi
++import kotlinx.coroutines.Dispatchers
++import kotlinx.coroutines.ExperimentalCoroutinesApi
++import kotlinx.coroutines.GlobalScope
++import kotlinx.coroutines.Job
++import kotlinx.coroutines.flow.MutableStateFlow
++import kotlinx.coroutines.flow.asStateFlow
++import kotlinx.coroutines.flow.collectLatest
++import kotlinx.coroutines.flow.combine
++import kotlinx.coroutines.flow.distinctUntilChanged
++import kotlinx.coroutines.flow.filter
++import kotlinx.coroutines.flow.first
++import kotlinx.coroutines.flow.transformLatest
++import kotlinx.coroutines.flow.update
++import kotlinx.coroutines.launch
++import kotlinx.coroutines.supervisorScope
++import kotlinx.coroutines.withContext
++import okhttp3.Headers
++import okio.appendingSink
++import okio.buffer
++import java.io.File
++
++// TODO: We need to fix the status of pending downloads because if we have 2 downloads in the queue,
++// and they're both paused and we try to resume the 2nd one, it will start downloading the 1st one
++// instead because the flow just emits the first one in the queue. A possible solution would be if
++// the 1st/higher priority download is paused, and we resume the 2nd/low priority one, we should
++// just reorder the 2nd one to the top and resume it.
++// the proper solution is to put whatever download we start/resume at a higher priority than all
++// the paused downloads I think.
++
++// TODO: fix the countless bugs with pausing and resuming downloads
++
++
++// TODO: notifications along with their actions and stuff
++
++// TODO: setting to control how many active downloads there can be
++
++class Downloader(val context: Context, private val downloadManager: DownloadManager) {
++
++ private val store = DownloadStore.getDatabase(context)
++ private val networkHelper = NetworkHelper(context)
++
++ private val _queueState = MutableStateFlow>(emptyList())
++ val queueState = _queueState.asStateFlow()
++
++ private var downloaderJob: Job? = null
++ private val notifier by lazy { DownloadNotifier(context) }
++
++ private val downloadJobs = mutableMapOf()
++
++ private val scope = CoroutineScope(Dispatchers.IO)
++
++ val isRunning: Boolean
++ get() = downloaderJob?.isActive ?: false
++
++ @Volatile
++ var isPaused: Boolean = false
++
++ init {
++ scope.launch(Dispatchers.IO) {
++ val storeItems = store.downloadDao().getAll().first()
++// Log.d("DOWNLOAD", "Loaded ${storeItems.size} downloads from database")
++// Log.d("DOWNLOAD", "Loaded $storeItems from database")
++ addAllToQueue(storeItems)
++ }
++ }
++
++ @OptIn(DelicateCoroutinesApi::class)
++ fun launchIO(block: suspend CoroutineScope.() -> Unit): Job =
++ GlobalScope.launch(Dispatchers.IO, CoroutineStart.DEFAULT, block)
++
++ fun enqueue(download: NPSPackageDownload, autoStart: Boolean) = launchIO {
++ store.downloadDao().insert(download)
++
++ val wasEmpty = queueState.value.isEmpty()
++
++ _queueState.update {
++ download.status = NPSPackageDownload.State.QUEUE
++ it + download
++ }
++
++ if (autoStart && !DownloadService.isRunning(context)) {
++ DownloadService.start(context, downloadManager)
++ }
++ }
++
++ fun start(): Boolean {
++ if (isRunning || queueState.value.isEmpty()) {
++// Log.d("DOWNLOAD", "Downloader is already running or queue is empty")
++ return false
++ }
++
++ val pending = queueState.value.filter { it.status != NPSPackageDownload.State.DOWNLOADED }
++// Log.d("DOWNLOAD", "Starting downloader job with ${pending.size} pending downloads")
++// Log.d("DOWNLOAD", "Pending are $pending")
++ pending.forEach { if (it.status != NPSPackageDownload.State.QUEUE) it.status = NPSPackageDownload.State.QUEUE }
++
++ isPaused = false
++
++ launchDownloaderJob()
++
++ return pending.isNotEmpty()
++ }
++
++ fun pauseAll() {
++ cancelDownloaderJob()
++ queueState.value
++ .filter { it.status == NPSPackageDownload.State.DOWNLOADING || it.status == NPSPackageDownload.State.QUEUE }
++ .forEach { it.status = NPSPackageDownload.State.PAUSED }
++ isPaused = true
++ }
++
++ fun stop(reason: String? = null) {
++ cancelDownloaderJob()
++ queueState.value
++ .filter { it.status == NPSPackageDownload.State.DOWNLOADING }
++ .forEach { it.status = NPSPackageDownload.State.ERROR }
++
++ queueState.value
++ .filter { it.status == NPSPackageDownload.State.QUEUE }
++ .forEach { it.status = NPSPackageDownload.State.PAUSED }
++
++ if (reason != null) {
++// notifier.notifyError(reason)
++ return
++ }
++
++ if (isPaused && queueState.value.isNotEmpty()) {
++// notifier.notifyPaused()
++ } else {
++// notifier.notifyFinished()
++ }
++
++ isPaused = false
++
++ // Prevent recursion when DownloadService.onDestroy() calls downloader.stop()
++ if (DownloadService.isRunning.value) {
++ DownloadService.stop(context)
++ }
++ }
++
++ fun pause(url: String) {
++ queueState.value
++ .filter { it.url == url }
++ .forEach { it.status = NPSPackageDownload.State.PAUSED }
++// _queueState.update { it }
++
++ downloadJobs[url]?.cancel()
++
++ if (areAllPaused()) {
++ stop()
++ }
++ }
++
++ fun resume(url: String) {
++ if (queueState.value.isEmpty()) {
++ Log.e("DOWNLOAD", "Can't resume because queue is empty")
++ return
++ }
++
++ // seems like this doesn't work ??
++// queueState.value
++// .filter { it.url == url }
++// .forEach { it.status = NPSPackageDownload.State.QUEUE }
++
++ // and this does
++ _queueState.update { list ->
++ list.filter { it.url != url } + list.filter { it.url == url }.map {
++ it.status = NPSPackageDownload.State.QUEUE
++ it
++ }
++ }
++
++// _queueState.update { list -> list.sortedByDescending { it.status.value } }
++// scope.launch {
++// _queueState.emit(queueState.value)
++// }
++
++ // TODO: this kinda works, theres a desync bug again
++ // it also reorders them when theres a download already running
++ // idfk
++ cancelDownloaderJob()
++ launchDownloaderJob()
++
++// queueState.value.sortedByDescending { it.status.value }
++
++// if (!isRunning) {
++// launchDownloaderJob()
++// }
++
++ // TODO: resume a single download
++ }
++
++ @OptIn(ExperimentalCoroutinesApi::class)
++ private fun launchDownloaderJob() {
++ if (isRunning) return
++// Log.d("DOWNLOAD", "Starting downloader job")
++
++ downloaderJob = scope.launch {
++ val activeDownloadsFlow = queueState.transformLatest { queue ->
++// Log.d("DOWNLOAD", "transforming ${queueState.value}")
++ while (true) {
++// Log.d("DOWNLOAD", "inside the transformLatest loop")
++ val activeDownloads = queue
++ .filter { it.status == NPSPackageDownload.State.DOWNLOADING || it.status == NPSPackageDownload.State.QUEUE }
++ .take(1)
++
++ emit(activeDownloads)
++
++ if (activeDownloads.isEmpty()) break
++// Log.d("DOWNLOAD", "emitting ${activeDownloads[0].name}")
++
++ val activeDownloadsErroredFlow =
++ combine(activeDownloads.map(NPSPackageDownload::statusFlow)) { states ->
++ states.contains(NPSPackageDownload.State.ERROR)
++ }.filter { it }
++
++// Log.d("DOWNLOAD", "errored flow ${activeDownloadsErroredFlow.first()}")
++ activeDownloadsErroredFlow.first()
++ }
++ }.distinctUntilChanged()
++
++// Log.d("DOWNLOAD", "left the transformLatest loop")
++// Log.d("DOWNLOAD", activeDownloadsFlow.first().toString())
++
++// activeDownloadsFlow.collectLatest { activeDownload ->
++// launchDownloadJob(activeDownload)
++// }
++
++
++ supervisorScope {
++ activeDownloadsFlow.collectLatest { activeDownloads ->
++// Log.d("DOWNLOAD", "collected : $activeDownloads")
++ val downloadJobsToStop = downloadJobs.filter { job ->
++ activeDownloads.any { it.url == job.key }
++ }
++ downloadJobsToStop.forEach { (url, job) ->
++// Log.d("DOWNLOAD", "Removing download job for $url from map")
++ job.cancel()
++ downloadJobs.remove(url)
++ }
++
++ val downloadsToStart = activeDownloads.filter { it.url !in downloadJobs }
++ downloadsToStart.forEach { download ->
++ val job = launchDownloadJob(download)
++ downloadJobs[download.url] = job
++
++ job.invokeOnCompletion { cause ->
++ if (cause is CancellationException) {
++// Log.d("DOWNLOAD", "child job got cancelled")
++ scope.launch(Dispatchers.IO) {
++ store.downloadDao().update(download)
++ }
++ }
++ }
++
++ downloaderJob?.invokeOnCompletion { cause ->
++// Log.d("DOWNLOAD", "completion")
++ if (cause is CancellationException) {
++// Log.d("DOWNLOAD", "I JUST FUCKING CANCELLED THE DAMN THING")
++ job.cancel()
++ }
++ }
++ }
++
++// activeDownloads.forEach { download ->
++// try {
++// val job = launchDownloadJob(download)
++//
++// downloadJobs.add(job)
++//
++// downloaderJob?.invokeOnCompletion { cause ->
++// Log.d("DOWNLOAD", "completion")
++// if (cause is CancellationException) {
++// Log.d("DOWNLOAD", "I JUST FUCKING CANCELLED THE DAMN THING")
++// job.cancel()
++// }
++// }
++// } catch (e: Throwable) {
++// if (e is CancellationException) {
++// Log.d("DOWNLOAD", "cancellation exception inside supervisor: $e")
++// throw e
++// }
++// }
++// }
++ }
++// downloadJobs.joinAll()
++ }
++ }
++ }
++
++ private suspend fun launchDownloadJob(download: NPSPackageDownload) = launchIO {
++ try {
++// Log.d("DOWNLOAD", "Starting download for ${download.name}")
++ downloadPackage(download, this)
++
++ // Remove successful download from queue
++ if (download.status == NPSPackageDownload.State.DOWNLOADED) {
++// Log.d("DOWNLOAD", "removing ${download.name} from queue")
++ removeFromQueue(download)
++ }
++ if (areAllDownloadsFinished()) {
++// Log.d("DOWNLOAD", "ALL DOWNLOADS FINISHED. STOPPING")
++ stop()
++ }
++ } catch (e: Throwable) {
++ if (e is CancellationException) {
++// Log.d("DOWNLOAD", "cancellation exception: $e")
++// Log.d("DOWNLOAD", "CANCELLING CHILD COROUTINE")
++// store.downloadDao().update(download)
++ throw e
++ }
++// logcat(LogPriority.ERROR, e)
++// notifier.onError(e.message)
++ stop()
++ }
++ }
++
++ private val progressListener = object : ProgressListener {
++ var savedProgress = 0L
++
++ var lastTick = 0L
++
++ override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
++// Log.d("DOWNLOAD", "Progress: $bytesRead / $contentLength")
++ val progress = (100 * (bytesRead.toFloat() / contentLength.toFloat())).toLong()
++ val currentTime = System.currentTimeMillis()
++ if (progress > savedProgress && currentTime - 200 > lastTick) {
++ savedProgress = progress
++ lastTick = currentTime
++ }
++
++ notifier.onProgressChange(contentLength, bytesRead)
++ }
++ }
++
++ private suspend fun downloadPackage(download: NPSPackageDownload, scope: CoroutineScope) {
++ download.status = NPSPackageDownload.State.DOWNLOADING
++
++ val res = networkHelper.client.newCachelessCallWithProgress(
++ GET(download.url, Headers.headersOf("Range", "bytes=${download.bytesDownloaded}-")),
++ download,
++ download.bytesDownloaded
++ ).awaitSuccess()
++ val file = File("${defaultDownloadDir}/${download.titleId}")
++ val fileBufferedSink = file.appendingSink().buffer()
++
++ try {
++// Log.d("DOWNLOAD", "Saving file to ${defaultDownloadDir}/${download.titleId}")
++
++ if (res.body!!.source().saveTo(
++ fileBufferedSink,
++ res.body!!.contentLength(),
++ download.bytesDownloaded,
++ scope
++ )
++ ) {
++ download.status = NPSPackageDownload.State.DOWNLOADED
++ }
++
++ } catch (e: Exception) {
++ if (e is CancellationException) {
++// Log.d("DOWNLOAD", "download func cancellation exception: $e")
++ throw e
++ }
++ download.status = NPSPackageDownload.State.ERROR
++ res.close()
++// Log.d("DOWNLOAD", "Download failed for ${download.name}")
++// Log.d("DOWNLOAD", "Exception: $e")
++ throw e
++ } finally {
++// outputStream?.close()
++ withContext(Dispatchers.IO) {
++ fileBufferedSink.close()
++ }
++ }
++ }
++
++ private fun cancelDownloaderJob() {
++ downloaderJob?.cancel()
++ downloaderJob = null
++// Log.d("DOWNLOAD", "Downloader job canceled")
++ }
++
++ private fun areAllDownloadsFinished(): Boolean {
++ return queueState.value.isEmpty()
++ }
++
++ private fun areAllPaused(): Boolean {
++ return queueState.value.all { it.status == NPSPackageDownload.State.PAUSED }
++ }
++
++ private fun addAllToQueue(downloads: List) {
++ _queueState.update {
++ downloads.forEach { download ->
++ download.status = NPSPackageDownload.State.PAUSED
++ }
++// store.downloadDao().addAll(downloads)
++ downloads
++ }
++// Log.d("DOWNLOAD", "enqueued something, current queue: ${queueState.value}")
++ }
++
++ // TODO: use this function to move the downloads that have successfully finished
++ // and keep the func below this for removal from the db when pressing the remove button
++ private fun moveFinishedDownloadsToDownloaded(downloads: List) {
++ _queueState.update {
++ it - downloads
++ }
++ }
++
++ private fun removeFromQueue(download: NPSPackageDownload) {
++ _queueState.update {
++ scope.launch {
++ store.downloadDao().delete(download.url)
++ }
++ // TODO: is this useless???
++// if (download.status == NPSPackageDownload.State.DOWNLOADING || download.status == NPSPackageDownload.State.QUEUE) {
++// download.status = NPSPackageDownload.State.PAUSED
++// }
++ it - download
++ }
++
++ downloadJobs.remove(download.url)
++ }
++
++ fun removeDownload(url: String) {
++ val download = queueState.value.find { it.url == url } ?: return
++ removeFromQueue(download)
++
++ val file = File("${defaultDownloadDir}/${download.titleId}")
++ file.delete()
++ }
++}
+\ No newline at end of file
+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..27e30b7
+--- /dev/null
++++ b/app/src/main/java/com/illusionware/npsbrowser/data/notification/Notifications.kt
+@@ -0,0 +1,30 @@
++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
++
++ 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")
++ .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..c5a554b
+--- /dev/null
++++ b/app/src/main/java/com/illusionware/npsbrowser/ui/screens/Downloads.kt
+@@ -0,0 +1,266 @@
++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.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
++ val isDownloaderRunning = packageDetailsViewModel.downloadManager.isDownloaderRunning.collectAsStateWithLifecycle().value
++
++ 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 (queue.isNotEmpty() && isDownloaderRunning && !packageDetailsViewModel.downloadManager.isDownloaderPaused) {
++ ExtendedFloatingActionButton(
++ text = { Text(text = "Pause All") },
++ icon = { Icon(imageVector = Icons.Filled.Pause, contentDescription = "Pause")},
++ onClick = {
++ packageDetailsViewModel.downloadManager.pauseDownloads()
++ }
++ )
++ } else if (queue.isNotEmpty()) {
++ 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 },
++ ) { i, item ->
++ DownloadItem(
++ item,
++ packageDetailsViewModel,
++ packageDetailsViewModel::removeDownload,
++ packageDetailsViewModel::pauseDownload,
++ packageDetailsViewModel::resumeDownload
++ )
++ }
++ }
++ }
++ }
++}
++
++@Composable
++fun DownloadItem(
++ item: NPSPackageDownload,
++ packageDetailsViewModel: PackageDetailsViewModel,
++ removeDownload: (String) -> Unit,
++ pauseDownload: (String) -> Unit,
++ resumeDownload: (String) -> Unit,
++) {
++ val progress = item.progressFlow.collectAsStateWithLifecycle().value
++ val bytesDownloaded = item.bytesDownloadedFlow.collectAsStateWithLifecycle().value
++ val isDownloaderRunning = packageDetailsViewModel.downloadManager.isDownloaderRunning.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 {
++// Log.d("DOWNLOAD", item.status.toString())
++ if (item.status == NPSPackageDownload.State.DOWNLOADING || item.status == NPSPackageDownload.State.QUEUE) {
++// Log.d("DOWNLOAD", "Pausing download")
++ pauseDownload(item.url)
++ } else {
++// Log.d("DOWNLOAD", "Resuming download")
++// if (!isDownloaderRunning) {
++// packageDetailsViewModel.downloadManager.startDownloads()
++// }
++ resumeDownload(item.url)
++ }
++ }
++ .size(48.dp)
++ .aspectRatio(1f)
++ .semantics {
++ role = Role.Button
++ },
++ verticalAlignment = Alignment.CenterVertically,
++ horizontalArrangement = Arrangement.Center
++ ) {
++ Icon(
++ imageVector = when (item.status) {
++ NPSPackageDownload.State.DOWNLOADING, NPSPackageDownload.State.QUEUE -> Icons.Filled.Pause
++ NPSPackageDownload.State.ERROR -> Icons.Filled.RestartAlt
++ else -> Icons.Filled.PlayArrow
++ },
++ contentDescription = when (item.status) {
++ NPSPackageDownload.State.DOWNLOADING, NPSPackageDownload.State.QUEUE -> "Pause"
++ 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 (item.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)
++ }
++ 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
++ ),
++ packageDetailsViewModel = viewModel(
++ factory = PackageDetailsViewModel.Factory
++ ),
++ 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..7f9d112 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
+@@ -62,6 +63,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
+@@ -111,11 +113,13 @@ fun OnBoarding(
+ factory = SettingsViewModel.Factory
+ ),
+ ) {
+- val systemUiController = rememberSystemUiController()
++// val systemUiController = rememberSystemUiController()
+ val pagerState = rememberPagerState(
+ initialPage = 0,
+ initialPageOffsetFraction = 0f
+- )
++ ) {
++ PAGE_COUNT
++ }
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+ val settingsPrefs = settingsViewModel.uiState.collectAsStateWithLifecycle().value
+@@ -125,23 +129,23 @@ fun OnBoarding(
+ else -> true
+ }
+
+- DisposableEffect(systemUiController) {
+- systemUiController.setStatusBarColor(
+- color = Color.Transparent,
+- darkIcons = darkIcons && !darkTheme
+- )
++ DisposableEffect(Unit) {
++// systemUiController.setStatusBarColor(
++// color = Color.Transparent,
++// darkIcons = darkIcons && !darkTheme
++// )
+ val activity = context as Activity
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+ onDispose {
+- systemUiController.setStatusBarColor(
+- color = Color.Transparent,
+- darkIcons = !darkTheme
+- )
++// systemUiController.setStatusBarColor(
++// color = Color.Transparent,
++// darkIcons = !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 +786,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..b86219b 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,8 @@ fun PackageDetails(
+ ),
+ packageDetailsViewModel: PackageDetailsViewModel,
+ ) {
+- val clipboard = LocalClipboardManager.current
++ val clipboard = LocalClipboard.current
++// val context = LocalContext.current
+ val sheetState = rememberModalBottomSheetState(
+ skipPartiallyExpanded = true
+ )
+@@ -107,7 +111,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 +120,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 +140,61 @@ fun PackageDetails(
+ )
+ }
+ NPSFloatingActionButton(
+- onClick = { /* TODO: */ },
++ onClick = {
++// val downloadNotif = NotificationCompat.Builder(context, Notifications.CHANNEL_DOWNLOADER)
++// .setSmallIcon(android.R.drawable.stat_sys_download)
++// .setContentTitle("Downloading ${item.name}")
++// .setTicker("Downloading ${item.name}")
++// .setContentText("0MB / ${item.pkgSize}")
++// .setColor(ColorAccent.toArgb())
++// .setOngoing(true)
++// .setGroup(Notifications.GROUP_DOWNLOADER)
++// .setProgress(100, 0, false)
++//
++// val groupNotif = NotificationCompat.Builder(context, Notifications.CHANNEL_DOWNLOADER)
++// .setSmallIcon(android.R.drawable.stat_sys_download)
++// .setContentTitle("Downloading packages")
++// .setTicker("Downloading packages")
++// .setColor(ColorAccent.toArgb())
++// .setOngoing(true)
++// .setGroup(Notifications.GROUP_DOWNLOADER)
++// .setGroupSummary(true)
++// .setProgress(100, 0, false)
++// .build()
++//
++// val id = Random(System.currentTimeMillis()).nextInt(1000)
++//
++// NotificationManagerCompat
++// .from(context)
++// .notify(id, downloadNotif.build())
++//
++// NotificationManagerCompat
++// .from(context)
++// .notify(Notifications.ID_DOWNLOADER_GROUP, groupNotif)
++
++// DownloadNotifier(context).notify()
++ packageDetailsViewModel.downloadManager.download(
++ NPSPackageDownload(
++ item.pkgUrl!!,
++ item.titleId,
++ item.name,
++ item.sha256,
++ item.pkgSizeInBytes?.toLong() ?: 0,
++ 0L,
++ System.currentTimeMillis()
++ ))
++// packageDetailsViewModel.downloadManager.startDownloads()
++// packageDetailsViewModel.downloader.enqueue(NPSPackageDownload(
++// item.pkgUrl!!,
++// item.titleId,
++// item.name,
++// item.sha256,
++// item.pkgSizeInBytes?.toLong() ?: 0,
++// System.currentTimeMillis()
++// ), true)
++// packageDetailsViewModel.downloader.start()
++// downloader.download(id, item)
++ },
+ onLongClick = { showBottomSheet = true },
+ enabled = !item.pkgUrl.isNullOrEmpty()
+ ) {
+@@ -209,6 +271,7 @@ fun PackageDetails(
+ },
+ sheetState = sheetState,
+ prefs = prefs,
++ packageDetailsViewModel = packageDetailsViewModel
+ )
+ }
+ }
+@@ -218,14 +281,15 @@ fun PackageDetails(
+ fun OneTimeConfigBottomSheet(
+ sheetState: SheetState,
+ onDismiss: () -> Unit,
+- prefs: SettingsPreferences
++ prefs: SettingsPreferences,
++ packageDetailsViewModel: PackageDetailsViewModel
+ ) {
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+- windowInsets = WindowInsets(top = 0.dp),
++ //windowInsets = WindowInsets(top = 0.dp),
+ ) {
+- OneTimeConfigSheetContent(prefs)
++ OneTimeConfigSheetContent(prefs, packageDetailsViewModel)
+ }
+ }
+
+@@ -290,9 +354,11 @@ fun Option(
+ }
+ }
+
+-@OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+-fun OneTimeConfigSheetContent(prefs: SettingsPreferences) {
++fun OneTimeConfigSheetContent(
++ prefs: SettingsPreferences,
++ packageDetailsViewModel: PackageDetailsViewModel
++) {
+ var autoDecrypt by remember { mutableStateOf(prefs.pkg2zipAutoDecrypt) }
+ var unpackInPlace by remember { mutableStateOf(prefs.unpackInDownload) }
+ var deleteAfter by remember { mutableStateOf(prefs.deleteAfterUnpack) }
+@@ -318,6 +384,8 @@ fun OneTimeConfigSheetContent(prefs: SettingsPreferences) {
+ }
+ }
+
++ val item = packageDetailsViewModel.uiState.collectAsStateWithLifecycle().value.item!!
++
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+@@ -385,7 +453,21 @@ fun OneTimeConfigSheetContent(prefs: SettingsPreferences) {
+ }
+ }
+ Button(
+- onClick = { /*TODO: do the download*/ },
++ onClick = {
++ // TODO: need a function to download with custom params
++ // TODO: also close the bottom sheet
++ packageDetailsViewModel.downloadManager.download(
++ NPSPackageDownload(
++ item.pkgUrl!!,
++ item.titleId,
++ item.name,
++ item.sha256,
++ item.pkgSizeInBytes?.toLong() ?: 0,
++ 0L,
++ System.currentTimeMillis()
++ )
++ )
++ },
+ modifier = Modifier.padding(horizontal = 24.dp),
+ ) {
+ Icon(
+@@ -425,31 +507,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..6f508fe 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
+@@ -2,6 +2,8 @@ package com.illusionware.npsbrowser.ui.theme
+
+ import android.app.Activity
+ import android.os.Build
++import android.view.WindowInsetsController
++import android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
+ import androidx.compose.foundation.isSystemInDarkTheme
+ import androidx.compose.material3.MaterialTheme
+ import androidx.compose.material3.darkColorScheme
+@@ -88,7 +90,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
+@@ -102,15 +103,15 @@ fun NPSBrowserTheme(
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as Activity).window
++ // WindowCompat still doesn't have a wrapper for both old and new API versions
++ // so we use the deprecated fields that work for both.
++ // window.insetsController.apply { setSystemBarsAppearance() } is for newer API versions only
+ 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
+- )
++ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
++ WindowCompat.getInsetsController(window, view).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..172eae0
+--- /dev/null
++++ b/app/src/main/java/com/illusionware/npsbrowser/util/system/OkioExtensions.kt
+@@ -0,0 +1,77 @@
++package com.illusionware.npsbrowser.util.system
++
++import android.util.Log
++import kotlinx.coroutines.CoroutineScope
++import kotlinx.coroutines.isActive
++import okio.BufferedSink
++import okio.BufferedSource
++import okio.buffer
++import okio.sink
++import java.io.File
++import java.io.OutputStream
++
++/**
++ * 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 {
++ val alreadyDownloadedBytes = bytesRead
++ var bytesRead = bytesRead
++ var finishedDownloading = false
++
++ use { input ->
++ try {
++ var noOfBytes = input.read(sink.buffer, DEFAULT_BUFFER_SIZE.toLong())
++ while (noOfBytes != -1L && scope.isActive) {
++ bytesRead += noOfBytes
++// publishUpdates(channel, bytesRead, totalBytes, DOWNLOADING, uri)
++ noOfBytes = input.read(sink.buffer, DEFAULT_BUFFER_SIZE.toLong())
++ }
++
++ if (bytesRead != totalBytes + alreadyDownloadedBytes) {
++ bytesRead = input.read(sink.buffer, totalBytes - bytesRead)
++ } else {
++ finishedDownloading = true
++ }
++// publishUpdates(channel, bytesRead, totalBytes, COMPLETED, uri)
++ } catch (e: Exception) {
++ Log.d("DOWNLOAD", "WE HIT AN EXCEPTION: $e")
++ } finally {
++ sink.close()
++ input.close()
++ }
++ }
++
++ return finishedDownloading
++}
++
++/**
++ * 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 @@
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+\ No newline at end of file
+diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
+index 10683c4..4360acf 100644
+--- a/app/src/main/res/values-zh-rCN/strings.xml
++++ b/app/src/main/res/values-zh-rCN/strings.xml
+@@ -21,15 +21,9 @@
+ 外观
+ 主题
+ 布局
+-
+-
+- - 明亮
+- - 黑暗
+- - 使用系统默认
+-
+-
+-
+- - 列表
+- - 网格
+-
++ 明亮
++ 黑暗
++ 使用系统默认
++ 列表
++ 网格
+
+diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
+index 964b6ae..7bf95b9 100644
+--- a/app/src/main/res/values-zh-rTW/strings.xml
++++ b/app/src/main/res/values-zh-rTW/strings.xml
+@@ -21,15 +21,9 @@
+ 外觀
+ 主題
+ 佈局
+-
+-
+- - 明亮
+- - 黑暗
+- - 使用系統默認
+-
+-
+-
+- - 列表
+- - 網格
+-
++ 明亮
++ 黑暗
++ 使用系統默認
++ 列表
++ 網格
+
+diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
+index dc380b1..e21fd27 100644
+--- a/app/src/main/res/values/strings.xml
++++ b/app/src/main/res/values/strings.xml
+@@ -21,15 +21,9 @@
+ Appearance
+ Theme
+ Layout
+-
+-
+- - Light
+- - Dark
+- - Use System Default
+-
+-
+-
+- - List
+- - Grid
+-
++ Light
++ Dark
++ Use System Default
++ List
++ Grid
+
+diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
+new file mode 100644
+index 0000000..af26fa3
+--- /dev/null
++++ b/app/src/main/res/xml/network_security_config.xml
+@@ -0,0 +1,7 @@
++
++
++
++ zeus.dl.playstation.net
++ ares.dl.playstation.net
++
++
+diff --git a/build.gradle.kts b/build.gradle.kts
+index 8462ad7..f93f06f 100644
+--- a/build.gradle.kts
++++ b/build.gradle.kts
+@@ -1,21 +1,23 @@
+ // Top-level build file where you can add configuration options common to all sub-projects/modules.
+
+ buildscript {
+- val kotlin_version = "1.8.22"
+-
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+- classpath("com.android.tools.build:gradle:8.0.2")
+- classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
++ classpath(libs.gradle)
++ classpath(libs.kotlin.gradle.plugin)
+
+ // NOTE: Do not place your application dependencies here; they belong
+ // in the individual module build.gradle files
+ }
+ }
+
++plugins {
++ alias(libs.plugins.compose.compiler) apply false
++}
++
+ allprojects {
+ repositories {
+ google()
+@@ -26,6 +28,6 @@ allprojects {
+
+ tasks {
+ register("clean", Delete::class) {
+- delete(rootProject.buildDir)
++ delete(rootProject.layout.buildDirectory)
+ }
+ }
+diff --git a/gradle.properties b/gradle.properties
+index 4b42278..347075c 100644
+--- a/gradle.properties
++++ b/gradle.properties
+@@ -17,6 +17,5 @@ org.gradle.jvmargs=-Xmx1536m
+ android.useAndroidX=true
+ # Kotlin code style for this project: "official" or "obsolete":
+ kotlin.code.style=official
+-android.defaults.buildfeatures.buildconfig=true
+ android.nonTransitiveRClass=false
+ android.nonFinalResIds=false
+diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
+new file mode 100644
+index 0000000..a2641ba
+--- /dev/null
++++ b/gradle/libs.versions.toml
+@@ -0,0 +1,51 @@
++[versions]
++activityCompose = "1.11.0"
++coil = "2.7.0"
++coilCompose = "2.7.0"
++composeBom = "2025.09.00"
++coreKtx = "1.17.0"
++coreSplashscreen = "1.0.1"
++datastorePreferences = "1.1.7"
++flowreactivenetwork = "1.0.4"
++gradle = "8.12.3"
++kotlinCsv = "cedb7c5ab2"
++kotlin = "2.2.20"
++lifecycleRuntimeKtx = "2.9.3"
++material = "1.13.0"
++material3 = "1.3.2"
++materialIconsExtended = "1.7.8"
++navigationCompose = "2.9.4"
++okio = "3.16.0"
++roomRuntime = "2.8.0"
++rxjava = "1.3.8"
++
++[libraries]
++androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
++androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
++androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
++androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
++androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
++androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
++androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
++androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" }
++androidx-material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" }
++androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
++androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomRuntime" }
++androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomRuntime" }
++androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" }
++androidx-ui = { module = "androidx.compose.ui:ui" }
++androidx-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
++androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
++androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
++coil = { module = "io.coil-kt:coil", version.ref = "coil" }
++coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" }
++flowreactivenetwork = { module = "ru.beryukhov:flowreactivenetwork", version.ref = "flowreactivenetwork" }
++gradle = { module = "com.android.tools.build:gradle", version.ref = "gradle" }
++kotlin-csv = { module = "com.github.IllusionMan1212:kotlin-csv", version.ref = "kotlinCsv" }
++kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
++material = { module = "com.google.android.material:material", version.ref = "material" }
++okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
++rxjava = { module = "io.reactivex:rxjava", version.ref = "rxjava" }
++
++[plugins]
++compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
+index 79160f2..58ed00a 100644
+--- a/gradle/wrapper/gradle-wrapper.properties
++++ b/gradle/wrapper/gradle-wrapper.properties
+@@ -1,6 +1,6 @@
+ #Wed Jun 21 06:42:34 EEST 2023
+ distributionBase=GRADLE_USER_HOME
+ distributionPath=wrapper/dists
+-distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
++distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+ zipStoreBase=GRADLE_USER_HOME
+ zipStorePath=wrapper/dists
diff --git a/gradle.properties b/gradle.properties
index 4b42278..347075c 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -17,6 +17,5 @@ org.gradle.jvmargs=-Xmx1536m
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
-android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..a2641ba
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,51 @@
+[versions]
+activityCompose = "1.11.0"
+coil = "2.7.0"
+coilCompose = "2.7.0"
+composeBom = "2025.09.00"
+coreKtx = "1.17.0"
+coreSplashscreen = "1.0.1"
+datastorePreferences = "1.1.7"
+flowreactivenetwork = "1.0.4"
+gradle = "8.12.3"
+kotlinCsv = "cedb7c5ab2"
+kotlin = "2.2.20"
+lifecycleRuntimeKtx = "2.9.3"
+material = "1.13.0"
+material3 = "1.3.2"
+materialIconsExtended = "1.7.8"
+navigationCompose = "2.9.4"
+okio = "3.16.0"
+roomRuntime = "2.8.0"
+rxjava = "1.3.8"
+
+[libraries]
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
+androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
+androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
+androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
+androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
+androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" }
+androidx-material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" }
+androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
+androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomRuntime" }
+androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomRuntime" }
+androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" }
+androidx-ui = { module = "androidx.compose.ui:ui" }
+androidx-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
+androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
+androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
+coil = { module = "io.coil-kt:coil", version.ref = "coil" }
+coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" }
+flowreactivenetwork = { module = "ru.beryukhov:flowreactivenetwork", version.ref = "flowreactivenetwork" }
+gradle = { module = "com.android.tools.build:gradle", version.ref = "gradle" }
+kotlin-csv = { module = "com.github.IllusionMan1212:kotlin-csv", version.ref = "kotlinCsv" }
+kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
+material = { module = "com.google.android.material:material", version.ref = "material" }
+okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
+rxjava = { module = "io.reactivex:rxjava", version.ref = "rxjava" }
+
+[plugins]
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 79160f2..58ed00a 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
#Wed Jun 21 06:42:34 EEST 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists