mirror of
https://github.com/IllusionMan1212/NPS-Browser.git
synced 2026-10-24 18:12:38 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d775395efd
|
||
|
|
9e582d3788
|
||
|
|
4b84275f3a
|
||
|
|
bcd2591ba7
|
@@ -1,4 +1,3 @@
|
|||||||
*.iml
|
|
||||||
.gradle
|
.gradle
|
||||||
/local.properties
|
/local.properties
|
||||||
/.idea/caches
|
/.idea/caches
|
||||||
@@ -7,7 +6,6 @@
|
|||||||
/.idea/workspace.xml
|
/.idea/workspace.xml
|
||||||
/.idea/navEditor.xml
|
/.idea/navEditor.xml
|
||||||
/.idea/assetWizardSettings.xml
|
/.idea/assetWizardSettings.xml
|
||||||
.DS_Store
|
|
||||||
/build
|
/build
|
||||||
/captures
|
/captures
|
||||||
.externalNativeBuild
|
.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.util.Properties
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
kotlin("android")
|
kotlin("android")
|
||||||
|
kotlin("kapt")
|
||||||
|
id("com.google.devtools.ksp") version "2.2.0-2.0.2"
|
||||||
|
alias(libs.plugins.compose.compiler)
|
||||||
}
|
}
|
||||||
|
|
||||||
val keystoreProperties = Properties()
|
val keystoreProperties = Properties()
|
||||||
@@ -14,12 +18,14 @@ if (keystorePropertiesFile.exists()) {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.illusionware.npsbrowser"
|
namespace = "com.illusionware.npsbrowser"
|
||||||
compileSdk = 33
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.illusionware.npsbrowser"
|
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
|
minSdk = 23
|
||||||
targetSdk = 33
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.0"
|
versionName = "0.1.0"
|
||||||
|
|
||||||
@@ -27,6 +33,14 @@ android {
|
|||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ksp {
|
||||||
|
arg("room.schemaLocation", "$projectDir/schemas")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = "1.5.2"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
@@ -60,12 +74,12 @@ android {
|
|||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
composeOptions {
|
buildToolsVersion = "36.0.0"
|
||||||
kotlinCompilerExtensionVersion = "1.4.8"
|
|
||||||
}
|
kotlin {
|
||||||
buildToolsVersion = "33.0.1"
|
compilerOptions {
|
||||||
kotlinOptions {
|
jvmTarget = JvmTarget.JVM_17
|
||||||
jvmTarget = "17"
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,43 +89,43 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.1")
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
implementation("androidx.activity:activity-compose:1.7.2")
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(platform("androidx.compose:compose-bom:2022.10.00"))
|
implementation(libs.androidx.ui)
|
||||||
implementation("androidx.compose.ui:ui")
|
implementation(libs.androidx.ui.graphics)
|
||||||
implementation("androidx.compose.ui:ui-graphics")
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation("androidx.core:core-ktx:1.10.1")
|
|
||||||
androidTestImplementation(platform("androidx.compose:compose-bom:2022.10.00"))
|
|
||||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
|
||||||
// Jetpack Compose
|
// Jetpack Compose
|
||||||
val composeBom = platform("androidx.compose:compose-bom:2023.04.01")
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(composeBom)
|
|
||||||
androidTestImplementation(composeBom)
|
// 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
|
// Material 3
|
||||||
implementation("androidx.compose.material3:material3:1.1.1")
|
implementation(libs.androidx.material3)
|
||||||
implementation("androidx.compose.material:material-icons-extended:1.4.3")
|
implementation(libs.androidx.material.icons.extended)
|
||||||
// Android Studio Preview support
|
// Android Studio Preview support
|
||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
|
||||||
// Preferences DataStore (alternative to SharedPreferences)
|
// 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(libs.androidx.core.splashscreen)
|
||||||
implementation("com.google.accompanist:accompanist-systemuicontroller:0.31.2-alpha")
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
|
||||||
implementation("com.github.IllusionMan1212:kotlin-csv:cedb7c5ab2")
|
implementation(libs.coil)
|
||||||
testImplementation("junit:junit:4.13.2")
|
implementation(libs.coil.compose)
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
implementation(libs.rxjava)
|
||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
implementation(libs.okio)
|
||||||
implementation("com.google.android.material:material:1.9.0")
|
implementation(libs.flowreactivenetwork)
|
||||||
implementation("com.github.bumptech.glide:glide:4.14.2")
|
|
||||||
annotationProcessor("com.github.bumptech.glide:compiler:4.14.2")
|
implementation(libs.kotlin.csv)
|
||||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
implementation(libs.material)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
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.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
|
<application
|
||||||
android:name=".NPSApp"
|
android:name=".NPSApp"
|
||||||
@@ -14,9 +22,10 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/NPSTheme"
|
android:theme="@style/NPSTheme"
|
||||||
android:usesCleartextTraffic="true">
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
>
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="screenSize|screenLayout|keyboardHidden|orientation"
|
android:configChanges="screenSize|screenLayout|keyboardHidden|orientation"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/NPSTheme.Splash"
|
android:theme="@style/NPSTheme.Splash"
|
||||||
@@ -27,6 +36,12 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".data.download.DownloadService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
/>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
+115
-52
@@ -1,8 +1,12 @@
|
|||||||
package com.illusionware.npsbrowser.activities
|
package com.illusionware.npsbrowser
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -26,12 +30,14 @@ import androidx.compose.foundation.lazy.grid.items
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.Close
|
||||||
|
import androidx.compose.material.icons.filled.Downloading
|
||||||
import androidx.compose.material.icons.filled.GridView
|
import androidx.compose.material.icons.filled.GridView
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Settings
|
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.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -46,7 +52,6 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.material3.TextField
|
import androidx.compose.material3.TextField
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.surfaceColorAtElevation
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -83,22 +88,21 @@ import androidx.navigation.compose.composable
|
|||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
|
||||||
import com.illusionware.npsbrowser.R
|
|
||||||
import com.illusionware.npsbrowser.Routes
|
|
||||||
import com.illusionware.npsbrowser.data.ItemLayout
|
import com.illusionware.npsbrowser.data.ItemLayout
|
||||||
import com.illusionware.npsbrowser.data.SettingsPreferences
|
import com.illusionware.npsbrowser.data.SettingsPreferences
|
||||||
import com.illusionware.npsbrowser.model.ConsoleType
|
import com.illusionware.npsbrowser.model.ConsoleType
|
||||||
import com.illusionware.npsbrowser.model.PackageItem
|
import com.illusionware.npsbrowser.model.PackageItem
|
||||||
import com.illusionware.npsbrowser.ui.components.*
|
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.OnBoarding
|
||||||
import com.illusionware.npsbrowser.ui.screens.PackageDetails
|
import com.illusionware.npsbrowser.ui.screens.PackageDetails
|
||||||
import com.illusionware.npsbrowser.ui.screens.SettingsScreen
|
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.ColorAccent
|
||||||
import com.illusionware.npsbrowser.ui.theme.ColorOnPrimaryLight
|
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.NPSBrowserTheme
|
||||||
import com.illusionware.npsbrowser.ui.theme.Typography
|
import com.illusionware.npsbrowser.ui.theme.Typography
|
||||||
import com.illusionware.npsbrowser.util.isNavigationBarNeedsScrim
|
|
||||||
import com.illusionware.npsbrowser.viewmodels.OnboardingViewModel
|
import com.illusionware.npsbrowser.viewmodels.OnboardingViewModel
|
||||||
import com.illusionware.npsbrowser.viewmodels.PackageDetailsViewModel
|
import com.illusionware.npsbrowser.viewmodels.PackageDetailsViewModel
|
||||||
import com.illusionware.npsbrowser.viewmodels.PackageListViewModel
|
import com.illusionware.npsbrowser.viewmodels.PackageListViewModel
|
||||||
@@ -113,8 +117,9 @@ val redHatDisplayFamily = FontFamily(
|
|||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
val settingsViewModel: SettingsViewModel = viewModel(
|
val settingsViewModel: SettingsViewModel = viewModel(
|
||||||
factory = SettingsViewModel.Factory
|
factory = SettingsViewModel.Factory
|
||||||
@@ -122,13 +127,19 @@ class MainActivity : ComponentActivity() {
|
|||||||
val onboardingViewModel: OnboardingViewModel = viewModel(
|
val onboardingViewModel: OnboardingViewModel = viewModel(
|
||||||
factory = OnboardingViewModel.Factory
|
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() }
|
val appTheme = runBlocking { settingsViewModel.getAppTheme() }
|
||||||
|
var seenOnboarding by remember { mutableStateOf(false) }
|
||||||
val settingsPrefs = settingsViewModel.uiState.collectAsStateWithLifecycle().value
|
val settingsPrefs = settingsViewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
seenOnboarding = onboardingViewModel.getOnboardingStatus()
|
||||||
|
}
|
||||||
|
|
||||||
val darkTheme = when (appTheme) {
|
val darkTheme = when (appTheme) {
|
||||||
0 -> false
|
0 -> false
|
||||||
1 -> true
|
1 -> true
|
||||||
@@ -138,11 +149,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
NPSBrowserTheme(darkTheme = darkTheme) {
|
NPSBrowserTheme(darkTheme = darkTheme) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = if (onboardingPrefs.seenOnboarding) MaterialTheme.colorScheme.background else ColorAccent
|
color = if (seenOnboarding) MaterialTheme.colorScheme.background else ColorAccent
|
||||||
) {
|
) {
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
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) {
|
composable(Routes.Home.route) {
|
||||||
HomePage(
|
HomePage(
|
||||||
@@ -153,6 +170,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
packageDetailsViewModel.setSelectedPackage(item)
|
packageDetailsViewModel.setSelectedPackage(item)
|
||||||
navController.navigate(Routes.PackageDetails.route)
|
navController.navigate(Routes.PackageDetails.route)
|
||||||
},
|
},
|
||||||
|
navigateToDownloads = {
|
||||||
|
navController.navigate(Routes.Downloads.route)
|
||||||
|
},
|
||||||
|
packageDetailsViewModel = packageDetailsViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.Settings.route) {
|
composable(Routes.Settings.route) {
|
||||||
@@ -175,6 +196,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
packageDetailsViewModel = packageDetailsViewModel,
|
packageDetailsViewModel = packageDetailsViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable(Routes.Downloads.route) {
|
||||||
|
Downloads(
|
||||||
|
navigationGoBack = { navController.popBackStack() },
|
||||||
|
packageDetailsViewModel = packageDetailsViewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,50 +214,44 @@ class MainActivity : ComponentActivity() {
|
|||||||
fun HomePage(
|
fun HomePage(
|
||||||
navigateToSettings: () -> Unit,
|
navigateToSettings: () -> Unit,
|
||||||
navigateToDetailsScreen: (item: PackageItem) -> Unit,
|
navigateToDetailsScreen: (item: PackageItem) -> Unit,
|
||||||
|
navigateToDownloads: () -> Unit,
|
||||||
settingsPrefs: SettingsPreferences,
|
settingsPrefs: SettingsPreferences,
|
||||||
settingsViewModel: SettingsViewModel,
|
settingsViewModel: SettingsViewModel,
|
||||||
|
packageListViewModel: PackageListViewModel = viewModel(
|
||||||
|
factory = PackageListViewModel.Factory
|
||||||
|
),
|
||||||
|
packageDetailsViewModel: PackageDetailsViewModel
|
||||||
) {
|
) {
|
||||||
var isSearchOpen by rememberSaveable { mutableStateOf(false)}
|
|
||||||
var searchQuery by rememberSaveable { mutableStateOf("")}
|
|
||||||
val searchBar = remember { FocusRequester() }
|
val searchBar = remember { FocusRequester() }
|
||||||
var layoutDialogOpen by remember { mutableStateOf(false) }
|
var layoutDialogOpen by remember { mutableStateOf(false) }
|
||||||
val context = LocalContext.current
|
val uiState = packageListViewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
val systemUiController = rememberSystemUiController()
|
|
||||||
val navbarScrimColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
|
|
||||||
|
|
||||||
val tsvs = settingsViewModel.availableTsvs()
|
val tsvs = settingsViewModel.availableTsvs()
|
||||||
var selectedBarItem by rememberSaveable { mutableStateOf(ConsoleType.PSVITA) }
|
var selectedBarItem by rememberSaveable { mutableStateOf(ConsoleType.PSVITA) }
|
||||||
|
|
||||||
LaunchedEffect(systemUiController) {
|
|
||||||
systemUiController.setNavigationBarColor(
|
|
||||||
color = if (context.isNavigationBarNeedsScrim()) {
|
|
||||||
navbarScrimColor.copy(alpha = 0.7f)
|
|
||||||
} else {
|
|
||||||
Color.Transparent
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
if (!isSearchOpen) {
|
if (!uiState.isSearchOpen) {
|
||||||
Text(text = stringResource(id = R.string.app_name))
|
Text(text = stringResource(id = R.string.app_name))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (isSearchOpen) {
|
if (uiState.isSearchOpen) {
|
||||||
NPSIconButton(
|
NPSIconButton(
|
||||||
tooltip = "Close Search",
|
tooltip = "Close Search",
|
||||||
onClick = { isSearchOpen = false }
|
onClick = {
|
||||||
|
packageListViewModel.closeSearch()
|
||||||
|
}
|
||||||
){
|
){
|
||||||
Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = "Close Search")
|
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close Search")
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
TextField(
|
TextField(
|
||||||
value = searchQuery,
|
value = uiState.searchQuery,
|
||||||
onValueChange = {str -> searchQuery = str},
|
onValueChange = {
|
||||||
|
packageListViewModel.search(it)
|
||||||
|
},
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1.0f)
|
.weight(1.0f)
|
||||||
.focusRequester(searchBar),
|
.focusRequester(searchBar),
|
||||||
@@ -248,10 +269,12 @@ fun HomePage(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
if (searchQuery.isNotEmpty()) {
|
if (uiState.searchQuery.isNotEmpty()) {
|
||||||
NPSIconButton(
|
NPSIconButton(
|
||||||
tooltip = "Clear Search",
|
tooltip = "Clear Search",
|
||||||
onClick = { searchQuery = "" }
|
onClick = {
|
||||||
|
packageListViewModel.clearSearch()
|
||||||
|
}
|
||||||
){
|
){
|
||||||
Icon(imageVector = Icons.Filled.Close, contentDescription = "Clear Search")
|
Icon(imageVector = Icons.Filled.Close, contentDescription = "Clear Search")
|
||||||
}
|
}
|
||||||
@@ -261,10 +284,10 @@ fun HomePage(
|
|||||||
searchBar.requestFocus()
|
searchBar.requestFocus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!isSearchOpen) {
|
if (!uiState.isSearchOpen) {
|
||||||
NPSIconButton(
|
NPSIconButton(
|
||||||
tooltip = stringResource(id = R.string.search_tooltip),
|
tooltip = stringResource(id = R.string.search_tooltip),
|
||||||
onClick = { isSearchOpen = true }
|
onClick = { packageListViewModel.openSearch() }
|
||||||
){
|
){
|
||||||
Icon(imageVector = Icons.Filled.Search, contentDescription = stringResource(
|
Icon(imageVector = Icons.Filled.Search, contentDescription = stringResource(
|
||||||
id = R.string.search_tooltip
|
id = R.string.search_tooltip
|
||||||
@@ -275,17 +298,44 @@ fun HomePage(
|
|||||||
tooltip = stringResource(id = R.string.layout),
|
tooltip = stringResource(id = R.string.layout),
|
||||||
onClick = { layoutDialogOpen = true }
|
onClick = { layoutDialogOpen = true }
|
||||||
) {
|
) {
|
||||||
Icon(imageVector = if (settingsPrefs.layout == ItemLayout.GRID.ordinal) Icons.Filled.GridView else Icons.Filled.ViewList, contentDescription = stringResource(
|
Icon(
|
||||||
id = R.string.layout
|
imageVector = if (settingsPrefs.layout == ItemLayout.GRID.ordinal) Icons.Filled.GridView else Icons.AutoMirrored.Filled.ViewList,
|
||||||
))
|
contentDescription = stringResource(
|
||||||
|
id = R.string.layout
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
NPSIconButton(
|
||||||
|
tooltip = "Download queue",
|
||||||
|
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(
|
NPSIconButton(
|
||||||
tooltip = stringResource(id = R.string.title_activity_settings),
|
tooltip = stringResource(id = R.string.title_activity_settings),
|
||||||
onClick = { navigateToSettings() }
|
onClick = { navigateToSettings() }
|
||||||
){
|
) {
|
||||||
Icon(imageVector = Icons.Filled.Settings, contentDescription = stringResource(
|
Icon(
|
||||||
id = R.string.title_activity_settings
|
imageVector = Icons.Filled.Settings,
|
||||||
))
|
contentDescription = stringResource(
|
||||||
|
id = R.string.title_activity_settings
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -306,7 +356,8 @@ fun HomePage(
|
|||||||
PackageList(
|
PackageList(
|
||||||
navigateToDetailsScreen = navigateToDetailsScreen,
|
navigateToDetailsScreen = navigateToDetailsScreen,
|
||||||
type = selectedBarItem,
|
type = selectedBarItem,
|
||||||
settingsPrefs = settingsPrefs
|
settingsPrefs = settingsPrefs,
|
||||||
|
viewModel = packageListViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,6 +372,18 @@ fun HomePage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NoResults() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(text = "No results found", color = MaterialTheme.colorScheme.outline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NavBar(selectedTab: ConsoleType, onTabSelected: (ConsoleType) -> Unit) {
|
fun NavBar(selectedTab: ConsoleType, onTabSelected: (ConsoleType) -> Unit) {
|
||||||
NavigationBar {
|
NavigationBar {
|
||||||
@@ -411,9 +474,7 @@ fun NoTsvs(padding: PaddingValues = PaddingValues(0.dp), navigateToSettings: ()
|
|||||||
fun PackageList(
|
fun PackageList(
|
||||||
type: ConsoleType = ConsoleType.PSVITA,
|
type: ConsoleType = ConsoleType.PSVITA,
|
||||||
navigateToDetailsScreen: (item: PackageItem) -> Unit,
|
navigateToDetailsScreen: (item: PackageItem) -> Unit,
|
||||||
viewModel: PackageListViewModel = viewModel(
|
viewModel: PackageListViewModel,
|
||||||
factory = PackageListViewModel.Factory
|
|
||||||
),
|
|
||||||
settingsPrefs: SettingsPreferences,
|
settingsPrefs: SettingsPreferences,
|
||||||
) {
|
) {
|
||||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
@@ -447,7 +508,7 @@ fun PackageList(
|
|||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (packages.isEmpty()) {
|
if (packages.isEmpty() && uiState.searchQuery.isEmpty()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -461,6 +522,8 @@ fun PackageList(
|
|||||||
color = MaterialTheme.colorScheme.outline
|
color = MaterialTheme.colorScheme.outline
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else if (packages.isEmpty() && uiState.searchQuery.isNotEmpty()) {
|
||||||
|
NoResults()
|
||||||
} else {
|
} else {
|
||||||
if (settingsPrefs.layout == ItemLayout.LIST.ordinal) {
|
if (settingsPrefs.layout == ItemLayout.LIST.ordinal) {
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
@@ -578,7 +641,7 @@ fun Tag(title: String, small: Boolean = false) {
|
|||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.clip(RoundedCornerShape(4.dp))
|
.clip(RoundedCornerShape(4.dp))
|
||||||
.background(Color(0xFFEAF654))
|
.background(ColorTag)
|
||||||
.layoutId("tag")
|
.layoutId("tag")
|
||||||
.padding(horizontal = if (small) 6.dp else 8.dp, vertical = if (small) 2.dp else 4.dp)
|
.padding(horizontal = if (small) 6.dp else 8.dp, vertical = if (small) 2.dp else 4.dp)
|
||||||
) {
|
) {
|
||||||
@@ -7,6 +7,14 @@ import androidx.datastore.preferences.core.Preferences
|
|||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.illusionware.npsbrowser.data.OnboardingPreferencesRepository
|
import com.illusionware.npsbrowser.data.OnboardingPreferencesRepository
|
||||||
import com.illusionware.npsbrowser.data.SettingsPreferencesRepository
|
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(
|
||||||
|
0xE5U, 0xE2U, 0x78U, 0xAAU, 0x1EU, 0xE3U, 0x40U, 0x82U, 0xA0U, 0x88U, 0x27U, 0x9CU, 0x83U, 0xF9U, 0xBBU, 0xC8U,
|
||||||
|
0x06U, 0x82U, 0x1CU, 0x52U, 0xF2U, 0xABU, 0x5DU, 0x2BU, 0x4AU, 0xBDU, 0x99U, 0x54U, 0x50U, 0x35U, 0x51U, 0x14U
|
||||||
|
)
|
||||||
|
|
||||||
private const val PREFERENCES_NAME = "preferences"
|
private const val PREFERENCES_NAME = "preferences"
|
||||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
|
||||||
@@ -16,10 +24,15 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
|
|||||||
class NPSApp: Application() {
|
class NPSApp: Application() {
|
||||||
lateinit var settingsPreferencesRepository: SettingsPreferencesRepository
|
lateinit var settingsPreferencesRepository: SettingsPreferencesRepository
|
||||||
lateinit var onboardingPreferencesRepository: OnboardingPreferencesRepository
|
lateinit var onboardingPreferencesRepository: OnboardingPreferencesRepository
|
||||||
|
lateinit var downloadManager: DownloadManager
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
settingsPreferencesRepository = SettingsPreferencesRepository(dataStore)
|
settingsPreferencesRepository = SettingsPreferencesRepository(dataStore)
|
||||||
onboardingPreferencesRepository = OnboardingPreferencesRepository(dataStore)
|
onboardingPreferencesRepository = OnboardingPreferencesRepository(dataStore)
|
||||||
|
// downloader = Downloader(this)
|
||||||
|
downloadManager = DownloadManager(this)
|
||||||
|
|
||||||
|
Notifications.initialize(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
package com.illusionware.npsbrowser
|
package com.illusionware.npsbrowser
|
||||||
|
|
||||||
sealed class Routes(val route: String) {
|
sealed class Routes(val route: String) {
|
||||||
object Home : Routes("home")
|
data object Home : Routes("home")
|
||||||
object Onboarding : Routes("onboarding")
|
data object Onboarding : Routes("onboarding")
|
||||||
object Settings : Routes("settings")
|
data object Settings : Routes("settings")
|
||||||
object PackageDetails : Routes("package_details")
|
data object PackageDetails : Routes("package_details")
|
||||||
|
data object Downloads : Routes("downloads")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ data class SettingsPreferences(
|
|||||||
val ps3Dlc: String,
|
val ps3Dlc: String,
|
||||||
val pspDlc: String,
|
val pspDlc: String,
|
||||||
val psvThemes: String,
|
val psvThemes: String,
|
||||||
val hmacKey: String,
|
|
||||||
val downloadDir: String,
|
val downloadDir: String,
|
||||||
val unpackDir: String,
|
val unpackDir: String,
|
||||||
val unpackInDownload: Boolean,
|
val unpackInDownload: Boolean,
|
||||||
@@ -64,7 +63,6 @@ class SettingsPreferencesRepository(
|
|||||||
private val PS3_DLC = stringPreferencesKey("ps3_dlc")
|
private val PS3_DLC = stringPreferencesKey("ps3_dlc")
|
||||||
private val PSP_DLC = stringPreferencesKey("psp_dlc")
|
private val PSP_DLC = stringPreferencesKey("psp_dlc")
|
||||||
private val PSV_THEMES = stringPreferencesKey("psv_themes")
|
private val PSV_THEMES = stringPreferencesKey("psv_themes")
|
||||||
private val HMAC_KEY = stringPreferencesKey("hmac_key")
|
|
||||||
private val DOWNLOAD_DIR = stringPreferencesKey("download_dir")
|
private val DOWNLOAD_DIR = stringPreferencesKey("download_dir")
|
||||||
private val UNPACK_DIR = stringPreferencesKey("unpack_dir")
|
private val UNPACK_DIR = stringPreferencesKey("unpack_dir")
|
||||||
private val UNPACK_IN_DOWNLOAD = booleanPreferencesKey("unpack_in_download")
|
private val UNPACK_IN_DOWNLOAD = booleanPreferencesKey("unpack_in_download")
|
||||||
@@ -108,12 +106,6 @@ class SettingsPreferencesRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setHMACKey(key: String) {
|
|
||||||
dataStore.edit { prefs ->
|
|
||||||
prefs[HMAC_KEY] = key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setDownloadDir(dir: String) {
|
suspend fun setDownloadDir(dir: String) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[DOWNLOAD_DIR] = dir
|
prefs[DOWNLOAD_DIR] = dir
|
||||||
@@ -207,7 +199,6 @@ class SettingsPreferencesRepository(
|
|||||||
prefs[PS3_DLC] ?: "",
|
prefs[PS3_DLC] ?: "",
|
||||||
prefs[PSP_DLC] ?: "",
|
prefs[PSP_DLC] ?: "",
|
||||||
prefs[PSV_THEMES] ?: "",
|
prefs[PSV_THEMES] ?: "",
|
||||||
prefs[HMAC_KEY] ?: "",
|
|
||||||
prefs[DOWNLOAD_DIR] ?: defaultDownloadDir,
|
prefs[DOWNLOAD_DIR] ?: defaultDownloadDir,
|
||||||
prefs[UNPACK_DIR] ?: defaultDownloadDir,
|
prefs[UNPACK_DIR] ?: defaultDownloadDir,
|
||||||
prefs[UNPACK_IN_DOWNLOAD] ?: false,
|
prefs[UNPACK_IN_DOWNLOAD] ?: false,
|
||||||
|
|||||||
@@ -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 contentId: String?,
|
||||||
val modificationDate: String?,
|
val modificationDate: String?,
|
||||||
val pkgSize: String,
|
val pkgSize: String,
|
||||||
|
val pkgSizeInBytes: ULong?,
|
||||||
val sha256: String?,
|
val sha256: String?,
|
||||||
val zRif: String?,
|
val zRif: String?,
|
||||||
val rap: 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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.foundation.layout.wrapContentWidth
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.AlertDialogDefaults
|
import androidx.compose.material3.AlertDialogDefaults
|
||||||
|
import androidx.compose.material3.BasicAlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -28,7 +27,7 @@ fun NPSAlertDialog(
|
|||||||
buttons: @Composable () -> Unit,
|
buttons: @Composable () -> Unit,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
AlertDialog(
|
BasicAlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
) {
|
) {
|
||||||
Surface (
|
Surface (
|
||||||
|
|||||||
@@ -1,20 +1,77 @@
|
|||||||
package com.illusionware.npsbrowser.ui.components
|
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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.PlainTooltipBox
|
|
||||||
import androidx.compose.material3.Text
|
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.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.Layout
|
||||||
|
import androidx.compose.ui.layout.layoutId
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun NPSIconButton(tooltip: String, onClick: () -> Unit, content: @Composable () -> Unit) {
|
fun NPSIconButton(
|
||||||
PlainTooltipBox(tooltip = {
|
tooltip: String,
|
||||||
Text(text = tooltip)
|
onClick: () -> Unit,
|
||||||
}) {
|
badge: (@Composable BoxScope.() -> Unit)? = null,
|
||||||
IconButton(onClick = onClick, Modifier.tooltipAnchor()) {
|
content: @Composable () -> Unit
|
||||||
content()
|
) {
|
||||||
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@@ -45,14 +46,15 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
|
||||||
import com.illusionware.npsbrowser.R
|
import com.illusionware.npsbrowser.R
|
||||||
import com.illusionware.npsbrowser.ui.theme.ColorAccent
|
import com.illusionware.npsbrowser.ui.theme.ColorAccent
|
||||||
import com.illusionware.npsbrowser.ui.theme.Typography
|
import com.illusionware.npsbrowser.ui.theme.Typography
|
||||||
@@ -62,8 +64,8 @@ import androidx.compose.foundation.pager.rememberPagerState
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.ContentPaste
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -74,22 +76,19 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.Layout
|
import androidx.compose.ui.layout.Layout
|
||||||
import androidx.compose.ui.layout.layoutId
|
import androidx.compose.ui.layout.layoutId
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.text.TextRange
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.illusionware.npsbrowser.data.SettingsPreferences
|
import com.illusionware.npsbrowser.data.SettingsPreferences
|
||||||
import com.illusionware.npsbrowser.model.ConsoleType
|
import com.illusionware.npsbrowser.model.ConsoleType
|
||||||
import com.illusionware.npsbrowser.model.PackageItemType
|
import com.illusionware.npsbrowser.model.PackageItemType
|
||||||
import com.illusionware.npsbrowser.ui.components.NPSIconButton
|
|
||||||
import com.illusionware.npsbrowser.viewmodels.OnboardingViewModel
|
import com.illusionware.npsbrowser.viewmodels.OnboardingViewModel
|
||||||
import com.illusionware.npsbrowser.viewmodels.SettingsViewModel
|
import com.illusionware.npsbrowser.viewmodels.SettingsViewModel
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
const val PAGE_COUNT = 3
|
const val PAGE_COUNT = 3
|
||||||
@@ -116,13 +115,15 @@ fun OnBoarding(
|
|||||||
factory = SettingsViewModel.Factory
|
factory = SettingsViewModel.Factory
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val systemUiController = rememberSystemUiController()
|
|
||||||
val pagerState = rememberPagerState(
|
val pagerState = rememberPagerState(
|
||||||
initialPage = 0,
|
initialPage = 0,
|
||||||
initialPageOffsetFraction = 0f
|
initialPageOffsetFraction = 0f
|
||||||
)
|
) {
|
||||||
|
PAGE_COUNT
|
||||||
|
}
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val view = LocalView.current
|
||||||
val settingsPrefs = settingsViewModel.uiState.collectAsStateWithLifecycle().value
|
val settingsPrefs = settingsViewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
|
|
||||||
val darkIcons = when (pagerState.currentPage) {
|
val darkIcons = when (pagerState.currentPage) {
|
||||||
@@ -130,23 +131,22 @@ fun OnBoarding(
|
|||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(systemUiController) {
|
DisposableEffect(view, darkIcons, darkTheme) {
|
||||||
systemUiController.setStatusBarColor(
|
|
||||||
color = Color.Transparent,
|
|
||||||
darkIcons = darkIcons && !darkTheme
|
|
||||||
)
|
|
||||||
val activity = context as Activity
|
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
|
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
onDispose {
|
onDispose {
|
||||||
systemUiController.setStatusBarColor(
|
insetsController.isAppearanceLightStatusBars = !darkTheme
|
||||||
color = Color.Transparent,
|
|
||||||
darkIcons = !darkTheme
|
|
||||||
)
|
|
||||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalPager(pageCount = PAGE_COUNT, state = pagerState) { page ->
|
HorizontalPager(state = pagerState) { page ->
|
||||||
when (page) {
|
when (page) {
|
||||||
0 -> OnBoardingInitial(
|
0 -> OnBoardingInitial(
|
||||||
incrementPage = {
|
incrementPage = {
|
||||||
@@ -626,10 +626,6 @@ fun OnBoardingSetupSettings(
|
|||||||
onboardingViewModel: OnboardingViewModel,
|
onboardingViewModel: OnboardingViewModel,
|
||||||
settingsPrefs: SettingsPreferences,
|
settingsPrefs: SettingsPreferences,
|
||||||
) {
|
) {
|
||||||
val clipboard = LocalClipboardManager.current
|
|
||||||
|
|
||||||
var hmacKey by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
|
|
||||||
|
|
||||||
val downloadDirPicker = rememberLauncherForActivityResult(
|
val downloadDirPicker = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||||
) { dirUri ->
|
) { dirUri ->
|
||||||
@@ -646,12 +642,6 @@ fun OnBoardingSetupSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setHmacKey(key: String) {
|
|
||||||
if (key.isNotEmpty()) {
|
|
||||||
settingsViewModel.setHMACKey(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold { padding ->
|
Scaffold { padding ->
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -685,37 +675,6 @@ fun OnBoardingSetupSettings(
|
|||||||
.padding(vertical = 16.dp),
|
.padding(vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
) {
|
) {
|
||||||
ColumnGroup(title = "HMAC Key", modifier = Modifier.padding(horizontal = 16.dp)) {
|
|
||||||
Text(
|
|
||||||
text = "The HMAC Key is used to decrypt the PS Vita game updates' urls. We cannot provide this key since it's the property of Sony",
|
|
||||||
style = Typography.bodyMedium.copy(color = MaterialTheme.colorScheme.outline),
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = hmacKey,
|
|
||||||
onValueChange = {
|
|
||||||
hmacKey = it
|
|
||||||
setHmacKey(it.text)
|
|
||||||
},
|
|
||||||
label = { Text(text = "HMAC Key") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true,
|
|
||||||
trailingIcon = {
|
|
||||||
NPSIconButton(
|
|
||||||
tooltip = "Paste",
|
|
||||||
onClick = {
|
|
||||||
val text = clipboard.getText()?.text ?: ""
|
|
||||||
hmacKey = TextFieldValue(text, selection = TextRange(text.length))
|
|
||||||
setHmacKey(text)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.ContentPaste,
|
|
||||||
contentDescription = "Paste",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ColumnGroup(title = "Downloads", titleModifier = Modifier.padding(horizontal = 16.dp)) {
|
ColumnGroup(title = "Downloads", titleModifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
@@ -828,7 +787,19 @@ fun Toggleable(title: String, checked: Boolean, onCheckedChange: (Boolean) -> Un
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Text(text = title, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1.0f))
|
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
|
package com.illusionware.npsbrowser.ui.screens
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.ClipData
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
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.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material.icons.Icons
|
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.Download
|
||||||
import androidx.compose.material.icons.filled.DownloadDone
|
import androidx.compose.material.icons.filled.DownloadDone
|
||||||
import androidx.compose.material.icons.filled.Public
|
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.painter.Painter
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.layout.ContentScale
|
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.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -64,10 +67,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import com.illusionware.npsbrowser.R
|
import com.illusionware.npsbrowser.R
|
||||||
import com.illusionware.npsbrowser.activities.PlaceholderColor
|
import com.illusionware.npsbrowser.PlaceholderColor
|
||||||
import com.illusionware.npsbrowser.data.ItemLayout
|
|
||||||
import com.illusionware.npsbrowser.data.SettingsPreferences
|
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.NPSAlertDialog
|
||||||
import com.illusionware.npsbrowser.ui.components.NPSFloatingActionButton
|
import com.illusionware.npsbrowser.ui.components.NPSFloatingActionButton
|
||||||
import com.illusionware.npsbrowser.ui.components.NPSIconButton
|
import com.illusionware.npsbrowser.ui.components.NPSIconButton
|
||||||
@@ -77,6 +79,7 @@ import com.illusionware.npsbrowser.viewmodels.PackageDetailsViewModel
|
|||||||
import com.illusionware.npsbrowser.viewmodels.SettingsViewModel
|
import com.illusionware.npsbrowser.viewmodels.SettingsViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PackageDetails(
|
fun PackageDetails(
|
||||||
@@ -86,7 +89,7 @@ fun PackageDetails(
|
|||||||
),
|
),
|
||||||
packageDetailsViewModel: PackageDetailsViewModel,
|
packageDetailsViewModel: PackageDetailsViewModel,
|
||||||
) {
|
) {
|
||||||
val clipboard = LocalClipboardManager.current
|
val clipboard = LocalClipboard.current
|
||||||
val sheetState = rememberModalBottomSheetState(
|
val sheetState = rememberModalBottomSheetState(
|
||||||
skipPartiallyExpanded = true
|
skipPartiallyExpanded = true
|
||||||
)
|
)
|
||||||
@@ -96,6 +99,29 @@ fun PackageDetails(
|
|||||||
|
|
||||||
val item = packageDetailsViewModel.uiState.collectAsStateWithLifecycle().value.item!!
|
val item = packageDetailsViewModel.uiState.collectAsStateWithLifecycle().value.item!!
|
||||||
var showBottomSheet by remember { mutableStateOf(false) }
|
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(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState)},
|
snackbarHost = { SnackbarHost(snackbarHostState)},
|
||||||
@@ -107,7 +133,7 @@ fun PackageDetails(
|
|||||||
.padding(vertical = 8.dp, horizontal = 4.dp),
|
.padding(vertical = 8.dp, horizontal = 4.dp),
|
||||||
) {
|
) {
|
||||||
NPSIconButton(tooltip = "Go Back", onClick = navigationGoBack) {
|
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(
|
NPSFloatingActionButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (!item.zRif.isNullOrEmpty()) {
|
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()) {
|
} else if (!item.rap.isNullOrEmpty()) {
|
||||||
clipboard.setText(AnnotatedString(item.rap))
|
scope.launch {
|
||||||
|
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(item.rap, item.rap)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
snackbarHostState.showSnackbar("License key copied to clipboard")
|
snackbarHostState.showSnackbar("License key copied to clipboard")
|
||||||
@@ -132,7 +162,9 @@ fun PackageDetails(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
NPSFloatingActionButton(
|
NPSFloatingActionButton(
|
||||||
onClick = { /* TODO: */ },
|
onClick = {
|
||||||
|
requestDownload()
|
||||||
|
},
|
||||||
onLongClick = { showBottomSheet = true },
|
onLongClick = { showBottomSheet = true },
|
||||||
enabled = !item.pkgUrl.isNullOrEmpty()
|
enabled = !item.pkgUrl.isNullOrEmpty()
|
||||||
) {
|
) {
|
||||||
@@ -209,8 +241,30 @@ fun PackageDetails(
|
|||||||
},
|
},
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
prefs = prefs,
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -218,14 +272,16 @@ fun PackageDetails(
|
|||||||
fun OneTimeConfigBottomSheet(
|
fun OneTimeConfigBottomSheet(
|
||||||
sheetState: SheetState,
|
sheetState: SheetState,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
prefs: SettingsPreferences
|
prefs: SettingsPreferences,
|
||||||
|
packageDetailsViewModel: PackageDetailsViewModel,
|
||||||
|
onDownload: () -> Unit,
|
||||||
) {
|
) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
sheetState = sheetState,
|
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
|
@Composable
|
||||||
fun OneTimeConfigSheetContent(prefs: SettingsPreferences) {
|
fun OneTimeConfigSheetContent(
|
||||||
|
prefs: SettingsPreferences,
|
||||||
|
packageDetailsViewModel: PackageDetailsViewModel,
|
||||||
|
onDownload: () -> Unit,
|
||||||
|
) {
|
||||||
var autoDecrypt by remember { mutableStateOf(prefs.pkg2zipAutoDecrypt) }
|
var autoDecrypt by remember { mutableStateOf(prefs.pkg2zipAutoDecrypt) }
|
||||||
var unpackInPlace by remember { mutableStateOf(prefs.unpackInDownload) }
|
var unpackInPlace by remember { mutableStateOf(prefs.unpackInDownload) }
|
||||||
var deleteAfter by remember { mutableStateOf(prefs.deleteAfterUnpack) }
|
var deleteAfter by remember { mutableStateOf(prefs.deleteAfterUnpack) }
|
||||||
@@ -318,6 +377,8 @@ fun OneTimeConfigSheetContent(prefs: SettingsPreferences) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val item = packageDetailsViewModel.uiState.collectAsStateWithLifecycle().value.item!!
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -385,7 +446,9 @@ fun OneTimeConfigSheetContent(prefs: SettingsPreferences) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = { /*TODO: do the download*/ },
|
onClick = {
|
||||||
|
onDownload()
|
||||||
|
},
|
||||||
modifier = Modifier.padding(horizontal = 24.dp),
|
modifier = Modifier.padding(horizontal = 24.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -425,32 +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,11 +23,10 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
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.ContentPaste
|
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.FileDownload
|
||||||
import androidx.compose.material.icons.outlined.Key
|
|
||||||
import androidx.compose.material.icons.outlined.Palette
|
import androidx.compose.material.icons.outlined.Palette
|
||||||
import androidx.compose.material.icons.outlined.Unarchive
|
import androidx.compose.material.icons.outlined.Unarchive
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -48,7 +47,6 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -70,6 +68,7 @@ import com.illusionware.npsbrowser.data.Theme
|
|||||||
import com.illusionware.npsbrowser.model.ConsoleType
|
import com.illusionware.npsbrowser.model.ConsoleType
|
||||||
import com.illusionware.npsbrowser.model.PackageItemType
|
import com.illusionware.npsbrowser.model.PackageItemType
|
||||||
|
|
||||||
|
// TODO: localize this
|
||||||
private val Themes = hashMapOf(
|
private val Themes = hashMapOf(
|
||||||
Theme.LIGHT.ordinal to "Light",
|
Theme.LIGHT.ordinal to "Light",
|
||||||
Theme.DARK.ordinal to "Dark",
|
Theme.DARK.ordinal to "Dark",
|
||||||
@@ -96,7 +95,7 @@ fun SettingsScreen(
|
|||||||
.padding(vertical = 8.dp, horizontal = 4.dp),
|
.padding(vertical = 8.dp, horizontal = 4.dp),
|
||||||
) {
|
) {
|
||||||
NPSIconButton(tooltip = "Go Back", onClick = { navigationGoBack() }) {
|
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)
|
Text(text = stringResource(id = R.string.title_activity_settings), style = Typography.titleLarge)
|
||||||
}
|
}
|
||||||
@@ -115,12 +114,10 @@ fun SettingsScreen(
|
|||||||
) {
|
) {
|
||||||
Appearance(viewModel, prefs)
|
Appearance(viewModel, prefs)
|
||||||
TsvFiles(viewModel, prefs)
|
TsvFiles(viewModel, prefs)
|
||||||
Updates(viewModel, prefs)
|
|
||||||
Downloads(viewModel, prefs)
|
Downloads(viewModel, prefs)
|
||||||
Pkg2Zip(viewModel, prefs)
|
Pkg2Zip(viewModel, prefs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +185,7 @@ fun TsvFiles(viewModel: SettingsViewModel, prefs: SettingsPreferences) {
|
|||||||
DialogSetting(
|
DialogSetting(
|
||||||
title = "Expand",
|
title = "Expand",
|
||||||
value = "PS3 DLC, PSP Games, PSP DLC, PSX Games, PSM Games",
|
value = "PS3 DLC, PSP Games, PSP DLC, PSX Games, PSM Games",
|
||||||
icon = Icons.Outlined.ArrowDropDown,
|
icon = Icons.Outlined.ExpandMore,
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
onClick = { expanded = true },
|
onClick = { expanded = true },
|
||||||
)
|
)
|
||||||
@@ -271,63 +268,7 @@ fun TSVPicker(
|
|||||||
onClick = {
|
onClick = {
|
||||||
filePicker.launch(arrayOf("text/tab-separated-values"))
|
filePicker.launch(arrayOf("text/tab-separated-values"))
|
||||||
}
|
}
|
||||||
)}
|
)
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Updates(viewModel: SettingsViewModel, prefs: SettingsPreferences) {
|
|
||||||
val clipboard = LocalClipboardManager.current
|
|
||||||
|
|
||||||
var hmacOpenDialog by remember { mutableStateOf(false) }
|
|
||||||
var dialogHmacKey by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
SideEffect {
|
|
||||||
dialogHmacKey = prefs.hmacKey
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingGroup(title = "Updates") {
|
|
||||||
DialogSetting(
|
|
||||||
title = "HMAC Key",
|
|
||||||
value = prefs.hmacKey.ifEmpty { "None" },
|
|
||||||
icon = Icons.Outlined.Key,
|
|
||||||
onClick = { hmacOpenDialog = true }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (hmacOpenDialog) {
|
|
||||||
NPSAlertDialog(
|
|
||||||
onDismiss = { hmacOpenDialog = false },
|
|
||||||
title = "HMAC Key",
|
|
||||||
buttons = {
|
|
||||||
TextButton(onClick = { hmacOpenDialog = false }) {
|
|
||||||
Text(text = "Cancel")
|
|
||||||
}
|
|
||||||
TextButton(onClick = {
|
|
||||||
viewModel.setHMACKey(dialogHmacKey)
|
|
||||||
hmacOpenDialog = false
|
|
||||||
}) {
|
|
||||||
Text(text = "OK")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = dialogHmacKey,
|
|
||||||
onValueChange = { dialogHmacKey = it },
|
|
||||||
label = { Text(text = "HMAC Key") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true,
|
|
||||||
trailingIcon = {
|
|
||||||
NPSIconButton(
|
|
||||||
tooltip = "Paste",
|
|
||||||
onClick = { dialogHmacKey = clipboard.getText()?.text ?: "" }
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.ContentPaste,
|
|
||||||
contentDescription = "Paste",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -575,7 +516,19 @@ fun ToggleSetting(title: String, icon: ImageVector? = null, checked: Boolean, on
|
|||||||
Box(modifier = Modifier.width(56.dp))
|
Box(modifier = Modifier.width(56.dp))
|
||||||
}
|
}
|
||||||
Text(text = title, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1.0f))
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ val ColorOnSecondaryDark = Color(0xDFFFFFFF)
|
|||||||
val ColorAmoledDark = Color(0xFF000000)
|
val ColorAmoledDark = Color(0xFF000000)
|
||||||
|
|
||||||
// Works on both Dark and Light
|
// Works on both Dark and Light
|
||||||
|
val ColorTag = Color(0xFFEAF654)
|
||||||
val ColorAccent = Color(0xFF545FBF)
|
val ColorAccent = Color(0xFF545FBF)
|
||||||
val ColorError = Color(0xFFF93422)
|
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.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
|
||||||
|
|
||||||
private val LightColorScheme = lightColorScheme(
|
private val LightColorScheme = lightColorScheme(
|
||||||
primary = md_theme_light_primary,
|
primary = md_theme_light_primary,
|
||||||
@@ -88,7 +87,6 @@ fun NPSBrowserTheme(
|
|||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean = true,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val systemUiController = rememberSystemUiController()
|
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -104,13 +102,10 @@ fun NPSBrowserTheme(
|
|||||||
val window = (view.context as Activity).window
|
val window = (view.context as Activity).window
|
||||||
window.statusBarColor = Color.Transparent.toArgb()
|
window.statusBarColor = Color.Transparent.toArgb()
|
||||||
window.navigationBarColor = Color.Transparent.toArgb()
|
window.navigationBarColor = Color.Transparent.toArgb()
|
||||||
// allows pages to extend to the status bar
|
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
|
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||||
systemUiController.setSystemBarsColor(
|
insetsController.isAppearanceLightStatusBars = !darkTheme
|
||||||
color = Color.Transparent,
|
insetsController.isAppearanceLightNavigationBars = !darkTheme
|
||||||
darkIcons = !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.annotation.SuppressLint
|
||||||
import android.content.Context
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -29,6 +30,10 @@ class OnboardingViewModel(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
suspend fun getOnboardingStatus(): Boolean {
|
||||||
|
return onboardingPreferencesRepository.getPreferences().first().seenOnboarding
|
||||||
|
}
|
||||||
|
|
||||||
fun finishOnboarding(shouldCreateDefaultDir: Boolean) {
|
fun finishOnboarding(shouldCreateDefaultDir: Boolean) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
if (shouldCreateDefaultDir) {
|
if (shouldCreateDefaultDir) {
|
||||||
|
|||||||
+20
-1
@@ -1,6 +1,12 @@
|
|||||||
package com.illusionware.npsbrowser.viewmodels
|
package com.illusionware.npsbrowser.viewmodels
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
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 com.illusionware.npsbrowser.model.PackageItem
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -10,11 +16,24 @@ data class SelectedPackageUiState(
|
|||||||
val item: PackageItem?,
|
val item: PackageItem?,
|
||||||
)
|
)
|
||||||
|
|
||||||
class PackageDetailsViewModel: ViewModel() {
|
class PackageDetailsViewModel(val downloadManager: DownloadManager): ViewModel() {
|
||||||
private val _uiState = MutableStateFlow(SelectedPackageUiState(null))
|
private val _uiState = MutableStateFlow(SelectedPackageUiState(null))
|
||||||
val uiState: StateFlow<SelectedPackageUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<SelectedPackageUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
fun setSelectedPackage(item: PackageItem) {
|
fun setSelectedPackage(item: PackageItem) {
|
||||||
_uiState.value = SelectedPackageUiState(item)
|
_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,22 +15,29 @@ import com.illusionware.npsbrowser.data.SettingsPreferencesRepository
|
|||||||
import com.illusionware.npsbrowser.model.ConsoleType
|
import com.illusionware.npsbrowser.model.ConsoleType
|
||||||
import com.illusionware.npsbrowser.model.PackageItem
|
import com.illusionware.npsbrowser.model.PackageItem
|
||||||
import com.illusionware.npsbrowser.model.PackageItemType
|
import com.illusionware.npsbrowser.model.PackageItemType
|
||||||
|
import com.illusionware.npsbrowser.util.system.prettyByte
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.internal.toLongOrDefault
|
import okhttp3.internal.toLongOrDefault
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import java.util.Locale.ENGLISH
|
||||||
|
|
||||||
data class PackageListUiState(
|
data class PackageListUiState(
|
||||||
val packages: ArrayList<PackageItem> = ArrayList(),
|
val packages: ArrayList<PackageItem> = ArrayList(),
|
||||||
val error: String = "",
|
val error: String = "",
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
|
val isSearchOpen: Boolean = false,
|
||||||
|
val searchQuery: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesRepository): ViewModel() {
|
class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesRepository): ViewModel() {
|
||||||
private val _uiState = MutableStateFlow(PackageListUiState())
|
private val _uiState = MutableStateFlow(PackageListUiState())
|
||||||
|
private val immutablePackages = ArrayList<PackageItem>()
|
||||||
val uiState: StateFlow<PackageListUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<PackageListUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
private val tsvReader = csvReader {
|
private val tsvReader = csvReader {
|
||||||
@@ -52,6 +59,7 @@ class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesReposit
|
|||||||
list.sortBy { it.name }
|
list.sortBy { it.name }
|
||||||
|
|
||||||
_uiState.emit(PackageListUiState(isLoading = false, packages = list))
|
_uiState.emit(PackageListUiState(isLoading = false, packages = list))
|
||||||
|
immutablePackages.addAll(list)
|
||||||
|
|
||||||
settingsRepo.tsvFiles().collect { tsv ->
|
settingsRepo.tsvFiles().collect { tsv ->
|
||||||
_uiState.emit(PackageListUiState())
|
_uiState.emit(PackageListUiState())
|
||||||
@@ -70,10 +78,45 @@ class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesReposit
|
|||||||
list.sortBy { it.name }
|
list.sortBy { it.name }
|
||||||
|
|
||||||
_uiState.emit(PackageListUiState(isLoading = false, packages = list))
|
_uiState.emit(PackageListUiState(isLoading = false, packages = list))
|
||||||
|
|
||||||
|
immutablePackages.addAll(list)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun search(query: String) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
packages = immutablePackages.filter { item ->
|
||||||
|
item.name.contains(query, true)
|
||||||
|
} as ArrayList<PackageItem>,
|
||||||
|
searchQuery = query
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSearch() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(isSearchOpen = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeSearch() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
packages = immutablePackages,
|
||||||
|
searchQuery = "",
|
||||||
|
isSearchOpen = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSearch() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(packages = immutablePackages, searchQuery = "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun parsePackageFromTSV(
|
private fun parsePackageFromTSV(
|
||||||
it: Triple<String, ConsoleType, PackageItemType>, app: NPSApp
|
it: Triple<String, ConsoleType, PackageItemType>, app: NPSApp
|
||||||
): ArrayList<PackageItem> {
|
): ArrayList<PackageItem> {
|
||||||
@@ -85,7 +128,7 @@ class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesReposit
|
|||||||
|
|
||||||
val file: InputStream?
|
val file: InputStream?
|
||||||
try {
|
try {
|
||||||
file = app.contentResolver.openInputStream(Uri.parse(fileUri))
|
file = app.contentResolver.openInputStream(fileUri.toUri())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
@@ -113,10 +156,10 @@ class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesReposit
|
|||||||
val tempsha256 = row["SHA256"]
|
val tempsha256 = row["SHA256"]
|
||||||
val tempDate = row["Last Modification Date"]
|
val tempDate = row["Last Modification Date"]
|
||||||
if (!tempSize.isNullOrEmpty()) {
|
if (!tempSize.isNullOrEmpty()) {
|
||||||
size = bytesIntoHumanReadable(tempSize.toLongOrDefault(0))
|
size = tempSize.toLongOrDefault(0).prettyByte()
|
||||||
}
|
}
|
||||||
if (!tempFW.isNullOrEmpty()) {
|
if (!tempFW.isNullOrEmpty()) {
|
||||||
minFW = String.format("%.2f", tempFW.toFloat())
|
minFW = String.format(ENGLISH, "%.2f", tempFW.toFloat())
|
||||||
}
|
}
|
||||||
if (!tempUrl.isNullOrEmpty() && tempUrl != "MISSING") {
|
if (!tempUrl.isNullOrEmpty() && tempUrl != "MISSING") {
|
||||||
pkgUrl = tempUrl
|
pkgUrl = tempUrl
|
||||||
@@ -146,6 +189,7 @@ class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesReposit
|
|||||||
contentId,
|
contentId,
|
||||||
modificationDate,
|
modificationDate,
|
||||||
size,
|
size,
|
||||||
|
tempSize?.toULongOrNull(),
|
||||||
sha256,
|
sha256,
|
||||||
zRif,
|
zRif,
|
||||||
rap,
|
rap,
|
||||||
@@ -161,27 +205,6 @@ class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesReposit
|
|||||||
return list
|
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 {
|
companion object {
|
||||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ class SettingsViewModel(private val settingsPrefsRepo: SettingsPreferencesReposi
|
|||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
"",
|
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
"",
|
"",
|
||||||
@@ -85,12 +84,6 @@ class SettingsViewModel(private val settingsPrefsRepo: SettingsPreferencesReposi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setHMACKey(hmacKey: String) {
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
settingsPrefsRepo.setHMACKey(hmacKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDownloadDir(downloadDir: String) {
|
fun setDownloadDir(downloadDir: String) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
settingsPrefsRepo.setDownloadDir(downloadDir)
|
settingsPrefsRepo.setDownloadDir(downloadDir)
|
||||||
|
|||||||
@@ -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="appearance">外观</string>
|
||||||
<string name="theme">主题</string>
|
<string name="theme">主题</string>
|
||||||
<string name="layout">布局</string>
|
<string name="layout">布局</string>
|
||||||
|
<string name="theme_light">明亮</string>
|
||||||
<string-array name="app_theme">
|
<string name="theme_dark">黑暗</string>
|
||||||
<item>明亮</item>
|
<string name="theme_system">使用系统默认</string>
|
||||||
<item>黑暗</item>
|
<string name="layout_list">列表</string>
|
||||||
<item>使用系统默认</item>
|
<string name="layout_grid">网格</string>
|
||||||
</string-array>
|
|
||||||
|
|
||||||
<string-array name="layout_type">
|
|
||||||
<item>列表</item>
|
|
||||||
<item>网格</item>
|
|
||||||
</string-array>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -21,15 +21,9 @@
|
|||||||
<string name="appearance">外觀</string>
|
<string name="appearance">外觀</string>
|
||||||
<string name="theme">主題</string>
|
<string name="theme">主題</string>
|
||||||
<string name="layout">佈局</string>
|
<string name="layout">佈局</string>
|
||||||
|
<string name="theme_light">明亮</string>
|
||||||
<string-array name="app_theme">
|
<string name="theme_dark">黑暗</string>
|
||||||
<item>明亮</item>
|
<string name="theme_system">使用系統默認</string>
|
||||||
<item>黑暗</item>
|
<string name="layout_list">列表</string>
|
||||||
<item>使用系統默認</item>
|
<string name="layout_grid">網格</string>
|
||||||
</string-array>
|
|
||||||
|
|
||||||
<string-array name="layout_type">
|
|
||||||
<item>列表</item>
|
|
||||||
<item>網格</item>
|
|
||||||
</string-array>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -21,15 +21,9 @@
|
|||||||
<string name="appearance">Appearance</string>
|
<string name="appearance">Appearance</string>
|
||||||
<string name="theme">Theme</string>
|
<string name="theme">Theme</string>
|
||||||
<string name="layout">Layout</string>
|
<string name="layout">Layout</string>
|
||||||
|
<string name="theme_light">Light</string>
|
||||||
<string-array name="app_theme">
|
<string name="theme_dark">Dark</string>
|
||||||
<item>Light</item>
|
<string name="theme_system">Use System Default</string>
|
||||||
<item>Dark</item>
|
<string name="layout_list">List</string>
|
||||||
<item>Use System Default</item>
|
<string name="layout_grid">Grid</string>
|
||||||
</string-array>
|
|
||||||
|
|
||||||
<string-array name="layout_type">
|
|
||||||
<item>List</item>
|
|
||||||
<item>Grid</item>
|
|
||||||
</string-array>
|
|
||||||
</resources>
|
</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.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
val kotlin_version = "1.8.22"
|
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath("com.android.tools.build:gradle:8.0.2")
|
classpath(libs.gradle)
|
||||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
|
classpath(libs.kotlin.gradle.plugin)
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
// in the individual module build.gradle files
|
// in the individual module build.gradle files
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.compose.compiler) apply false
|
||||||
|
}
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
@@ -26,6 +28,6 @@ allprojects {
|
|||||||
|
|
||||||
tasks {
|
tasks {
|
||||||
register("clean", Delete::class) {
|
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
|
android.useAndroidX=true
|
||||||
# Kotlin code style for this project: "official" or "obsolete":
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
android.defaults.buildfeatures.buildconfig=true
|
|
||||||
android.nonTransitiveRClass=false
|
android.nonTransitiveRClass=false
|
||||||
android.nonFinalResIds=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
|
#Wed Jun 21 06:42:34 EEST 2023
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
Reference in New Issue
Block a user