mirror of
https://github.com/IllusionMan1212/NPS-Browser.git
synced 2026-10-24 02:12:37 +00:00
WIP downloading
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
+53
-39
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,17 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<application
|
||||
android:name=".NPSApp"
|
||||
@@ -14,7 +22,8 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/NPSTheme"
|
||||
android:usesCleartextTraffic="true">
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
>
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="screenSize|screenLayout|keyboardHidden|orientation"
|
||||
@@ -27,6 +36,12 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".data.download.DownloadService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync"
|
||||
/>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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<Preferences> 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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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<NPSPackageDownload> = 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<NPSPackageDownload> = 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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<List<NPSPackageDownload>>
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<List<NPSPackageDownload>>(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<NPSPackageDownload>) {
|
||||
_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"
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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<Response> {
|
||||
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<Response> {
|
||||
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<StackTraceElement>): 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")
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.illusionware.npsbrowser.data.network
|
||||
|
||||
interface ProgressListener {
|
||||
fun update(bytesRead: Long, contentLength: Long, done: Boolean)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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?,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NPSPackageDownload?>(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.illusionware.npsbrowser.util.system
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
suspend fun <T> withUIContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.illusionware.npsbrowser.util
|
||||
package com.illusionware.npsbrowser.util.system
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<NotificationWithIdAndTag>) {
|
||||
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
|
||||
}
|
||||
@@ -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()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -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) {
|
||||
|
||||
+20
-1
@@ -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<SelectedPackageUiState> = _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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PackageItem> = 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 {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM17,13l-5,5 -5,-5h3V9h4v4h3z"/>
|
||||
</vector>
|
||||
@@ -1,213 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/linear"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/singleAppError"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="60dp"
|
||||
android:fontFamily="sans-serif-light"
|
||||
android:gravity="center"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@color/colorError"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
tools:text="error" />
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/scrollView2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:fillViewport="true"
|
||||
app:layout_constraintBottom_toTopOf="@+id/singleAppError"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/linearLayout">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constrainedHeight="false"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/singleAppTitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="48dp"
|
||||
android:fontFamily="sans-serif-light"
|
||||
android:textAlignment="center"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="1.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="Game Title" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/singleAppDownloadButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="66dp"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginEnd="48dp"
|
||||
android:text="@string/download"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/singleAppTitle" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/singleAppCopyZRIFButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="66dp"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="6dp"
|
||||
android:layout_marginEnd="48dp"
|
||||
android:text="@string/copy_zrif"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/singleAppDownloadButton" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/singleAppContentIdTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_marginEnd="48dp"
|
||||
android:fontFamily="sans-serif-black"
|
||||
android:text="@string/content_id"
|
||||
android:textAlignment="center"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/singleAppCopyZRIFButton" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/singleAppContentId"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginEnd="48dp"
|
||||
android:fontFamily="sans-serif-light"
|
||||
android:textAlignment="center"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/singleAppContentIdTitle"
|
||||
tools:text="Content ID" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/singleAppSizeTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_marginEnd="48dp"
|
||||
android:fontFamily="sans-serif-black"
|
||||
android:text="@string/size"
|
||||
android:textAlignment="center"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/singleAppContentId" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/singleAppFwVerTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_marginEnd="48dp"
|
||||
android:fontFamily="sans-serif-black"
|
||||
android:text="@string/minfw"
|
||||
android:textAlignment="center"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/singleAppSize" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/singleAppSize"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginEnd="48dp"
|
||||
android:fontFamily="sans-serif-light"
|
||||
android:textAlignment="center"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/singleAppSizeTitle"
|
||||
tools:text="Size" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/singleAppFwVer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginEnd="48dp"
|
||||
android:fontFamily="sans-serif-light"
|
||||
android:textAlignment="center"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="1.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/singleAppFwVerTitle"
|
||||
tools:text="MinFW" />
|
||||
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</ScrollView>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/linearLayout"
|
||||
android:layout_width="410dp"
|
||||
android:layout_height="250dp"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/singleAppIcon"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="100dp"
|
||||
android:layout_marginTop="0dp"
|
||||
android:layout_marginEnd="100dp"
|
||||
android:contentDescription="@string/single_app_icon"
|
||||
app:srcCompat="@drawable/ic_games_placeholder_24dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -21,15 +21,9 @@
|
||||
<string name="appearance">外观</string>
|
||||
<string name="theme">主题</string>
|
||||
<string name="layout">布局</string>
|
||||
|
||||
<string-array name="app_theme">
|
||||
<item>明亮</item>
|
||||
<item>黑暗</item>
|
||||
<item>使用系统默认</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="layout_type">
|
||||
<item>列表</item>
|
||||
<item>网格</item>
|
||||
</string-array>
|
||||
<string name="theme_light">明亮</string>
|
||||
<string name="theme_dark">黑暗</string>
|
||||
<string name="theme_system">使用系统默认</string>
|
||||
<string name="layout_list">列表</string>
|
||||
<string name="layout_grid">网格</string>
|
||||
</resources>
|
||||
|
||||
@@ -21,15 +21,9 @@
|
||||
<string name="appearance">外觀</string>
|
||||
<string name="theme">主題</string>
|
||||
<string name="layout">佈局</string>
|
||||
|
||||
<string-array name="app_theme">
|
||||
<item>明亮</item>
|
||||
<item>黑暗</item>
|
||||
<item>使用系統默認</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="layout_type">
|
||||
<item>列表</item>
|
||||
<item>網格</item>
|
||||
</string-array>
|
||||
<string name="theme_light">明亮</string>
|
||||
<string name="theme_dark">黑暗</string>
|
||||
<string name="theme_system">使用系統默認</string>
|
||||
<string name="layout_list">列表</string>
|
||||
<string name="layout_grid">網格</string>
|
||||
</resources>
|
||||
|
||||
@@ -21,15 +21,9 @@
|
||||
<string name="appearance">Appearance</string>
|
||||
<string name="theme">Theme</string>
|
||||
<string name="layout">Layout</string>
|
||||
|
||||
<string-array name="app_theme">
|
||||
<item>Light</item>
|
||||
<item>Dark</item>
|
||||
<item>Use System Default</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="layout_type">
|
||||
<item>List</item>
|
||||
<item>Grid</item>
|
||||
</string-array>
|
||||
<string name="theme_light">Light</string>
|
||||
<string name="theme_dark">Dark</string>
|
||||
<string name="theme_system">Use System Default</string>
|
||||
<string name="layout_list">List</string>
|
||||
<string name="layout_grid">Grid</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">zeus.dl.playstation.net</domain>
|
||||
<domain includeSubdomains="true">ares.dl.playstation.net</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
+7
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+3699
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user