WIP downloading

This commit is contained in:
2026-04-30 19:49:10 +02:00
parent 9e582d3788
commit d775395efd
53 changed files with 6075 additions and 446 deletions
+18 -3
View File
@@ -2,9 +2,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application
android:name=".NPSApp"
@@ -14,7 +22,8 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/NPSTheme"
android:usesCleartextTraffic="true">
android:networkSecurityConfig="@xml/network_security_config"
>
<activity
android:name=".MainActivity"
android:configChanges="screenSize|screenLayout|keyboardHidden|orientation"
@@ -27,6 +36,12 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".data.download.DownloadService"
android:exported="false"
android:foregroundServiceType="dataSync"
/>
</application>
</manifest>
@@ -3,6 +3,10 @@ package com.illusionware.npsbrowser
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
@@ -26,13 +30,14 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ViewList
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Downloading
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.ViewList
import androidx.compose.material3.Badge
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -47,7 +52,6 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -84,20 +88,21 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.illusionware.npsbrowser.data.ItemLayout
import com.illusionware.npsbrowser.data.SettingsPreferences
import com.illusionware.npsbrowser.model.ConsoleType
import com.illusionware.npsbrowser.model.PackageItem
import com.illusionware.npsbrowser.ui.components.*
import com.illusionware.npsbrowser.ui.screens.Downloads
import com.illusionware.npsbrowser.ui.screens.OnBoarding
import com.illusionware.npsbrowser.ui.screens.PackageDetails
import com.illusionware.npsbrowser.ui.screens.SettingsScreen
import com.illusionware.npsbrowser.ui.screens.readexProFamily
import com.illusionware.npsbrowser.ui.theme.ColorAccent
import com.illusionware.npsbrowser.ui.theme.ColorOnPrimaryLight
import com.illusionware.npsbrowser.ui.theme.ColorTag
import com.illusionware.npsbrowser.ui.theme.NPSBrowserTheme
import com.illusionware.npsbrowser.ui.theme.Typography
import com.illusionware.npsbrowser.util.isNavigationBarNeedsScrim
import com.illusionware.npsbrowser.viewmodels.OnboardingViewModel
import com.illusionware.npsbrowser.viewmodels.PackageDetailsViewModel
import com.illusionware.npsbrowser.viewmodels.PackageListViewModel
@@ -112,8 +117,9 @@ val redHatDisplayFamily = FontFamily(
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
val settingsViewModel: SettingsViewModel = viewModel(
factory = SettingsViewModel.Factory
@@ -121,13 +127,19 @@ class MainActivity : ComponentActivity() {
val onboardingViewModel: OnboardingViewModel = viewModel(
factory = OnboardingViewModel.Factory
)
val packageDetailsViewModel: PackageDetailsViewModel = viewModel()
val packageDetailsViewModel: PackageDetailsViewModel = viewModel(
factory = PackageDetailsViewModel.Factory
)
val onboardingPrefs = onboardingViewModel.uiState.collectAsStateWithLifecycle().value
val appTheme = runBlocking { settingsViewModel.getAppTheme() }
var seenOnboarding by remember { mutableStateOf(false) }
val settingsPrefs = settingsViewModel.uiState.collectAsStateWithLifecycle().value
val navController = rememberNavController()
runBlocking {
seenOnboarding = onboardingViewModel.getOnboardingStatus()
}
val darkTheme = when (appTheme) {
0 -> false
1 -> true
@@ -137,11 +149,17 @@ class MainActivity : ComponentActivity() {
NPSBrowserTheme(darkTheme = darkTheme) {
Surface(
modifier = Modifier.fillMaxSize(),
color = if (onboardingPrefs.seenOnboarding) MaterialTheme.colorScheme.background else ColorAccent
color = if (seenOnboarding) MaterialTheme.colorScheme.background else ColorAccent
) {
NavHost(
navController = navController,
startDestination = if (onboardingPrefs.seenOnboarding) Routes.Home.route else Routes.Onboarding.route,
startDestination = if (seenOnboarding) Routes.Home.route else Routes.Onboarding.route,
enterTransition = {
fadeIn(animationSpec = tween(300))
},
exitTransition = {
fadeOut(animationSpec = tween(300))
},
) {
composable(Routes.Home.route) {
HomePage(
@@ -152,6 +170,10 @@ class MainActivity : ComponentActivity() {
packageDetailsViewModel.setSelectedPackage(item)
navController.navigate(Routes.PackageDetails.route)
},
navigateToDownloads = {
navController.navigate(Routes.Downloads.route)
},
packageDetailsViewModel = packageDetailsViewModel,
)
}
composable(Routes.Settings.route) {
@@ -174,6 +196,12 @@ class MainActivity : ComponentActivity() {
packageDetailsViewModel = packageDetailsViewModel,
)
}
composable(Routes.Downloads.route) {
Downloads(
navigationGoBack = { navController.popBackStack() },
packageDetailsViewModel = packageDetailsViewModel,
)
}
}
}
}
@@ -186,32 +214,20 @@ class MainActivity : ComponentActivity() {
fun HomePage(
navigateToSettings: () -> Unit,
navigateToDetailsScreen: (item: PackageItem) -> Unit,
navigateToDownloads: () -> Unit,
settingsPrefs: SettingsPreferences,
settingsViewModel: SettingsViewModel,
packageListViewModel: PackageListViewModel = viewModel(
factory = PackageListViewModel.Factory
),
packageDetailsViewModel: PackageDetailsViewModel
) {
val searchBar = remember { FocusRequester() }
var layoutDialogOpen by remember { mutableStateOf(false) }
val context = LocalContext.current
val systemUiController = rememberSystemUiController()
val navbarScrimColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
val uiState = packageListViewModel.uiState.collectAsStateWithLifecycle().value
val tsvs = settingsViewModel.availableTsvs()
var selectedBarItem by rememberSaveable { mutableStateOf(ConsoleType.PSVITA) }
LaunchedEffect(systemUiController) {
systemUiController.setNavigationBarColor(
color = if (context.isNavigationBarNeedsScrim()) {
navbarScrimColor.copy(alpha = 0.7f)
} else {
Color.Transparent
}
)
}
Scaffold(
topBar = {
TopAppBar(
@@ -228,7 +244,7 @@ fun HomePage(
packageListViewModel.closeSearch()
}
){
Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = "Close Search")
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close Search")
}
Spacer(Modifier.width(12.dp))
TextField(
@@ -283,7 +299,7 @@ fun HomePage(
onClick = { layoutDialogOpen = true }
) {
Icon(
imageVector = if (settingsPrefs.layout == ItemLayout.GRID.ordinal) Icons.Filled.GridView else Icons.Filled.ViewList,
imageVector = if (settingsPrefs.layout == ItemLayout.GRID.ordinal) Icons.Filled.GridView else Icons.AutoMirrored.Filled.ViewList,
contentDescription = stringResource(
id = R.string.layout
)
@@ -291,14 +307,29 @@ fun HomePage(
}
NPSIconButton(
tooltip = "Download queue",
onClick = { /*TODO*/ }
onClick = navigateToDownloads,
badge = {
val count = packageDetailsViewModel.downloadManager.queueState.collectAsStateWithLifecycle().value.count()
if (count > 0) {
Badge(
containerColor = ColorTag,
contentColor = Color.Black,
) {
Text(
text = count.toString(),
fontWeight = FontWeight.Bold,
fontFamily = readexProFamily
)
}
}
}
) {
Icon(imageVector = Icons.Filled.Downloading, contentDescription = "Download queue")
}
NPSIconButton(
tooltip = stringResource(id = R.string.title_activity_settings),
onClick = { navigateToSettings() }
){
) {
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = stringResource(
@@ -610,7 +641,7 @@ fun Tag(title: String, small: Boolean = false) {
Box(
Modifier
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFFEAF654))
.background(ColorTag)
.layoutId("tag")
.padding(horizontal = if (small) 6.dp else 8.dp, vertical = if (small) 2.dp else 4.dp)
) {
@@ -7,6 +7,8 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import com.illusionware.npsbrowser.data.OnboardingPreferencesRepository
import com.illusionware.npsbrowser.data.SettingsPreferencesRepository
import com.illusionware.npsbrowser.data.download.DownloadManager
import com.illusionware.npsbrowser.data.notification.Notifications
@OptIn(ExperimentalUnsignedTypes::class)
val HMAC_KEY = ubyteArrayOf(
@@ -22,10 +24,15 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
class NPSApp: Application() {
lateinit var settingsPreferencesRepository: SettingsPreferencesRepository
lateinit var onboardingPreferencesRepository: OnboardingPreferencesRepository
lateinit var downloadManager: DownloadManager
override fun onCreate() {
super.onCreate()
settingsPreferencesRepository = SettingsPreferencesRepository(dataStore)
onboardingPreferencesRepository = OnboardingPreferencesRepository(dataStore)
// downloader = Downloader(this)
downloadManager = DownloadManager(this)
Notifications.initialize(this)
}
}
@@ -1,8 +1,9 @@
package com.illusionware.npsbrowser
sealed class Routes(val route: String) {
object Home : Routes("home")
object Onboarding : Routes("onboarding")
object Settings : Routes("settings")
object PackageDetails : Routes("package_details")
data object Home : Routes("home")
data object Onboarding : Routes("onboarding")
data object Settings : Routes("settings")
data object PackageDetails : Routes("package_details")
data object Downloads : Routes("downloads")
}
@@ -207,4 +207,4 @@ class SettingsPreferencesRepository(
prefs[PKG2ZIP_AUTO_DECRYPT] ?: true,
)
}
}
}
@@ -0,0 +1,89 @@
package com.illusionware.npsbrowser.data.download
import android.content.Context
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart
class DownloadManager(private val context: Context) {
private val downloader = Downloader(context, this)
val queueState
get() = downloader.queueState
fun downloaderStart() = downloader.start()
fun downloaderStop(reason: String? = null) = downloader.stop(reason)
val isDownloaderRunning
get() = DownloadService.isRunning
val isDownloaderPaused
get() = downloader.isPaused
fun startDownloads() {
if (DownloadService.isRunning(context)) {
return
}
DownloadService.start(context, this)
}
fun pauseDownload(url: String) = downloader.pause(url)
fun resumeDownload(url: String) = downloader.resume(url)
fun pauseDownloads() {
downloader.pauseAll()
downloader.stop()
}
fun shouldConfirmOverwrite(url: String): Boolean {
return downloader.shouldConfirmOverwrite(url)
}
fun download(package_: NPSPackageDownload, autoStart: Boolean = true, overwriteExisting: Boolean = false) {
downloader.enqueue(package_, autoStart, overwriteExisting)
}
fun cancelDownload(url: String) {
val wasRunning = downloader.isRunning
// if (wasRunning) {
// downloader.pauseAll()
// }
downloader.removeDownload(url)
if (wasRunning && queueState.value.isEmpty()) {
downloader.stop()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
fun statusFlow(): Flow<NPSPackageDownload> = queueState
.flatMapLatest { downloads ->
downloads
.map { download ->
download.statusFlow.drop(1).map { download }
}
.merge()
}
.onStart {
emitAll(queueState.value.filter { download -> download.status == NPSPackageDownload.State.DOWNLOADING }.asFlow())
}
@OptIn(ExperimentalCoroutinesApi::class)
fun progressFlow(): Flow<NPSPackageDownload> = queueState
.flatMapLatest { downloads ->
downloads
.map { download ->
download.progressFlow.drop(1).map { download }
}
.merge()
}
.onStart {
emitAll(queueState.value.filter { download -> download.status == NPSPackageDownload.State.DOWNLOADING }.asFlow())
}
}
@@ -0,0 +1,122 @@
package com.illusionware.npsbrowser.data.download
import android.content.Context
import androidx.core.app.NotificationCompat
import com.illusionware.npsbrowser.R
import com.illusionware.npsbrowser.data.notification.Notifications
import com.illusionware.npsbrowser.util.system.cancelNotification
import com.illusionware.npsbrowser.util.system.notificationBuilder
import com.illusionware.npsbrowser.util.system.notify
/**
* DownloadNotifier is used to show notifications when downloading one or multiple chapters.
*
* @param context context of application
*/
internal class DownloadNotifier(private val context: Context) {
private val progressNotificationBuilder by lazy {
context.notificationBuilder(Notifications.CHANNEL_DOWNLOADER) {
setSmallIcon(R.drawable.baseline_cloud_download_24)
setCategory(NotificationCompat.CATEGORY_PROGRESS)
setAutoCancel(false)
setOnlyAlertOnce(true)
setSilent(true)
setOngoing(true)
setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
}
}
private val errorNotificationBuilder by lazy {
context.notificationBuilder(Notifications.CHANNEL_DOWNLOADER) {
setSmallIcon(R.drawable.baseline_cloud_download_24)
setCategory(NotificationCompat.CATEGORY_STATUS)
setAutoCancel(true)
setSilent(true)
}
}
/**
* Status of download. Used for correct notification icon.
*/
private var isDownloading = false
/**
* Shows a notification from this builder.
*
* @param id the id of the notification.
*/
private fun NotificationCompat.Builder.show(id: Int) {
context.notify(id, build())
}
/**
* Dismiss the downloader's notification. Downloader error notifications use a different id, so
* those can only be dismissed by the user.
*/
private fun dismissProgress() {
context.cancelNotification(Notifications.ID_DOWNLOADER_GROUP)
}
private fun dismissError() {
context.cancelNotification(Notifications.ID_DOWNLOADER_ERROR)
}
/**
* Called when download progress changes.
*
* @param download download object containing download information.
*/
fun onProgressChange(size: Long, progress: Long) {
with(progressNotificationBuilder) {
if (!isDownloading) {
clearActions()
isDownloading = true
}
setContentTitle("Downloading packages")
setContentText("${progress / 1024 / 1024}MB / ${size / 1024 / 1024}MB")
setProgress((size / 1024).toInt(), (progress / 1024).toInt(), false)
dismissError()
show(Notifications.ID_DOWNLOADER_GROUP)
}
}
fun onPaused(reason: String? = null) {
with(progressNotificationBuilder) {
setContentTitle("Downloads paused")
setContentText(reason ?: "Waiting to resume downloads")
setProgress(0, 0, false)
setOngoing(false)
show(Notifications.ID_DOWNLOADER_GROUP)
}
isDownloading = false
}
/**
* Resets the state once downloads are completed.
*/
fun onComplete() {
dismissProgress()
dismissError()
// Reset states to default
isDownloading = false
}
fun onError(error: String) {
with(errorNotificationBuilder) {
setContentTitle("Download issue")
setStyle(NotificationCompat.BigTextStyle().bigText(error))
setContentText(error)
setProgress(0, 0, false)
show(Notifications.ID_DOWNLOADER_ERROR)
}
isDownloading = false
}
}
@@ -0,0 +1,24 @@
package com.illusionware.npsbrowser.data.download
import android.content.Context
import androidx.annotation.WorkerThread
import kotlinx.coroutines.flow.map
class DownloadRepository(private val downloadDAO: NPSPackageDownloadDao) {
val allDownloads = downloadDAO.getAll()
@WorkerThread
suspend fun insert(download: NPSPackageDownload) {
downloadDAO.insert(download)
}
@WorkerThread
suspend fun delete(url: String) {
downloadDAO.delete(url)
}
@WorkerThread
suspend fun deleteAll() {
downloadDAO.deleteAll()
}
}
@@ -0,0 +1,130 @@
package com.illusionware.npsbrowser.data.download
import android.app.Notification
import android.app.Service
import android.content.pm.ServiceInfo
import android.content.Context
import android.content.Intent
import android.os.IBinder
import android.os.PowerManager.WakeLock
import android.util.Log
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import com.illusionware.npsbrowser.R
import com.illusionware.npsbrowser.data.notification.Notifications
import com.illusionware.npsbrowser.util.system.acquireWakeLock
import com.illusionware.npsbrowser.util.system.isOnline
import com.illusionware.npsbrowser.util.system.isServiceRunning
import com.illusionware.npsbrowser.util.system.notificationBuilder
import com.illusionware.npsbrowser.util.system.toast
import com.illusionware.npsbrowser.util.system.withUIContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import ru.beryukhov.reactivenetwork.ReactiveNetwork
class DownloadService : Service() {
companion object {
private lateinit var _downloadManager: DownloadManager
private val _isRunning = MutableStateFlow(false)
val isRunning = _isRunning.asStateFlow()
fun start(context: Context, downloadManager: DownloadManager) {
Log.d("DOWNLOAD", "Starting download service")
val intent = Intent(context, DownloadService::class.java)
_downloadManager = downloadManager
ContextCompat.startForegroundService(context, intent)
}
fun stop(context: Context) {
context.stopService(Intent(context, DownloadService::class.java))
}
fun isRunning(context: Context): Boolean {
return context.isServiceRunning(DownloadService::class.java)
}
}
private lateinit var wakeLock: WakeLock
private lateinit var scope: CoroutineScope
override fun onCreate() {
Log.d("DOWNLOAD", "In onCreate, acquring wakelock")
super.onCreate()
scope = CoroutineScope(Dispatchers.IO)
ServiceCompat.startForeground(
this,
Notifications.ID_DOWNLOADER_GROUP,
getPlaceholderNotification(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
wakeLock = acquireWakeLock(javaClass.name)
_isRunning.value = true
listenNetworkChanges()
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
_isRunning.value = false
_downloadManager.downloaderStop()
if (wakeLock.isHeld) {
wakeLock.release()
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_NOT_STICKY
}
override fun onBind(p0: Intent?): IBinder? {
return null
}
private fun downloaderStop(string: String) {
_downloadManager.downloaderStop(string)
}
private fun listenNetworkChanges() {
Log.d("DOWNLOAD", "Listening to network changes")
ReactiveNetwork()
.observeNetworkConnectivity(applicationContext)
.onEach {
withUIContext {
if (isOnline()) {
Log.d("DOWNLOAD", "We're online so we start the downloader")
val started = _downloadManager.downloaderStart()
if (!started) stopSelf()
} else {
downloaderStop("Not connected to the internet")
}
}
}
.catch { error ->
withUIContext {
// logcat(LogPriority.ERROR, error)
toast("Download failed: $error")
stopSelf()
}
}
.launchIn(scope)
}
private fun getPlaceholderNotification(): Notification {
return notificationBuilder(Notifications.CHANNEL_DOWNLOADER) {
setSmallIcon(R.drawable.baseline_cloud_download_24)
setCategory(androidx.core.app.NotificationCompat.CATEGORY_SERVICE)
setOnlyAlertOnce(true)
setOngoing(true)
setSilent(true)
setContentTitle("Preparing downloads")
setContentText("Starting downloader service")
}.build()
}
}
@@ -0,0 +1,159 @@
package com.illusionware.npsbrowser.data.download
import android.content.Context
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.Update
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.illusionware.npsbrowser.data.network.ProgressListener
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
private const val DOWNLOAD_STORE_NAME = "downloads"
private const val DOWNLOAD_TABLE_NAME = "download"
@Entity(tableName = DOWNLOAD_TABLE_NAME)
data class NPSPackageDownload(
@PrimaryKey @ColumnInfo(name = "url")
val url: String,
val titleId: String,
val name: String,
val sha256: String?,
val size: Long,
var bytesDownloaded: Long,
val createdAt: Long,
@ColumnInfo(defaultValue = "0")
var statusValue: Int = State.PAUSED.value,
var savedFileName: String? = null,
): ProgressListener {
@Transient
private val _statusFlow = MutableStateFlow(State.PAUSED)
@Transient
val statusFlow = _statusFlow.asStateFlow()
var status: State
get() = _statusFlow.value
set(status) {
statusValue = status.value
_statusFlow.value = status
}
@Transient
private val _progressFlow = MutableStateFlow(
if (size > 0) bytesDownloaded.toFloat() / size.toFloat() else 0f
)
@Transient
var progressFlow = _progressFlow.asStateFlow()
private var progress: Float
get() = _progressFlow.value
set(value) {
_progressFlow.value = value
}
@Transient
private val _bytesReadFlow = MutableStateFlow(bytesDownloaded)
@Transient
var bytesDownloadedFlow = _bytesReadFlow.asStateFlow()
private var bytesRead: Long
get() = _bytesReadFlow.value
set(value) {
_bytesReadFlow.value = value
}
enum class State(val value: Int) {
PAUSED(0),
QUEUE(1),
DOWNLOADING(2),
DOWNLOADED(3),
ERROR(4),
;
companion object {
fun fromValue(value: Int): State = entries.firstOrNull { it.value == value } ?: PAUSED
}
}
fun restoreTransientState() {
_statusFlow.value = State.fromValue(statusValue)
_progressFlow.value = if (size > 0) {
(bytesDownloaded.toFloat() / size.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
_bytesReadFlow.value = bytesDownloaded
}
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
progress = if (size > 0) {
(bytesRead.toFloat() / size.toFloat()).coerceIn(0f, 1f)
} else {
0.0f
}
bytesDownloaded = bytesRead
this.bytesRead = bytesRead
}
}
@Dao
interface NPSPackageDownloadDao {
@Query("SELECT * FROM download ORDER BY createdAt ASC")
fun getAll(): Flow<List<NPSPackageDownload>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(download: NPSPackageDownload)
@Update(onConflict = OnConflictStrategy.REPLACE)
suspend fun update(download: NPSPackageDownload)
@Query("DELETE FROM download WHERE url = :url")
suspend fun delete(url: String)
@Query("DELETE FROM download")
suspend fun deleteAll()
}
@Database(entities = [NPSPackageDownload::class], version = 2)
abstract class DownloadStore : RoomDatabase() {
abstract fun downloadDao(): NPSPackageDownloadDao
companion object {
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE $DOWNLOAD_TABLE_NAME ADD COLUMN statusValue INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE $DOWNLOAD_TABLE_NAME ADD COLUMN savedFileName TEXT")
}
}
@Volatile
private var INSTANCE: DownloadStore? = null
fun getDatabase(context: Context): DownloadStore {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
DownloadStore::class.java,
DOWNLOAD_STORE_NAME
)
.addMigrations(MIGRATION_1_2)
.build()
INSTANCE = instance
instance
}
}
}
}
@@ -0,0 +1,466 @@
package com.illusionware.npsbrowser.data.download
import android.content.Context
import com.illusionware.npsbrowser.data.defaultDownloadDir
import com.illusionware.npsbrowser.data.network.GET
import com.illusionware.npsbrowser.data.network.NetworkHelper
import com.illusionware.npsbrowser.data.network.ProgressListener
import com.illusionware.npsbrowser.data.network.awaitSuccess
import com.illusionware.npsbrowser.data.network.newCachelessCallWithProgress
import com.illusionware.npsbrowser.util.system.saveTo
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.Headers
import okio.appendingSink
import okio.buffer
import java.io.File
class Downloader(val context: Context, private val downloadManager: DownloadManager) {
private val invalidFilenameChars = Regex("[\\\\/:*?\"<>|]")
private val whitespaceRegex = Regex("\\s+")
private val store = DownloadStore.getDatabase(context)
private val networkHelper = NetworkHelper(context)
private val dao = store.downloadDao()
private val _queueState = MutableStateFlow<List<NPSPackageDownload>>(emptyList())
val queueState = _queueState.asStateFlow()
private val queueMutex = Mutex()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var workerJob: Job? = null
private var currentDownloadJob: Job? = null
@Volatile
private var currentDownloadUrl: String? = null
private val notifier by lazy { DownloadNotifier(context) }
val isRunning: Boolean
get() = workerJob?.isActive ?: false
@Volatile
var isPaused: Boolean = false
init {
scope.launch {
val storeItems = dao.getAll().first()
addAllToQueue(storeItems)
}
}
fun shouldConfirmOverwrite(url: String): Boolean {
val existing = queueState.value.find { it.url == url } ?: return false
return existing.status == NPSPackageDownload.State.DOWNLOADED && downloadedFileFor(existing).exists()
}
fun enqueue(download: NPSPackageDownload, autoStart: Boolean, overwriteExisting: Boolean) {
scope.launch {
val shouldInsert = queueMutex.withLock {
val queue = _queueState.value.toMutableList()
val existing = queue.find { it.url == download.url }
if (existing != null && existing.status == NPSPackageDownload.State.DOWNLOADED && downloadedFileFor(existing).exists()) {
if (!overwriteExisting) {
_queueState.value = queue
return@withLock false
}
downloadedFileFor(existing).delete()
}
queue.removeAll { it.url == download.url }
download.savedFileName = null
download.bytesDownloaded = 0L
download.status = NPSPackageDownload.State.QUEUE
download.restoreTransientState()
queue.add(download)
_queueState.value = queue
true
}
if (!shouldInsert) {
return@launch
}
dao.insert(download)
if (autoStart) {
isPaused = false
launchWorkerIfNeeded()
if (!DownloadService.isRunning(context)) {
DownloadService.start(context, downloadManager)
}
}
}
}
fun start(): Boolean {
if (isRunning) return true
val hasPending = queueState.value.any {
it.status == NPSPackageDownload.State.QUEUE ||
it.status == NPSPackageDownload.State.PAUSED ||
it.status == NPSPackageDownload.State.ERROR
}
if (!hasPending) return false
scope.launch {
queueMutex.withLock {
val queue = _queueState.value.toMutableList()
queue.forEach { download ->
if (download.status == NPSPackageDownload.State.PAUSED || download.status == NPSPackageDownload.State.ERROR) {
download.status = NPSPackageDownload.State.QUEUE
}
}
_queueState.value = queue
}
isPaused = false
launchWorkerIfNeeded()
}
return true
}
fun pauseAll() {
scope.launch {
queueMutex.withLock {
val queue = _queueState.value.toMutableList()
queue.forEach { download ->
if (download.status == NPSPackageDownload.State.DOWNLOADING || download.status == NPSPackageDownload.State.QUEUE) {
download.status = NPSPackageDownload.State.PAUSED
}
}
_queueState.value = queue
}
currentDownloadJob?.cancel()
workerJob?.cancel()
currentDownloadJob = null
workerJob = null
isPaused = true
notifier.onPaused()
}
}
fun stop(reason: String? = null) {
scope.launch {
queueMutex.withLock {
val queue = _queueState.value.toMutableList()
queue.forEach { download ->
if (download.status == NPSPackageDownload.State.DOWNLOADING || download.status == NPSPackageDownload.State.QUEUE) {
download.status = NPSPackageDownload.State.PAUSED
}
}
_queueState.value = queue
}
currentDownloadJob?.cancel()
workerJob?.cancel()
currentDownloadJob = null
workerJob = null
isPaused = queueState.value.isNotEmpty()
if (reason != null) {
notifier.onPaused(reason)
} else if (isPaused) {
notifier.onPaused()
} else {
notifier.onComplete()
}
if (reason == null && DownloadService.isRunning.value) {
DownloadService.stop(context)
}
}
}
fun pause(url: String) {
scope.launch {
var pauseCurrent = false
queueMutex.withLock {
val queue = _queueState.value.toMutableList()
queue.forEach { download ->
if (download.url == url) {
if (download.status == NPSPackageDownload.State.DOWNLOADING || download.status == NPSPackageDownload.State.QUEUE) {
download.status = NPSPackageDownload.State.PAUSED
}
pauseCurrent = currentDownloadUrl == url
}
}
_queueState.value = queue
}
if (pauseCurrent) {
currentDownloadJob?.cancel()
}
if (areAllPaused()) {
workerJob?.cancel()
workerJob = null
isPaused = true
notifier.onPaused()
if (DownloadService.isRunning.value) {
DownloadService.stop(context)
}
}
}
}
fun resume(url: String) {
scope.launch {
if (queueState.value.isEmpty()) return@launch
queueMutex.withLock {
val queue = _queueState.value.toMutableList()
val index = queue.indexOfFirst { it.url == url }
if (index == -1) return@withLock
val target = queue.removeAt(index)
if (target.status == NPSPackageDownload.State.DOWNLOADED && downloadedFileFor(target).exists()) {
queue.add(index, target)
_queueState.value = queue
return@withLock
}
target.status = NPSPackageDownload.State.QUEUE
// Prioritize explicit resume action so the tapped item starts next.
queue.add(0, target)
_queueState.value = queue
}
isPaused = false
launchWorkerIfNeeded()
if (!DownloadService.isRunning(context)) {
DownloadService.start(context, downloadManager)
}
}
}
private fun launchWorkerIfNeeded() {
if (workerJob?.isActive == true) return
workerJob = scope.launch {
while (true) {
val nextDownload = queueMutex.withLock {
_queueState.value.firstOrNull { it.status == NPSPackageDownload.State.QUEUE }
} ?: break
currentDownloadUrl = nextDownload.url
val job = scope.launch {
processDownload(nextDownload)
}
currentDownloadJob = job
job.join()
currentDownloadJob = null
currentDownloadUrl = null
}
if (queueState.value.none { it.status == NPSPackageDownload.State.QUEUE || it.status == NPSPackageDownload.State.DOWNLOADING }) {
notifier.onComplete()
isPaused = queueState.value.isNotEmpty() && queueState.value.all { it.status == NPSPackageDownload.State.PAUSED }
if (DownloadService.isRunning.value) {
DownloadService.stop(context)
}
}
workerJob = null
}
}
private suspend fun processDownload(download: NPSPackageDownload) {
try {
queueMutex.withLock {
download.status = NPSPackageDownload.State.DOWNLOADING
_queueState.value = _queueState.value.toList()
}
coroutineScope {
downloadPackage(download, this)
}
queueMutex.withLock {
download.bytesDownloaded = download.size
download.restoreTransientState()
download.status = NPSPackageDownload.State.DOWNLOADED
_queueState.value = _queueState.value.toList()
}
dao.update(download)
} catch (_: CancellationException) {
dao.update(download)
} catch (error: Throwable) {
queueMutex.withLock {
download.status = NPSPackageDownload.State.ERROR
_queueState.value = _queueState.value.toList()
}
dao.update(download)
notifier.onError(error.message ?: "Download failed")
}
}
private suspend fun downloadPackage(download: NPSPackageDownload, scope: CoroutineScope) {
val file = prepareTargetFile(download)
val resumedBytes = if (file.exists()) file.length() else 0L
if (download.bytesDownloaded != resumedBytes) {
download.bytesDownloaded = resumedBytes
download.restoreTransientState()
dao.update(download)
}
val startBytes = download.bytesDownloaded
var lastPersist = 0L
val listener = object : ProgressListener {
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
download.update(bytesRead, contentLength, done)
val totalSize = if (contentLength > 0) {
contentLength + startBytes
} else {
download.size
}
notifier.onProgressChange(totalSize, bytesRead)
val now = System.currentTimeMillis()
if (done || now - lastPersist >= 1000L) {
lastPersist = now
this@Downloader.scope.launch {
dao.update(download)
}
}
}
}
val res = networkHelper.client.newCachelessCallWithProgress(
GET(download.url, Headers.headersOf("Range", "bytes=${download.bytesDownloaded}-")),
listener,
download.bytesDownloaded
).awaitSuccess()
file.parentFile?.mkdirs()
val fileBufferedSink = file.appendingSink().buffer()
try {
res.body!!.source().saveTo(
fileBufferedSink,
res.body!!.contentLength(),
download.bytesDownloaded,
scope
)
dao.update(download)
} catch (e: Exception) {
if (e is CancellationException) {
throw e
}
throw e
} finally {
withContext(Dispatchers.IO) {
fileBufferedSink.close()
}
res.close()
}
}
private fun areAllPaused(): Boolean {
val unfinished = queueState.value.filter { it.status != NPSPackageDownload.State.DOWNLOADED }
return unfinished.isNotEmpty() && unfinished.all { it.status == NPSPackageDownload.State.PAUSED }
}
private fun addAllToQueue(downloads: List<NPSPackageDownload>) {
_queueState.update {
downloads.forEach { download ->
download.restoreTransientState()
when (download.status) {
NPSPackageDownload.State.DOWNLOADING,
NPSPackageDownload.State.QUEUE,
-> download.status = NPSPackageDownload.State.PAUSED
NPSPackageDownload.State.DOWNLOADED -> {
if (!downloadedFileFor(download).exists()) {
download.savedFileName = null
download.bytesDownloaded = 0L
download.restoreTransientState()
download.status = NPSPackageDownload.State.ERROR
}
}
else -> Unit
}
}
downloads
}
}
fun removeDownload(url: String) {
scope.launch {
val removed = queueMutex.withLock {
val queue = _queueState.value.toMutableList()
val item = queue.find { it.url == url } ?: return@withLock null
queue.remove(item)
_queueState.value = queue
item
} ?: return@launch
if (currentDownloadUrl == removed.url) {
currentDownloadJob?.cancel()
}
dao.delete(url)
val file = downloadedFileFor(removed)
file.delete()
if (queueState.value.isEmpty()) {
workerJob?.cancel()
workerJob = null
currentDownloadJob = null
notifier.onComplete()
if (DownloadService.isRunning.value) {
DownloadService.stop(context)
}
}
}
}
private fun prepareTargetFile(download: NPSPackageDownload): File {
val existingFileName = download.savedFileName
if (existingFileName != null) {
return File(defaultDownloadDir, existingFileName)
}
val candidate = File(defaultDownloadDir, download.defaultFileName())
download.savedFileName = candidate.name
return candidate
}
private fun downloadedFileFor(download: NPSPackageDownload): File {
return File(defaultDownloadDir, download.savedFileName ?: download.defaultFileName())
}
private fun NPSPackageDownload.defaultFileName(): String {
val normalizedName = name
.trim()
.replace(invalidFilenameChars, "_")
.replace(whitespaceRegex, " ")
.replace(Regex("\\.pkg$", RegexOption.IGNORE_CASE), "")
.trimEnd('.', ' ')
.ifBlank { titleId }
return "[$titleId] $normalizedName.pkg"
}
}
@@ -0,0 +1,25 @@
package com.illusionware.npsbrowser.data.network
import android.content.Context
import okhttp3.Cache
import okhttp3.OkHttpClient
import java.io.File
import java.util.concurrent.TimeUnit
class NetworkHelper(context: Context) {
private val cacheDir = File(context.cacheDir, "network_cache")
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
private val baseClientBuilder: OkHttpClient.Builder
get() {
val builder = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.callTimeout(2, TimeUnit.MINUTES)
return builder
}
val client by lazy { baseClientBuilder.cache(Cache(cacheDir, cacheSize)).build() }
}
@@ -0,0 +1,131 @@
package com.illusionware.npsbrowser.data.network
import android.util.Log
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import rx.Producer
import rx.Subscription
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resumeWithException
fun Call.asObservable(): Observable<Response> {
return Observable.unsafeCreate { subscriber ->
// Since Call is a one-shot type, clone it for each new subscriber.
val call = clone()
// Wrap the call in a helper which handles both unsubscription and backpressure.
val requestArbiter = object : AtomicBoolean(), Producer, Subscription {
override fun request(n: Long) {
if (n == 0L || !compareAndSet(false, true)) return
try {
val response = call.execute()
if (!subscriber.isUnsubscribed) {
subscriber.onNext(response)
subscriber.onCompleted()
}
} catch (e: Exception) {
if (!subscriber.isUnsubscribed) {
subscriber.onError(e)
}
}
}
override fun unsubscribe() {
call.cancel()
}
override fun isUnsubscribed(): Boolean {
return call.isCanceled()
}
}
subscriber.add(requestArbiter)
subscriber.setProducer(requestArbiter)
}
}
fun Call.asObservableSuccess(): Observable<Response> {
return asObservable().doOnNext { response ->
if (!response.isSuccessful) {
response.close()
throw HttpException(response.code)
}
}
}
// Based on https://github.com/gildor/kotlin-coroutines-okhttp
@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun Call.await(callStack: Array<StackTraceElement>): Response {
Log.d("DOWNLOAD", "inside the await we want")
return suspendCancellableCoroutine { continuation ->
val callback =
object : Callback {
override fun onResponse(call: Call, response: Response) {
continuation.resume(response) {
response.body?.close()
}
}
override fun onFailure(call: Call, e: IOException) {
// Don't bother with resuming the continuation if it is already cancelled.
if (continuation.isCancelled) return
val exception = IOException(e.message, e).apply { stackTrace = callStack }
continuation.resumeWithException(exception)
}
}
enqueue(callback)
continuation.invokeOnCancellation {
try {
Log.d("DOWNLOAD", "request is being cancelled")
cancel()
} catch (ex: Throwable) {
// Ignore cancel exception
}
}
}
}
suspend fun Call.await(): Response {
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
return await(callStack)
}
suspend fun Call.awaitSuccess(): Response {
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
val response = await(callStack)
if (!response.isSuccessful) {
response.close()
throw HttpException(response.code).apply { stackTrace = callStack }
}
return response
}
fun OkHttpClient.newCachelessCallWithProgress(
request: Request,
listener: ProgressListener,
bytesRead: Long
): Call {
val progressClient = newBuilder()
.cache(null)
.addNetworkInterceptor { chain ->
val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder()
.body(ProgressResponseBody(originalResponse.body!!, listener, bytesRead))
.build()
}
.build()
return progressClient.newCall(request)
}
class HttpException(private val code: Int) : IllegalStateException("HTTP error $code")
@@ -0,0 +1,5 @@
package com.illusionware.npsbrowser.data.network
interface ProgressListener {
fun update(bytesRead: Long, contentLength: Long, done: Boolean)
}
@@ -0,0 +1,48 @@
package com.illusionware.npsbrowser.data.network
import okhttp3.MediaType
import okhttp3.ResponseBody
import okio.Buffer
import okio.BufferedSource
import okio.ForwardingSource
import okio.Source
import okio.buffer
import java.io.IOException
class ProgressResponseBody(
private val responseBody: ResponseBody,
private val progressListener: ProgressListener,
private val bytesRead: Long
) : ResponseBody() {
private val bufferedSource: BufferedSource by lazy {
source(responseBody.source()).buffer()
}
override fun contentType(): MediaType? {
return responseBody.contentType()
}
override fun contentLength(): Long {
return responseBody.contentLength()
}
override fun source(): BufferedSource {
return bufferedSource
}
private fun source(source: Source): Source {
return object : ForwardingSource(source) {
var totalBytesRead = bytesRead
@Throws(IOException::class)
override fun read(sink: Buffer, byteCount: Long): Long {
val bytesRead = super.read(sink, byteCount)
// read() returns the number of bytes read, or -1 if this source is exhausted.
totalBytesRead += if (bytesRead != -1L) bytesRead else 0
progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
return bytesRead
}
}
}
}
@@ -0,0 +1,31 @@
package com.illusionware.npsbrowser.data.network
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import java.util.concurrent.TimeUnit
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, TimeUnit.MINUTES).build()
private val DEFAULT_HEADERS = Headers.Builder().build()
fun GET(
url: String,
headers: Headers = DEFAULT_HEADERS,
cache: CacheControl = DEFAULT_CACHE_CONTROL,
): Request {
return GET(url.toHttpUrl(), headers, cache)
}
fun GET(
url: HttpUrl,
headers: Headers = DEFAULT_HEADERS,
cache: CacheControl = DEFAULT_CACHE_CONTROL,
): Request {
return Request.Builder()
.url(url)
.headers(headers)
.cacheControl(cache)
.build()
}
@@ -0,0 +1,32 @@
package com.illusionware.npsbrowser.data.notification
import android.content.Context
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationChannelGroupCompat
import androidx.core.app.NotificationManagerCompat
object Notifications {
private const val GROUP_DOWNLOADER = "group_downloader"
const val CHANNEL_DOWNLOADER = "channel_downloader"
const val ID_DOWNLOADER_GROUP = 1
const val ID_DOWNLOADER_ERROR = 2
fun initialize(context: Context) {
val notificationManager = NotificationManagerCompat.from(context)
notificationManager.createNotificationChannelGroup(
NotificationChannelGroupCompat.Builder(GROUP_DOWNLOADER)
.setName("Download")
.build()
)
notificationManager.createNotificationChannel(
NotificationChannelCompat.Builder(CHANNEL_DOWNLOADER, NotificationManagerCompat.IMPORTANCE_LOW)
.setName("Progress")
.setDescription("Download progress and downloader status")
.setShowBadge(false)
.setGroup(GROUP_DOWNLOADER)
.build()
)
}
}
@@ -8,6 +8,7 @@ data class PackageItem(
val contentId: String?,
val modificationDate: String?,
val pkgSize: String,
val pkgSizeInBytes: ULong?,
val sha256: String?,
val zRif: String?,
val rap: String?,
@@ -2,14 +2,13 @@ package com.illusionware.npsbrowser.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -28,7 +27,7 @@ fun NPSAlertDialog(
buttons: @Composable () -> Unit,
content: @Composable () -> Unit
) {
AlertDialog(
BasicAlertDialog(
onDismissRequest = onDismiss,
) {
Surface (
@@ -1,20 +1,77 @@
package com.illusionware.npsbrowser.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.PlainTooltipBox
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layoutId
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NPSIconButton(tooltip: String, onClick: () -> Unit, content: @Composable () -> Unit) {
PlainTooltipBox(tooltip = {
Text(text = tooltip)
}) {
IconButton(onClick = onClick, Modifier.tooltipAnchor()) {
content()
fun NPSIconButton(
tooltip: String,
onClick: () -> Unit,
badge: (@Composable BoxScope.() -> Unit)? = null,
content: @Composable () -> Unit
) {
Layout(
content = {
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
Text(text = tooltip)
},
state = rememberTooltipState(),
) {
IconButton(
onClick = onClick,
) {
content()
}
}
Box(Modifier.layoutId("badge")) {
badge?.let { it() }
}
}
) { measurables, constraints ->
val iconPlaceable = measurables[0].measure(constraints)
val badgePlaceable = measurables.firstOrNull { it.layoutId == "badge" }?.measure(constraints)
layout(iconPlaceable.width, iconPlaceable.height) {
iconPlaceable.placeRelative(0, 0)
badgePlaceable?.placeRelative(
x = iconPlaceable.width - badgePlaceable.width - 16,
y = 16,
)
}
}
// PlainTooltipBox(tooltip = {
// Text(text = tooltip)
// }) {
// if (badge != null) {
// IconButton(
// onClick = onClick,
// Modifier.tooltipAnchor(),
// ) {
// BadgedBox(badge = badge) {
// content()
// }
// }
// } else {
// IconButton(
// onClick = onClick,
// Modifier.tooltipAnchor(),
// ) {
// content()
// }
// }
// }
}
@@ -0,0 +1,269 @@
package com.illusionware.npsbrowser.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material3.Badge
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.illusionware.npsbrowser.data.download.NPSPackageDownload
import com.illusionware.npsbrowser.ui.components.NPSIconButton
import com.illusionware.npsbrowser.ui.theme.Typography
import com.illusionware.npsbrowser.util.system.prettyByte
import com.illusionware.npsbrowser.viewmodels.PackageDetailsViewModel
@Composable
fun Downloads(navigationGoBack: () -> Unit, packageDetailsViewModel: PackageDetailsViewModel) {
val queue = packageDetailsViewModel.downloadManager.queueState.collectAsStateWithLifecycle().value
packageDetailsViewModel.downloadManager.statusFlow().collectAsStateWithLifecycle(initialValue = null)
val hasActiveDownloads = queue.any {
it.status == NPSPackageDownload.State.DOWNLOADING ||
it.status == NPSPackageDownload.State.QUEUE
}
val hasResumableDownloads = queue.any {
it.status == NPSPackageDownload.State.PAUSED ||
it.status == NPSPackageDownload.State.ERROR
}
Scaffold(
topBar = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.statusBarsPadding()
.padding(vertical = 8.dp, horizontal = 4.dp),
) {
NPSIconButton(tooltip = "Go Back", onClick = { navigationGoBack() }) {
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Go Back" )
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(text = "Downloads", style = Typography.titleLarge)
if (queue.isNotEmpty()) {
Badge(containerColor = MaterialTheme.colorScheme.outlineVariant) {
Text(
text = queue.size.toString(),
style = Typography.titleSmall,
)
}
}
}
}
},
floatingActionButton = {
if (hasActiveDownloads) {
ExtendedFloatingActionButton(
text = { Text(text = "Pause All") },
icon = { Icon(imageVector = Icons.Filled.Pause, contentDescription = "Pause")},
onClick = {
packageDetailsViewModel.downloadManager.pauseDownloads()
}
)
} else if (hasResumableDownloads) {
ExtendedFloatingActionButton(
text = { Text(text = "Resume") },
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = "Resume")},
onClick = {
packageDetailsViewModel.downloadManager.startDownloads()
}
)
}
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.background(MaterialTheme.colorScheme.surface),
) {
LazyColumn(Modifier.background(MaterialTheme.colorScheme.surface)) {
itemsIndexed(
queue,
key = { _, item -> item.url },
) { _, item ->
DownloadItem(
item,
packageDetailsViewModel::removeDownload,
packageDetailsViewModel::pauseDownload,
packageDetailsViewModel::resumeDownload
)
}
}
}
}
}
@Composable
fun DownloadItem(
item: NPSPackageDownload,
removeDownload: (String) -> Unit,
pauseDownload: (String) -> Unit,
resumeDownload: (String) -> Unit,
) {
val progress = item.progressFlow.collectAsStateWithLifecycle().value
val bytesDownloaded = item.bytesDownloadedFlow.collectAsStateWithLifecycle().value
val status = item.statusFlow.collectAsStateWithLifecycle().value
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Row(
modifier = Modifier
.clip(shape = RoundedCornerShape(12.dp))
.clickable {
if (status == NPSPackageDownload.State.DOWNLOADING || status == NPSPackageDownload.State.QUEUE) {
pauseDownload(item.url)
} else if (status != NPSPackageDownload.State.DOWNLOADED) {
resumeDownload(item.url)
}
}
.size(48.dp)
.aspectRatio(1f)
.semantics {
role = Role.Button
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = when (status) {
NPSPackageDownload.State.DOWNLOADING, NPSPackageDownload.State.QUEUE -> Icons.Filled.Pause
NPSPackageDownload.State.DOWNLOADED -> Icons.Filled.DownloadDone
NPSPackageDownload.State.ERROR -> Icons.Filled.RestartAlt
else -> Icons.Filled.PlayArrow
},
contentDescription = when (status) {
NPSPackageDownload.State.DOWNLOADING, NPSPackageDownload.State.QUEUE -> "Pause"
NPSPackageDownload.State.DOWNLOADED -> "Downloaded"
NPSPackageDownload.State.ERROR -> "Retry"
else -> "Resume"
},
modifier = Modifier.size(32.dp)
)
}
Column(
modifier = Modifier
.padding(end = 4.dp)
.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = item.name,
style = Typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(text = "${bytesDownloaded.prettyByte()} / ${item.size.prettyByte()}", style = Typography.bodySmall, color = MaterialTheme.colorScheme.outline)
}
when (status) {
NPSPackageDownload.State.DOWNLOADING -> {
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.height(2.dp),
strokeCap = StrokeCap.Round,
)
}
NPSPackageDownload.State.QUEUE -> {
Text("Pending", style = Typography.bodySmall, color = MaterialTheme.colorScheme.outline)
}
NPSPackageDownload.State.ERROR -> {
Text("Failed", style = Typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
NPSPackageDownload.State.DOWNLOADED -> {
Text("Downloaded", style = Typography.bodySmall, color = MaterialTheme.colorScheme.primary)
}
else -> {
Text("Paused", style = Typography.bodySmall, color = MaterialTheme.colorScheme.outline)
}
}
}
Row(
modifier = Modifier
.clip(shape = RoundedCornerShape(12.dp))
.clickable { removeDownload(item.url) }
.size(48.dp)
.aspectRatio(1f)
.semantics {
role = Role.Button
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Filled.Clear,
contentDescription = "Remove",
)
}
}
}
@Composable
@Preview
fun DownloadPreview() {
val packageDetailsViewModel: PackageDetailsViewModel = viewModel(
factory = PackageDetailsViewModel.Factory
)
Downloads({}, packageDetailsViewModel)
}
@Composable
@Preview(showBackground = true)
fun DownloadItemPreview() {
DownloadItem(
item = NPSPackageDownload(
name = "Test",
url = "https://google.com",
size = 385273850L,
createdAt = 0L,
sha256 = "",
titleId = "BRUH00001",
bytesDownloaded = 0L
),
removeDownload = {},
pauseDownload = {},
resumeDownload = {},
)
}
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ButtonDefaults
@@ -45,14 +46,15 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.illusionware.npsbrowser.R
import com.illusionware.npsbrowser.ui.theme.ColorAccent
import com.illusionware.npsbrowser.ui.theme.Typography
@@ -62,6 +64,7 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
@@ -85,6 +88,7 @@ import com.illusionware.npsbrowser.model.ConsoleType
import com.illusionware.npsbrowser.model.PackageItemType
import com.illusionware.npsbrowser.viewmodels.OnboardingViewModel
import com.illusionware.npsbrowser.viewmodels.SettingsViewModel
import androidx.core.view.WindowCompat
import kotlinx.coroutines.launch
const val PAGE_COUNT = 3
@@ -111,13 +115,15 @@ fun OnBoarding(
factory = SettingsViewModel.Factory
),
) {
val systemUiController = rememberSystemUiController()
val pagerState = rememberPagerState(
initialPage = 0,
initialPageOffsetFraction = 0f
)
) {
PAGE_COUNT
}
val scope = rememberCoroutineScope()
val context = LocalContext.current
val view = LocalView.current
val settingsPrefs = settingsViewModel.uiState.collectAsStateWithLifecycle().value
val darkIcons = when (pagerState.currentPage) {
@@ -125,23 +131,22 @@ fun OnBoarding(
else -> true
}
DisposableEffect(systemUiController) {
systemUiController.setStatusBarColor(
color = Color.Transparent,
darkIcons = darkIcons && !darkTheme
)
DisposableEffect(view, darkIcons, darkTheme) {
val activity = context as Activity
val window = activity.window
val insetsController = WindowCompat.getInsetsController(window, view)
window.statusBarColor = Color.Transparent.toArgb()
insetsController.isAppearanceLightStatusBars = darkIcons && !darkTheme
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
onDispose {
systemUiController.setStatusBarColor(
color = Color.Transparent,
darkIcons = !darkTheme
)
insetsController.isAppearanceLightStatusBars = !darkTheme
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
HorizontalPager(pageCount = PAGE_COUNT, state = pagerState) { page ->
HorizontalPager(state = pagerState) { page ->
when (page) {
0 -> OnBoardingInitial(
incrementPage = {
@@ -782,7 +787,19 @@ fun Toggleable(title: String, checked: Boolean, onCheckedChange: (Boolean) -> Un
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = title, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1.0f))
Switch(checked = checked, onCheckedChange = null)
Switch(
checked = checked,
onCheckedChange = null,
thumbContent = if (checked) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
} else null
)
}
}
}
@@ -1,5 +1,7 @@
package com.illusionware.npsbrowser.ui.screens
import android.annotation.SuppressLint
import android.content.ClipData
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -20,7 +22,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.filled.Public
@@ -50,13 +52,14 @@ import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -65,9 +68,8 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.illusionware.npsbrowser.R
import com.illusionware.npsbrowser.PlaceholderColor
import com.illusionware.npsbrowser.data.ItemLayout
import com.illusionware.npsbrowser.data.SettingsPreferences
import com.illusionware.npsbrowser.data.Theme
import com.illusionware.npsbrowser.data.download.NPSPackageDownload
import com.illusionware.npsbrowser.ui.components.NPSAlertDialog
import com.illusionware.npsbrowser.ui.components.NPSFloatingActionButton
import com.illusionware.npsbrowser.ui.components.NPSIconButton
@@ -77,6 +79,7 @@ import com.illusionware.npsbrowser.viewmodels.PackageDetailsViewModel
import com.illusionware.npsbrowser.viewmodels.SettingsViewModel
import kotlinx.coroutines.launch
@SuppressLint("MissingPermission")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PackageDetails(
@@ -86,7 +89,7 @@ fun PackageDetails(
),
packageDetailsViewModel: PackageDetailsViewModel,
) {
val clipboard = LocalClipboardManager.current
val clipboard = LocalClipboard.current
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
@@ -96,6 +99,29 @@ fun PackageDetails(
val item = packageDetailsViewModel.uiState.collectAsStateWithLifecycle().value.item!!
var showBottomSheet by remember { mutableStateOf(false) }
var pendingOverwriteDownload by remember { mutableStateOf<NPSPackageDownload?>(null) }
fun buildDownloadRequest(): NPSPackageDownload {
return NPSPackageDownload(
item.pkgUrl!!,
item.titleId,
item.name,
item.sha256,
item.pkgSizeInBytes?.toLong() ?: 0,
0L,
System.currentTimeMillis()
)
}
fun requestDownload(overwriteExisting: Boolean = false) {
val downloadRequest = buildDownloadRequest()
if (!overwriteExisting && packageDetailsViewModel.downloadManager.shouldConfirmOverwrite(downloadRequest.url)) {
pendingOverwriteDownload = downloadRequest
return
}
packageDetailsViewModel.downloadManager.download(downloadRequest, overwriteExisting = overwriteExisting)
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState)},
@@ -107,7 +133,7 @@ fun PackageDetails(
.padding(vertical = 8.dp, horizontal = 4.dp),
) {
NPSIconButton(tooltip = "Go Back", onClick = navigationGoBack) {
Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = "Go Back" )
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Go Back" )
}
}
},
@@ -116,9 +142,13 @@ fun PackageDetails(
NPSFloatingActionButton(
onClick = {
if (!item.zRif.isNullOrEmpty()) {
clipboard.setText(AnnotatedString(item.zRif))
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(item.zRif, item.zRif)))
}
} else if (!item.rap.isNullOrEmpty()) {
clipboard.setText(AnnotatedString(item.rap))
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(item.rap, item.rap)))
}
}
scope.launch {
snackbarHostState.showSnackbar("License key copied to clipboard")
@@ -132,7 +162,9 @@ fun PackageDetails(
)
}
NPSFloatingActionButton(
onClick = { /* TODO: */ },
onClick = {
requestDownload()
},
onLongClick = { showBottomSheet = true },
enabled = !item.pkgUrl.isNullOrEmpty()
) {
@@ -209,8 +241,30 @@ fun PackageDetails(
},
sheetState = sheetState,
prefs = prefs,
packageDetailsViewModel = packageDetailsViewModel,
onDownload = { requestDownload() }
)
}
pendingOverwriteDownload?.let { downloadRequest ->
NPSAlertDialog(
onDismiss = { pendingOverwriteDownload = null },
title = "Overwrite existing package?",
buttons = {
TextButton(onClick = { pendingOverwriteDownload = null }) {
Text(text = "Keep Existing")
}
TextButton(onClick = {
pendingOverwriteDownload = null
packageDetailsViewModel.downloadManager.download(downloadRequest, overwriteExisting = true)
}) {
Text(text = "Overwrite")
}
}
) {
Text("This package is already downloaded. Choose whether to keep the existing file or overwrite it.")
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -218,14 +272,16 @@ fun PackageDetails(
fun OneTimeConfigBottomSheet(
sheetState: SheetState,
onDismiss: () -> Unit,
prefs: SettingsPreferences
prefs: SettingsPreferences,
packageDetailsViewModel: PackageDetailsViewModel,
onDownload: () -> Unit,
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
windowInsets = WindowInsets(top = 0.dp),
//windowInsets = WindowInsets(top = 0.dp),
) {
OneTimeConfigSheetContent(prefs)
OneTimeConfigSheetContent(prefs, packageDetailsViewModel, onDownload)
}
}
@@ -290,9 +346,12 @@ fun Option(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OneTimeConfigSheetContent(prefs: SettingsPreferences) {
fun OneTimeConfigSheetContent(
prefs: SettingsPreferences,
packageDetailsViewModel: PackageDetailsViewModel,
onDownload: () -> Unit,
) {
var autoDecrypt by remember { mutableStateOf(prefs.pkg2zipAutoDecrypt) }
var unpackInPlace by remember { mutableStateOf(prefs.unpackInDownload) }
var deleteAfter by remember { mutableStateOf(prefs.deleteAfterUnpack) }
@@ -318,6 +377,8 @@ fun OneTimeConfigSheetContent(prefs: SettingsPreferences) {
}
}
val item = packageDetailsViewModel.uiState.collectAsStateWithLifecycle().value.item!!
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -385,7 +446,9 @@ fun OneTimeConfigSheetContent(prefs: SettingsPreferences) {
}
}
Button(
onClick = { /*TODO: do the download*/ },
onClick = {
onDownload()
},
modifier = Modifier.padding(horizontal = 24.dp),
) {
Icon(
@@ -425,31 +488,3 @@ fun OneTimeConfigSheetContent(prefs: SettingsPreferences) {
}
}
}
@Composable
@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
fun BottomSheetPreview() {
val prefs = SettingsPreferences(
Theme.SYSTEM.ordinal,
ItemLayout.LIST.ordinal,
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
false,
false,
"",
true,
)
Column {
OneTimeConfigSheetContent(prefs = prefs)
}
}
@@ -23,9 +23,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.FileDownload
import androidx.compose.material.icons.outlined.Palette
@@ -46,7 +45,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
@@ -70,6 +68,7 @@ import com.illusionware.npsbrowser.data.Theme
import com.illusionware.npsbrowser.model.ConsoleType
import com.illusionware.npsbrowser.model.PackageItemType
// TODO: localize this
private val Themes = hashMapOf(
Theme.LIGHT.ordinal to "Light",
Theme.DARK.ordinal to "Dark",
@@ -96,7 +95,7 @@ fun SettingsScreen(
.padding(vertical = 8.dp, horizontal = 4.dp),
) {
NPSIconButton(tooltip = "Go Back", onClick = { navigationGoBack() }) {
Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = "Go Back" )
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Go Back" )
}
Text(text = stringResource(id = R.string.title_activity_settings), style = Typography.titleLarge)
}
@@ -19,6 +19,7 @@ val ColorOnSecondaryDark = Color(0xDFFFFFFF)
val ColorAmoledDark = Color(0xFF000000)
// Works on both Dark and Light
val ColorTag = Color(0xFFEAF654)
val ColorAccent = Color(0xFF545FBF)
val ColorError = Color(0xFFF93422)
@@ -15,7 +15,6 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import com.google.accompanist.systemuicontroller.rememberSystemUiController
private val LightColorScheme = lightColorScheme(
primary = md_theme_light_primary,
@@ -88,7 +87,6 @@ fun NPSBrowserTheme(
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val systemUiController = rememberSystemUiController()
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
@@ -104,13 +102,10 @@ fun NPSBrowserTheme(
val window = (view.context as Activity).window
window.statusBarColor = Color.Transparent.toArgb()
window.navigationBarColor = Color.Transparent.toArgb()
// allows pages to extend to the status bar
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
systemUiController.setSystemBarsColor(
color = Color.Transparent,
darkIcons = !darkTheme
)
val insetsController = WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !darkTheme
insetsController.isAppearanceLightNavigationBars = !darkTheme
}
}
@@ -1,10 +0,0 @@
package com.illusionware.npsbrowser.util
import android.content.Context
import android.os.Build
// Borrowed from https://github.com/tachiyomiorg/tachiyomi/tree/master
fun Context.isNavigationBarNeedsScrim(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ||
InternalResourceHelper.getBoolean(this, "config_navBarNeedsScrim", true)
}
@@ -0,0 +1,29 @@
package com.illusionware.npsbrowser.util.system
import android.app.ActivityManager
import android.content.Context
import android.os.PowerManager
import android.widget.Toast
import androidx.core.content.getSystemService
fun Context.isServiceRunning(serviceClass: Class<*>): Boolean {
val className = serviceClass.name
val manager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
return manager.getRunningServices(Integer.MAX_VALUE).any { className == it.service.className }
}
val Context.powerManager: PowerManager
get() = getSystemService()!!
fun Context.acquireWakeLock(tag: String): PowerManager.WakeLock {
val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag:WakeLock")
wakeLock.acquire()
return wakeLock
}
fun Context.toast(text: String?, duration: Int = Toast.LENGTH_SHORT, block: (Toast) -> Unit = {}): Toast {
return Toast.makeText(applicationContext, text.orEmpty(), duration).also {
block(it)
it.show()
}
}
@@ -0,0 +1,7 @@
package com.illusionware.npsbrowser.util.system
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend fun <T> withUIContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)
@@ -0,0 +1,32 @@
package com.illusionware.npsbrowser.util.system
import android.content.Context
import android.os.Build
import java.util.Locale.ENGLISH
// Borrowed from https://github.com/tachiyomiorg/tachiyomi/tree/master
fun Context.isNavigationBarNeedsScrim(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ||
InternalResourceHelper.getBoolean(this, "config_navBarNeedsScrim", true)
}
fun Long.prettyByte(): String {
val kilobyte: Long = 1024
val megabyte = kilobyte * 1024
val gigabyte = megabyte * 1024
val terabyte = gigabyte * 1024
return if (this in 0 until kilobyte) {
"$this" + "B"
} else if (this in kilobyte until megabyte) {
String.format(ENGLISH, "%.2f", (this.toDouble() / kilobyte.toDouble())) + "KB"
} else if (this in megabyte until gigabyte) {
String.format(ENGLISH, "%.2f", (this.toDouble() / megabyte.toDouble())) + "MB"
} else if (this in gigabyte until terabyte) {
String.format(ENGLISH, "%.2f", (this.toDouble() / gigabyte.toDouble())) + "GB"
} else if (this >= terabyte) {
String.format(ENGLISH, "%.2f", (this.toDouble() / terabyte.toDouble())) + "TB"
} else {
"$this" + "B"
}
}
@@ -1,4 +1,4 @@
package com.illusionware.npsbrowser.util
package com.illusionware.npsbrowser.util.system
import android.annotation.SuppressLint
import android.content.Context
@@ -0,0 +1,40 @@
package com.illusionware.npsbrowser.util.system
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.wifi.WifiManager
import android.os.Build
import androidx.core.content.getSystemService
val Context.connectivityManager: ConnectivityManager
get() = getSystemService()!!
val Context.wifiManager: WifiManager
get() = getSystemService()!!
fun Context.isOnline(): Boolean {
val activeNetwork = connectivityManager.activeNetwork ?: return false
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false
val maxTransport = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 -> NetworkCapabilities.TRANSPORT_LOWPAN
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> NetworkCapabilities.TRANSPORT_WIFI_AWARE
else -> NetworkCapabilities.TRANSPORT_VPN
}
return (NetworkCapabilities.TRANSPORT_CELLULAR..maxTransport).any(networkCapabilities::hasTransport)
}
fun Context.isConnectedToWifi(): Boolean {
if (!wifiManager.isWifiEnabled) return false
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val activeNetwork = connectivityManager.activeNetwork ?: return false
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
} else {
@Suppress("DEPRECATION")
wifiManager.connectionInfo.bssid != null
}
}
@@ -0,0 +1,60 @@
package com.illusionware.npsbrowser.util.system
import android.Manifest
import android.app.Notification
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.compose.ui.graphics.toArgb
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationChannelGroupCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.NotificationManagerCompat.NotificationWithIdAndTag
import androidx.core.content.PermissionChecker
import androidx.core.content.getSystemService
import com.illusionware.npsbrowser.ui.theme.ColorAccent
val Context.notificationManager: NotificationManager
get() = getSystemService()!!
fun Context.notify(id: Int, channelId: String, block: (NotificationCompat.Builder.() -> Unit)? = null) {
val notification = notificationBuilder(channelId, block).build()
this.notify(id, notification)
}
fun Context.notify(id: Int, notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && PermissionChecker.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PermissionChecker.PERMISSION_GRANTED) {
return
}
NotificationManagerCompat.from(this).notify(id, notification)
}
fun Context.notify(notificationWithIdAndTags: List<NotificationWithIdAndTag>) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && PermissionChecker.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PermissionChecker.PERMISSION_GRANTED) {
return
}
NotificationManagerCompat.from(this).notify(notificationWithIdAndTags)
}
fun Context.cancelNotification(id: Int) {
NotificationManagerCompat.from(this).cancel(id)
}
/**
* Helper method to create a notification builder.
*
* @param channelId the channel id.
* @param block the function that will execute inside the builder.
* @return a notification to be displayed or updated.
*/
fun Context.notificationBuilder(channelId: String, block: (NotificationCompat.Builder.() -> Unit)? = null): NotificationCompat.Builder {
val builder = NotificationCompat.Builder(this, channelId)
.setColor(ColorAccent.toArgb())
if (block != null) {
builder.block()
}
return builder
}
@@ -0,0 +1,62 @@
package com.illusionware.npsbrowser.util.system
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ensureActive
import okio.BufferedSink
import okio.BufferedSource
/**
* Saves the given source to a file and closes it. Directories will be created if needed.
*
* @param file the file where the source is copied.
*/
//fun BufferedSource.saveTo(file: File) {
// try {
// // Create parent dirs if needed
// file.parentFile?.mkdirs()
//
// // Copy to destination
// saveTo(file.outputStream())
// } catch (e: Exception) {
// close()
// file.delete()
// throw e
// }
//}
fun BufferedSource.saveTo(sink: BufferedSink, totalBytes: Long, bytesRead: Long, scope: CoroutineScope): Boolean {
var downloaded = bytesRead
use { input ->
sink.use { out ->
while (true) {
scope.ensureActive()
val read = input.read(out.buffer, DEFAULT_BUFFER_SIZE.toLong())
if (read == -1L) break
downloaded += read
out.emitCompleteSegments()
}
out.flush()
}
}
if (totalBytes <= 0L) return true
val expected = bytesRead + totalBytes
return downloaded >= expected
}
/**
* Saves the given source to an output stream and closes both resources.
*
* @param stream the stream where the source is copied.
*/
//fun BufferedSource.saveTo(stream: OutputStream, scope: CoroutineScope) {
// use { input ->
// stream.sink().buffer().use {
// if (scope.isActive) {
// it.writeAll(input)
// it.flush()
// }
// }
// }
//}
@@ -13,6 +13,7 @@ import com.illusionware.npsbrowser.data.OnboardingPreferencesRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.io.File
@@ -29,6 +30,10 @@ class OnboardingViewModel(
)
)
suspend fun getOnboardingStatus(): Boolean {
return onboardingPreferencesRepository.getPreferences().first().seenOnboarding
}
fun finishOnboarding(shouldCreateDefaultDir: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
if (shouldCreateDefaultDir) {
@@ -1,6 +1,12 @@
package com.illusionware.npsbrowser.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.illusionware.npsbrowser.NPSApp
import com.illusionware.npsbrowser.data.download.DownloadManager
import com.illusionware.npsbrowser.model.PackageItem
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -10,11 +16,24 @@ data class SelectedPackageUiState(
val item: PackageItem?,
)
class PackageDetailsViewModel: ViewModel() {
class PackageDetailsViewModel(val downloadManager: DownloadManager): ViewModel() {
private val _uiState = MutableStateFlow(SelectedPackageUiState(null))
val uiState: StateFlow<SelectedPackageUiState> = _uiState.asStateFlow()
fun setSelectedPackage(item: PackageItem) {
_uiState.value = SelectedPackageUiState(item)
}
fun removeDownload(url: String) = downloadManager.cancelDownload(url)
fun pauseDownload(url: String) = downloadManager.pauseDownload(url)
fun resumeDownload(url: String) = downloadManager.resumeDownload(url)
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as NPSApp)
PackageDetailsViewModel(application.downloadManager)
}
}
}
}
@@ -15,6 +15,7 @@ import com.illusionware.npsbrowser.data.SettingsPreferencesRepository
import com.illusionware.npsbrowser.model.ConsoleType
import com.illusionware.npsbrowser.model.PackageItem
import com.illusionware.npsbrowser.model.PackageItemType
import com.illusionware.npsbrowser.util.system.prettyByte
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -23,6 +24,8 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import okhttp3.internal.toLongOrDefault
import java.io.InputStream
import androidx.core.net.toUri
import java.util.Locale.ENGLISH
data class PackageListUiState(
val packages: ArrayList<PackageItem> = ArrayList(),
@@ -125,7 +128,7 @@ class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesReposit
val file: InputStream?
try {
file = app.contentResolver.openInputStream(Uri.parse(fileUri))
file = app.contentResolver.openInputStream(fileUri.toUri())
} catch (e: Exception) {
throw e
}
@@ -153,10 +156,10 @@ class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesReposit
val tempsha256 = row["SHA256"]
val tempDate = row["Last Modification Date"]
if (!tempSize.isNullOrEmpty()) {
size = bytesIntoHumanReadable(tempSize.toLongOrDefault(0))
size = tempSize.toLongOrDefault(0).prettyByte()
}
if (!tempFW.isNullOrEmpty()) {
minFW = String.format("%.2f", tempFW.toFloat())
minFW = String.format(ENGLISH, "%.2f", tempFW.toFloat())
}
if (!tempUrl.isNullOrEmpty() && tempUrl != "MISSING") {
pkgUrl = tempUrl
@@ -186,6 +189,7 @@ class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesReposit
contentId,
modificationDate,
size,
tempSize?.toULongOrNull(),
sha256,
zRif,
rap,
@@ -201,27 +205,6 @@ class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesReposit
return list
}
private fun bytesIntoHumanReadable(bytes: Long): String {
val kilobyte: Long = 1024
val megabyte = kilobyte * 1024
val gigabyte = megabyte * 1024
val terabyte = gigabyte * 1024
return if (bytes in 0 until kilobyte) {
"$bytes" + "B"
} else if (bytes in kilobyte until megabyte) {
String.format("%.2f", (bytes.toDouble() / kilobyte.toDouble())) + "KB"
} else if (bytes in megabyte until gigabyte) {
String.format("%.2f", (bytes.toDouble() / megabyte.toDouble())) + "MB"
} else if (bytes in gigabyte until terabyte) {
String.format("%.2f", (bytes.toDouble() / gigabyte.toDouble())) + "GB"
} else if (bytes >= terabyte) {
String.format("%.2f", (bytes.toDouble() / terabyte.toDouble())) + "TB"
} else {
"$bytes" + "B"
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM17,13l-5,5 -5,-5h3V9h4v4h3z"/>
</vector>
@@ -1,213 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/linear"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/singleAppError"
android:layout_width="match_parent"
android:layout_height="60dp"
android:fontFamily="sans-serif-light"
android:gravity="center"
android:textAlignment="center"
android:textColor="@color/colorError"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="error" />
<ScrollView
android:id="@+id/scrollView2"
android:layout_width="0dp"
android:layout_height="0dp"
android:fillViewport="true"
app:layout_constraintBottom_toTopOf="@+id/singleAppError"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/linearLayout">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constrainedHeight="false"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/singleAppTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="48dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="48dp"
android:fontFamily="sans-serif-light"
android:textAlignment="center"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Game Title" />
<Button
android:id="@+id/singleAppDownloadButton"
android:layout_width="0dp"
android:layout_height="66dp"
android:layout_marginStart="48dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="48dp"
android:text="@string/download"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/singleAppTitle" />
<Button
android:id="@+id/singleAppCopyZRIFButton"
android:layout_width="0dp"
android:layout_height="66dp"
android:layout_marginStart="48dp"
android:layout_marginTop="6dp"
android:layout_marginEnd="48dp"
android:text="@string/copy_zrif"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/singleAppDownloadButton" />
<TextView
android:id="@+id/singleAppContentIdTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="48dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="48dp"
android:fontFamily="sans-serif-black"
android:text="@string/content_id"
android:textAlignment="center"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/singleAppCopyZRIFButton" />
<TextView
android:id="@+id/singleAppContentId"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="48dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="48dp"
android:fontFamily="sans-serif-light"
android:textAlignment="center"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/singleAppContentIdTitle"
tools:text="Content ID" />
<TextView
android:id="@+id/singleAppSizeTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="48dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="48dp"
android:fontFamily="sans-serif-black"
android:text="@string/size"
android:textAlignment="center"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/singleAppContentId" />
<TextView
android:id="@+id/singleAppFwVerTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="48dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="48dp"
android:fontFamily="sans-serif-black"
android:text="@string/minfw"
android:textAlignment="center"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/singleAppSize" />
<TextView
android:id="@+id/singleAppSize"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="48dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="48dp"
android:fontFamily="sans-serif-light"
android:textAlignment="center"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/singleAppSizeTitle"
tools:text="Size" />
<TextView
android:id="@+id/singleAppFwVer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="48dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="48dp"
android:fontFamily="sans-serif-light"
android:textAlignment="center"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/singleAppFwVerTitle"
tools:text="MinFW" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
<LinearLayout
android:id="@+id/linearLayout"
android:layout_width="410dp"
android:layout_height="250dp"
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/singleAppIcon"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="100dp"
android:layout_marginTop="0dp"
android:layout_marginEnd="100dp"
android:contentDescription="@string/single_app_icon"
app:srcCompat="@drawable/ic_games_placeholder_24dp" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
+5 -11
View File
@@ -21,15 +21,9 @@
<string name="appearance">外观</string>
<string name="theme">主题</string>
<string name="layout">布局</string>
<string-array name="app_theme">
<item>明亮</item>
<item>黑暗</item>
<item>使用系统默认</item>
</string-array>
<string-array name="layout_type">
<item>列表</item>
<item>网格</item>
</string-array>
<string name="theme_light">明亮</string>
<string name="theme_dark">黑暗</string>
<string name="theme_system">使用系统默认</string>
<string name="layout_list">列表</string>
<string name="layout_grid">网格</string>
</resources>
+5 -11
View File
@@ -21,15 +21,9 @@
<string name="appearance">外觀</string>
<string name="theme">主題</string>
<string name="layout">佈局</string>
<string-array name="app_theme">
<item>明亮</item>
<item>黑暗</item>
<item>使用系統默認</item>
</string-array>
<string-array name="layout_type">
<item>列表</item>
<item>網格</item>
</string-array>
<string name="theme_light">明亮</string>
<string name="theme_dark">黑暗</string>
<string name="theme_system">使用系統默認</string>
<string name="layout_list">列表</string>
<string name="layout_grid">網格</string>
</resources>
+5 -11
View File
@@ -21,15 +21,9 @@
<string name="appearance">Appearance</string>
<string name="theme">Theme</string>
<string name="layout">Layout</string>
<string-array name="app_theme">
<item>Light</item>
<item>Dark</item>
<item>Use System Default</item>
</string-array>
<string-array name="layout_type">
<item>List</item>
<item>Grid</item>
</string-array>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
<string name="theme_system">Use System Default</string>
<string name="layout_list">List</string>
<string name="layout_grid">Grid</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">zeus.dl.playstation.net</domain>
<domain includeSubdomains="true">ares.dl.playstation.net</domain>
</domain-config>
</network-security-config>