Compare commits

1 Commits
117 changed files with 1805 additions and 10142 deletions
+2
View File
@@ -1,3 +1,4 @@
*.iml
.gradle
/local.properties
/.idea/caches
@@ -6,6 +7,7 @@
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
-21
View File
@@ -1,21 +0,0 @@
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)
+87
View File
@@ -0,0 +1,87 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-parcelize'
id 'kotlin-kapt'
}
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('keystore.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
namespace 'com.illusionware.npsbrowser'
compileSdkVersion 33
defaultConfig {
applicationId "com.illusionware.npsbrowser"
minSdkVersion 23
targetSdkVersion 33
versionCode 1
versionName "0.1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildFeatures {
dataBinding = true
viewBinding = true
}
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
buildToolsVersion '33.0.1'
}
repositories {
google()
mavenCentral()
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.6.0'
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.2.0'
implementation 'com.github.doyaaaaaken:kotlin-csv-jvm:1.8.0'
implementation 'com.github.wrdlbrnft:sorted-list-adapter:0.3.0.27'
implementation "androidx.cardview:cardview:1.0.0"
implementation 'androidx.navigation:navigation-fragment-ktx:2.5.3'
implementation 'androidx.navigation:navigation-ui-ktx:2.5.3'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
implementation 'com.google.android.material:material:1.8.0'
implementation 'com.github.bumptech.glide:glide:4.14.2'
annotationProcessor 'com.github.bumptech.glide:compiler:4.14.2'
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
}
-131
View File
@@ -1,131 +0,0 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
kotlin("android")
kotlin("kapt")
id("com.google.devtools.ksp") version "2.2.0-2.0.2"
alias(libs.plugins.compose.compiler)
}
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("keystore.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.illusionware.npsbrowser"
compileSdk = 36
defaultConfig {
applicationId = "com.illusionware.npsbrowser"
// TODO: Network Security Config needs 24+ API level, don't know if I want to raise the min
// just for that
minSdk = 23
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.2"
}
buildFeatures {
compose = true
aidl = false
renderScript = false
shaders = false
}
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String?
}
}
buildTypes {
named("release") {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles("proguard-android-optimize.txt", "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildToolsVersion = "36.0.0"
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
}
}
repositories {
google()
mavenCentral()
}
dependencies {
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.core.ktx)
// Jetpack Compose
implementation(platform(libs.androidx.compose.bom))
// Room database
implementation(libs.androidx.room.runtime)
// To use Kotlin Symbol Processing (KSP)
ksp(libs.androidx.room.compiler)
// optional - Kotlin Extensions and Coroutines support for Room
implementation(libs.androidx.room.ktx)
// Material 3
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
// Android Studio Preview support
implementation(libs.androidx.ui.tooling.preview)
debugImplementation(libs.androidx.ui.tooling)
// Preferences DataStore (alternative to SharedPreferences)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.navigation.compose)
implementation(libs.coil)
implementation(libs.coil.compose)
implementation(libs.rxjava)
implementation(libs.okio)
implementation(libs.flowreactivenetwork)
implementation(libs.kotlin.csv)
implementation(libs.material)
}
+1 -1
View File
@@ -1,6 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
@@ -1,70 +0,0 @@
{
"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')"
]
}
}
@@ -1,78 +0,0 @@
{
"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')"
]
}
}
+28 -46
View File
@@ -1,47 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application
android:name=".NPSApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/NPSTheme"
android:networkSecurityConfig="@xml/network_security_config"
>
<activity
android:name=".MainActivity"
android:configChanges="screenSize|screenLayout|keyboardHidden|orientation"
android:exported="true"
android:theme="@style/NPSTheme.Splash"
>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".data.download.DownloadService"
android:exported="false"
android:foregroundServiceType="dataSync"
/>
</application>
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:usesCleartextTraffic="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/NPSTheme">
<activity android:name=".activities.SplashActivity" android:theme="@style/NPSTheme.Splash"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".activities.MainActivity"
android:configChanges="screenSize|screenLayout|keyboardHidden|orientation">
</activity>
</application>
</manifest>
@@ -0,0 +1,61 @@
package com.illusionware.npsbrowser
import com.github.wrdlbrnft.sortedlistadapter.SortedListAdapter
data class AppData(
val titleID: String?, val region: String?, val title: String?, val link: String?,
val license: String?, val contentID: String?, val lastDateTime: String?,
val fileSize: Number?, val version: String?, val sha256: String?, val minFW: String?
) : SortedListAdapter.ViewModel {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as AppData
if (titleID != other.titleID) return false
if (region != other.region) return false
if (title != other.title) return false
if (link != other.link) return false
if (license != other.license) return false
if (contentID != other.contentID) return false
if (lastDateTime != other.lastDateTime) return false
if (fileSize != other.fileSize) return false
if (version != other.version) return false
if (sha256 != other.sha256) return false
if (minFW != other.minFW) return false
return true
}
override fun hashCode(): Int {
var result = titleID?.hashCode() ?: 0
result = 31 * result + (region?.hashCode() ?: 0)
result = 31 * result + (title?.hashCode() ?: 0)
result = 31 * result + (link?.hashCode() ?: 0)
result = 31 * result + (license?.hashCode() ?: 0)
result = 31 * result + (contentID?.hashCode() ?: 0)
result = 31 * result + (lastDateTime?.hashCode() ?: 0)
result = 31 * result + (fileSize?.hashCode() ?: 0)
result = 31 * result + (version?.hashCode() ?: 0)
result = 31 * result + (sha256?.hashCode() ?: 0)
result = 31 * result + (minFW?.hashCode() ?: 0)
return result
}
override fun <T : Any> isSameModelAs(model: T): Boolean {
if (model is AppData) {
val other: AppData = model
return "${other.titleID} ${other.title}" == "$titleID $title"
}
return false
}
override fun <T : Any> isContentTheSameAs(model: T): Boolean {
if (model is AppData) {
val other: AppData = model
return title?.equals(other.title) ?: (other.title == null)
}
return false
}
}
@@ -1,774 +0,0 @@
package com.illusionware.npsbrowser
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ViewList
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Downloading
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Badge
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.illusionware.npsbrowser.data.ItemLayout
import com.illusionware.npsbrowser.data.SettingsPreferences
import com.illusionware.npsbrowser.model.ConsoleType
import com.illusionware.npsbrowser.model.PackageItem
import com.illusionware.npsbrowser.ui.components.*
import com.illusionware.npsbrowser.ui.screens.Downloads
import com.illusionware.npsbrowser.ui.screens.OnBoarding
import com.illusionware.npsbrowser.ui.screens.PackageDetails
import com.illusionware.npsbrowser.ui.screens.SettingsScreen
import com.illusionware.npsbrowser.ui.screens.readexProFamily
import com.illusionware.npsbrowser.ui.theme.ColorAccent
import com.illusionware.npsbrowser.ui.theme.ColorOnPrimaryLight
import com.illusionware.npsbrowser.ui.theme.ColorTag
import com.illusionware.npsbrowser.ui.theme.NPSBrowserTheme
import com.illusionware.npsbrowser.ui.theme.Typography
import com.illusionware.npsbrowser.viewmodels.OnboardingViewModel
import com.illusionware.npsbrowser.viewmodels.PackageDetailsViewModel
import com.illusionware.npsbrowser.viewmodels.PackageListViewModel
import com.illusionware.npsbrowser.viewmodels.SettingsViewModel
import kotlinx.coroutines.runBlocking
val PlaceholderColor = Color(0x1F888888)
val redHatDisplayFamily = FontFamily(
Font(R.font.red_hat_display_bold, FontWeight.Bold),
)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
val settingsViewModel: SettingsViewModel = viewModel(
factory = SettingsViewModel.Factory
)
val onboardingViewModel: OnboardingViewModel = viewModel(
factory = OnboardingViewModel.Factory
)
val packageDetailsViewModel: PackageDetailsViewModel = viewModel(
factory = PackageDetailsViewModel.Factory
)
val appTheme = runBlocking { settingsViewModel.getAppTheme() }
var seenOnboarding by remember { mutableStateOf(false) }
val settingsPrefs = settingsViewModel.uiState.collectAsStateWithLifecycle().value
val navController = rememberNavController()
runBlocking {
seenOnboarding = onboardingViewModel.getOnboardingStatus()
}
val darkTheme = when (appTheme) {
0 -> false
1 -> true
else -> isSystemInDarkTheme()
}
NPSBrowserTheme(darkTheme = darkTheme) {
Surface(
modifier = Modifier.fillMaxSize(),
color = if (seenOnboarding) MaterialTheme.colorScheme.background else ColorAccent
) {
NavHost(
navController = navController,
startDestination = if (seenOnboarding) Routes.Home.route else Routes.Onboarding.route,
enterTransition = {
fadeIn(animationSpec = tween(300))
},
exitTransition = {
fadeOut(animationSpec = tween(300))
},
) {
composable(Routes.Home.route) {
HomePage(
navigateToSettings = { navController.navigate(Routes.Settings.route) },
settingsPrefs = settingsPrefs,
settingsViewModel = settingsViewModel,
navigateToDetailsScreen = { item ->
packageDetailsViewModel.setSelectedPackage(item)
navController.navigate(Routes.PackageDetails.route)
},
navigateToDownloads = {
navController.navigate(Routes.Downloads.route)
},
packageDetailsViewModel = packageDetailsViewModel,
)
}
composable(Routes.Settings.route) {
SettingsScreen(
navigationGoBack = { navController.popBackStack() },
)
}
composable(Routes.Onboarding.route) {
OnBoarding(
navigateToHome = {
navController.popBackStack()
navController.navigate(Routes.Home.route)
},
darkTheme = darkTheme,
)
}
composable(Routes.PackageDetails.route) {
PackageDetails(
navigationGoBack = { navController.popBackStack() },
packageDetailsViewModel = packageDetailsViewModel,
)
}
composable(Routes.Downloads.route) {
Downloads(
navigationGoBack = { navController.popBackStack() },
packageDetailsViewModel = packageDetailsViewModel,
)
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomePage(
navigateToSettings: () -> Unit,
navigateToDetailsScreen: (item: PackageItem) -> Unit,
navigateToDownloads: () -> Unit,
settingsPrefs: SettingsPreferences,
settingsViewModel: SettingsViewModel,
packageListViewModel: PackageListViewModel = viewModel(
factory = PackageListViewModel.Factory
),
packageDetailsViewModel: PackageDetailsViewModel
) {
val searchBar = remember { FocusRequester() }
var layoutDialogOpen by remember { mutableStateOf(false) }
val uiState = packageListViewModel.uiState.collectAsStateWithLifecycle().value
val tsvs = settingsViewModel.availableTsvs()
var selectedBarItem by rememberSaveable { mutableStateOf(ConsoleType.PSVITA) }
Scaffold(
topBar = {
TopAppBar(
title = {
if (!uiState.isSearchOpen) {
Text(text = stringResource(id = R.string.app_name))
}
},
actions = {
if (uiState.isSearchOpen) {
NPSIconButton(
tooltip = "Close Search",
onClick = {
packageListViewModel.closeSearch()
}
){
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close Search")
}
Spacer(Modifier.width(12.dp))
TextField(
value = uiState.searchQuery,
onValueChange = {
packageListViewModel.search(it)
},
Modifier
.weight(1.0f)
.focusRequester(searchBar),
placeholder = {
Text(text = stringResource(id = R.string.search), style = Typography.bodyLarge)
},
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
cursorColor = MaterialTheme.colorScheme.primary,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
)
Spacer(Modifier.width(12.dp))
if (uiState.searchQuery.isNotEmpty()) {
NPSIconButton(
tooltip = "Clear Search",
onClick = {
packageListViewModel.clearSearch()
}
){
Icon(imageVector = Icons.Filled.Close, contentDescription = "Clear Search")
}
}
LaunchedEffect(Unit) {
searchBar.requestFocus()
}
}
if (!uiState.isSearchOpen) {
NPSIconButton(
tooltip = stringResource(id = R.string.search_tooltip),
onClick = { packageListViewModel.openSearch() }
){
Icon(imageVector = Icons.Filled.Search, contentDescription = stringResource(
id = R.string.search_tooltip
))
}
}
NPSIconButton(
tooltip = stringResource(id = R.string.layout),
onClick = { layoutDialogOpen = true }
) {
Icon(
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(
tooltip = stringResource(id = R.string.title_activity_settings),
onClick = { navigateToSettings() }
) {
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = stringResource(
id = R.string.title_activity_settings
)
)
}
}
)
},
bottomBar = {
if (tsvs.isNotEmpty()) {
NavBar(
selectedTab = selectedBarItem,
onTabSelected = { selectedBarItem = it },
)
}
}
) { padding ->
Column(Modifier.padding(padding)) {
if (tsvs.isEmpty()) {
NoTsvs(navigateToSettings = navigateToSettings)
} else {
PackageList(
navigateToDetailsScreen = navigateToDetailsScreen,
type = selectedBarItem,
settingsPrefs = settingsPrefs,
viewModel = packageListViewModel,
)
}
}
if (layoutDialogOpen) {
LayoutDialog(
onDismiss = { layoutDialogOpen = false },
prefs = settingsPrefs,
viewModel = settingsViewModel,
)
}
}
}
@Composable
fun NoResults() {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(text = "No results found", color = MaterialTheme.colorScheme.outline)
}
}
@Composable
fun NavBar(selectedTab: ConsoleType, onTabSelected: (ConsoleType) -> Unit) {
NavigationBar {
NavigationBarItem(
selected = selectedTab == ConsoleType.PSVITA,
icon = { Icon(painterResource(id = R.drawable.vita_icon), contentDescription = "PS Vita") },
label = { Text(text = "PS Vita") },
onClick = {
onTabSelected(ConsoleType.PSVITA)
}
)
NavigationBarItem(
selected = selectedTab == ConsoleType.PS3,
icon = { Icon(painterResource(id = R.drawable.ps3_icon), contentDescription = "PS3") },
label = { Text(text = "PS3") },
onClick = {
onTabSelected(ConsoleType.PS3)
}
)
NavigationBarItem(
selected = selectedTab == ConsoleType.PSP,
icon = { Icon(painterResource(id = R.drawable.psp_icon), contentDescription = "PSP") },
label = { Text(text = "PSP") },
onClick = {
onTabSelected(ConsoleType.PSP)
}
)
NavigationBarItem(
selected = selectedTab == ConsoleType.PSX,
icon = { Icon(painterResource(id = R.drawable.psx_icon), contentDescription = "PSX") },
label = { Text(text = "PSX") },
onClick = {
onTabSelected(ConsoleType.PSX)
}
)
NavigationBarItem(
selected = selectedTab == ConsoleType.PSM,
icon = { Icon(painterResource(id = R.drawable.psm_icon), contentDescription = "PSM") },
label = { Text(text = "PSM") },
onClick = {
onTabSelected(ConsoleType.PSM)
}
)
}
}
@Composable
@Preview
fun NoTsvs(padding: PaddingValues = PaddingValues(0.dp), navigateToSettings: () -> Unit = {}) {
Column(
Modifier
.fillMaxSize()
.padding(padding),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(id = R.drawable.ph_info),
contentDescription = "Info",
tint = MaterialTheme.colorScheme.outline,
modifier = Modifier.size(92.dp)
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "You haven't added any TSVs",
color = MaterialTheme.colorScheme.outline,
style = Typography.bodyLarge
)
Row {
Text(
text = "Add your TSV files from the ",
color = MaterialTheme.colorScheme.outline,
style = Typography.bodyLarge
)
Text(
text = "settings",
color = ColorAccent,
style = Typography.bodyLarge,
modifier = Modifier.clickable {
navigateToSettings()
}
)
}
}
}
@Composable
fun PackageList(
type: ConsoleType = ConsoleType.PSVITA,
navigateToDetailsScreen: (item: PackageItem) -> Unit,
viewModel: PackageListViewModel,
settingsPrefs: SettingsPreferences,
) {
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
val packages = when (type) {
ConsoleType.PSVITA -> {
uiState.packages.filter { it.consoleType == ConsoleType.PSVITA }
}
ConsoleType.PS3 -> {
uiState.packages.filter { it.consoleType == ConsoleType.PS3 }
}
ConsoleType.PSP -> {
uiState.packages.filter { it.consoleType == ConsoleType.PSP }
}
ConsoleType.PSX -> {
uiState.packages.filter { it.consoleType == ConsoleType.PSX }
}
ConsoleType.PSM -> {
uiState.packages.filter { it.consoleType == ConsoleType.PSM }
}
}
Column(Modifier.fillMaxSize()) {
if (uiState.isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
if (packages.isEmpty() && uiState.searchQuery.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
"You haven't added any TSVs for this console yet",
style = Typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.outline
)
}
} else if (packages.isEmpty() && uiState.searchQuery.isNotEmpty()) {
NoResults()
} else {
if (settingsPrefs.layout == ItemLayout.LIST.ordinal) {
LazyColumn {
items(packages) {
PackageListItem(it, navigateToDetailsScreen)
}
}
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(140.dp),
contentPadding = PaddingValues(8.dp),
) {
items(packages) {
PackageGridItem(it, navigateToDetailsScreen)
}
}
}
}
}
}
}
@Composable
fun PackageListItem(
item: PackageItem,
navigateToDetailsScreen: (item: PackageItem) -> Unit,
) {
Box(
Modifier
.fillMaxWidth()
.clickable { navigateToDetailsScreen(item) }
) {
Row(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.weight(1.0f)
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("TODO:")
.crossfade(true)
.error(R.drawable.default_game_icon)
.build(),
placeholder = ColorPainter(PlaceholderColor),
contentDescription = "Package Icon",
modifier = Modifier.size(48.dp),
contentScale = ContentScale.Crop,
)
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = item.name,
color = MaterialTheme.colorScheme.onBackground,
style = Typography.bodyLarge,
overflow = TextOverflow.Ellipsis,
maxLines = 2,
modifier = Modifier
.width(IntrinsicSize.Min)
.weight(1.0f)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
ProvideTextStyle(value = Typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline)) {
Text(text = item.region)
if (!item.minFW.isNullOrEmpty()) {
Text(text = item.minFW)
}
if (item.pkgSize.isNotEmpty()) {
Text(text = item.pkgSize)
}
Tag(title = item.dataType.name, small = true)
}
}
}
}
Column {
if (item.pkgUrl.isNullOrEmpty()) {
Icon(
painter = painterResource(id = R.drawable.no_url_icon),
contentDescription = "No PKG URL",
tint = Color(0xFFCB5A5A)
)
}
if (
item.consoleType != ConsoleType.PSX &&
(item.zRif.isNullOrEmpty() && item.rap.isNullOrEmpty())) {
Icon(
painter = painterResource(id = R.drawable.no_key_icon),
contentDescription = "No License Key",
tint = Color(0xFFCB5A5A)
)
}
}
}
}
}
@Composable
fun Tag(title: String, small: Boolean = false) {
Box(
Modifier
.clip(RoundedCornerShape(4.dp))
.background(ColorTag)
.layoutId("tag")
.padding(horizontal = if (small) 6.dp else 8.dp, vertical = if (small) 2.dp else 4.dp)
) {
Text(
text = title.uppercase(),
color = ColorOnPrimaryLight,
style = Typography.bodySmall.copy(
fontWeight = FontWeight.Bold,
fontFamily = redHatDisplayFamily,
fontSize = if (small) 8.sp else 12.sp
)
)
}
}
@Composable
fun PackageGridItem(
item: PackageItem,
navigateToDetailsScreen: (item: PackageItem) -> Unit,
) {
Box(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { navigateToDetailsScreen(item) },
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.Start,
modifier = Modifier.padding(8.dp),
) {
Layout(
content = {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("TODO:")
.crossfade(true)
.error(R.drawable.default_game_icon)
.build(),
placeholder = ColorPainter(PlaceholderColor),
contentDescription = "Package Icon",
modifier = Modifier
.aspectRatio(1f / 1f)
.fillMaxWidth(),
contentScale = ContentScale.Crop,
)
Tag(title = item.dataType.name)
}
) { measurables, constraints ->
val imagePlaceable = measurables[0].measure(constraints)
val tagPlaceable = measurables.first { it.layoutId == "tag" }.measure(constraints)
layout(imagePlaceable.width, imagePlaceable.height) {
imagePlaceable.placeRelative(0, 0)
tagPlaceable.placeRelative(
x = imagePlaceable.width - tagPlaceable.width - 16,
y = 16,
)
}
}
Text(
text = item.name,
color = MaterialTheme.colorScheme.onBackground,
style = Typography.bodyLarge,
overflow = TextOverflow.Ellipsis,
maxLines = 2,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ProvideTextStyle(value = Typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline)) {
Text(text = item.region)
if (!item.minFW.isNullOrEmpty()) {
Text(text = item.minFW)
}
if (item.pkgSize.isNotEmpty()) {
Text(text = item.pkgSize)
}
}
}
Row {
if (item.pkgUrl.isNullOrEmpty()) {
Icon(
painter = painterResource(id = R.drawable.no_url_icon),
contentDescription = "No PKG URL",
tint = Color(0xFFCB5A5A)
)
}
if (item.zRif.isNullOrEmpty() && item.rap.isNullOrEmpty()) {
Icon(
painter = painterResource(id = R.drawable.no_key_icon),
contentDescription = "No License Key",
tint = Color(0xFFCB5A5A)
)
}
}
}
}
}
@Composable
fun LayoutDialog(
onDismiss: () -> Unit,
prefs: SettingsPreferences,
viewModel: SettingsViewModel
) {
NPSAlertDialog(
onDismiss = onDismiss,
title = stringResource(id = R.string.layout),
buttons = {
TextButton(onClick = onDismiss) {
Text(text = "Cancel")
}
}
) {
Column {
NPSRadioButton(
text = "List",
selected = prefs.layout == ItemLayout.LIST.ordinal,
onClick = { viewModel.setLayout(ItemLayout.LIST) },
)
NPSRadioButton(
text = "Grid",
selected = prefs.layout == ItemLayout.GRID.ordinal,
onClick = { viewModel.setLayout(ItemLayout.GRID) },
)
}
}
}
@@ -1,38 +0,0 @@
package com.illusionware.npsbrowser
import android.app.Application
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import com.illusionware.npsbrowser.data.OnboardingPreferencesRepository
import com.illusionware.npsbrowser.data.SettingsPreferencesRepository
import com.illusionware.npsbrowser.data.download.DownloadManager
import com.illusionware.npsbrowser.data.notification.Notifications
@OptIn(ExperimentalUnsignedTypes::class)
val HMAC_KEY = ubyteArrayOf(
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 val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = PREFERENCES_NAME
)
class NPSApp: Application() {
lateinit var settingsPreferencesRepository: SettingsPreferencesRepository
lateinit var onboardingPreferencesRepository: OnboardingPreferencesRepository
lateinit var downloadManager: DownloadManager
override fun onCreate() {
super.onCreate()
settingsPreferencesRepository = SettingsPreferencesRepository(dataStore)
onboardingPreferencesRepository = OnboardingPreferencesRepository(dataStore)
// downloader = Downloader(this)
downloadManager = DownloadManager(this)
Notifications.initialize(this)
}
}
@@ -0,0 +1,44 @@
package com.illusionware.npsbrowser
import android.content.Context
import android.view.GestureDetector
import android.view.GestureDetector.SimpleOnGestureListener
import android.view.MotionEvent
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener
class RecyclerTouchListener(context: Context?, recyclerView: RecyclerView,
private val clickListener: ClickListener?) : OnItemTouchListener {
interface ClickListener {
fun onClick(view: View?, position: Int)
fun onLongClick(view: View?, recyclerView: RecyclerView?, position: Int)
}
private val gestureDetector: GestureDetector
override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
val child = rv.findChildViewUnder(e.x, e.y)
if (child != null && clickListener != null && gestureDetector.onTouchEvent(e)) {
clickListener.onClick(child, rv.getChildAdapterPosition(child))
}
return false
}
override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {}
override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {}
init {
gestureDetector = GestureDetector(context, object : SimpleOnGestureListener() {
override fun onSingleTapUp(e: MotionEvent): Boolean {
return true
}
override fun onLongPress(e: MotionEvent) {
val child = recyclerView.findChildViewUnder(e.x, e.y)
if (child != null && clickListener != null) {
clickListener.onLongClick(child, recyclerView, recyclerView.getChildAdapterPosition(child))
}
}
})
}
}
@@ -1,9 +0,0 @@
package com.illusionware.npsbrowser
sealed class Routes(val route: String) {
data object Home : Routes("home")
data object Onboarding : Routes("onboarding")
data object Settings : Routes("settings")
data object PackageDetails : Routes("package_details")
data object Downloads : Routes("downloads")
}
@@ -0,0 +1,34 @@
package com.illusionware.npsbrowser
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.ListPreference
class ThemePreference : ListPreference {
constructor(context : Context?, attrs : AttributeSet?, defStyleAttr : Int) : super(context!!, attrs, defStyleAttr)
constructor(context : Context?, attrs : AttributeSet?) : super(context!!, attrs)
constructor(context : Context?) : super(context!!)
override fun callChangeListener(newValue : Any?) : Boolean {
AppCompatDelegate.setDefaultNightMode(when ((newValue as String).toInt()) {
0 -> AppCompatDelegate.MODE_NIGHT_NO
1 -> AppCompatDelegate.MODE_NIGHT_YES
2 -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
else -> AppCompatDelegate.MODE_NIGHT_UNSPECIFIED
})
return super.callChangeListener(newValue)
}
override fun setValue(value: String?) {
super.setValue(value)
summary = value
}
override fun setSummary(summary: CharSequence?) {
super.setSummary(entry)
}
}
@@ -0,0 +1,40 @@
package com.illusionware.npsbrowser.activities
import android.content.SharedPreferences
import android.content.res.Configuration
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.PreferenceManager
import com.illusionware.npsbrowser.R
open class BaseActivity : AppCompatActivity() {
private lateinit var sharedPreferences : SharedPreferences
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
PreferenceManager.setDefaultValues(this, R.xml.root_preferences, false)
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
val decor: View = window.decorView
AppCompatDelegate.setDefaultNightMode(when ((sharedPreferences.getString("app_theme", "2")?.toInt())) {
0 -> {
decor.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
AppCompatDelegate.MODE_NIGHT_NO
}
1 -> {
decor.systemUiVisibility = 0;
AppCompatDelegate.MODE_NIGHT_YES
}
2 -> {
decor.systemUiVisibility = if (resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES) 0 else View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
else -> AppCompatDelegate.MODE_NIGHT_UNSPECIFIED
})
}
}
@@ -0,0 +1,55 @@
package com.illusionware.npsbrowser.activities
import android.os.Bundle
import android.view.MenuItem
import androidx.fragment.app.FragmentManager
import com.illusionware.npsbrowser.R
import com.illusionware.npsbrowser.fragments.mainactivity.MainActivityFragment
class MainActivity : BaseActivity(), FragmentManager.OnBackStackChangedListener {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTheme(R.style.NPSTheme)
setContentView(R.layout.activity_main)
setSupportActionBar(findViewById(R.id.toolbar))
supportFragmentManager.addOnBackStackChangedListener(this);
if (supportFragmentManager.backStackEntryCount > 0) {
showUpButton()
}
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.replace(R.id.container, MainActivityFragment())
.commitNow()
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
supportFragmentManager.popBackStack()
return true
}
}
return super.onOptionsItemSelected(item)
}
override fun onBackStackChanged() {
// enable Up button only if there are entries on the backstack
if (supportFragmentManager.backStackEntryCount < 1) {
hideUpButton()
} else {
showUpButton()
}
}
private fun showUpButton() {
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
}
private fun hideUpButton() {
supportActionBar!!.setDisplayHomeAsUpEnabled(false)
}
}
@@ -0,0 +1,35 @@
package com.illusionware.npsbrowser.activities
import android.content.Intent
import android.os.Bundle
import android.view.View
class SplashActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startActivity(Intent(this, MainActivity::class.java))
finish()
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) hideSystemUI()
}
private fun hideSystemUI() {
// Enables regular immersive mode.
// For "lean back" mode, remove SYSTEM_UI_FLAG_IMMERSIVE.
// Or for "sticky immersive," replace it with SYSTEM_UI_FLAG_IMMERSIVE_STICKY
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE
// Set the content to appear under the system bars so that the
// content doesn't resize when the system bars hide and show.
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
// Hide the nav bar and status bar
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN)
}
}
@@ -0,0 +1,86 @@
package com.illusionware.npsbrowser.adapters
import android.content.Context
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.content.res.ResourcesCompat
import com.github.wrdlbrnft.modularadapter.ModularAdapter
import com.github.wrdlbrnft.sortedlistadapter.SortedListAdapter
import com.illusionware.npsbrowser.AppData
import com.illusionware.npsbrowser.R
import com.illusionware.npsbrowser.activities.MainActivity
import com.illusionware.npsbrowser.fragments.SingleAppFragment
import com.illusionware.npsbrowser.fragments.mainactivity.MainActivityFragment
import java.util.Comparator
private val comparator: Comparator<AppData> = SortedListAdapter.ComparatorBuilder<AppData>()
.setOrderForModel(AppData::class.java) { a, b -> a.title?.compareTo(b.title!!)!! }
.build()
class AppAdapter(val context: Context) : SortedListAdapter<AppData>(context, AppData::class.java, comparator) {
private val LIST_ITEM = 0
private val GRID_ITEM = 1
private var isSwitchView = true
inner class AppViewHolder(itemView: View) :
SortedListAdapter.ViewHolder<AppData?>(itemView) {
var appTitle: TextView = itemView.findViewById(R.id.appTitle)
var appRegion: TextView = itemView.findViewById(R.id.appRegion)
var appMinFW: TextView = itemView.findViewById(R.id.appMinFW)
var appLastDate: TextView = itemView.findViewById(R.id.appLastDate)
override fun performBind(item: AppData) {
appTitle.text = item.titleID + " | " + item.title
appTitle.isSelected = true
appRegion.text = item.region
appMinFW.text = item.minFW
appMinFW.background = null;
if (appMinFW.text.isNotBlank()) {
val drawable = ResourcesCompat.getDrawable(
context.resources,
R.drawable.rect_red,
null
)
appMinFW.background = drawable;
}
appLastDate.text = item.lastDateTime
}
}
override fun onCreateViewHolder(inflater: LayoutInflater, parent: ViewGroup, viewType: Int): AppViewHolder {
return if (viewType == LIST_ITEM) {
AppViewHolder(
inflater.inflate(
R.layout.recycler_item_list,
parent,
false
)
)
} else {
AppViewHolder(
inflater.inflate(
R.layout.recycler_item_grid,
parent,
false
)
)
}
}
override fun getItemViewType(position: Int): Int {
return if (isSwitchView) {
LIST_ITEM
} else {
GRID_ITEM
}
}
fun toggleItemViewType(type: Int): Boolean {
isSwitchView = type == 0
return isSwitchView
}
}
@@ -1,36 +0,0 @@
package com.illusionware.npsbrowser.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
data class OnboardingPreferences(
val seenOnboarding: Boolean,
)
class OnboardingPreferencesRepository(
private val dataStore: DataStore<Preferences>,
) {
companion object {
private val SEEN_ONBOARDING = booleanPreferencesKey("seen_onboarding")
}
fun getPreferences(): Flow<OnboardingPreferences> =
runBlocking { dataStore.data.map { mapOnboardingSettings(it) } }
suspend fun setSeenOnboarding(seen: Boolean) {
dataStore.edit { prefs ->
prefs[SEEN_ONBOARDING] = seen
}
}
private fun mapOnboardingSettings(prefs: Preferences): OnboardingPreferences {
return OnboardingPreferences(
prefs[SEEN_ONBOARDING] ?: false,
)
}
}
@@ -1,210 +0,0 @@
package com.illusionware.npsbrowser.data
import android.net.Uri
import android.os.Environment
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.illusionware.npsbrowser.model.ConsoleType
import com.illusionware.npsbrowser.model.PackageItemType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
val defaultDownloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath + "/NPSBrowser"
enum class ItemLayout {
LIST,
GRID,
}
enum class Theme {
LIGHT,
DARK,
SYSTEM,
}
data class SettingsPreferences(
val appTheme: Int,
val layout: Int,
val psvGames: String,
val ps3Games: String,
val pspGames: String,
val psxGames: String,
val psmGames: String,
val psvDlc: String,
val ps3Dlc: String,
val pspDlc: String,
val psvThemes: String,
val downloadDir: String,
val unpackDir: String,
val unpackInDownload: Boolean,
val deleteAfterUnpack: Boolean,
val pkg2zipParams: String,
val pkg2zipAutoDecrypt: Boolean,
)
class SettingsPreferencesRepository(
private val dataStore: DataStore<Preferences>
) {
companion object {
private val APP_THEME = intPreferencesKey("app_theme")
private val LAYOUT = intPreferencesKey("layout")
private val PSV_GAMES = stringPreferencesKey("psv_games")
private val PS3_GAMES = stringPreferencesKey("ps3_games")
private val PSP_GAMES = stringPreferencesKey("psp_games")
private val PSX_GAMES = stringPreferencesKey("psx_games")
private val PSM_GAMES = stringPreferencesKey("psm_games")
private val PSV_DLC = stringPreferencesKey("psv_dlc")
private val PS3_DLC = stringPreferencesKey("ps3_dlc")
private val PSP_DLC = stringPreferencesKey("psp_dlc")
private val PSV_THEMES = stringPreferencesKey("psv_themes")
private val DOWNLOAD_DIR = stringPreferencesKey("download_dir")
private val UNPACK_DIR = stringPreferencesKey("unpack_dir")
private val UNPACK_IN_DOWNLOAD = booleanPreferencesKey("unpack_in_download")
private val DELETE_AFTER_UNPACK = booleanPreferencesKey("delete_after_unpack")
private val PKG2ZIP_PARAMS = stringPreferencesKey("pkg2zip_params")
private val PKG2ZIP_AUTO_DECRYPT = booleanPreferencesKey("pkg2zip_auto_decrypt")
}
private val tsvFiles = MutableSharedFlow<Triple<String, ConsoleType, PackageItemType>>()
fun getPreferences(): Flow<SettingsPreferences> =
dataStore.data.map { mapSettingsPreferences(it) }
suspend fun availableTsvs(): MutableList<Triple<String, ConsoleType, PackageItemType>> {
val list = mutableListOf<Triple<String, ConsoleType, PackageItemType>>()
return dataStore.data.map {
if (it[PSV_GAMES]?.isNotEmpty() == true) list.add(Triple(it[PSV_GAMES] ?: "", ConsoleType.PSVITA, PackageItemType.GAME))
if (it[PSV_DLC]?.isNotEmpty() == true) list.add(Triple(it[PSV_DLC] ?: "", ConsoleType.PSVITA, PackageItemType.DLC))
if (it[PSV_THEMES]?.isNotEmpty() == true) list.add(Triple(it[PSV_THEMES] ?: "", ConsoleType.PSVITA, PackageItemType.THEME))
if (it[PS3_GAMES]?.isNotEmpty() == true) list.add(Triple(it[PS3_GAMES] ?: "", ConsoleType.PS3, PackageItemType.GAME))
if (it[PS3_DLC]?.isNotEmpty() == true) list.add(Triple(it[PS3_DLC] ?: "", ConsoleType.PS3, PackageItemType.DLC))
if (it[PSP_GAMES]?.isNotEmpty() == true) list.add(Triple(it[PSP_GAMES] ?: "", ConsoleType.PSP, PackageItemType.GAME))
if (it[PSP_DLC]?.isNotEmpty() == true) list.add(Triple(it[PSP_DLC] ?: "", ConsoleType.PSP, PackageItemType.DLC))
if (it[PSX_GAMES]?.isNotEmpty() == true) list.add(Triple(it[PSX_GAMES] ?: "", ConsoleType.PSX, PackageItemType.GAME))
if (it[PSM_GAMES]?.isNotEmpty() == true) list.add(Triple(it[PSM_GAMES] ?: "", ConsoleType.PSM, PackageItemType.GAME))
list
}.first()
}
suspend fun setTheme(theme: Theme) {
dataStore.edit { prefs ->
prefs[APP_THEME] = theme.ordinal
}
}
suspend fun setLayout(layout: ItemLayout) {
dataStore.edit { prefs ->
prefs[LAYOUT] = layout.ordinal
}
}
suspend fun setDownloadDir(dir: String) {
dataStore.edit { prefs ->
prefs[DOWNLOAD_DIR] = dir
}
}
suspend fun setUnpackDir(dir: String) {
dataStore.edit { prefs ->
prefs[UNPACK_DIR] = dir
}
}
suspend fun setUnpackInDownload(unpackInDownload: Boolean) {
dataStore.edit { prefs ->
prefs[UNPACK_IN_DOWNLOAD] = unpackInDownload
}
}
suspend fun setDeleteAfterUnpack(deleteAfterUnpack: Boolean) {
dataStore.edit { prefs ->
prefs[DELETE_AFTER_UNPACK] = deleteAfterUnpack
}
}
suspend fun setPkg2zipParams(params: String) {
dataStore.edit { prefs ->
prefs[PKG2ZIP_PARAMS] = params
}
}
suspend fun setPkg2zipAutoDecrypt(autoDecrypt: Boolean) {
dataStore.edit { prefs ->
prefs[PKG2ZIP_AUTO_DECRYPT] = autoDecrypt
}
}
fun tsvFiles() = tsvFiles
suspend fun setTSVFile(console: ConsoleType, contentType: PackageItemType, file: Uri) {
tsvFiles.emit(Triple(file.toString(), console, contentType))
dataStore.edit { prefs ->
when (console) {
ConsoleType.PSVITA -> {
when (contentType) {
PackageItemType.GAME -> prefs[PSV_GAMES] = file.toString()
PackageItemType.DLC -> prefs[PSV_DLC] = file.toString()
PackageItemType.THEME -> prefs[PSV_THEMES] = file.toString()
}
}
ConsoleType.PS3 -> {
when (contentType) {
PackageItemType.GAME -> prefs[PS3_GAMES] = file.toString()
PackageItemType.DLC -> prefs[PS3_DLC] = file.toString()
else -> {}
}
}
ConsoleType.PSP -> {
when (contentType) {
PackageItemType.GAME -> prefs[PSP_GAMES] = file.toString()
PackageItemType.DLC -> prefs[PSP_DLC] = file.toString()
else -> {}
}
}
ConsoleType.PSX -> {
when (contentType) {
PackageItemType.GAME -> prefs[PSX_GAMES] = file.toString()
else -> {}
}
}
ConsoleType.PSM -> {
when (contentType) {
PackageItemType.GAME -> prefs[PSM_GAMES] = file.toString()
else -> {}
}
}
}
}
}
private fun mapSettingsPreferences(prefs: Preferences): SettingsPreferences {
return SettingsPreferences(
prefs[APP_THEME] ?: Theme.SYSTEM.ordinal,
prefs[LAYOUT] ?: ItemLayout.LIST.ordinal,
prefs[PSV_GAMES] ?: "",
prefs[PS3_GAMES] ?: "",
prefs[PSP_GAMES] ?: "",
prefs[PSX_GAMES] ?: "",
prefs[PSM_GAMES] ?: "",
prefs[PSV_DLC] ?: "",
prefs[PS3_DLC] ?: "",
prefs[PSP_DLC] ?: "",
prefs[PSV_THEMES] ?: "",
prefs[DOWNLOAD_DIR] ?: defaultDownloadDir,
prefs[UNPACK_DIR] ?: defaultDownloadDir,
prefs[UNPACK_IN_DOWNLOAD] ?: false,
prefs[DELETE_AFTER_UNPACK] ?: false,
prefs[PKG2ZIP_PARAMS] ?: "-x {pkgFile} \"{zRifKey}\"",
prefs[PKG2ZIP_AUTO_DECRYPT] ?: true,
)
}
}
@@ -1,89 +0,0 @@
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())
}
}
@@ -1,122 +0,0 @@
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
}
}
@@ -1,24 +0,0 @@
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()
}
}
@@ -1,130 +0,0 @@
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()
}
}
@@ -1,159 +0,0 @@
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
}
}
}
}
@@ -1,466 +0,0 @@
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"
}
}
@@ -1,25 +0,0 @@
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() }
}
@@ -1,131 +0,0 @@
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")
@@ -1,5 +0,0 @@
package com.illusionware.npsbrowser.data.network
interface ProgressListener {
fun update(bytesRead: Long, contentLength: Long, done: Boolean)
}
@@ -1,48 +0,0 @@
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
}
}
}
}
@@ -1,31 +0,0 @@
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()
}
@@ -1,32 +0,0 @@
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()
)
}
}
@@ -0,0 +1,42 @@
package com.illusionware.npsbrowser.fragments
import android.os.Bundle
import android.view.*
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction.TRANSIT_FRAGMENT_OPEN
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager
import com.illusionware.npsbrowser.R
class SettingsFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
PreferenceManager.setDefaultValues(requireContext(), R.xml.root_preferences, false)
activity?.supportFragmentManager
?.beginTransaction()
?.replace(
R.id.prefs,
PrefsFragment()
)
?.setTransition(TRANSIT_FRAGMENT_OPEN)
?.commit()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
activity?.title = resources.getString(R.string.title_activity_settings)
return inflater.inflate(R.layout.settings_fragment, container, false)
}
class PrefsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.root_preferences, rootKey)
PreferenceManager.setDefaultValues(requireContext(), R.xml.root_preferences, false)
}
}
}
@@ -0,0 +1,193 @@
package com.illusionware.npsbrowser.fragments
import android.app.DownloadManager
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context.CLIPBOARD_SERVICE
import android.content.Context.DOWNLOAD_SERVICE
import android.net.Uri
import android.os.Bundle
import android.os.Environment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import com.bumptech.glide.Glide
import com.illusionware.npsbrowser.AppData
import com.illusionware.npsbrowser.R
class SingleAppFragment : Fragment() {
private val baseStoreURL = "https://store.playstation.com/store/api/chihiro/00_09_000/container"
companion object {
lateinit var app : AppData
}
// TODO: either use binding or make vars for all the views
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
super.onCreateView(inflater, container, savedInstanceState)
activity?.title = app.title
val view = inflater.inflate(R.layout.single_app_fragment, container, false)
val downloadManager = requireActivity().getSystemService(DOWNLOAD_SERVICE) as DownloadManager
view?.findViewById<TextView>(R.id.singleAppTitle)?.text = "[${app.titleID}] ${app.title}"
if (app.fileSize != null) {
view?.findViewById<TextView>(R.id.singleAppSize)?.text = prettifySize()
} else {
view?.findViewById<TextView>(R.id.singleAppSizeTitle)?.visibility = View.INVISIBLE
}
view?.findViewById<TextView>(R.id.singleAppContentId)?.text = app.contentID
if (app.version != null) {
view?.findViewById<TextView>(R.id.singleAppVer)?.text = app.version
} else {
view?.findViewById<TextView>(R.id.singleAppVerTitle)?.visibility = View.INVISIBLE
}
view?.findViewById<TextView>(R.id.singleAppFwVer)?.text = app.minFW
// TODO: get and set app icon
if (app.minFW.isNullOrBlank()) {
view?.findViewById<TextView>(R.id.singleAppFwVerTitle)?.visibility = View.INVISIBLE
}
if (app.license == "MISSING") {
view?.findViewById<TextView>(R.id.singleAppError)?.text = getString(R.string.no_license)
view?.findViewById<Button>(R.id.singleAppCopyZRIFButton)?.isEnabled = false
}
if (app.link == "CART ONLY") {
view?.findViewById<TextView>(R.id.singleAppError)?.text = getString(R.string.cart_only)
view?.findViewById<Button>(R.id.singleAppDownloadButton)?.isEnabled = false
view?.findViewById<Button>(R.id.singleAppCopyPKGButton)?.isEnabled = false
view?.findViewById<Button>(R.id.singleAppCopyZRIFButton)?.isEnabled = false
} else if (app.link == "MISSING") {
view?.findViewById<TextView>(R.id.singleAppError)?.text = getString(R.string.no_download_link)
view?.findViewById<Button>(R.id.singleAppDownloadButton)?.isEnabled = false
view?.findViewById<Button>(R.id.singleAppCopyPKGButton)?.isEnabled = false
view?.findViewById<Button>(R.id.singleAppCopyZRIFButton)?.isEnabled = false
}
view?.findViewById<TextView>(R.id.singleAppContentId)?.setOnLongClickListener {
val clipboardManager = requireActivity().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
val clipData = ClipData.newPlainText(
"Content ID",
view.findViewById<TextView>(R.id.singleAppContentId)?.text
)
clipboardManager.setPrimaryClip(clipData)
Toast.makeText(requireContext(), getString(R.string.content_id_copied), Toast.LENGTH_SHORT).show()
true
}
// click listener for the download button
view?.findViewById<Button>(R.id.singleAppDownloadButton)?.setOnClickListener {
try {
val request = DownloadManager.Request(Uri.parse(app.link))
request.setTitle(app.title)
request.setDescription(getString(R.string.downloading))
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
request.setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS,
app.title!!.replace("[\\\\/:*?\"<>|]".toRegex(), " ") + ".pkg"
) // TODO: set up preference for this
downloadManager.enqueue(request)
} catch (e: Exception) {
Toast.makeText(requireContext(), getString(R.string.download_error), Toast.LENGTH_SHORT).show()
}
}
// click listener for the copy pkg button
view?.findViewById<Button>(R.id.singleAppCopyPKGButton)?.setOnClickListener {
val clipboardManager = requireActivity().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
val clipData = ClipData.newPlainText("PKG URL", app.link)
clipboardManager.setPrimaryClip(clipData)
Toast.makeText(requireContext(),getString(R.string.pkg_url_copied),Toast.LENGTH_SHORT).show()
}
// click listener for the copy zRIF button
view?.findViewById<Button>(R.id.singleAppCopyZRIFButton)?.setOnClickListener {
val clipboardManager = requireActivity().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
val clipData = ClipData.newPlainText("zRIF", app.license)
clipboardManager.setPrimaryClip(clipData)
Toast.makeText(requireContext(),getString(R.string.zrif_copied),Toast.LENGTH_SHORT).show()
}
// set the height for the error text to 0 if there's no error so that the scrollview can take more space
if (view?.findViewById<TextView>(R.id.singleAppError)?.text.isNullOrEmpty()) {
view?.findViewById<TextView>(R.id.singleAppError)?.layoutParams?.height = 0
}
if (app.contentID != null) {
val circularProgressDrawable = CircularProgressDrawable(requireContext())
circularProgressDrawable.setColorSchemeColors(0x43EB92)
circularProgressDrawable.strokeWidth = 10f
circularProgressDrawable.centerRadius = 180f
circularProgressDrawable.start()
val image : ImageView? = view?.findViewById(R.id.singleAppIcon)
val iconURL = getImage(app.contentID!!)
if (iconURL != null) {
// TODO: make the options a requestOptions
Glide.with(this).load(iconURL).placeholder(circularProgressDrawable).error(R.drawable.ic_games_placeholder_24dp).into(image!!)
}
}
return view
}
private fun prettifySize(): String {
if (app.fileSize!!.toFloat() / (1024 * 1024 * 1024) > 1) {
return "%.1f GB".format(toGB(app.fileSize!!))
}
if (app.fileSize!!.toFloat() / (1024 * 1024) > 1) {
return "%.1f MB".format(toMB(app.fileSize!!))
}
if (app.fileSize!!.toFloat() / 1024 > 1) {
return "%.1f KB".format(toKB(app.fileSize!!))
}
return "${app.fileSize!!} Bytes"
}
private fun toKB(size: Number): Number {
return size.toFloat() / 1024
}
private fun toMB(size: Number): Number {
return size.toFloat() / (1024 * 1024)
}
private fun toGB(size: Number): Number {
return size.toFloat() / (1024 * 1024 * 1024)
}
private fun getImage(contentID: String): String? {
when (contentID.substring(0, 2)) {
"EP" -> {
// european games
return "$baseStoreURL/SA/en/999/${contentID}/image"
}
"UP" -> {
// united states games
return "$baseStoreURL/US/en/999/${contentID}/image"
}
"JP" -> {
// japanese games
return "$baseStoreURL/jp/ja/999/${contentID}/image"
}
"KP" -> {
// korean games
return "$baseStoreURL/kr/ko/999/${contentID}/image"
}
"HP" -> {
// asia games
return "$baseStoreURL/HK/zh/999/${contentID}/image"
}
}
return null
}
}
@@ -0,0 +1,284 @@
package com.illusionware.npsbrowser.fragments.mainactivity
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.os.Bundle
import android.view.*
import android.widget.Toast
import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat.checkSelfPermission
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProviders
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.github.doyaaaaaken.kotlincsv.dsl.csvReader
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.illusionware.npsbrowser.AppData
import com.illusionware.npsbrowser.R
import com.illusionware.npsbrowser.RecyclerTouchListener
import com.illusionware.npsbrowser.fragments.SingleAppFragment
import com.illusionware.npsbrowser.adapters.AppAdapter
import com.illusionware.npsbrowser.fragments.SettingsFragment
import java.io.InputStream
import java.util.*
import kotlin.collections.ArrayList
class MainActivityFragment : Fragment() {
var recyclerView : RecyclerView? = null
var viewAdapter: AppAdapter? = null
var filteredModelList: ArrayList<AppData>? = null
companion object {
var apps : ArrayList<AppData>? = arrayListOf()
}
private val tsvReader = csvReader {
charset = "UTF-8"
quoteChar = '\\'
escapeChar = '\\'
delimiter = '\t'
skipEmptyLine = true
skipMissMatchedRow = true
}
private lateinit var viewModel: MainActivityViewModel
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
setHasOptionsMenu(true)
activity?.title = resources.getString(R.string.app_name)
val view = inflater.inflate(R.layout.main_fragment, container, false)
view?.findViewById<FloatingActionButton>(R.id.tsvChooserButton)?.setOnClickListener {
if (!isStoragePermissionGranted())
return@setOnClickListener
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "text/tab-separated-values"
}
startActivityForResult(intent, 1)
}
viewModel = ViewModelProviders.of(this).get(MainActivityViewModel::class.java)
// TODO: Use the ViewModel
recyclerView = view?.findViewById(R.id.appsRecycler)
if (viewAdapter == null) {
viewAdapter = AppAdapter(requireContext())
viewAdapter?.edit()?.add(apps!!)?.commit()
}
if (apps != null) {
filteredModelList = apps
}
viewAdapter?.notifyDataSetChanged()
recyclerView?.adapter = viewAdapter
val orientation = resources.configuration.orientation
val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
val isViewAsList = prefs.getString("layout_type", "0")
if (isViewAsList == "0") {
viewAdapter?.toggleItemViewType(0)
recyclerView?.layoutManager = LinearLayoutManager(context)
viewAdapter?.notifyDataSetChanged()
} else {
viewAdapter?.toggleItemViewType(1)
recyclerView?.layoutManager = GridLayoutManager(
context,
if (orientation == Configuration.ORIENTATION_LANDSCAPE) 4 else 2
)
viewAdapter?.notifyDataSetChanged()
}
recyclerView?.addOnItemTouchListener(RecyclerTouchListener(
requireContext(),
recyclerView!!,
object : RecyclerTouchListener.ClickListener {
override fun onClick(view: View?, position: Int) {
SingleAppFragment.app = filteredModelList!![position]
activity?.supportFragmentManager
?.beginTransaction()
?.replace(R.id.container,
SingleAppFragment()
)
?.addToBackStack("single app")
?.commit()
}
override fun onLongClick(
view: View?,
recyclerView: RecyclerView?,
position: Int
) {
Toast.makeText(context, "TODO: Long Click $position", Toast.LENGTH_SHORT).show()
}
}
))
return view
}
override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
super.onActivityResult(requestCode, resultCode, resultData)
if (requestCode == 1 && resultCode == Activity.RESULT_OK) {
resultData?.data?.also { uri ->
val inputStream: InputStream? = activity?.contentResolver?.openInputStream(uri)
val entries : List<Map<String, String?>> = tsvReader.readAllWithHeader(inputStream!!)
apps?.clear()
apps = ArrayList(entries.size)
entries.forEach { entry ->
if (entry["Name"].isNullOrEmpty()) {
return@forEach
}
val id = entry["Title ID"]
val region = entry["Region"]
val name = entry["Name"]
val link = entry["PKG direct link"]
val license = entry["zRIF"]
val contentID = entry["Content ID"]
var lastDateTime = entry["Last Modification Date"]
if (!lastDateTime.isNullOrEmpty()) {
lastDateTime = lastDateTime.substring(0, 10)
}
val fileSize = entry["File Size"]?.toLongOrNull()
val version = entry["Update Version"]
val sha256 = entry["SHA256"]
val minFW = entry["Required FW"]
val app = AppData(
id, region, name, link ?: "MISSING", license ?: "MISSING",
contentID ?: "MISSING", lastDateTime ?: "MISSING",
fileSize, version, sha256 ?: "MISSING", minFW
)
apps?.add(app)
}
apps?.sortBy { it.title }
filteredModelList = apps
recyclerView = view?.findViewById(R.id.appsRecycler)
viewAdapter = AppAdapter(requireContext())
viewAdapter?.edit()?.add(apps!!)?.commit()
val orientation = this.resources.configuration.orientation
val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
val isViewAsList = prefs.getString("layout_type", "0")
if (isViewAsList == "0") {
viewAdapter?.toggleItemViewType(0)
recyclerView?.layoutManager = LinearLayoutManager(context)
viewAdapter?.notifyDataSetChanged()
} else {
viewAdapter?.toggleItemViewType(1)
recyclerView?.layoutManager = GridLayoutManager(
context,
if (orientation == Configuration.ORIENTATION_LANDSCAPE) 4 else 2
)
viewAdapter?.notifyDataSetChanged()
}
recyclerView?.adapter = viewAdapter
}
}
}
override fun onCreateOptionsMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.main, menu)
val searchItem = menu.findItem(R.id.action_search)
val searchView: SearchView = searchItem.actionView as SearchView
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
return false
}
override fun onQueryTextChange(query: String): Boolean {
filteredModelList = filter(apps!!, query) as ArrayList<AppData>?
viewAdapter?.edit()?.replaceAll(filteredModelList!!)?.commit()
recyclerView?.scrollToPosition(0)
return false
}
})
super.onCreateOptionsMenu(menu, menuInflater)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_settings -> {
activity?.supportFragmentManager
?.beginTransaction()
?.replace(
R.id.container,
SettingsFragment()
)
?.addToBackStack("settings")
?.commit()
true
}
R.id.action_search -> {
true
}
else -> return super.onOptionsItemSelected(item)
}
}
override fun onPause() {
super.onPause()
viewAdapter?.edit()?.replaceAll(apps!!)?.commit()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
val orientation = newConfig.orientation
val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
val isViewAsList = prefs.getString("layout_type", "0")
if (isViewAsList == "0") {
val pos = (recyclerView?.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
viewAdapter?.toggleItemViewType(0)
recyclerView?.layoutManager = LinearLayoutManager(context)
(recyclerView?.layoutManager as LinearLayoutManager).scrollToPosition(pos)
viewAdapter?.notifyDataSetChanged()
} else {
val pos = (recyclerView?.layoutManager as GridLayoutManager).findFirstVisibleItemPosition()
viewAdapter?.toggleItemViewType(1)
recyclerView?.layoutManager = GridLayoutManager(
context,
if (orientation == Configuration.ORIENTATION_LANDSCAPE) 4 else 2
)
(recyclerView?.layoutManager as GridLayoutManager).scrollToPosition(pos)
viewAdapter?.notifyDataSetChanged()
}
}
private fun isStoragePermissionGranted(): Boolean {
return if (checkSelfPermission(
this.requireContext(),
Manifest.permission.READ_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED) {
true
} else {
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
false
}
}
private fun filter(models: List<AppData>, query: String): List<AppData>? {
val lowerCaseQuery = query.lowercase(Locale.getDefault())
val filteredModelList: MutableList<AppData> = ArrayList()
for (model in models) {
val title: String = model.title?.lowercase(Locale.getDefault())!!
val id: String = model.titleID?.lowercase(Locale.getDefault())!!
if (title.contains(lowerCaseQuery) || id.contains(lowerCaseQuery)) {
filteredModelList.add(model)
}
}
return filteredModelList
}
}
@@ -0,0 +1,7 @@
package com.illusionware.npsbrowser.fragments.mainactivity
import androidx.lifecycle.ViewModel
class MainActivityViewModel : ViewModel() {
// TODO: Implement the ViewModel
}
@@ -1,9 +0,0 @@
package com.illusionware.npsbrowser.model
enum class ConsoleType {
PSVITA,
PS3,
PSP,
PSX,
PSM,
}
@@ -1,19 +0,0 @@
package com.illusionware.npsbrowser.model
data class PackageItem(
val titleId: String,
val region: String,
val name: String,
val pkgUrl: String?,
val contentId: String?,
val modificationDate: String?,
val pkgSize: String,
val pkgSizeInBytes: ULong?,
val sha256: String?,
val zRif: String?,
val rap: String?,
val minFW: String?,
val consoleType: ConsoleType,
val dataType: PackageItemType,
)
@@ -1,7 +0,0 @@
package com.illusionware.npsbrowser.model
enum class PackageItemType {
GAME,
DLC,
THEME,
}
@@ -1,50 +0,0 @@
package com.illusionware.npsbrowser.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.illusionware.npsbrowser.ui.theme.Typography
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NPSAlertDialog(
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
title: String,
buttons: @Composable () -> Unit,
content: @Composable () -> Unit
) {
BasicAlertDialog(
onDismissRequest = onDismiss,
) {
Surface (
modifier = Modifier.wrapContentHeight().wrapContentWidth(),
shape = MaterialTheme.shapes.large,
tonalElevation = AlertDialogDefaults.TonalElevation
) {
Column(
modifier = modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = title, style = Typography.titleLarge)
content()
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
buttons()
}
}
}
}
}
@@ -1,95 +0,0 @@
package com.illusionware.npsbrowser.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Eco
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.onLongClick
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NPSFloatingActionButton(
modifier: Modifier = Modifier,
enabled: Boolean = true,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
shape: Shape = FloatingActionButtonDefaults.shape,
containerColor: Color = FloatingActionButtonDefaults.containerColor,
contentColor: Color = MaterialTheme.colorScheme.primary,
elevation: Dp = 6.dp,
content: @Composable () -> Unit,
) {
val haptic = LocalHapticFeedback.current
Surface(
modifier = modifier
.alpha(if (enabled) 1f else 0.5f)
.then(if (enabled) Modifier.shadow(6.dp, shape) else Modifier.clip(shape))
.then(if (enabled) Modifier.combinedClickable(
onClick = { onClick() },
onLongClick = {
if (onLongClick != null) {
onLongClick()
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
}
) else Modifier)
.semantics(
mergeDescendants = true,
properties = {
if (!enabled) disabled()
onClick { onClick(); true }
if (onLongClick != null) {
onLongClick { onLongClick(); true }
}
role = Role.Button
},
),
color = containerColor,
tonalElevation = 3.dp,
shadowElevation = elevation,
contentColor = contentColor,
) {
Box(
modifier = Modifier.padding(16.dp)
) {
Row {
content()
}
}
}
}
@Composable
@Preview
fun NPSFABPreview() {
NPSFloatingActionButton(
onClick = {},
) {
Icon(imageVector = Icons.Outlined.Eco, contentDescription = "Eco")
}
}
@@ -1,77 +0,0 @@
package com.illusionware.npsbrowser.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layoutId
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NPSIconButton(
tooltip: String,
onClick: () -> Unit,
badge: (@Composable BoxScope.() -> Unit)? = null,
content: @Composable () -> Unit
) {
Layout(
content = {
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
Text(text = tooltip)
},
state = rememberTooltipState(),
) {
IconButton(
onClick = onClick,
) {
content()
}
}
Box(Modifier.layoutId("badge")) {
badge?.let { it() }
}
}
) { measurables, constraints ->
val iconPlaceable = measurables[0].measure(constraints)
val badgePlaceable = measurables.firstOrNull { it.layoutId == "badge" }?.measure(constraints)
layout(iconPlaceable.width, iconPlaceable.height) {
iconPlaceable.placeRelative(0, 0)
badgePlaceable?.placeRelative(
x = iconPlaceable.width - badgePlaceable.width - 16,
y = 16,
)
}
}
// PlainTooltipBox(tooltip = {
// Text(text = tooltip)
// }) {
// if (badge != null) {
// IconButton(
// onClick = onClick,
// Modifier.tooltipAnchor(),
// ) {
// BadgedBox(badge = badge) {
// content()
// }
// }
// } else {
// IconButton(
// onClick = onClick,
// Modifier.tooltipAnchor(),
// ) {
// content()
// }
// }
// }
}
@@ -1,23 +0,0 @@
package com.illusionware.npsbrowser.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@Composable
@Preview
fun NPSRadioButton(text: String = "Radio Button", onClick: () -> Unit = {}, selected: Boolean = true) {
Box (Modifier.clickable { onClick() }.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = selected, onClick = onClick)
Text(text = text)
}
}
}
@@ -1,269 +0,0 @@
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 = {},
)
}
@@ -1,862 +0,0 @@
package com.illusionware.npsbrowser.ui.screens
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.content.pm.ActivityInfo
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.illusionware.npsbrowser.R
import com.illusionware.npsbrowser.ui.theme.ColorAccent
import com.illusionware.npsbrowser.ui.theme.Typography
import com.illusionware.npsbrowser.ui.theme.md_theme_dark_onSurface
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.illusionware.npsbrowser.data.SettingsPreferences
import com.illusionware.npsbrowser.model.ConsoleType
import com.illusionware.npsbrowser.model.PackageItemType
import com.illusionware.npsbrowser.viewmodels.OnboardingViewModel
import com.illusionware.npsbrowser.viewmodels.SettingsViewModel
import androidx.core.view.WindowCompat
import kotlinx.coroutines.launch
const val PAGE_COUNT = 3
val readexProFamily = FontFamily(
Font(R.font.readex_pro_extra_light, FontWeight.ExtraLight),
Font(R.font.readex_pro_light, FontWeight.Light),
Font(R.font.readex_pro_regular, FontWeight.Normal),
Font(R.font.readex_pro_medium, FontWeight.Medium),
Font(R.font.readex_pro_semi_bold, FontWeight.SemiBold),
Font(R.font.readex_pro_bold, FontWeight.Bold),
)
@OptIn(ExperimentalFoundationApi::class)
@SuppressLint("SourceLockedOrientationActivity")
@Composable
fun OnBoarding(
navigateToHome: () -> Unit = {},
darkTheme: Boolean = false,
onboardingViewModel: OnboardingViewModel = viewModel(
factory = OnboardingViewModel.Factory
),
settingsViewModel: SettingsViewModel = viewModel(
factory = SettingsViewModel.Factory
),
) {
val pagerState = rememberPagerState(
initialPage = 0,
initialPageOffsetFraction = 0f
) {
PAGE_COUNT
}
val scope = rememberCoroutineScope()
val context = LocalContext.current
val view = LocalView.current
val settingsPrefs = settingsViewModel.uiState.collectAsStateWithLifecycle().value
val darkIcons = when (pagerState.currentPage) {
0 -> false
else -> true
}
DisposableEffect(view, darkIcons, darkTheme) {
val activity = context as Activity
val window = activity.window
val insetsController = WindowCompat.getInsetsController(window, view)
window.statusBarColor = Color.Transparent.toArgb()
insetsController.isAppearanceLightStatusBars = darkIcons && !darkTheme
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
onDispose {
insetsController.isAppearanceLightStatusBars = !darkTheme
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
HorizontalPager(state = pagerState) { page ->
when (page) {
0 -> OnBoardingInitial(
incrementPage = {
scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) }
},
)
1 -> OnBoardingSetupTSVs(
page = page,
incrementPage = {
scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) }
},
skipToEnd = {
scope.launch { pagerState.animateScrollToPage(PAGE_COUNT - 1) }
},
settingsViewModel,
)
// 2 -> OnBoardingStoragePermission(
// page = page,
// incrementPage = {
// scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) }
// },
// decrementPage = {
// scope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) }
// },
// skipToEnd = {
// scope.launch { pagerState.animateScrollToPage(PAGE_COUNT - 1) }
// },
// )
2 -> OnBoardingSetupSettings(
page = page,
incrementPage = {
scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) }
},
decrementPage = {
scope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) }
},
skipToEnd = {
scope.launch { pagerState.animateScrollToPage(PAGE_COUNT - 1) }
},
navigateToHome = navigateToHome,
settingsViewModel,
onboardingViewModel,
settingsPrefs,
)
}
}
}
@Composable
@Preview
fun OnBoardingInitial(incrementPage: () -> Unit = {}) {
val density = LocalDensity.current
var visible by remember { mutableStateOf(false) }
LaunchedEffect(key1 = visible) {
visible = true
}
Scaffold(containerColor = ColorAccent, contentColor = MaterialTheme.colorScheme.onBackground) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.statusBarsPadding(),
verticalArrangement = Arrangement.Center,
) {
Column(
verticalArrangement = Arrangement.spacedBy(56.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
AnimatedVisibility(
visible = visible,
enter = slideInVertically {
h -> h
} + fadeIn(
initialAlpha = 0.2f
),
) {
Image(
painter = painterResource(id = R.drawable.nps_fg_onboarding),
contentDescription = "App Logo",
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 64.dp)
)
}
AnimatedVisibility(
visible = visible,
enter = slideInVertically(tween(delayMillis = 100)) {
with(density) { 50.dp.roundToPx() }
} + fadeIn(
animationSpec = tween(delayMillis = 50),
initialAlpha = 0.0f,
),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(48.dp)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
ProvideTextStyle(value = Typography.displayMedium.copy(color = Color.White)) {
Text(text = "No Ads.")
Text(text = "No Waiting.")
Text(text = "No Bullshit.")
}
}
Text(
text = "Browse and download from a huge library of games, themes, and DLC to your heart's content.",
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 32.dp),
style = Typography.titleLarge.copy(color = md_theme_dark_onSurface),
)
}
}
Spacer(modifier = Modifier.height(16.dp))
AnimatedVisibility(
visible = visible,
enter = fadeIn(
animationSpec = tween(delayMillis = 200, durationMillis = 500),
initialAlpha = 0.0f,
),
) {
TextButton(
onClick = incrementPage,
colors = ButtonDefaults.textButtonColors(contentColor = Color.White, containerColor = Color(0xFF1A91EC)),
contentPadding = PaddingValues(horizontal = 24.dp)
) {
Text(text = "Get Started")
}
}
}
}
}
}
@Composable
fun OnBoardingSetupTSVs(
page: Int = 1,
incrementPage: () -> Unit = {},
skipToEnd: () -> Unit = {},
settingsViewModel: SettingsViewModel,
) {
Scaffold { padding ->
Column(
Modifier
.padding(padding)
.padding(vertical = 32.dp)
.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = "Set up TSVs", style = Typography.displayMedium)
Spacer(modifier = Modifier.height(8.dp))
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "You can set up your TSV files on this screen",
style = Typography.titleMedium.copy(color = MaterialTheme.colorScheme.outline),
textAlign = TextAlign.Center,
)
Text(
text = "You are expected to provide the TSV files yourself. NPS Browser will NOT provide you with these files",
style = Typography.titleSmall.copy(color = Color(0xFFD63A3A)),
textAlign = TextAlign.Center
)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.weight(1.0f, fill = false)
.padding(vertical = 16.dp),
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
TSVGroup(title = "PS Vita") {
TSVButton(
"Games",
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.GAME, it) }
)
TSVButton(
"Themes",
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.THEME, it) }
)
TSVButton(
"DLC",
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.DLC, it) }
)
}
TSVGroup(title = "PSP") {
TSVButton(
"Games",
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSP, PackageItemType.GAME, it) }
)
TSVButton(
"DLC",
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSP, PackageItemType.DLC, it) }
)
Box(modifier = Modifier.width(110.dp))
}
TSVGroup(title = "PS3") {
TSVButton(
"Games",
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PS3, PackageItemType.GAME, it) }
)
TSVButton(
"DLC",
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PS3, PackageItemType.DLC, it) }
)
Box(modifier = Modifier.width(110.dp))
}
TSVGroup(title = "PSX") {
TSVButton(
"Games",
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSX, PackageItemType.GAME, it) }
)
}
TSVGroup(title = "PSM") {
TSVButton(
"Games",
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSM, PackageItemType.GAME, it) }
)
}
}
}
Column(
modifier = Modifier.padding(horizontal = 16.dp)
) {
OnBoardingBottomButtons(
page = page,
incrementPage = incrementPage,
skipToEnd = skipToEnd,
)
}
}
}
}
}
@Composable
fun ColumnGroup(
title: String,
modifier: Modifier = Modifier,
titleModifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
Column(modifier = modifier) {
Text(
modifier = titleModifier,
text = title,
style = TextStyle.Default.copy(
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
),
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
content = content
)
}
}
@Composable
fun TSVGroup(title: String, content: @Composable RowScope.() -> Unit) {
Column {
Text(
text = title,
style = TextStyle.Default.copy(
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
),
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
content = content
)
}
}
@Composable
fun TSVButton(title: String, onFinish: (Uri) -> Unit) {
val context = LocalContext.current
var hasSelected by rememberSaveable { mutableStateOf(false) }
val filePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { fileUri ->
if (fileUri != null) {
context.contentResolver.takePersistableUriPermission(
fileUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
onFinish(fileUri)
hasSelected = true
}
}
FilledTonalButton(
onClick = {
filePicker.launch(arrayOf("text/tab-separated-values"))
},
modifier = Modifier
.width(110.dp)
.height(160.dp)
.padding(vertical = 8.dp, horizontal = 0.dp)
.graphicsLayer(clip = false),
shape = MaterialTheme.shapes.medium,
contentPadding = PaddingValues(8.dp),
colors = ButtonDefaults.filledTonalButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground,
containerColor = if (!hasSelected) MaterialTheme.colorScheme.secondaryContainer else Color(0x335EC03C)
),
) {
Layout(
modifier = Modifier.graphicsLayer(clip = false),
content = {
Column(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
painter = painterResource(id = R.drawable.tsv_file),
contentDescription = "Add TSV",
modifier = Modifier
.weight(1.0f)
.fillMaxWidth(),
tint = MaterialTheme.colorScheme.onBackground,
)
Text(text = title, style = Typography.titleMedium)
}
Icon(
modifier = Modifier.layoutId("selected"),
imageVector = Icons.Filled.CheckCircle,
contentDescription = "Selected",
tint = Color(0xFF5EC03C)
)
}
) { measurables, constraints ->
val buttonPlaceable = measurables[0].measure(constraints)
val iconPlaceable = measurables.first { it.layoutId == "selected" }.measure(constraints)
layout(buttonPlaceable.width, buttonPlaceable.height) {
if (hasSelected) {
iconPlaceable.placeRelative(
x = buttonPlaceable.width - iconPlaceable.width,
y = -8,
)
}
buttonPlaceable.placeRelative(0, 0)
}
}
}
}
//@Composable
//fun OnBoardingStoragePermission(
// page: Int = 2,
// incrementPage: () -> Unit = {},
// decrementPage: () -> Unit = {},
// skipToEnd: () -> Unit = {},
//) {
// val hasPermission = when (PackageManager.PERMISSION_GRANTED) {
// ContextCompat.checkSelfPermission(
// LocalContext.current,
// Manifest.permission.WRITE_EXTERNAL_STORAGE
// ) -> true
// else -> false
// }
//
// val permissionLegacy = rememberLauncherForActivityResult(
// contract = ActivityResultContracts.RequestPermission(),
// ) {}
//
// Scaffold { padding ->
// Column(
// Modifier
// .padding(padding)
// .padding(16.dp)
// .fillMaxSize(),
// verticalArrangement = Arrangement.SpaceBetween
// ) {
// Column(
// Modifier
// .padding(vertical = 32.dp)
// .weight(1.0f)
// .fillMaxHeight(),
// horizontalAlignment = Alignment.CenterHorizontally,
// ) {
// Image(
// painter = painterResource(id = R.drawable.folder_bubbles),
// contentDescription = "Folder Image",
// contentScale = ContentScale.Crop,
// modifier = Modifier.fillMaxWidth()
// )
// Text(
// text = "Storage".uppercase(),
// style = TextStyle.Default.copy(
// fontWeight = FontWeight.Bold,
// fontSize = 28.sp,
// fontFamily = readexProFamily
// ),
// )
// Spacer(modifier = Modifier.height(24.dp))
// Text(
// "Granting storage permission allows us to bypass newer Android limitations and choose any directory as your download directory",
// style = TextStyle.Default.copy(
// fontSize = 20.sp,
// fontFamily = readexProFamily
// ),
// textAlign = TextAlign.Center,
// )
// Spacer(modifier = Modifier
// .fillMaxHeight()
// .weight(1.0f))
// Button(
// onClick = {
// permissionLegacy.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
// },
// modifier = Modifier.fillMaxWidth(),
// enabled = !hasPermission,
// ) {
// Text(
// text = if (hasPermission) "Granted" else "Grant",
// style = TextStyle.Default.copy(
// fontSize = 18.sp,
// fontFamily = readexProFamily
// ),
// )
// }
// }
// Column(Modifier.fillMaxWidth()) {
// OnBoardingBottomButtons(
// page = page,
// incrementPage = incrementPage,
// decrementPage = decrementPage,
// skipToEnd = skipToEnd
// )
// }
// }
// }
//}
@Composable
fun OnBoardingSetupSettings(
page: Int = 2,
incrementPage: () -> Unit = {},
decrementPage: () -> Unit = {},
skipToEnd: () -> Unit = {},
navigateToHome: () -> Unit = {},
settingsViewModel: SettingsViewModel,
onboardingViewModel: OnboardingViewModel,
settingsPrefs: SettingsPreferences,
) {
val downloadDirPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { dirUri ->
if (dirUri != null) {
settingsViewModel.setDownloadDir(dirUri.toString())
}
}
val unpackDirPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { dirUri ->
if (dirUri != null) {
settingsViewModel.setUnpackDir(dirUri.toString())
}
}
Scaffold { padding ->
Column(
Modifier
.padding(padding)
.padding(vertical = 32.dp)
.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Configure App", style = Typography.displayMedium)
Text(
text = "Set the app's settings to your liking and control how it behaves",
style = Typography.titleMedium.copy(color = MaterialTheme.colorScheme.outline),
textAlign = TextAlign.Center,
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.weight(1.0f, fill = false)
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
ColumnGroup(title = "Downloads", titleModifier = Modifier.padding(horizontal = 16.dp)) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = "Choose your desired directory to download and unpack packages",
style = Typography.bodyMedium.copy(color = MaterialTheme.colorScheme.outline),
)
Column {
Toggleable(
title = "Unpack in download directory",
checked = settingsPrefs.unpackInDownload,
onCheckedChange = {
settingsViewModel.setUnpackInDownload(it)
},
)
Toggleable(
title = "Delete package after unpacking",
checked = settingsPrefs.deleteAfterUnpack,
onCheckedChange = {
settingsViewModel.setDeleteAfterUnpack(it)
},
)
Box(
Modifier
.fillMaxWidth()
.clickable { downloadDirPicker.launch(Uri.EMPTY) }
) {
Column(Modifier.padding(16.dp)) {
Text(
text = "Download Directory",
)
Text(
text = Uri.decode(settingsPrefs.downloadDir).ifEmpty { "None" },
color = MaterialTheme.colorScheme.outline,
)
}
}
Box(
Modifier
.fillMaxWidth()
.clickable { unpackDirPicker.launch(Uri.EMPTY) }
) {
Column(Modifier.padding(16.dp)) {
Text(
text = "Unpack Directory",
)
Text(
text = Uri.decode(settingsPrefs.unpackDir).ifEmpty { "None" },
color = MaterialTheme.colorScheme.outline,
)
}
}
}
}
ColumnGroup(title = "PKG2ZIP", titleModifier = Modifier.padding(horizontal = 16.dp)) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = "Modify the parameters and arguments of pkg2zip",
style = Typography.bodyMedium.copy(color = MaterialTheme.colorScheme.outline),
)
Toggleable(
title = "Automatically decrypt downloaded content",
checked = settingsPrefs.pkg2zipAutoDecrypt,
onCheckedChange = {
settingsViewModel.setPkg2zipAutoDecrypt(it)
},
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
value = settingsPrefs.pkg2zipParams,
onValueChange = {
settingsViewModel.setPkg2zipParams(it)
}, // TODO: validate args are legal ??
label = { Text(text = "Pkg2zip args") },
placeholder = { Text(text = "-x {pkgFile} \"{zRifKey}\"") },
)
}
}
Column(
modifier = Modifier.padding(horizontal = 16.dp),
) {
OnBoardingBottomButtons(
page = page,
incrementPage = incrementPage,
decrementPage = decrementPage,
skipToEnd = skipToEnd,
navigateToHome = navigateToHome,
settingsViewModel = settingsViewModel,
onboardingViewModel = onboardingViewModel,
)
}
}
}
}
}
@Composable
fun Toggleable(title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Box(
modifier = Modifier
.clickable onCheckedChange@{
onCheckedChange(!checked)
}
.padding(vertical = 16.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = title, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1.0f))
Switch(
checked = checked,
onCheckedChange = null,
thumbContent = if (checked) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
} else null
)
}
}
}
@Composable
fun OnBoardingBottomButtons(
page: Int,
incrementPage: () -> Unit = {},
decrementPage: () -> Unit = {},
skipToEnd: () -> Unit = {},
navigateToHome: () -> Unit = {},
settingsViewModel: SettingsViewModel? = null,
onboardingViewModel: OnboardingViewModel? = null
) {
val finalPage = PAGE_COUNT - 1
Row(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.1f),
horizontalArrangement = if (page != finalPage) Arrangement.SpaceBetween else Arrangement.End,
) {
if (page != finalPage) {
FilledTonalButton(
onClick = skipToEnd,
colors = ButtonDefaults.filledTonalButtonColors(
contentColor = MaterialTheme.colorScheme.primary
),
contentPadding = PaddingValues(horizontal = 18.dp)
) {
Text(text = "Skip")
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (page != 1) {
Button(onClick = decrementPage, contentPadding = PaddingValues(horizontal = 18.dp)) {
Text(text = "Prev")
}
}
if (page == finalPage) {
Button(
onClick = {
navigateToHome()
onboardingViewModel?.finishOnboarding(settingsViewModel!!.shouldCreateDefaultDir())
},
contentPadding = PaddingValues(horizontal = 18.dp)
) {
Text(text = "Finish")
}
} else {
Button(
onClick = incrementPage,
contentPadding = PaddingValues(horizontal = 18.dp)
) {
Text(text = "Next")
}
}
}
}
}
@@ -1,490 +0,0 @@
package com.illusionware.npsbrowser.ui.screens
import android.annotation.SuppressLint
import android.content.ClipData
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.filled.Public
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SheetState
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.illusionware.npsbrowser.R
import com.illusionware.npsbrowser.PlaceholderColor
import com.illusionware.npsbrowser.data.SettingsPreferences
import com.illusionware.npsbrowser.data.download.NPSPackageDownload
import com.illusionware.npsbrowser.ui.components.NPSAlertDialog
import com.illusionware.npsbrowser.ui.components.NPSFloatingActionButton
import com.illusionware.npsbrowser.ui.components.NPSIconButton
import com.illusionware.npsbrowser.ui.theme.ColorSecondaryLight
import com.illusionware.npsbrowser.ui.theme.Typography
import com.illusionware.npsbrowser.viewmodels.PackageDetailsViewModel
import com.illusionware.npsbrowser.viewmodels.SettingsViewModel
import kotlinx.coroutines.launch
@SuppressLint("MissingPermission")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PackageDetails(
navigationGoBack: () -> Unit = {},
settingsViewModel: SettingsViewModel = viewModel(
factory = SettingsViewModel.Factory
),
packageDetailsViewModel: PackageDetailsViewModel,
) {
val clipboard = LocalClipboard.current
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val prefs = settingsViewModel.uiState.collectAsStateWithLifecycle().value
val item = packageDetailsViewModel.uiState.collectAsStateWithLifecycle().value.item!!
var showBottomSheet by remember { mutableStateOf(false) }
var pendingOverwriteDownload by remember { mutableStateOf<NPSPackageDownload?>(null) }
fun buildDownloadRequest(): NPSPackageDownload {
return NPSPackageDownload(
item.pkgUrl!!,
item.titleId,
item.name,
item.sha256,
item.pkgSizeInBytes?.toLong() ?: 0,
0L,
System.currentTimeMillis()
)
}
fun requestDownload(overwriteExisting: Boolean = false) {
val downloadRequest = buildDownloadRequest()
if (!overwriteExisting && packageDetailsViewModel.downloadManager.shouldConfirmOverwrite(downloadRequest.url)) {
pendingOverwriteDownload = downloadRequest
return
}
packageDetailsViewModel.downloadManager.download(downloadRequest, overwriteExisting = overwriteExisting)
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState)},
topBar = {
Row(
verticalAlignment = Alignment.CenterVertically,
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" )
}
}
},
floatingActionButton = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
NPSFloatingActionButton(
onClick = {
if (!item.zRif.isNullOrEmpty()) {
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(item.zRif, item.zRif)))
}
} else if (!item.rap.isNullOrEmpty()) {
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(item.rap, item.rap)))
}
}
scope.launch {
snackbarHostState.showSnackbar("License key copied to clipboard")
}
},
enabled = !item.zRif.isNullOrEmpty() || !item.rap.isNullOrEmpty()
) {
Icon(
painter = painterResource(id = R.drawable.key_copy_fill),
contentDescription = "Copy License Key"
)
}
NPSFloatingActionButton(
onClick = {
requestDownload()
},
onLongClick = { showBottomSheet = true },
enabled = !item.pkgUrl.isNullOrEmpty()
) {
Icon(imageVector = Icons.Filled.Download, contentDescription = "Download")
}
}
},
) { padding ->
Column(Modifier.padding(padding)) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("TODO")
.crossfade(true)
.error(R.drawable.default_game_icon)
.build(),
placeholder = ColorPainter(PlaceholderColor),
contentDescription = "Package Icon",
modifier = Modifier
.aspectRatio(1f / 1f)
.fillMaxWidth(),
contentScale = ContentScale.Crop,
)
Column(Modifier.padding(16.dp)) {
Text(
text = "[${item.titleId}] ${item.name}",
style = Typography.headlineSmall.copy(fontSize = 20.sp),
fontWeight = FontWeight.Light,
)
if (!item.contentId.isNullOrEmpty()) {
Spacer(modifier = Modifier.padding(4.dp))
Text(
text = item.contentId,
style = Typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
Spacer(modifier = Modifier.padding(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
if (item.region.isNotEmpty()) {
Info(
label = item.region,
iconDescription = "Region",
icon = Icons.Filled.Public
)
}
if (item.pkgSize.isNotEmpty()) {
Info(
label = item.pkgSize,
iconDescription = "Package",
icon = painterResource(id = R.drawable.package_ic),
)
}
if (!item.minFW.isNullOrEmpty()) {
Info(
label = item.minFW,
iconDescription = "Minimum Firmware",
icon = painterResource(id = R.drawable.gear),
)
}
}
}
}
}
if (showBottomSheet) {
OneTimeConfigBottomSheet(
onDismiss = {
scope.launch {
sheetState.hide()
showBottomSheet = false
}
},
sheetState = sheetState,
prefs = prefs,
packageDetailsViewModel = packageDetailsViewModel,
onDownload = { requestDownload() }
)
}
pendingOverwriteDownload?.let { downloadRequest ->
NPSAlertDialog(
onDismiss = { pendingOverwriteDownload = null },
title = "Overwrite existing package?",
buttons = {
TextButton(onClick = { pendingOverwriteDownload = null }) {
Text(text = "Keep Existing")
}
TextButton(onClick = {
pendingOverwriteDownload = null
packageDetailsViewModel.downloadManager.download(downloadRequest, overwriteExisting = true)
}) {
Text(text = "Overwrite")
}
}
) {
Text("This package is already downloaded. Choose whether to keep the existing file or overwrite it.")
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OneTimeConfigBottomSheet(
sheetState: SheetState,
onDismiss: () -> Unit,
prefs: SettingsPreferences,
packageDetailsViewModel: PackageDetailsViewModel,
onDownload: () -> Unit,
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
//windowInsets = WindowInsets(top = 0.dp),
) {
OneTimeConfigSheetContent(prefs, packageDetailsViewModel, onDownload)
}
}
@Composable
fun Info(label: String, iconDescription: String, icon: Painter) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = icon,
contentDescription = iconDescription,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp)
)
Text(
text = label,
style = Typography.bodyMedium,
)
}
}
@Composable
fun Info(label: String, iconDescription: String, icon: ImageVector) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = iconDescription,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp)
)
Text(
text = label,
style = Typography.bodyMedium,
)
}
}
@Composable
fun Option(
title: String,
value: String,
onClick: () -> Unit,
) {
Box(
Modifier
.fillMaxWidth()
.clickable { onClick() }
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 16.dp, horizontal = 24.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = title, color = MaterialTheme.colorScheme.onBackground)
Text(text = value, color = ColorSecondaryLight, fontSize = 14.sp)
}
}
}
}
@Composable
fun OneTimeConfigSheetContent(
prefs: SettingsPreferences,
packageDetailsViewModel: PackageDetailsViewModel,
onDownload: () -> Unit,
) {
var autoDecrypt by remember { mutableStateOf(prefs.pkg2zipAutoDecrypt) }
var unpackInPlace by remember { mutableStateOf(prefs.unpackInDownload) }
var deleteAfter by remember { mutableStateOf(prefs.deleteAfterUnpack) }
var downloadDir by remember { mutableStateOf(Uri.decode(prefs.downloadDir)) }
var decParamsDialog by remember { mutableStateOf(false) }
var unpackDir by remember { mutableStateOf(Uri.decode(prefs.unpackDir)) }
var pkg2zipParams by remember { mutableStateOf(prefs.pkg2zipParams) }
var displayPkg2zipParams by remember { mutableStateOf(prefs.pkg2zipParams) }
val downloadDirPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { dirUri ->
if (dirUri != null) {
downloadDir = dirUri.toString()
}
}
val unpackDirPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { dirUri ->
if (dirUri != null) {
unpackDir = dirUri.toString()
}
}
val item = packageDetailsViewModel.uiState.collectAsStateWithLifecycle().value.item!!
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.navigationBarsPadding()
) {
Text(
text = "One-time Configuration",
textAlign = TextAlign.Center,
style = Typography.titleLarge
)
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
) {
FilterChip(
label = { Text(text = "Decrypt automatically") },
selected = autoDecrypt,
onClick = { autoDecrypt = !autoDecrypt }
)
FilterChip(
label = { Text(text = "Unpack in place") },
selected = unpackInPlace,
onClick = { unpackInPlace = !unpackInPlace },
)
FilterChip(
label = { Text(text = "Delete after") },
selected = deleteAfter,
onClick = { deleteAfter = !deleteAfter },
)
}
Column(
modifier = Modifier.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.End,
) {
Column {
Option(
title = "Download Directory",
value = Uri.decode(downloadDir).ifEmpty { "None" },
onClick = {
downloadDirPicker.launch(Uri.EMPTY)
}
)
AnimatedVisibility(
visible = !unpackInPlace
) {
Option(
title = "Unpack Directory",
value = Uri.decode(unpackDir).ifEmpty { "None" },
onClick = {
unpackDirPicker.launch(Uri.EMPTY)
}
)
}
AnimatedVisibility(
visible = autoDecrypt
) {
Option(
title = "Decryption Parameters",
value = pkg2zipParams,
onClick = { decParamsDialog = true }
)
}
}
Button(
onClick = {
onDownload()
},
modifier = Modifier.padding(horizontal = 24.dp),
) {
Icon(
imageVector = Icons.Filled.DownloadDone,
contentDescription = "Download",
Modifier.size(20.dp)
)
Spacer(modifier = Modifier.size(8.dp))
Text("Download")
}
}
}
if (decParamsDialog) {
NPSAlertDialog(
onDismiss = {
decParamsDialog = false
displayPkg2zipParams = pkg2zipParams
},
title = "Decryption Parameters",
buttons = {
TextButton(onClick = {
pkg2zipParams = displayPkg2zipParams
decParamsDialog = false
}) {
Text(text = "OK")
}
}
) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = displayPkg2zipParams,
label = { Text(text = "Decryption Parameters") },
onValueChange = {
displayPkg2zipParams = it
},
)
}
}
}
@@ -1,534 +0,0 @@
package com.illusionware.npsbrowser.ui.screens
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.FileDownload
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.Unarchive
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.illusionware.npsbrowser.R
import com.illusionware.npsbrowser.ui.components.NPSAlertDialog
import com.illusionware.npsbrowser.ui.components.NPSIconButton
import com.illusionware.npsbrowser.ui.components.NPSRadioButton
import com.illusionware.npsbrowser.ui.theme.ColorSecondaryLight
import com.illusionware.npsbrowser.ui.theme.Typography
import com.illusionware.npsbrowser.viewmodels.SettingsViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import com.illusionware.npsbrowser.data.SettingsPreferences
import com.illusionware.npsbrowser.data.Theme
import com.illusionware.npsbrowser.model.ConsoleType
import com.illusionware.npsbrowser.model.PackageItemType
// TODO: localize this
private val Themes = hashMapOf(
Theme.LIGHT.ordinal to "Light",
Theme.DARK.ordinal to "Dark",
Theme.SYSTEM.ordinal to "Use System Theme"
)
@Composable
@Preview
fun SettingsScreen(
navigationGoBack: () -> Unit = {},
viewModel: SettingsViewModel = viewModel(
factory = SettingsViewModel.Factory
)
) {
val prefs = viewModel.uiState.collectAsStateWithLifecycle().value
Scaffold (
topBar = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.statusBarsPadding()
.padding(vertical = 8.dp, horizontal = 4.dp),
) {
NPSIconButton(tooltip = "Go Back", onClick = { navigationGoBack() }) {
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Go Back" )
}
Text(text = stringResource(id = R.string.title_activity_settings), style = Typography.titleLarge)
}
},
contentWindowInsets = WindowInsets(bottom = 0.dp),
) { padding ->
Column(modifier = Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
) {
Column(
verticalArrangement = Arrangement.spacedBy(20.dp),
modifier = Modifier
.padding(vertical = 16.dp)
.navigationBarsPadding()
) {
Appearance(viewModel, prefs)
TsvFiles(viewModel, prefs)
Downloads(viewModel, prefs)
Pkg2Zip(viewModel, prefs)
}
}
}
}
@Composable
fun Appearance(viewModel: SettingsViewModel, prefs: SettingsPreferences) {
var themeOpenDialog by remember { mutableStateOf(false) }
SettingGroup(title = stringResource(id = R.string.appearance)) {
DialogSetting(
title = stringResource(id = R.string.theme),
value = Themes[prefs.appTheme] ?: "None",
icon = Icons.Outlined.Palette,
onClick = { themeOpenDialog = true }
)
}
if (themeOpenDialog) {
ThemeDialog(
prefs = prefs,
onDismiss = { themeOpenDialog = false },
onThemeChange = {
viewModel.setTheme(it)
}
)
}
}
@Composable
fun TsvFiles(viewModel: SettingsViewModel, prefs: SettingsPreferences) {
var expanded by remember { mutableStateOf(false) }
SettingGroup(title = "TSV Files") {
TSVPicker(
title = "PSV Games",
value = Uri.decode(prefs.psvGames.ifEmpty { "Browse" }),
icon = painterResource(id = R.drawable.vita_icon),
onFilePick = {
viewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.GAME, it)
}
)
TSVPicker(
title = "PSV DLC",
value = Uri.decode(prefs.psvDlc.ifEmpty { "Browse" }),
icon = null,
onFilePick = {
viewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.DLC, it)
}
)
TSVPicker(
title = "PSV Themes",
value = Uri.decode(prefs.psvThemes.ifEmpty { "Browse" }),
icon = null,
onFilePick = {
viewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.THEME, it)
},
)
TSVPicker(
title = "PS3 Games",
value = Uri.decode(prefs.ps3Games).ifEmpty { "Browse" },
icon = painterResource(id = R.drawable.ps3_icon),
onFilePick = {
viewModel.setTSVFile(ConsoleType.PS3, PackageItemType.GAME, it)
}
)
if (!expanded) {
DialogSetting(
title = "Expand",
value = "PS3 DLC, PSP Games, PSP DLC, PSX Games, PSM Games",
icon = Icons.Outlined.ExpandMore,
singleLine = true,
onClick = { expanded = true },
)
}
AnimatedVisibility(
visible = expanded,
enter = fadeIn() + expandVertically(),
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
TSVPicker(
title = "PS3 DLC",
value = Uri.decode(prefs.ps3Dlc).ifEmpty { "Browse" },
icon = null,
onFilePick = {
viewModel.setTSVFile(ConsoleType.PS3, PackageItemType.DLC, it)
}
)
TSVPicker(
title = "PSP Games",
value = Uri.decode(prefs.pspGames).ifEmpty { "Browse" },
icon = painterResource(id = R.drawable.psp_icon),
onFilePick = {
viewModel.setTSVFile(ConsoleType.PSP, PackageItemType.GAME, it)
}
)
TSVPicker(
title = "PSP DLC",
value = Uri.decode(prefs.pspDlc).ifEmpty { "Browse" },
icon = null,
onFilePick = {
viewModel.setTSVFile(ConsoleType.PSP, PackageItemType.DLC, it)
}
)
TSVPicker(
title = "PSX Games",
value = Uri.decode(prefs.psxGames).ifEmpty { "Browse" },
icon = painterResource(id = R.drawable.psx_icon),
onFilePick = {
viewModel.setTSVFile(ConsoleType.PSX, PackageItemType.GAME, it)
}
)
TSVPicker(
title = "PSM Games",
value = Uri.decode(prefs.psmGames).ifEmpty { "Browse" },
icon = painterResource(id = R.drawable.psm_icon),
onFilePick = {
viewModel.setTSVFile(ConsoleType.PSM, PackageItemType.GAME, it)
}
)
}
}
}
}
@Composable
fun TSVPicker(
title: String,
value: String,
icon: Painter? = null,
onFilePick: (Uri) -> Unit,
) {
val context = LocalContext.current
val filePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { fileUri ->
if (fileUri != null) {
context.contentResolver.takePersistableUriPermission(
fileUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
onFilePick(fileUri)
}
}
DialogSetting(
title = title,
value = value,
icon = icon,
onClick = {
filePicker.launch(arrayOf("text/tab-separated-values"))
}
)
}
@Composable
fun Downloads(viewModel: SettingsViewModel, prefs: SettingsPreferences) {
val downloadDirPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { dirUri ->
if (dirUri != null) {
viewModel.setDownloadDir(dirUri.toString())
}
}
val unpackDirPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { dirUri ->
if (dirUri != null) {
viewModel.setUnpackDir(dirUri.toString())
}
}
SettingGroup(title = "Downloads") {
ToggleSetting(
title = "Unpack in Download Directory",
checked = prefs.unpackInDownload,
onClick = {
viewModel.setUnpackInDownload(it)
}
)
ToggleSetting(
title = "Delete package after unpacking",
checked = prefs.deleteAfterUnpack,
onClick = {
viewModel.setDeleteAfterUnpack(it)
}
)
DialogSetting(
title = "Download Directory",
value = Uri.decode(prefs.downloadDir).ifEmpty { "None" },
icon = Icons.Outlined.FileDownload,
onClick = {
downloadDirPicker.launch(Uri.EMPTY)
}
)
DialogSetting(
title = "Unpack Directory",
value = Uri.decode(prefs.unpackDir).ifEmpty { "None" },
icon = Icons.Outlined.Unarchive,
enabled = !prefs.unpackInDownload,
onClick = {
unpackDirPicker.launch(Uri.EMPTY)
}
)
}
}
@Composable
fun Pkg2Zip(viewModel: SettingsViewModel, prefs: SettingsPreferences) {
var pkg2zipOpenDialog by remember { mutableStateOf(false) }
var dialogPkg2zipParams by remember { mutableStateOf("") }
SideEffect {
dialogPkg2zipParams = prefs.pkg2zipParams
}
SettingGroup(title = "Pkg2Zip") {
ToggleSetting(
title = "Automatically decrypt downloaded content",
checked = prefs.pkg2zipAutoDecrypt,
onClick = {
viewModel.setPkg2zipAutoDecrypt(it)
}
)
DialogSetting(
title = "Decryption params",
value = prefs.pkg2zipParams.ifEmpty { "None" },
icon = painterResource(
id = R.drawable.lock_open
),
onClick = { pkg2zipOpenDialog = true }
)
}
if (pkg2zipOpenDialog) {
NPSAlertDialog(
onDismiss = { pkg2zipOpenDialog = false },
title = "PKG2ZIP Args",
buttons = {
TextButton(onClick = { pkg2zipOpenDialog = false }) {
Text(text = "Cancel")
}
TextButton(onClick = {
viewModel.setPkg2zipParams(dialogPkg2zipParams)
pkg2zipOpenDialog = false
}) {
Text(text = "OK")
}
}
) {
OutlinedTextField(
value = dialogPkg2zipParams,
onValueChange = { dialogPkg2zipParams = it },
label = { Text(text = "PKG2ZIP Args") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
}
}
@Composable
fun ThemeDialog(
prefs: SettingsPreferences,
onDismiss: () -> Unit,
onThemeChange: (v: Theme) -> Unit
) {
NPSAlertDialog(
onDismiss = onDismiss,
title = stringResource(id = R.string.theme),
buttons = {
TextButton(onClick = onDismiss) {
Text(text = "Cancel")
}
},
modifier = Modifier.fillMaxWidth()
) {
Column {
NPSRadioButton(
text = "Light",
onClick = {
onThemeChange(Theme.LIGHT)
},
selected = prefs.appTheme == Theme.LIGHT.ordinal,
)
NPSRadioButton(
text = "Dark",
onClick = {
onThemeChange(Theme.DARK)
},
selected = prefs.appTheme == Theme.DARK.ordinal,
)
NPSRadioButton(
text = "Use System Theme",
onClick = {
onThemeChange(Theme.SYSTEM)
},
selected = prefs.appTheme == Theme.SYSTEM.ordinal,
)
}
}
}
@Composable
fun SettingGroup(title: String, content: @Composable () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Header(title)
Spacer(modifier = Modifier.size(2.dp))
content()
}
}
@Composable
fun Header(title: String) {
Row(Modifier.padding(start = 56.dp)) {
Text(text = title, color = MaterialTheme.colorScheme.primary)
}
}
@Composable
fun DialogSetting(title: String, value: String, icon: Painter?, onClick: () -> Unit) {
Box(
Modifier
.fillMaxWidth()
.clickable { onClick() }
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 8.dp)
) {
if (icon != null) {
Icon(painter = icon, contentDescription = null, modifier = Modifier.width(56.dp))
} else {
Box(modifier = Modifier.width(56.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = title, color = MaterialTheme.colorScheme.onBackground)
Text(text = value, color = ColorSecondaryLight, fontSize = 14.sp)
}
}
}
}
@Composable
fun DialogSetting(
title: String,
value: String,
icon: ImageVector?,
onClick: () -> Unit,
singleLine : Boolean = false,
enabled: Boolean = true
) {
Box(
Modifier
.fillMaxWidth()
.alpha(if (enabled) 1f else 0.5f)
.then(if (enabled) Modifier.clickable { onClick() } else Modifier)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 8.dp)
) {
if (icon != null) {
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.width(56.dp))
} else {
Box(modifier = Modifier.width(56.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = title, color = MaterialTheme.colorScheme.onBackground)
Text(
text = value,
color = ColorSecondaryLight,
fontSize = 14.sp,
maxLines = if (singleLine) 1 else Int.MAX_VALUE,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(end = 24.dp)
)
}
}
}
}
@Composable
fun ToggleSetting(title: String, icon: ImageVector? = null, checked: Boolean, onClick: (Boolean) -> Unit) {
Box(
modifier = Modifier.clickable onClick@{
onClick(!checked)
},
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.padding(top = 8.dp, end = 24.dp, bottom = 8.dp),
) {
if (icon != null) {
Icon(imageVector = icon, contentDescription = null, Modifier.width(56.dp))
} else {
Box(modifier = Modifier.width(56.dp))
}
Text(text = title, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1.0f))
Switch(
checked = checked,
onCheckedChange = null,
thumbContent = if (checked) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
} else null
)
}
}
}
@@ -1,91 +0,0 @@
package com.illusionware.npsbrowser.ui.theme
import androidx.compose.ui.graphics.Color
val ColorPrimaryLight = Color(0xFFFFFFFF)
val ColorPrimaryVariantLight = Color(0xFFEFEFEF)
val ColorOnPrimaryLight = Color(0xFF000000)
val ColorSecondaryLight = Color(0xFF999999)
val ColorSecondaryVariantLight = Color(0xFF888888)
val ColorOnSecondaryLight = Color(0xFF111111)
val ColorAmoledLight = Color(0xFFFFFFFF)
val ColorPrimaryDark = Color(0xFF222222)
val ColorPrimaryVariantDark = Color(0xFF202020)
val ColorOnPrimaryDark = Color(0xFFFFFFFF)
val ColorSecondaryDark = Color(0xFF696969)
val ColorSecondaryVariantDark = Color(0xFF515151)
val ColorOnSecondaryDark = Color(0xDFFFFFFF)
val ColorAmoledDark = Color(0xFF000000)
// Works on both Dark and Light
val ColorTag = Color(0xFFEAF654)
val ColorAccent = Color(0xFF545FBF)
val ColorError = Color(0xFFF93422)
// Light Mode
val md_theme_light_primary = Color(0xFF5951B3)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFE4DFFF)
val md_theme_light_onPrimaryContainer = Color(0xFF140067)
val md_theme_light_secondary = Color(0xFF5E5C71)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFE4DFF9)
val md_theme_light_onSecondaryContainer = Color(0xFF1B192C)
val md_theme_light_tertiary = Color(0xFF5754A8)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFE3DFFF)
val md_theme_light_onTertiaryContainer = Color(0xFF110563)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFFFFBFF)
val md_theme_light_onBackground = Color(0xFF1C1B1F)
val md_theme_light_surface = Color(0xFFFFFBFF)
val md_theme_light_onSurface = Color(0xFF1C1B1F)
val md_theme_light_surfaceVariant = Color(0xFFE5E1EC)
val md_theme_light_onSurfaceVariant = Color(0xFF47464F)
val md_theme_light_outline = Color(0xFF787680)
val md_theme_light_inverseOnSurface = Color(0xFFF4EFF4)
val md_theme_light_inverseSurface = Color(0xFF313034)
val md_theme_light_inversePrimary = Color(0xFFC5C0FF)
val md_theme_light_shadow = Color(0xFF000000)
val md_theme_light_surfaceTint = Color(0xFF5951B3)
val md_theme_light_outlineVariant = Color(0xFFC8C5D0)
val md_theme_light_scrim = Color(0xFF000000)
// Dark Mode
val md_theme_dark_primary = Color(0xFFC5C0FF)
val md_theme_dark_onPrimary = Color(0xFF2A1D82)
val md_theme_dark_primaryContainer = Color(0xFF413899)
val md_theme_dark_onPrimaryContainer = Color(0xFFE4DFFF)
val md_theme_dark_secondary = Color(0xFFC8C4DC)
val md_theme_dark_onSecondary = Color(0xFF302E41)
val md_theme_dark_secondaryContainer = Color(0xFF464559)
val md_theme_dark_onSecondaryContainer = Color(0xFFE4DFF9)
val md_theme_dark_tertiary = Color(0xFFC3C0FF)
val md_theme_dark_onTertiary = Color(0xFF282377)
val md_theme_dark_tertiaryContainer = Color(0xFF3F3C8F)
val md_theme_dark_onTertiaryContainer = Color(0xFFE3DFFF)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF1C1B1F)
val md_theme_dark_onBackground = Color(0xFFE5E1E6)
val md_theme_dark_surface = Color(0xFF1C1B1F)
val md_theme_dark_onSurface = Color(0xFFE5E1E6)
val md_theme_dark_surfaceVariant = Color(0xFF47464F)
val md_theme_dark_onSurfaceVariant = Color(0xFFC8C5D0)
val md_theme_dark_outline = Color(0xFF928F99)
val md_theme_dark_inverseOnSurface = Color(0xFF1C1B1F)
val md_theme_dark_inverseSurface = Color(0xFFE5E1E6)
val md_theme_dark_inversePrimary = Color(0xFF5951B3)
val md_theme_dark_shadow = Color(0xFF000000)
val md_theme_dark_surfaceTint = Color(0xFFC5C0FF)
val md_theme_dark_outlineVariant = Color(0xFF47464F)
val md_theme_dark_scrim = Color(0xFF000000)
val seed = Color(0xFF5E56B8)
@@ -1,117 +0,0 @@
package com.illusionware.npsbrowser.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LightColorScheme = lightColorScheme(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
onPrimaryContainer = md_theme_light_onPrimaryContainer,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
secondaryContainer = md_theme_light_secondaryContainer,
onSecondaryContainer = md_theme_light_onSecondaryContainer,
tertiary = md_theme_light_tertiary,
onTertiary = md_theme_light_onTertiary,
tertiaryContainer = md_theme_light_tertiaryContainer,
onTertiaryContainer = md_theme_light_onTertiaryContainer,
error = md_theme_light_error,
errorContainer = md_theme_light_errorContainer,
onError = md_theme_light_onError,
onErrorContainer = md_theme_light_onErrorContainer,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
surfaceVariant = md_theme_light_surfaceVariant,
onSurfaceVariant = md_theme_light_onSurfaceVariant,
outline = md_theme_light_outline,
inverseOnSurface = md_theme_light_inverseOnSurface,
inverseSurface = md_theme_light_inverseSurface,
inversePrimary = md_theme_light_inversePrimary,
surfaceTint = md_theme_light_surfaceTint,
outlineVariant = md_theme_light_outlineVariant,
scrim = md_theme_light_scrim,
)
private val DarkColorScheme = darkColorScheme(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
secondaryContainer = md_theme_dark_secondaryContainer,
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
tertiary = md_theme_dark_tertiary,
onTertiary = md_theme_dark_onTertiary,
tertiaryContainer = md_theme_dark_tertiaryContainer,
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
error = md_theme_dark_error,
errorContainer = md_theme_dark_errorContainer,
onError = md_theme_dark_onError,
onErrorContainer = md_theme_dark_onErrorContainer,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
surfaceVariant = md_theme_dark_surfaceVariant,
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
outline = md_theme_dark_outline,
inverseOnSurface = md_theme_dark_inverseOnSurface,
inverseSurface = md_theme_dark_inverseSurface,
inversePrimary = md_theme_dark_inversePrimary,
surfaceTint = md_theme_dark_surfaceTint,
outlineVariant = md_theme_dark_outlineVariant,
scrim = md_theme_dark_scrim,
)
@Composable
fun NPSBrowserTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = Color.Transparent.toArgb()
window.navigationBarColor = Color.Transparent.toArgb()
WindowCompat.setDecorFitsSystemWindows(window, false)
val insetsController = WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !darkTheme
insetsController.isAppearanceLightNavigationBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content,
)
}
@@ -1,34 +0,0 @@
package com.illusionware.npsbrowser.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -1,29 +0,0 @@
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()
}
}
@@ -1,7 +0,0 @@
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)
@@ -1,32 +0,0 @@
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,28 +0,0 @@
package com.illusionware.npsbrowser.util.system
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Resources
// Borrowed from https://github.com/tachiyomiorg/tachiyomi/tree/master
object InternalResourceHelper {
fun getBoolean(context: Context, resName: String, defaultValue: Boolean): Boolean {
val id = getResourceId(resName, "bool")
return if (id != 0) {
context.createPackageContext("android", 0).resources.getBoolean(id)
} else {
defaultValue
}
}
/**
* Get resource id from system resources
* @param resName resource name to get
* @param type resource type of [resName] to get
* @return 0 if not available
*/
@SuppressLint("DiscouragedApi")
private fun getResourceId(resName: String, type: String): Int {
return Resources.getSystem().getIdentifier(resName, type, "android")
}
}
@@ -1,40 +0,0 @@
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
}
}
@@ -1,60 +0,0 @@
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
}
@@ -1,62 +0,0 @@
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()
// }
// }
// }
//}
@@ -1,57 +0,0 @@
package com.illusionware.npsbrowser.viewmodels
import android.os.Environment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.illusionware.npsbrowser.NPSApp
import com.illusionware.npsbrowser.data.OnboardingPreferences
import com.illusionware.npsbrowser.data.OnboardingPreferencesRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.io.File
class OnboardingViewModel(
private val onboardingPreferencesRepository: OnboardingPreferencesRepository
): ViewModel() {
val uiState: StateFlow<OnboardingPreferences> =
onboardingPreferencesRepository.getPreferences().stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(2_000),
OnboardingPreferences(
false,
)
)
suspend fun getOnboardingStatus(): Boolean {
return onboardingPreferencesRepository.getPreferences().first().seenOnboarding
}
fun finishOnboarding(shouldCreateDefaultDir: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
if (shouldCreateDefaultDir) {
val dir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "NPSBrowser")
if (!dir.exists()) {
dir.mkdir()
}
}
onboardingPreferencesRepository.setSeenOnboarding(true)
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as NPSApp)
OnboardingViewModel(application.onboardingPreferencesRepository)
}
}
}
}
@@ -1,39 +0,0 @@
package com.illusionware.npsbrowser.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.illusionware.npsbrowser.NPSApp
import com.illusionware.npsbrowser.data.download.DownloadManager
import com.illusionware.npsbrowser.model.PackageItem
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
data class SelectedPackageUiState(
val item: PackageItem?,
)
class PackageDetailsViewModel(val downloadManager: DownloadManager): ViewModel() {
private val _uiState = MutableStateFlow(SelectedPackageUiState(null))
val uiState: StateFlow<SelectedPackageUiState> = _uiState.asStateFlow()
fun setSelectedPackage(item: PackageItem) {
_uiState.value = SelectedPackageUiState(item)
}
fun removeDownload(url: String) = downloadManager.cancelDownload(url)
fun pauseDownload(url: String) = downloadManager.pauseDownload(url)
fun resumeDownload(url: String) = downloadManager.resumeDownload(url)
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as NPSApp)
PackageDetailsViewModel(application.downloadManager)
}
}
}
}
@@ -1,216 +0,0 @@
package com.illusionware.npsbrowser.viewmodels
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.github.doyaaaaaken.kotlincsv.dsl.context.ExcessFieldsRowBehaviour
import com.github.doyaaaaaken.kotlincsv.dsl.context.InsufficientFieldsRowBehaviour
import com.github.doyaaaaaken.kotlincsv.dsl.csvReader
import com.illusionware.npsbrowser.NPSApp
import com.illusionware.npsbrowser.data.SettingsPreferencesRepository
import com.illusionware.npsbrowser.model.ConsoleType
import com.illusionware.npsbrowser.model.PackageItem
import com.illusionware.npsbrowser.model.PackageItemType
import com.illusionware.npsbrowser.util.system.prettyByte
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import okhttp3.internal.toLongOrDefault
import java.io.InputStream
import androidx.core.net.toUri
import java.util.Locale.ENGLISH
data class PackageListUiState(
val packages: ArrayList<PackageItem> = ArrayList(),
val error: String = "",
val isLoading: Boolean = true,
val isSearchOpen: Boolean = false,
val searchQuery: String = "",
)
class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesRepository): ViewModel() {
private val _uiState = MutableStateFlow(PackageListUiState())
private val immutablePackages = ArrayList<PackageItem>()
val uiState: StateFlow<PackageListUiState> = _uiState.asStateFlow()
private val tsvReader = csvReader {
charset = "UTF-8"
quoteChar = '"'
delimiter = '\t'
excessFieldsRowBehaviour = ExcessFieldsRowBehaviour.TRIM
insufficientFieldsRowBehaviour = InsufficientFieldsRowBehaviour.EMPTY_STRING
}
init {
viewModelScope.launch(Dispatchers.IO) {
val list = ArrayList<PackageItem>()
settingsRepo.availableTsvs().forEach {
list += parsePackageFromTSV(it, app)
}
list.sortBy { it.name }
_uiState.emit(PackageListUiState(isLoading = false, packages = list))
immutablePackages.addAll(list)
settingsRepo.tsvFiles().collect { tsv ->
_uiState.emit(PackageListUiState())
val alreadyExists = list.find { item ->
item.consoleType == tsv.second && item.dataType == tsv.third
}
if (alreadyExists != null) {
list.removeAll { item ->
item.consoleType == tsv.second && item.dataType == tsv.third
}
}
list.addAll(parsePackageFromTSV(tsv, app))
list.sortBy { it.name }
_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(
it: Triple<String, ConsoleType, PackageItemType>, app: NPSApp
): ArrayList<PackageItem> {
val list = ArrayList<PackageItem>()
val fileUri = it.first
val console = it.second
val type = it.third
val file: InputStream?
try {
file = app.contentResolver.openInputStream(fileUri.toUri())
} catch (e: Exception) {
throw e
}
val tsv = tsvReader.readAllWithHeader(file!!)
tsv.forEach { row ->
if (row["Name"].isNullOrEmpty()) {
return@forEach
}
var size = ""
var minFW: String? = null
var pkgUrl: String? = null
var zRif: String? = null
var rap: String? = null
var contentId: String? = null
var sha256: String? = null
var modificationDate: String? = null
val tempSize = row["File Size"]
val tempFW = row["Required FW"]
val tempUrl = row["PKG direct link"]
val tempZRif = row["zRIF"]
val tempRap = row["RAP"]
val tempContentId = row["Content ID"]
val tempsha256 = row["SHA256"]
val tempDate = row["Last Modification Date"]
if (!tempSize.isNullOrEmpty()) {
size = tempSize.toLongOrDefault(0).prettyByte()
}
if (!tempFW.isNullOrEmpty()) {
minFW = String.format(ENGLISH, "%.2f", tempFW.toFloat())
}
if (!tempUrl.isNullOrEmpty() && tempUrl != "MISSING") {
pkgUrl = tempUrl
}
if (!tempZRif.isNullOrEmpty() && tempZRif != "MISSING") {
zRif = tempZRif
}
if (!tempRap.isNullOrEmpty() && tempRap != "MISSING") {
rap = tempRap
}
if (!tempContentId.isNullOrEmpty()) {
contentId = tempContentId
}
if (!tempsha256.isNullOrEmpty()) {
sha256 = tempsha256
}
if (!tempDate.isNullOrEmpty()) {
modificationDate = tempDate
}
list.add(
PackageItem(
row["Title ID"] ?: "",
row["Region"] ?: "",
row["Name"]?.trim() ?: "",
pkgUrl,
contentId,
modificationDate,
size,
tempSize?.toULongOrNull(),
sha256,
zRif,
rap,
minFW,
console,
type,
)
)
}
file.close()
return list
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as NPSApp)
PackageListViewModel(application, application.settingsPreferencesRepository)
}
}
}
}
@@ -1,131 +0,0 @@
package com.illusionware.npsbrowser.viewmodels
import android.net.Uri
import android.os.Environment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.illusionware.npsbrowser.NPSApp
import com.illusionware.npsbrowser.data.ItemLayout
import com.illusionware.npsbrowser.data.SettingsPreferences
import com.illusionware.npsbrowser.data.SettingsPreferencesRepository
import com.illusionware.npsbrowser.data.Theme
import com.illusionware.npsbrowser.model.ConsoleType
import com.illusionware.npsbrowser.model.PackageItemType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class SettingsViewModel(private val settingsPrefsRepo: SettingsPreferencesRepository): ViewModel() {
val uiState: StateFlow<SettingsPreferences> =
settingsPrefsRepo.getPreferences().stateIn(viewModelScope, SharingStarted.Lazily, SettingsPreferences(
Theme.SYSTEM.ordinal,
ItemLayout.LIST.ordinal,
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
false,
false,
"",
true,
))
fun availableTsvs(): MutableList<Pair<ConsoleType, PackageItemType>> {
val list = mutableListOf<Pair<ConsoleType, PackageItemType>>()
if (uiState.value.psvGames.isNotEmpty()) list.add(Pair(ConsoleType.PSVITA, PackageItemType.GAME))
if (uiState.value.ps3Games.isNotEmpty()) list.add(Pair(ConsoleType.PS3, PackageItemType.GAME))
if (uiState.value.pspGames.isNotEmpty()) list.add(Pair(ConsoleType.PSP, PackageItemType.GAME))
if (uiState.value.psxGames.isNotEmpty()) list.add(Pair(ConsoleType.PSX, PackageItemType.GAME))
if (uiState.value.psmGames.isNotEmpty()) list.add(Pair(ConsoleType.PSM, PackageItemType.GAME))
return list
}
suspend fun getAppTheme(): Int {
return settingsPrefsRepo.getPreferences().first().appTheme
}
fun shouldCreateDefaultDir(): Boolean {
val defaultDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath + "/NPSBrowser"
return uiState.value.downloadDir == defaultDir
|| uiState.value.unpackDir == defaultDir
}
fun setTSVFile(console: ConsoleType, itemType: PackageItemType, file: Uri) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setTSVFile(console, itemType, file)
}
}
fun setTheme(theme: Theme) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setTheme(theme)
}
}
fun setLayout(layout: ItemLayout) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setLayout(layout)
}
}
fun setDownloadDir(downloadDir: String) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setDownloadDir(downloadDir)
}
}
fun setUnpackDir(unpackDir: String) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setUnpackDir(unpackDir)
}
}
fun setUnpackInDownload(unpackInDownload: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setUnpackInDownload(unpackInDownload)
}
}
fun setDeleteAfterUnpack(deleteAfterUnpack: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setDeleteAfterUnpack(deleteAfterUnpack)
}
}
fun setPkg2zipParams(pkg2zipParams: String) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setPkg2zipParams(pkg2zipParams)
}
}
fun setPkg2zipAutoDecrypt(pkg2zipAutoDecrypt: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setPkg2zipAutoDecrypt(pkg2zipAutoDecrypt)
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as NPSApp)
SettingsViewModel(application.settingsPreferencesRepository)
}
}
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" >
<solid android:color="@android:color/holo_blue_dark" />
<stroke android:width="2dip" android:color="@android:color/holo_blue_dark"/>
<corners android:radius="10dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" >
<solid android:color="@android:color/holo_red_dark" />
<stroke android:width="2dip" android:color="@android:color/holo_red_dark"/>
<corners android:radius="10dp" />
</shape>
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android" android:opacity="opaque">
<!-- The background color -->
<item android:drawable="@drawable/splash_gradient"/>
<!-- App logo -->
<item
android:drawable="@drawable/ic_nps_fg_no_shadow"
android:gravity="center"
android:width="400dp"
android:height="400dp" />
</layer-list>
@@ -1,5 +0,0 @@
<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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

@@ -1,20 +0,0 @@
<vector android:height="256dp" android:viewportHeight="100"
android:viewportWidth="100" android:width="256dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#ee3e54" android:pathData="M13,27A2,2 0,1 0,13 31A2,2 0,1 0,13 27Z"/>
<path android:fillColor="#f1bc19" android:pathData="M77,12A1,1 0,1 0,77 14A1,1 0,1 0,77 12Z"/>
<path android:fillColor="#fce0a2" android:pathData="M50,13A37,37 0,1 0,50 87A37,37 0,1 0,50 13Z"/>
<path android:fillColor="#f1bc19" android:pathData="M83,11A4,4 0,1 0,83 19A4,4 0,1 0,83 11Z"/>
<path android:fillColor="#ee3e54" android:pathData="M87,22A2,2 0,1 0,87 26A2,2 0,1 0,87 22Z"/>
<path android:fillColor="#fbcd59" android:pathData="M81,74A2,2 0,1 0,81 78,2 2,0 1,0 81,74zM15,59A4,4 0,1 0,15 67,4 4,0 1,0 15,59z"/>
<path android:fillColor="#ee3e54" android:pathData="M25,85A2,2 0,1 0,25 89A2,2 0,1 0,25 85Z"/>
<path android:fillColor="#fff" android:pathData="M18.5,51A2.5,2.5 0,1 0,18.5 56A2.5,2.5 0,1 0,18.5 51Z"/>
<path android:fillColor="#f1bc19" android:pathData="M21,66A1,1 0,1 0,21 68A1,1 0,1 0,21 66Z"/>
<path android:fillColor="#fff" android:pathData="M80,33A1,1 0,1 0,80 35A1,1 0,1 0,80 33Z"/>
<path android:fillColor="#cdcbbd" android:pathData="M29.51,69.3c-0.5,0 -1.13,-0.14 -1.48,-1.27c-0.27,-0.87 -0.27,-1.95 -0.27,-2.65L27.7,37.8v-3.82c0,-1.69 1.32,-3.07 2.94,-3.07h17.65c1.66,0 3.01,1.46 3.01,3.24v1.46c0,1.24 0.97,2.26 2.16,2.26h16.09c0.42,0 0.76,0.38 0.76,0.86v23.57c0,0.33 0.01,0.75 0.03,1.21c0.05,1.7 0.13,4.46 -0.44,5.8H29.51z"/>
<path android:fillColor="#472b29" android:pathData="M48.29,31.6c1.27,0 2.31,1.14 2.31,2.54v1.46c0,1.63 1.28,2.96 2.86,2.96l16.08,-0c0.01,0 0.07,0.05 0.07,0.16v23.57c0,0.34 0.01,0.76 0.03,1.23c0.04,1.29 0.11,3.74 -0.24,5.08h-39.88c-0.27,0 -0.57,0 -0.81,-0.79c-0.24,-0.77 -0.24,-1.78 -0.24,-2.44l0,-0.09L28.4,37.8v-3.82c0,-1.31 1.01,-2.37 2.24,-2.37H48.29M48.29,30.2H30.65c-2.01,0 -3.64,1.7 -3.64,3.77v3.82l0.05,27.48C27.05,66.7 27,70 29.51,70h40.8C71.37,68.36 71,64.1 71,62.28V38.72c0,-0.86 -0.65,-1.56 -1.46,-1.56H53.46c-0.81,0 -1.46,-0.7 -1.46,-1.56v-1.46C52,31.98 50.33,30.2 48.29,30.2L48.29,30.2z"/>
<path android:fillColor="#472b29" android:pathData="M41.5,35h-11c-0.28,0 -0.5,-0.22 -0.5,-0.5s0.22,-0.5 0.5,-0.5h11c0.28,0 0.5,0.22 0.5,0.5S41.78,35 41.5,35z"/>
<path android:fillColor="#472b29" android:pathData="M48.5,35h-4c-0.28,0 -0.5,-0.22 -0.5,-0.5s0.22,-0.5 0.5,-0.5h4c0.28,0 0.5,0.22 0.5,0.5S48.78,35 48.5,35z"/>
<path android:fillColor="#fdfcee" android:pathData="M29.48,69.3c-0.61,0 -1.11,-0.52 -1.14,-1.18L34.03,42.7h41.92l-5.64,25.37c-0.01,0.05 -0.02,0.1 -0.02,0.15c0,0.29 -0.11,0.57 -0.3,0.77c-0.18,0.19 -0.43,0.3 -0.68,0.3H29.48z"/>
<path android:fillColor="#472b29" android:pathData="M75.07,43.4l-5.45,24.52c-0.02,0.1 -0.03,0.2 -0.03,0.31c0,0.12 -0.04,0.23 -0.12,0.3c-0.05,0.05 -0.11,0.08 -0.17,0.08H29.48c-0.21,0 -0.39,-0.19 -0.43,-0.44L34.59,43.4H75.07M76.82,42H33.47l-5.83,26.05c0,1.08 0.82,1.95 1.84,1.95h39.83c0.93,0 1.68,-0.8 1.68,-1.78L76.82,42L76.82,42z"/>
<path android:fillColor="#472b29" android:pathData="M50.5,55h-14c-0.15,0 -0.3,-0.07 -0.39,-0.19s-0.13,-0.28 -0.09,-0.43l1.5,-6C37.57,48.16 37.77,48 38,48h14c0.15,0 0.3,0.07 0.39,0.19s0.13,0.28 0.09,0.43l-1.5,6C50.93,54.84 50.73,55 50.5,55zM37.14,54h12.97l1.25,-5H38.39L37.14,54z"/>
</vector>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFCA00"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M19.1,12.9a2.8,2.8 0,0 0,0.1 -0.9,2.8 2.8,0 0,0 -0.1,-0.9l2.1,-1.6a0.7,0.7 0,0 0,0.1 -0.6L19.4,5.5a0.7,0.7 0,0 0,-0.6 -0.2l-2.4,1a6.5,6.5 0,0 0,-1.6 -0.9l-0.4,-2.6a0.5,0.5 0,0 0,-0.5 -0.4H10.1a0.5,0.5 0,0 0,-0.5 0.4L9.3,5.4a5.6,5.6 0,0 0,-1.7 0.9l-2.4,-1a0.4,0.4 0,0 0,-0.5 0.2l-2,3.4c-0.1,0.2 0,0.4 0.2,0.6l2,1.6a2.8,2.8 0,0 0,-0.1 0.9,2.8 2.8,0 0,0 0.1,0.9L2.8,14.5a0.7,0.7 0,0 0,-0.1 0.6l1.9,3.4a0.7,0.7 0,0 0,0.6 0.2l2.4,-1a6.5,6.5 0,0 0,1.6 0.9l0.4,2.6a0.5,0.5 0,0 0,0.5 0.4h3.8a0.5,0.5 0,0 0,0.5 -0.4l0.3,-2.6a5.6,5.6 0,0 0,1.7 -0.9l2.4,1a0.4,0.4 0,0 0,0.5 -0.2l2,-3.4c0.1,-0.2 0,-0.4 -0.2,-0.6ZM12,15.6A3.6,3.6 0,1 1,15.6 12,3.6 3.6,0 0,1 12,15.6Z"/>
</vector>
@@ -1,7 +0,0 @@
<vector android:height="24dp" android:viewportHeight="256"
android:viewportWidth="256" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#000000"
android:pathData="m139.8,13.09a72.78,72.78 0,0 0,-69.17 95.25l-52.33,52.31a7.27,7.27 0,0 0,-2.13 5.16v36.36a7.27,7.27 0,0 0,7.27 7.27h12.95v-24.08a7.27,7.27 0,0 1,2.14 -5.14l52.31,-52.33A72.78,72.78 0,0 1,160.02 32.66,72.72 72.72,0 0,1 194.86,43 72.72,72.72 0,0 0,139.8 13.09ZM178.52,73.52a14.54,14.54 0,0 0,-9.95 3.66,14.54 14.54,0 0,1 -0.27,0.64 14.54,14.54 0,0 0,5.89 22,15.7 15.7,0 0,1 -0.98,-4.92 15.7,15.7 0,0 1,15.7 -15.69,15.7 15.7,0 0,1 0.16,0.02 14.54,14.54 0,0 0,-10.55 -5.7z" android:strokeWidth="0.908993"/>
<path android:fillColor="#000000"
android:pathData="M169.69,40.2A72.78,72.78 0,0 0,100.52 135.45l-52.33,52.32a7.27,7.27 0,0 0,-2.13 5.14v36.36a7.27,7.27 0,0 0,7.27 7.27h36.36a7.27,7.27 0,0 0,7.27 -7.27v-14.54h14.54a7.27,7.27 0,0 0,7.27 -7.27v-14.54h14.54a7.27,7.27 0,0 0,5.14 -2.13l8.69,-8.7A72.72,72.72 0,1 0,169.69 40.2ZM187.87,109.29a14.54,14.54 0,1 1,14.54 -14.54,14.54 14.54,0 0,1 -14.54,14.54z" android:strokeWidth="0.908993"/>
</vector>
-9
View File
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="256"
android:viewportHeight="256">
<path
android:pathData="M208,76L100,76L100,56a28,28 0,0 1,28 -28c13.51,0 25.65,9.62 28.24,22.39a12,12 0,1 0,23.52 -4.78C174.87,21.5 153.1,4 128,4A52.06,52.06 0,0 0,76 56L76,76L48,76A20,20 0,0 0,28 96L28,208a20,20 0,0 0,20 20L208,228a20,20 0,0 0,20 -20L228,96A20,20 0,0 0,208 76ZM204,204L52,204L52,100L204,100ZM112,152a16,16 0,1 1,16 16A16,16 0,0 1,112 152Z"
android:fillColor="#000000"/>
</vector>
-11
View File
@@ -1,11 +0,0 @@
<vector android:height="24dp" android:viewportHeight="12"
android:viewportWidth="12" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<group>
<clip-path android:pathData="M0,0h12v12h-12z"/>
<path android:fillColor="#000000" android:pathData="M6,1C3.24,1 1,3.24 1,6C1,8.76 3.24,11 6,11C8.76,11 11,8.76 11,6C11,3.24 8.76,1 6,1ZM6,10C3.79,10 2,8.21 2,6C2,5.075 2.315,4.225 2.845,3.55L8.45,9.155C7.775,9.685 6.925,10 6,10ZM9.155,8.45L3.55,2.845C4.225,2.315 5.075,2 6,2C8.21,2 10,3.79 10,6C10,6.925 9.685,7.775 9.155,8.45Z"/>
<group>
<clip-path android:pathData="M2,2h8v8h-8z"/>
<path android:fillColor="#000000" android:fillType="evenOdd" android:pathData="M5.2,4.8C5.2,4.38 5.332,3.972 5.577,3.631C5.823,3.291 6.169,3.036 6.567,2.903C6.965,2.77 7.395,2.766 7.795,2.891C8.196,3.016 8.547,3.264 8.799,3.599C9.051,3.934 9.191,4.341 9.2,4.76C9.208,5.18 9.084,5.591 8.846,5.936C8.608,6.282 8.267,6.543 7.872,6.684C7.477,6.825 7.047,6.838 6.644,6.722L5.883,7.483C5.808,7.558 5.706,7.6 5.6,7.6H5.2V8C5.2,8.106 5.158,8.208 5.083,8.283C5.008,8.358 4.906,8.4 4.8,8.4H4.4V8.8C4.4,8.906 4.358,9.008 4.283,9.083C4.208,9.158 4.106,9.2 4,9.2H3.2C3.094,9.2 2.992,9.158 2.917,9.083C2.842,9.008 2.8,8.906 2.8,8.8V8C2.8,7.894 2.842,7.792 2.917,7.717L5.278,5.356C5.226,5.175 5.2,4.988 5.2,4.8ZM7.2,3.6C7.12,3.6 7.044,3.632 6.988,3.688C6.932,3.744 6.9,3.82 6.9,3.9C6.9,3.98 6.932,4.056 6.988,4.112C7.044,4.168 7.12,4.2 7.2,4.2C7.359,4.2 7.512,4.263 7.624,4.376C7.737,4.488 7.8,4.641 7.8,4.8C7.8,4.88 7.832,4.956 7.888,5.012C7.944,5.068 8.02,5.1 8.1,5.1C8.18,5.1 8.256,5.068 8.312,5.012C8.368,4.956 8.4,4.88 8.4,4.8C8.4,4.482 8.274,4.177 8.049,3.951C7.823,3.726 7.518,3.6 7.2,3.6Z"/>
</group>
</group>
</vector>
-15
View File
@@ -1,15 +0,0 @@
<vector android:height="24dp" android:viewportHeight="12"
android:viewportWidth="12" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<group>
<clip-path android:pathData="M0,0h12v12h-12z"/>
<path android:fillColor="#000000" android:pathData="M6,1C3.24,1 1,3.24 1,6C1,8.76 3.24,11 6,11C8.76,11 11,8.76 11,6C11,3.24 8.76,1 6,1ZM6,10C3.79,10 2,8.21 2,6C2,5.075 2.315,4.225 2.845,3.55L8.45,9.155C7.775,9.685 6.925,10 6,10ZM9.155,8.45L3.55,2.845C4.225,2.315 5.075,2 6,2C8.21,2 10,3.79 10,6C10,6.925 9.685,7.775 9.155,8.45Z"/>
<path android:fillColor="#00000000"
android:pathData="M5.822,4.231L6.441,3.612C6.703,3.374 7.046,3.245 7.401,3.253C7.755,3.261 8.093,3.406 8.343,3.657C8.594,3.907 8.739,4.245 8.747,4.599C8.755,4.953 8.626,5.297 8.387,5.559L7.503,6.441C7.376,6.569 7.224,6.67 7.057,6.739C6.891,6.809 6.712,6.844 6.531,6.844C6.351,6.844 6.172,6.809 6.005,6.739C5.838,6.67 5.687,6.569 5.559,6.441"
android:strokeColor="#000000" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="1"/>
<path android:fillColor="#00000000"
android:pathData="M6.178,7.769L5.559,8.387C5.297,8.626 4.954,8.755 4.599,8.747C4.245,8.739 3.907,8.594 3.657,8.343C3.406,8.093 3.261,7.755 3.253,7.401C3.245,7.047 3.374,6.703 3.613,6.441L4.497,5.559C4.624,5.431 4.776,5.33 4.943,5.261C5.109,5.191 5.288,5.156 5.469,5.156C5.649,5.156 5.828,5.191 5.995,5.261C6.162,5.33 6.313,5.431 6.441,5.559"
android:strokeColor="#000000" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="1"/>
</group>
</vector>
@@ -1,118 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="51.512dp"
android:height="31.41dp"
android:viewportWidth="51.512"
android:viewportHeight="31.41">
<path
android:pathData="m14.423,19.84v-8.27h2.22c1,1.85 2.94,5.2 3.32,6.1v0c-0.09,-0.86 -0.09,-2.29 -0.09,-3.65v-2.45h1.62v8.27h-2.12c-0.89,-1.62 -3,-5.47 -3.42,-6.42v0c0.06,0.72 0.08,2.42 0.08,3.9v2.52z"
android:fillColor="#fff"
android:strokeColor="#00000000"/>
<path
android:pathData="m23.582,11.57h3.42c1.76,0 3,1 3,2.56 0,1.79 -1.36,2.62 -3,2.62h-1.63v3.09h-1.79zM25.372,15.33h1.37c0.82,0 1.41,-0.32 1.41,-1.17 0,-0.85 -0.62,-1.17 -1.38,-1.17h-1.4z"
android:fillColor="#fff"
android:strokeColor="#00000000"/>
<path
android:pathData="m32.592,17.435a1.54,1.54 0,0 0,1.74 1.2c1,0 1.37,-0.38 1.37,-1 0,-0.62 -0.4,-1 -1.82,-1.3 -2.25,-0.53 -2.8,-1.35 -2.8,-2.46 0,-1.44 1.07,-2.42 3,-2.42 2.2,0 3.07,1.18 3.19,2.39h-1.81c-0.09,-0.51 -0.37,-1.06 -1.43,-1.06 -0.73,0 -1.17,0.3 -1.17,0.88 0,0.58 0.35,0.81 1.69,1.12 2.4,0.57 2.93,1.46 2.93,2.63 0,1.51 -1.14,2.54 -3.33,2.54 -2.19,0 -3.2,-1 -3.4,-2.54z"
android:fillColor="#fff"
android:strokeColor="#00000000"/>
<path
android:pathData="m10.951,3.846v0.75z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m3.621,13.07h1.54,0.06l0.71,0.73a0.14,0.14 0,0 1,0 0.19l-0.71,0.73a0.09,0.09 0,0 1,-0.06 0h-1.54a0.1,0.1 0,0 1,-0.09 -0.11v-1.43a0.1,0.1 0,0 1,0.09 -0.11z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m3.603,12.694 l-0.018,0.002c-0.258,0.025 -0.455,0.265 -0.428,0.523l-0.002,-0.039v1.43l0.002,-0.039c-0.027,0.258 0.169,0.497 0.428,0.521l0.018,0.002h1.559l-0.125,-0.021c0.1,0.035 0.21,0.035 0.311,0l0.082,-0.029 0.773,-0.795 0.004,-0.004c0.18,-0.195 0.18,-0.504 0,-0.699l-0.004,-0.004 -0.822,-0.848h-0.219zM3.905,13.444h1.156l0.438,0.451 -0.438,0.449h-1.156z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m5.891,17.05v-1.54a0.05,0.05 0,0 1,0 -0.05l0.73,-0.72a0.17,0.17 0,0 1,0.19 0l0.74,0.72a0.08,0.08 0,0 1,0 0.05v1.54a0.11,0.11 0,0 1,-0.11 0.1h-1.43a0.11,0.11 0,0 1,-0.12 -0.1z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m6.411,14.429 l-0.029,0.02 -0.791,0.781 -0.025,0.043c-0.076,0.131 -0.076,0.295 0,0.426l-0.049,-0.188v1.557,0.016c0.023,0.264 0.266,0.466 0.529,0.441h1.395c0.247,0 0.462,-0.193 0.484,-0.439v-0.018,-1.557l-0.018,0.117c0.03,-0.092 0.03,-0.193 0,-0.285l-0.029,-0.088 -0.83,-0.807 -0.027,-0.02c-0.183,-0.123 -0.426,-0.123 -0.609,0zM6.718,15.171 L7.175,15.618v1.156h-0.908v-1.158z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m9.871,14.78h-1.53a0.06,0.06 0,0 1,-0.06 0L7.561,14.05a0.17,0.17 0,0 1,0 -0.19l0.72,-0.73a0.06,0.06 0,0 1,0.06 0h1.53a0.1,0.1 0,0 1,0.1 0.11L9.971,14.7a0.1,0.1 0,0 1,-0.1 0.08z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m8.341,12.755 l0.188,0.051c-0.134,-0.077 -0.301,-0.077 -0.436,0l-0.043,0.025 -0.781,0.791 -0.02,0.029c-0.123,0.183 -0.123,0.424 0,0.607l0.02,0.029 0.781,0.791 0.043,0.025c0.134,0.077 0.301,0.077 0.436,0l-0.188,0.051h1.521c0.227,0.005 0.431,-0.159 0.477,-0.381l0.008,-0.037v-1.498l-0.002,0.039c0.028,-0.275 -0.201,-0.525 -0.477,-0.523zM8.439,13.505h1.158v0.9h-1.158l-0.445,-0.451z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m7.601,10.8v1.54a0.14,0.14 0,0 1,0 0.06l-0.74,0.71a0.14,0.14 0,0 1,-0.19 0L5.941,12.4a0.09,0.09 0,0 1,0 -0.06L5.941,10.8c0,-0.05 0,-0.09 0.12,-0.09h1.48a0.1,0.1 0,0 1,0.06 0.09z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m6.062,10.335c-0.102,0 -0.249,0.004 -0.381,0.146 -0.132,0.143 -0.115,0.293 -0.115,0.318v1.541l0.021,-0.125c-0.035,0.1 -0.035,0.208 0,0.309l0.029,0.084 0.797,0.773 0.004,0.004c0.195,0.18 0.502,0.18 0.697,0l0.004,-0.002 0.824,-0.791 0.025,-0.111c0.016,-0.073 0.016,-0.148 0,-0.221l0.008,0.08v-1.543,-0.004c-0.003,-0.185 -0.115,-0.354 -0.285,-0.428l-0.072,-0.031zM6.316,11.085h0.91v1.156l-0.459,0.439 -0.451,-0.439z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m42.812,19.171c-1.007,0 -1.83,0.823 -1.83,1.83 0,1.007 0.823,1.83 1.83,1.83 1.007,0 1.83,-0.823 1.83,-1.83 0,-1.007 -0.823,-1.83 -1.83,-1.83zM42.812,19.77c0.683,0 1.229,0.548 1.229,1.23 0,0.683 -0.546,1.229 -1.229,1.229 -0.683,0 -1.23,-0.546 -1.23,-1.229 0,-0.683 0.548,-1.23 1.23,-1.23z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m8.71,19.171c-1.007,0 -1.83,0.823 -1.83,1.83 0,1.007 0.823,1.83 1.83,1.83 1.007,0 1.83,-0.823 1.83,-1.83 0,-1.007 -0.823,-1.83 -1.83,-1.83zM8.71,19.77c0.683,0 1.23,0.548 1.23,1.23 0,0.683 -0.548,1.229 -1.23,1.229 -0.683,0 -1.229,-0.546 -1.229,-1.229 0,-0.683 0.546,-1.23 1.229,-1.23z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m43.441,14.02a1.05,1.05 0,0 1,-1.05 1.05,1.05 1.05,0 0,1 -1.05,-1.05 1.05,1.05 0,0 1,1.05 -1.05,1.05 1.05,0 0,1 1.05,1.05z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m42.392,12.595c-0.783,0 -1.426,0.643 -1.426,1.426 0,0.783 0.643,1.424 1.426,1.424 0.783,0 1.424,-0.641 1.424,-1.424 0,-0.783 -0.641,-1.426 -1.424,-1.426zM42.392,13.345c0.377,0 0.674,0.299 0.674,0.676 0,0.377 -0.297,0.674 -0.674,0.674 -0.377,0 -0.676,-0.297 -0.676,-0.674 0,-0.377 0.299,-0.676 0.676,-0.676z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m45.941,16.52a1.05,1.05 0,0 1,-1.05 1.05,1.05 1.05,0 0,1 -1.05,-1.05 1.05,1.05 0,0 1,1.05 -1.05,1.05 1.05,0 0,1 1.05,1.05z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m44.892,15.095c-0.783,0 -1.426,0.643 -1.426,1.426 0,0.783 0.643,1.424 1.426,1.424 0.783,0 1.424,-0.641 1.424,-1.424 0,-0.783 -0.641,-1.426 -1.424,-1.426zM44.892,15.845c0.377,0 0.674,0.299 0.674,0.676 0,0.377 -0.297,0.674 -0.674,0.674 -0.377,0 -0.676,-0.297 -0.676,-0.674 0,-0.377 0.299,-0.676 0.676,-0.676z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m48.441,14.02a1.05,1.05 0,0 1,-1.05 1.05,1.05 1.05,0 0,1 -1.05,-1.05 1.05,1.05 0,0 1,1.05 -1.05,1.05 1.05,0 0,1 1.05,1.05z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m47.392,12.595c-0.783,0 -1.426,0.643 -1.426,1.426 0,0.783 0.643,1.424 1.426,1.424 0.783,0 1.424,-0.641 1.424,-1.424 0,-0.783 -0.641,-1.426 -1.424,-1.426zM47.392,13.345c0.377,0 0.674,0.299 0.674,0.676 0,0.377 -0.297,0.674 -0.674,0.674 -0.377,0 -0.676,-0.297 -0.676,-0.674 0,-0.377 0.299,-0.676 0.676,-0.676z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m45.941,11.52a1.05,1.05 0,0 1,-1.05 1.05,1.05 1.05,0 0,1 -1.05,-1.05 1.05,1.05 0,0 1,1.05 -1.05,1.05 1.05,0 0,1 1.05,1.05z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m44.892,10.095c-0.783,0 -1.426,0.643 -1.426,1.426 0,0.783 0.643,1.424 1.426,1.424 0.783,0 1.424,-0.641 1.424,-1.424 0,-0.783 -0.641,-1.426 -1.424,-1.426zM44.892,10.845c0.377,0 0.674,0.299 0.674,0.676 0,0.377 -0.297,0.674 -0.674,0.674 -0.377,0 -0.676,-0.297 -0.676,-0.674 0,-0.377 0.299,-0.676 0.676,-0.676z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m10.99,7.32v0.5,16.26h29.531L40.521,7.32ZM11.99,8.32h27.531v14.76h-27.531z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="M12.201,3.5C5.454,3.5 0,8.952 0,15.699v0.012c0,6.748 5.454,12.199 12.201,12.199h27.109c6.748,0 12.201,-5.452 12.201,-12.199v-0.012C51.512,8.952 46.058,3.5 39.311,3.5ZM12.201,4.801h27.109c6.05,0 10.9,4.849 10.9,10.898v0.012c0,6.05 -4.851,10.898 -10.9,10.898L12.201,26.609c-6.05,0 -10.9,-4.849 -10.9,-10.898v-0.012c0,-6.05 4.851,-10.898 10.9,-10.898z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m5.637,24.529 l-0.012,1 4.623,0.051h0.309c-0.109,-0.014 0.023,0.022 0.158,0.186 0.138,0.167 0.298,0.413 0.441,0.66 0.287,0.495 0.508,0.988 0.508,0.988l0.912,-0.408c0,0 -0.236,-0.532 -0.555,-1.082 -0.159,-0.275 -0.337,-0.555 -0.535,-0.795 -0.198,-0.24 -0.383,-0.488 -0.797,-0.545l-0.035,-0.004h-0.4z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m11.664,4.025c0,0 -0.221,0.493 -0.508,0.988 -0.143,0.247 -0.303,0.495 -0.441,0.662 -0.135,0.163 -0.268,0.2 -0.158,0.186 -0.088,0.006 -0.176,0.007 -0.264,0l-0.023,-0.002 -4.645,0.051 0.012,1 4.619,-0.051 -0.047,-0.002c0.151,0.013 0.302,0.013 0.453,0h0.014l0.014,-0.002c0.414,-0.057 0.599,-0.303 0.797,-0.543 0.198,-0.24 0.376,-0.522 0.535,-0.797 0.318,-0.55 0.555,-1.08 0.555,-1.08z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m45.756,24.529 l-4.627,0.051h-0.393l-0.033,0.004c-0.412,0.056 -0.602,0.305 -0.801,0.545 -0.199,0.24 -0.376,0.522 -0.535,0.797 -0.318,0.551 -0.553,1.082 -0.553,1.082l0.914,0.404c0,0 0.219,-0.492 0.504,-0.986 0.142,-0.247 0.302,-0.494 0.439,-0.66 0.135,-0.163 0.266,-0.199 0.162,-0.186h0.299l4.633,-0.051z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
<path
android:pathData="m39.729,4.027 l-0.914,0.406c0,0 0.235,0.531 0.553,1.082 0.159,0.275 0.336,0.557 0.535,0.797 0.199,0.24 0.388,0.487 0.801,0.543l0.012,0.002h0.014c0.148,0.013 0.297,0.013 0.445,0l-0.049,0.002 4.631,0.051 0.01,-1 -4.654,-0.051 -0.023,0.002c-0.085,0.007 -0.169,0.006 -0.254,0 0.104,0.013 -0.027,-0.023 -0.162,-0.186C40.534,5.509 40.375,5.263 40.232,5.016 39.947,4.521 39.729,4.027 39.729,4.027Z"
android:fillColor="#2f2f2f"
android:strokeColor="#00000000"/>
</vector>
-4
View File
@@ -1,4 +0,0 @@
<vector android:height="24dp" android:viewportHeight="256"
android:viewportWidth="256" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#000000" android:pathData="M223.68,66.15 L135.68,18a15.88,15.88 0,0 0,-15.36 0l-88,48.17a16,16 0,0 0,-8.32 14v95.64a16,16 0,0 0,8.32 14l88,48.17a15.88,15.88 0,0 0,15.36 0l88,-48.17a16,16 0,0 0,8.32 -14L232,80.18A16,16 0,0 0,223.68 66.15ZM128,32l80.35,44L178.57,92.29l-80.35,-44ZM128,120L47.65,76 81.56,57.43l80.35,44ZM216,175.85h0l-80,43.79L136,133.83l32,-17.51L168,152a8,8 0,0 0,16 0L184,107.56l32,-17.51v85.76Z"/>
</vector>
-9
View File
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="256"
android:viewportHeight="256">
<path
android:pathData="M128,24A104,104 0,1 0,232 128,104.11 104.11,0 0,0 128,24ZM128,216a88,88 0,1 1,88 -88A88.1,88.1 0,0 1,128 216ZM144,176a8,8 0,0 1,-8 8,16 16,0 0,1 -16,-16L120,128a8,8 0,0 1,0 -16,16 16,0 0,1 16,16v40A8,8 0,0 1,144 176ZM112,84a12,12 0,1 1,12 12A12,12 0,0 1,112 84Z"
android:fillColor="#000000"/>
</vector>
-9
View File
@@ -1,9 +0,0 @@
<vector android:height="24dp" android:viewportHeight="425.72"
android:viewportWidth="425.72" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#000000"
android:pathData="m70.82,87.19c-6.6,1.9 -7.7,2.7 -9.1,6.9 -0.5,1.8 -2.3,4 -4,4.9 -4.3,2.6 -5.6,5.9 -7,17.9 -1.2,9.7 -1.5,10.7 -3.8,11.9 -4.9,2.7 -15,14.9 -19.5,23.7 -7.3,14.2 -9.7,23.8 -17.6,71.7 -8.9,53.3 -10.3,63.7 -9.7,71.5 1.1,16.1 9.2,31.1 20,37.5 13.7,8 31.4,9.1 44.9,2.7 10.9,-5.1 14.4,-9.5 36.1,-45l11.8,-19.3 5.5,6.1c3,3.3 7.5,7.2 9.9,8.6 22,12.9 50.7,5.2 62.3,-16.6l3.2,-6l20.4,-0 20.5,-0l2.6,5.1c3.2,6.4 10.4,14.2 16.2,17.6 17.9,10.5 41.6,7.3 55.3,-7.5 2.2,-2.3 4.1,-4.2 4.3,-4.2 0.2,-0 7,10.3 15,22.8 8,12.6 16.2,24.7 18.2,27 8,9.4 18.8,14.2 32,14.2 24.4,-0 39.7,-11.3 45.4,-33.6 1.1,-4.2 2,-9.7 2,-12.1 0,-9.3 -14.7,-106.4 -18.5,-122.1 -0.9,-3.7 -3.7,-11 -6.2,-16.2 -3.7,-7.7 -6,-10.8 -12.2,-17 -4.2,-4.2 -8.6,-8.3 -9.9,-9.1 -2,-1.2 -2.5,-2.8 -3.8,-12.9 -1.7,-12.7 -2.6,-14.8 -8.3,-18.6 -2.2,-1.4 -4.2,-3.6 -4.5,-4.8 -1,-4.1 -10.3,-6.6 -24.6,-6.6 -14,-0 -23.4,2.5 -24.6,6.4 -0.4,1.1 -2.6,3.8 -5,5.9 -4.7,4.1 -5.7,6.6 -7.4,17.7 -0.6,4.1 -1.3,8.5 -1.6,9.7l-0.4,2.3l-36.1,-0c-31.7,-0 -36.4,0.2 -39.2,1.7 -4.4,2.2 -16.9,2.3 -22.1,-0 -5.2,-2.3 -18.6,-2.4 -18.6,-0.2 0,0.8 -0.9,1.5 -2,1.5 -1.1,-0 -2,-0.7 -2,-1.5 0,-1.1 -1.2,-1.5 -4.5,-1.5 -3.3,-0 -4.5,0.4 -4.5,1.5 0,0.8 -0.7,1.5 -1.5,1.5 -0.8,-0 -1.5,-0.7 -1.5,-1.5 0,-1.1 -1.2,-1.5 -4.4,-1.5 -3,-0 -4.6,0.5 -5,1.5 -0.3,0.8 -1.2,1.5 -2.1,1.5 -0.8,-0 -1.5,-0.7 -1.5,-1.5 0,-1.1 -1.2,-1.5 -4.5,-1.5 -3.3,-0 -4.5,0.4 -4.5,1.5 0,0.8 -0.9,1.5 -2,1.5 -1.1,-0 -2,-0.7 -2,-1.5 0,-1.2 -1.4,-1.5 -6.5,-1.5 -7.4,-0 -6.6,1.3 -8.9,-13.6 -1.6,-10.1 -3.2,-13.3 -8.1,-16.4 -1.8,-1.1 -4.2,-3.5 -5.3,-5.3 -2.6,-4.6 -7.3,-5.9 -22.7,-6.3 -9.5,-0.3 -14.4,-0 -18.4,1.1zM102.72,122.29c3.6,0.8 10.3,3.3 15,5.6 11.9,5.7 23.8,17.7 29.8,29.9 6.7,13.6 8.8,28.2 6.1,43.6 -0.9,5.3 -1.4,6.3 -3.1,6.3 -1.7,-0 -1.9,-0.4 -1.3,-2.3 1.6,-5.2 1.7,-25.1 0.1,-31.9 -5.4,-23 -24.1,-41.9 -46.9,-47.3 -8,-1.9 -22.5,-1.9 -30.6,-0 -21.9,5.2 -41.4,24.7 -46.6,46.6 -1.9,8.2 -1.9,22.6 0.1,30.9 4.1,17.5 17.1,33.8 33.4,41.9 13.9,6.8 25.7,8.5 40.6,5.6 6.2,-1.2 8,-1.2 8.6,-0.2 1.5,2.4 -6.9,4.1 -20.7,4.1 -11.1,0.1 -14,-0.2 -20,-2.2 -17.5,-5.8 -32.6,-18.6 -40.3,-34.3 -9.6,-19.5 -9.7,-40.9 -0.1,-60.7 5.9,-12.2 17.9,-24.2 29.9,-30 4.7,-2.3 11.2,-4.8 14.5,-5.6 8,-1.9 23.4,-1.9 31.5,-0zM355.12,122.19c22,4.7 42.2,23.1 49.2,44.9 12.3,38.2 -11.3,78.8 -50.7,87.2 -5.2,1.1 -10.5,1.5 -17.9,1.1 -11.4,-0.5 -16,-1.8 -16,-4.3 0,-1.1 0.5,-1.4 1.6,-1 4.1,1.6 16.6,2.8 22.9,2.3 26.5,-2.3 48.8,-20.2 56.7,-45.7 3.4,-11 3.2,-26.4 -0.6,-37.7 -14.8,-45.3 -72.1,-59.1 -105.6,-25.4 -15.4,15.5 -22,37.1 -17.6,57.8 1.2,5.8 1.1,6.3 -0.4,6.3 -4.1,-0 -5.4,-23.3 -2,-36.4 3.1,-12.2 8.2,-20.9 18,-30.6 9.6,-9.7 18.3,-14.9 30.2,-18 8.9,-2.3 22.6,-2.5 32.2,-0.5zM347.42,141.79c13.7,6.6 8.8,27 -6.5,27.2 -8.5,0.1 -14.2,-5.6 -14.2,-14.4 0,-5.6 2.9,-10.4 8,-13 4.5,-2.4 7.4,-2.4 12.7,0.2zM89.72,146.19 L92.02,148.69l-5.9,-0 -5.9,-0l3,-2.5c3.7,-3 3.7,-3 6.5,-0zM96.52,164.09c0.3,7.1 0.1,8.1 -2,10.3 -2.4,2.6 -6.9,5.3 -8.7,5.3 -0.6,-0 -2.9,-1.6 -5.1,-3.6l-4,-3.6l0,-7.7c0,-4.3 0.3,-8.1 0.7,-8.5 0.4,-0.4 4.8,-0.5 9.8,-0.4l9,0.3zM381.62,174.49c10,5 10.6,18.6 1.2,24.9 -9.2,6.2 -22.1,-1 -22.1,-12.2 0,-10.7 11.3,-17.6 20.9,-12.7zM311.82,175.99c9.4,6.3 8.8,19.9 -1.2,24.9 -9.6,4.9 -20.9,-2 -20.9,-12.7 0,-11.2 12.9,-18.4 22.1,-12.2zM72.42,181.89c6,6.5 6,8.2 0,14.5 -2.9,3.1 -3.5,3.3 -9.8,3.3 -9,-0 -9.9,-1 -9.9,-10.5 0,-9.5 0.9,-10.5 9.9,-10.5 6.3,-0 6.9,0.2 9.8,3.2zM117.82,179.69c1.6,0.9 1.9,2.2 1.9,9.3 0,5.1 -0.5,8.8 -1.2,9.5 -0.7,0.7 -4,1.2 -7.9,1.2 -7.5,-0 -10.2,-1.5 -13.4,-7.3 -1.8,-3.4 -1.8,-3.5 0.5,-7.2 1.3,-2 3.7,-4.3 5.4,-5.1 3.7,-1.6 12,-1.8 14.7,-0.4zM188.52,182.29c2.4,1.7 3,7 1.2,10.5 -0.9,1.6 -2.2,1.9 -9.3,1.9 -5.1,-0 -8.8,-0.5 -9.5,-1.2 -1.9,-1.9 -1.5,-10.5 0.6,-11.7 2.8,-1.7 14.4,-1.3 17,0.5zM255.62,185.09c3.8,1.7 4.9,4.2 2.9,6.2 -2.3,2.4 -17,4.5 -20.1,3 -3.2,-1.7 -3.7,-9.8 -0.7,-11.9 1.9,-1.3 2.9,-1.3 8.8,-0 3.7,0.8 7.8,2 9.1,2.7zM131.42,191.49 L128.72,194.19l0,-5.8 0,-5.9l2.7,3.2 2.6,3.2zM45.72,190.29c0,5 -0.2,5.5 -1.6,4.4 -3.3,-2.8 -3.7,-5 -1.3,-7.6 1.2,-1.3 2.4,-2.4 2.6,-2.4 0.2,-0 0.3,2.5 0.3,5.6zM92.82,202.59c3.8,3.7 3.9,4.1 3.9,10.8 0,4 -0.5,7.4 -1.2,8.1 -0.7,0.7 -4.2,1.2 -8.8,1.2 -4.6,-0 -8.1,-0.5 -8.8,-1.2 -0.7,-0.7 -1.2,-4.1 -1.2,-8.4 0,-7.1 0,-7.2 4,-10.8 2.2,-2 5,-3.6 6.1,-3.6 1.2,-0 3.9,1.7 6,3.9zM221.42,204.79c12.1,7.5 6.5,26.9 -7.7,26.9 -5,-0 -10.7,-3.8 -13,-8.5 -2.5,-5.1 -2.5,-6.9 0,-12.2 3.5,-7.9 13.3,-10.8 20.7,-6.2zM349.22,209.09c10.6,8.1 4.4,25.6 -9,25.6 -7.8,-0 -13.6,-5.4 -14.3,-13.2 -0.3,-4.4 0,-5.9 2,-8.8 4.9,-7.3 14.3,-8.9 21.3,-3.6zM163.72,220.29c6.8,3.1 12.3,8.3 15.8,14.9 2.3,4.3 2.7,6.2 2.7,13.5 0,7.3 -0.4,9.2 -2.7,13.6 -7.2,13.8 -23.8,21.1 -38.4,17 -20.1,-5.8 -30.1,-26.6 -21.1,-44.2 8.1,-15.7 27.2,-22.2 43.7,-14.8zM292.32,220.19c20.8,9.4 25.7,36.2 9.2,51 -6.8,6.2 -14,8.9 -23.3,8.9 -9.9,-0 -16.7,-2.8 -23.7,-9.7 -8.1,-8.2 -11,-21 -7.2,-32.1 2,-5.8 10,-14.7 15.8,-17.6 9.3,-4.5 20,-4.7 29.2,-0.5zM90.72,232.29c-2.7,3.2 -4.6,3.4 -7,0.9l-2.3,-2.5l5.2,-0c4.7,-0 5.2,0.2 4.1,1.6z" android:strokeColor="#00000000"/>
<path android:fillColor="#000000"
android:pathData="m138.22,225.79c-5.1,2.5 -10.1,7.4 -12.8,12.4 -1.7,3.1 -2.2,5.8 -2.2,11.5 0,8.7 2,13.2 8.2,19.2 5.1,4.9 10.5,7 17.9,7 10.3,0.1 18.7,-5.1 23.7,-14.6 3,-5.8 3.1,-17.4 0,-23.1 -2.8,-5.2 -8.8,-11 -13.5,-13 -5.4,-2.2 -16.3,-1.9 -21.3,0.6z" android:strokeColor="#00000000"/>
<path android:fillColor="#000000"
android:pathData="m268.12,225.59c-15.5,7.1 -20.2,27.5 -9.4,41.1 6.8,8.6 21.4,11.9 31.3,7.1 8.8,-4.3 15.7,-14.9 15.7,-24.1 0,-9.2 -6.8,-19.6 -15.4,-23.6 -6.5,-3 -16.2,-3.2 -22.2,-0.5z" android:strokeColor="#00000000"/>
</vector>
-11
View File
@@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="29.424"
android:viewportHeight="29.424">
<path
android:fillColor="#FF000000"
android:pathData="m14.712,-0c-3.803,0 -7.607,0.701 -10.992,2.103 -0.704,0.292 -1.325,0.913 -1.617,1.617 -2.804,6.77 -2.804,15.214 0,21.984 0.292,0.704 0.913,1.325 1.617,1.617 6.77,2.804 15.214,2.804 21.984,0 0.704,-0.292 1.325,-0.913 1.617,-1.617 2.804,-6.77 2.804,-15.214 0,-21.984 -0.292,-0.704 -0.913,-1.325 -1.617,-1.617 -3.385,-1.402 -7.188,-2.103 -10.992,-2.103zM20.15,5.003c1.807,3.129 3.612,6.258 5.419,9.387l-10.839,0c1.806,-3.129 3.613,-6.258 5.419,-9.387zM5.277,5.652l8.719,0l0,8.719L5.277,14.371ZM6.678,7.052l0,5.918l5.918,0L12.596,7.052ZM20.15,7.869c-0.979,1.696 -1.959,3.392 -2.938,5.088l5.876,0c-0.979,-1.696 -1.959,-3.392 -2.938,-5.088zM20.14,14.938c0.039,-0 0.077,0 0.116,0.001 0.364,-0 0.727,0.041 1.082,0.123 2.378,0.501 4.09,3.008 3.664,5.406 -0.324,2.452 -2.777,4.359 -5.235,4.047 -2.465,-0.199 -4.49,-2.554 -4.305,-5.022 0.071,-2.433 2.252,-4.544 4.679,-4.555zM12.904,15.595c0.34,0.336 0.68,0.672 1.02,1.008 -1.085,1.098 -2.169,2.195 -3.254,3.293 1.097,1.085 2.194,2.169 3.291,3.254 -0.335,0.34 -0.671,0.68 -1.006,1.02l-3.293,-3.256 -3.256,3.293c-0.34,-0.336 -0.68,-0.672 -1.02,-1.008l3.256,-3.293c-1.098,-1.085 -2.195,-2.169 -3.293,-3.254 0.336,-0.34 0.672,-0.68 1.008,-1.02l3.293,3.254c1.085,-1.097 2.169,-2.194 3.254,-3.291zM20.347,16.447c-0.03,0 -0.061,0.001 -0.091,0.002 -1.242,-0.016 -2.443,0.735 -2.969,1.86 -0.891,1.722 0.112,4.06 1.987,4.576 1.838,0.634 4.008,-0.707 4.238,-2.642 0.356,-1.888 -1.252,-3.803 -3.164,-3.796z"
android:strokeWidth="1"
android:strokeLineCap="square"/>
</vector>
-6
View File
@@ -1,6 +0,0 @@
<vector android:height="24dp" android:viewportHeight="426.81"
android:viewportWidth="426.81" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#000000"
android:pathData="m73.19,122.41 l-14.23,14h-28.7,-4.43c-6.4,7.57 -10.74,14.82 -14.96,24.9 -12,28.5 -13.7,60.5 -4.5,88.3 5.4,16.2 20.4,39.7 32.2,50.3l5,4.5L196.27,304.4 348.87,304.4l9,-9 8.9,-9 17,-0.2 17,-0.3 3.8,-5c9.1,-11.9 17.3,-32 20.2,-49.4 5.55,-33.2 -4.23,-71.49 -23.84,-95.5L391.7,136l-9.93,0.2c-8.1,0.1 -15.3,0.1 -15.9,-0.2 -0.57,-0.19 -2.84,-2.48 -5.02,-5.08l-8.42,-8.52L213.47,122.41ZM87.37,133.4h124.5,124.5v72,72h-124.5,-124.5v-72zM381.24,162.24c6.86,0 12.42,5.56 12.42,12.42 0,6.86 -5.56,12.42 -12.42,12.42 -6.86,0 -12.42,-5.56 -12.42,-12.42 0,-6.86 5.56,-12.42 12.42,-12.42zM36.78,167.09h16.94c0.75,0 1.35,0.61 1.35,1.35v15.7c0,0.87 -0.35,1.69 -0.98,2.29l-7.89,7.58c-0.55,0.52 -1.41,0.52 -1.96,0l-7.91,-7.58c-0.62,-0.6 -0.98,-1.43 -0.97,-2.29l0.05,-15.7c0,-0.75 0.61,-1.36 1.36,-1.36zM355.46,188.03c6.86,0 12.42,5.56 12.42,12.42 0,6.86 -5.56,12.42 -12.42,12.42 -6.86,0 -12.42,-5.56 -12.42,-12.42 0,-6.86 5.56,-12.42 12.42,-12.42zM407.02,188.03c6.86,0 12.42,5.56 12.42,12.42 0,6.86 -5.56,12.42 -12.42,12.42 -6.86,0 -12.42,-5.56 -12.42,-12.42 0,-6.86 5.56,-12.42 12.42,-12.42zM29.38,190.15c0.87,-0 1.69,0.35 2.29,0.97l7.58,7.91c0.52,0.55 0.52,1.41 -0,1.96l-7.58,7.89c-0.6,0.62 -1.43,0.98 -2.29,0.98h-15.7c-0.75,0 -1.35,-0.61 -1.35,-1.35v-16.94c0,-0.75 0.61,-1.36 1.35,-1.36zM61.25,190.33h15.7c0.75,0 1.35,0.61 1.35,1.35v16.94c0,0.75 -0.61,1.36 -1.36,1.36l-15.7,0.05c-0.87,0 -1.7,-0.35 -2.29,-0.97l-7.58,-7.91c-0.52,-0.55 -0.52,-1.41 0,-1.96l7.58,-7.89c0.6,-0.62 1.43,-0.98 2.3,-0.98zM45.39,205.76c0.35,-0 0.71,0.13 0.98,0.39l7.91,7.58c0.62,0.6 0.98,1.43 0.98,2.29l-0.05,15.7c-0,0.75 -0.61,1.36 -1.36,1.36h-16.94c-0.75,0 -1.35,-0.61 -1.35,-1.35v-15.7c0,-0.87 0.35,-1.69 0.98,-2.29l7.89,-7.58c0.27,-0.26 0.63,-0.39 0.98,-0.39zM381.24,213.81c6.86,0 12.42,5.56 12.42,12.42 0,6.86 -5.56,12.42 -12.42,12.42 -6.86,0 -12.42,-5.56 -12.42,-12.42 0,-6.86 5.56,-12.42 12.42,-12.42zM48.04,243.19a14.41,14.41 0,0 1,14.41 14.41,14.41 14.41,0 0,1 -14.41,14.41 14.41,14.41 0,0 1,-14.41 -14.41,14.41 14.41,0 0,1 6.47,-11.69c0.3,-0.22 0.61,-0.43 0.93,-0.62a14.41,14.41 0,0 1,2.57 -1.21c1.17,-0.41 2.39,-0.66 3.65,-0.73a14.41,14.41 0,0 1,0.8 -0.16zM36.97,289.26h25.36l11.85,10.16h-25.36l-0.01,-0.01h-2.03l-4.9,-5 -4.9,-5h0.17z"
android:strokeColor="#00000000" android:strokeWidth="0.0999997"/>
</vector>
-9
View File
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="50"
android:viewportHeight="50">
<path
android:fillColor="#FF000000"
android:pathData="M19.313,4C19.012,4 18.707,3.988 18.406,4.188C18.105,4.387 18,4.699 18,5L18,41.594C18,41.992 18.289,42.395 18.688,42.594L26.688,45L27,45C27.199,45 27.395,44.914 27.594,44.813C27.895,44.613 28,44.301 28,44L28,13.406C28.602,13.707 29,14.301 29,15L29,26.094C29,26.395 29.199,26.805 29.5,26.906C29.699,27.008 31.199,27.906 34,27.906C36.699,27.906 40,26.414 40,19.313C40,13.613 36.813,9.293 31.313,7.594ZM17,26.406L5.906,30.406L4.313,31C1.613,32.102 0,33.887 0,35.688C0,39.488 2.699,41.688 7.5,41.688C10.102,41.688 13.301,41.113 17,39.813L17,36C16.102,36.301 15.113,36.699 14.313,37C12.711,37.602 11.5,37.813 10.5,37.813C9,37.813 8.301,37.301 8,37C7.602,36.699 7.398,36.313 7.5,35.813C7.602,34.813 8.801,33.895 11,33.094C11.5,32.895 14.898,31.699 17,31ZM36.5,28.906C34.102,29.008 31.602,29.395 29,30.094L29,34.688C30.102,34.289 31.586,33.801 33.688,33C38.488,31.301 40.492,31.488 41.094,31.688C42.293,31.789 42.801,32.5 43,33C43.5,34.5 41.613,35.188 38.813,36.188C37.512,36.688 31.898,38.688 29,39.688L29,44.313L44.5,38.813L45.688,38.313C47.688,37.613 50.199,36.301 50,34C49.898,31.801 47.211,30.695 45.313,30.094C42.512,29.195 39.5,28.805 36.5,28.906Z"/>
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" >
<gradient
android:startColor="#FF00A7FF"
android:endColor="#FF93278F"
android:angle="270"/>
</shape>
-10
View File
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="67.733dp"
android:height="67.733dp"
android:viewportWidth="67.733"
android:viewportHeight="67.733">
<path
android:fillColor="#FF000000"
android:pathData="M56.541,21.775 L41.725,6.959c-0.403,-0.393 -0.945,-0.612 -1.508,-0.609L14.817,6.35c-2.338,0 -4.233,1.895 -4.233,4.233v23.283c-0,1.169 0.948,2.117 2.117,2.117h42.333c1.169,0 2.117,-0.948 2.117,-2.117v-10.583c0.003,-0.563 -0.215,-1.105 -0.609,-1.508zM40.217,23.283v-11.642l11.642,11.642zM24.342,44.45c0,1.169 -0.948,2.117 -2.117,2.117h-2.646v10.583c0,2.822 -4.233,2.822 -4.233,0v-10.583h-2.646c-2.818,-0.004 -2.818,-4.229 0,-4.233h9.525c1.169,0 2.117,0.948 2.117,2.117zM40.217,53.975c0,3.466 -2.461,5.556 -6.615,5.556 -1.921,-0.006 -3.788,-0.637 -5.318,-1.799v0c-0.501,-0.394 -0.786,-1.003 -0.767,-1.64 -0,-1.169 0.948,-2.117 2.117,-2.117 0.49,0.004 0.965,0.171 1.349,0.476h-0.026c0.781,0.533 1.7,0.827 2.646,0.847 2.381,0 2.381,-0.794 2.381,-1.323 0,-0.529 0,-0.238 -0.317,-0.423 -0.317,-0.185 -1.535,-0.582 -2.434,-0.82 -2.275,-0.609 -5.715,-1.535 -5.715,-5.106 0,-3.572 2.514,-5.556 6.085,-5.556 1.921,0.006 3.788,0.637 5.318,1.799v0c0.501,0.394 0.786,1.003 0.767,1.64 0,1.169 -0.948,2.117 -2.117,2.117 -0.49,-0.004 -0.965,-0.171 -1.349,-0.476h0.026c-0.781,-0.533 -1.7,-0.827 -2.646,-0.847 -1.217,0 -1.852,0.45 -1.852,1.323v0.053c0.291,0.344 1.746,0.741 2.566,0.952 2.196,0.582 5.9,1.561 5.9,5.345zM55.798,42.855c-1.081,-0.457 -2.328,0.054 -2.778,1.138l-3.334,8.017 -3.334,-8.017c-1.095,-2.606 -5.005,-0.968 -3.916,1.64l5.292,12.7c0.719,1.75 3.197,1.75 3.916,0l2.864,-6.874 2.427,-5.826c0.457,-1.081 -0.054,-2.328 -1.138,-2.778z"
android:strokeWidth="0.264583"/>
</vector>
-8
View File
@@ -1,8 +0,0 @@
<vector android:height="24dp" android:viewportHeight="115.43"
android:viewportWidth="115.43" android:width="24dp"
xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#000000" android:fillType="nonZero"
android:pathData="m27.32,30.4c-15.13,0 -27.32,12.18 -27.32,27.32 0,15.13 12.18,27.32 27.32,27.32l60.79,0c15.13,0 27.32,-12.18 27.32,-27.32 0,-15.13 -12.18,-27.32 -27.32,-27.32zM24.62,38.44L90.61,38.44L90.61,76.22L24.62,76.22ZM10.54,44.52l4.03,0c0.18,0 0.32,0.14 0.32,0.32l0,3.74c0,0.21 -0.08,0.4 -0.23,0.55l-1.88,1.8c-0.13,0.12 -0.34,0.12 -0.47,0l-1.88,-1.8c-0.15,-0.14 -0.23,-0.34 -0.23,-0.55l0.01,-3.74c0,-0.18 0.15,-0.32 0.32,-0.32zM104.06,44.6c1.54,0 2.78,1.25 2.78,2.78 0,1.54 -1.25,2.78 -2.78,2.78 -1.54,0 -2.78,-1.25 -2.78,-2.78 0,-1.54 1.25,-2.78 2.78,-2.78zM109.1,49.65c1.54,0 2.78,1.25 2.78,2.78 0,1.54 -1.25,2.78 -2.78,2.78 -1.54,0 -2.78,-1.25 -2.78,-2.78 0,-1.54 1.25,-2.78 2.78,-2.78zM99.01,49.65c1.54,0 2.78,1.25 2.78,2.78 0,1.54 -1.25,2.78 -2.78,2.78 -1.54,0 -2.78,-1.25 -2.78,-2.78 0,-1.54 1.25,-2.78 2.78,-2.78zM8.78,50c0.21,-0 0.4,0.08 0.55,0.23l1.81,1.88c0.12,0.13 0.12,0.34 -0,0.47l-1.8,1.88c-0.14,0.15 -0.34,0.23 -0.55,0.23l-3.74,0c-0.18,0 -0.32,-0.14 -0.32,-0.32l0,-4.03c0,-0.18 0.14,-0.32 0.32,-0.32zM16.37,50.05l3.74,0c0.18,0 0.32,0.14 0.32,0.32l0,4.03c0,0.18 -0.14,0.32 -0.32,0.32l-3.74,0.01c-0.21,0 -0.4,-0.08 -0.55,-0.23l-1.8,-1.88c-0.12,-0.13 -0.12,-0.34 0,-0.47l1.8,-1.88c0.14,-0.15 0.34,-0.23 0.55,-0.23zM12.59,53.72c0.08,-0 0.17,0.03 0.23,0.09l1.88,1.81c0.15,0.14 0.23,0.34 0.23,0.55l-0.01,3.74c-0,0.18 -0.15,0.32 -0.32,0.32l-4.03,0c-0.18,0 -0.32,-0.14 -0.32,-0.32l0,-3.74c0,-0.21 0.08,-0.4 0.23,-0.55l1.88,-1.8c0.06,-0.06 0.15,-0.09 0.23,-0.09zM104.06,54.69c1.54,0 2.78,1.25 2.78,2.78 0,1.54 -1.25,2.78 -2.78,2.78 -1.54,0 -2.78,-1.25 -2.78,-2.78 0,-1.54 1.25,-2.78 2.78,-2.78zM15.83,66.39a2.87,2.87 0,0 1,2.87 2.87,2.87 2.87,0 0,1 -2.87,2.87 2.87,2.87 0,0 1,-2.87 -2.87,2.87 2.87,0 0,1 2.87,-2.87zM99.39,66.41a2.87,2.87 89.99,0 1,2.87 2.87,2.87 2.87,89.99 0,1 -2.87,2.87 2.87,2.87 89.99,0 1,-2.87 -2.87,2.87 2.87,89.99 0,1 2.87,-2.87z"
android:strokeColor="#00000000" android:strokeLineCap="square"
android:strokeLineJoin="round" android:strokeWidth="1"/>
</vector>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+31
View File
@@ -0,0 +1,31 @@
<?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/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false">
<include
android:id="@+id/include"
layout="@layout/appbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<FrameLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="56dp"
android:background="@color/colorAmoled"
android:backgroundTint="@color/colorAmoled"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/include"
tools:context=".activities.MainActivity" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
This layout is used only on API 21 & 22 (Lollipop) because on these API levels,
Android doesn't scale SVGs very well - we noticed the logo stretched across the
screen and looked ugly.
In SplashActivity, setContentView(R.layout.activity_splash) is called only for API 21 & 22.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/splashRoot"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center">
<androidx.appcompat.widget.AppCompatImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:srcCompat="@drawable/splash" />
</LinearLayout>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.appbar.AppBarLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/toolbar_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:descendantFocusability="afterDescendants"
app:liftOnScroll="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
style="@style/Widget.MaterialComponents.Toolbar.Primary"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/colorAmoled"
android:theme="@style/NPSTheme.ActionBar"
app:layout_scrollFlags="scroll|enterAlways|snap" />
</com.google.android.material.appbar.AppBarLayout>
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout 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:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false"
tools:context=".fragments.mainactivity.MainActivityFragment">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/appsRecycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:spanCount="2"
tools:listitem="@layout/recycler_item_grid" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/tsvChooserButton"
android:layout_width="52dp"
android:layout_height="52dp"
android:layout_marginBottom="40dp"
android:backgroundTint="@color/colorPrimary"
android:clickable="true"
android:focusable="true"
android:src="@drawable/ic_add_24dp"
app:borderWidth="2dp"
app:fabCustomSize="52dp"
app:layout_anchor="@+id/appsRecycler"
app:layout_anchorGravity="bottom|center"
app:maxImageSize="32dp" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
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:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="0dp">
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="214dp"
android:backgroundTint="@color/colorAmoled"
app:cardBackgroundColor="@color/colorPrimary"
app:cardElevation="4dp"
app:cardUseCompatPadding="false"
app:contentPadding="15dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/imageView2"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_games_placeholder_24dp" />
<TextView
android:id="@+id/appTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:ellipsize="marquee"
android:enabled="true"
android:focusable="true"
android:focusableInTouchMode="true"
android:marqueeRepeatLimit="marquee_forever"
android:scrollHorizontally="true"
android:singleLine="true"
android:textAlignment="center"
android:textAppearance="?android:attr/textAppearanceListItem"
android:textColor="@color/colorOnPrimary"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageView2"
tools:text="App Title" />
<TextView
android:id="@+id/appRegion"
android:layout_width="36dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginBottom="8dp"
android:background="@drawable/rect_light_blue"
android:paddingStart="4dp"
android:paddingLeft="10dp"
android:paddingTop="4dp"
android:paddingEnd="4dp"
android:paddingRight="10dp"
android:paddingBottom="4dp"
android:textAlignment="center"
android:textColor="@android:color/white"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="ASIA" />
<TextView
android:id="@+id/appMinFW"
android:layout_width="36dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginBottom="8dp"
android:background="@drawable/rect_red"
android:padding="4dp"
android:textAlignment="center"
android:textColor="@android:color/white"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/appRegion"
tools:text="3.69" />
<TextView
android:id="@+id/appLastDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:padding="4dp"
android:textColor="@color/colorOnSecondary"
android:textSize="10sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:text="2018-04-22" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>
</androidx.cardview.widget.CardView>
@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
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:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="0dp">
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:backgroundTint="@color/colorAmoled"
app:cardBackgroundColor="@color/colorPrimary"
app:cardElevation="4dp"
app:cardUseCompatPadding="false"
app:contentPadding="15dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/imageView"
android:layout_width="62dp"
android:layout_height="56dp"
android:contentDescription="app icon"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_games_placeholder_24dp" />
<TextView
android:id="@+id/appTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:ellipsize="marquee"
android:focusable="auto"
android:focusableInTouchMode="true"
android:marqueeRepeatLimit="marquee_forever"
android:scrollHorizontally="true"
android:singleLine="true"
android:textAlignment="viewStart"
android:textAppearance="?android:attr/textAppearanceListItem"
android:textColor="@color/colorOnPrimary"
android:textSize="18sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView"
app:layout_constraintTop_toTopOf="parent"
tools:text="App Title" />
<TextView
android:id="@+id/appRegion"
android:layout_width="40dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:background="@drawable/rect_light_blue"
android:paddingStart="4dp"
android:paddingLeft="10dp"
android:paddingTop="4dp"
android:paddingEnd="4dp"
android:paddingRight="10dp"
android:paddingBottom="4dp"
android:textAlignment="center"
android:textColor="@android:color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/appMinFW"
app:layout_constraintStart_toEndOf="@+id/imageView"
tools:text="ASIA" />
<TextView
android:id="@+id/appMinFW"
android:layout_width="40dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:background="@drawable/rect_red"
android:padding="4dp"
android:textAlignment="center"
android:textColor="@android:color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/appRegion"
tools:text="3.69" />
<TextView
android:id="@+id/appLastDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="4dp"
android:textColor="@color/colorOnSecondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:text="2020-02-12" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>
</androidx.cardview.widget.CardView>
@@ -0,0 +1,21 @@
<?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/prefers"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false">
<FrameLayout
android:id="@+id/prefs"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,259 @@
<?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/colorAccent"
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/singleAppCopyPKGButton"
android:layout_width="0dp"
android:layout_height="66dp"
android:layout_marginStart="48dp"
android:layout_marginTop="6dp"
android:layout_marginEnd="48dp"
android:text="@string/copy_pkg"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/singleAppDownloadButton" />
<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/singleAppCopyPKGButton" />
<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/singleAppVerTitle"
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/version"
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/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/singleAppVer"
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/singleAppVerTitle"
tools:text="Version" />
<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>

Some files were not shown because too many files have changed in this diff Show More