Major overhaul of project files, including reworking of app file management. May be broken, but pushing to stave progress.
This commit is contained in:
Binary file not shown.
@@ -5,16 +5,12 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "net.pythonsden.ao3_"
|
||||
compileSdk {
|
||||
version = release(36) {
|
||||
minorApiLevel = 1
|
||||
}
|
||||
}
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "net.pythonsden.ao3_"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
@@ -51,6 +47,7 @@ dependencies {
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
|
||||
implementation(libs.epublib) {
|
||||
exclude(group = "org.slf4j", module = "slf4j-simple")
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".AO3Application"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package net.pythonsden.ao3_
|
||||
|
||||
import android.app.Application
|
||||
import net.pythonsden.ao3_.data.SettingsRepository
|
||||
import net.pythonsden.ao3_.data.repository.EpubRepository
|
||||
import net.pythonsden.ao3_.data.repository.FileRepository
|
||||
|
||||
class AO3Application : Application() {
|
||||
lateinit var settingsRepository: SettingsRepository
|
||||
lateinit var fileRepository: FileRepository
|
||||
lateinit var epubRepository: EpubRepository
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
settingsRepository = SettingsRepository(this)
|
||||
fileRepository = FileRepository(this)
|
||||
epubRepository = EpubRepository()
|
||||
}
|
||||
}
|
||||
@@ -1,81 +1,41 @@
|
||||
package net.pythonsden.ao3_
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.URLUtil
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.DriveFileMove
|
||||
import androidx.compose.material.icons.filled.AutoFixHigh
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ContentPaste
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.navigation.NavController
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
@@ -83,12 +43,16 @@ import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import nl.siegmann.epublib.domain.Book
|
||||
import nl.siegmann.epublib.domain.Resource
|
||||
import nl.siegmann.epublib.epub.EpubReader
|
||||
import net.pythonsden.ao3_.browser.AO3WebView
|
||||
import net.pythonsden.ao3_.downloads.DownloadsScreen
|
||||
import net.pythonsden.ao3_.reader.ReaderScreen
|
||||
import net.pythonsden.ao3_.ui.theme.AO3clientTheme
|
||||
import net.pythonsden.ao3_.viewmodel.BrowserViewModel
|
||||
import net.pythonsden.ao3_.viewmodel.DownloadsViewModel
|
||||
import net.pythonsden.ao3_.viewmodel.MainViewModel
|
||||
import net.pythonsden.ao3_.viewmodel.ReaderViewModel
|
||||
import net.pythonsden.ao3_.viewmodel.ViewModelFactory
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
@@ -99,8 +63,14 @@ class MainActivity : ComponentActivity() {
|
||||
setupCookieManager()
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
val app = application as AO3Application
|
||||
val factory = ViewModelFactory(
|
||||
app.settingsRepository,
|
||||
app.fileRepository,
|
||||
app.epubRepository
|
||||
)
|
||||
AO3clientTheme {
|
||||
MainScreen()
|
||||
MainScreen(factory)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,54 +98,35 @@ sealed class Screen(val route: String, val label: String, val icon: @Composable
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
fun MainScreen(factory: ViewModelFactory) {
|
||||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { context.getSharedPreferences("ao3_prefs", Context.MODE_PRIVATE) }
|
||||
|
||||
val mainViewModel: MainViewModel = viewModel(factory = factory)
|
||||
val browserViewModel: BrowserViewModel = viewModel(factory = factory)
|
||||
val downloadsViewModel: DownloadsViewModel = viewModel(factory = factory)
|
||||
val readerViewModel: ReaderViewModel = viewModel(factory = factory)
|
||||
|
||||
var isOfflineMode by remember {
|
||||
mutableStateOf(prefs.getBoolean("offline_mode", false))
|
||||
}
|
||||
val isOfflineMode by mainViewModel.offlineMode.collectAsState()
|
||||
val lastRoute by mainViewModel.lastRoute.collectAsState()
|
||||
val lastEpubPath by mainViewModel.lastEpubPath.collectAsState()
|
||||
val canGoBackInBrowser by browserViewModel.canGoBack.collectAsState()
|
||||
|
||||
var webViewRef by remember { mutableStateOf<WebView?>(null) }
|
||||
var canGoBackInBrowser by remember { mutableStateOf(false) }
|
||||
|
||||
// Save state whenever it changes
|
||||
LaunchedEffect(navBackStackEntry) {
|
||||
navBackStackEntry?.destination?.route?.let { route ->
|
||||
val editor = prefs.edit()
|
||||
editor.putString("last_route", route)
|
||||
if (route == "reader/{filePath}") {
|
||||
val filePath = navBackStackEntry?.arguments?.getString("filePath")
|
||||
if (filePath != null) {
|
||||
editor.putString("last_epub_path", filePath)
|
||||
}
|
||||
}
|
||||
editor.apply()
|
||||
}
|
||||
}
|
||||
|
||||
// Restore state on launch
|
||||
// Handle Restoration
|
||||
var isRestored by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
if (!isRestored) {
|
||||
val lastRoute = prefs.getString("last_route", null)
|
||||
LaunchedEffect(lastRoute, isOfflineMode) {
|
||||
if (!isRestored && lastRoute != null) {
|
||||
val startRoute = if (isOfflineMode) Screen.Downloads.route else Screen.Browser.route
|
||||
|
||||
if (lastRoute != null && lastRoute != startRoute) {
|
||||
if (lastRoute != startRoute) {
|
||||
if (lastRoute == "reader/{filePath}") {
|
||||
val lastEpubPath = prefs.getString("last_epub_path", null)
|
||||
if (lastEpubPath != null) {
|
||||
val encoded = URLEncoder.encode(lastEpubPath, StandardCharsets.UTF_8.toString())
|
||||
lastEpubPath?.let { path ->
|
||||
val encoded = URLEncoder.encode(path, StandardCharsets.UTF_8.toString())
|
||||
navController.navigate("reader/$encoded")
|
||||
}
|
||||
} else if (!isOfflineMode || lastRoute == Screen.Downloads.route) {
|
||||
navController.navigate(lastRoute) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
navController.navigate(lastRoute!!) {
|
||||
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
@@ -185,6 +136,13 @@ fun MainScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
// Save current route
|
||||
LaunchedEffect(navBackStackEntry) {
|
||||
navBackStackEntry?.destination?.route?.let { route ->
|
||||
mainViewModel.updateLastRoute(route)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (currentDestination?.route != "reader/{filePath}") {
|
||||
@@ -192,9 +150,8 @@ fun MainScreen() {
|
||||
title = { Text("AO3 Client") },
|
||||
navigationIcon = {
|
||||
if (currentDestination?.route == Screen.Browser.route && canGoBackInBrowser) {
|
||||
IconButton(onClick = { webViewRef?.goBack() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
// This would ideally be handled by a message to the browser component
|
||||
// For now, Browser handles back via BackHandler internally
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
@@ -206,13 +163,10 @@ fun MainScreen() {
|
||||
Switch(
|
||||
checked = isOfflineMode,
|
||||
onCheckedChange = { checked ->
|
||||
isOfflineMode = checked
|
||||
prefs.edit().putBoolean("offline_mode", checked).apply()
|
||||
mainViewModel.toggleOfflineMode(checked)
|
||||
if (checked && currentDestination?.route == Screen.Browser.route) {
|
||||
navController.navigate(Screen.Downloads.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
@@ -230,10 +184,11 @@ fun MainScreen() {
|
||||
NavigationBar {
|
||||
val screens = if (isOfflineMode) listOf(Screen.Downloads) else listOf(Screen.Browser, Screen.Downloads)
|
||||
screens.forEach { screen ->
|
||||
val selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true
|
||||
NavigationBarItem(
|
||||
icon = screen.icon,
|
||||
label = { Text(screen.label) },
|
||||
selected = currentDestination?.route == screen.route,
|
||||
selected = selected,
|
||||
onClick = {
|
||||
navController.navigate(screen.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
@@ -260,17 +215,18 @@ fun MainScreen() {
|
||||
Text("Offline mode is enabled.")
|
||||
}
|
||||
} else {
|
||||
val lastUrl = prefs.getString("last_url", "https://archiveofourown.org/") ?: "https://archiveofourown.org/"
|
||||
val lastUrl by browserViewModel.lastUrl.collectAsState()
|
||||
AO3WebView(
|
||||
url = lastUrl,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
onWebViewCreated = { webViewRef = it },
|
||||
onCanGoBackChanged = { canGoBackInBrowser = it }
|
||||
onUrlChanged = { browserViewModel.updateLastUrl(it) },
|
||||
onCanGoBackChanged = { browserViewModel.setCanGoBack(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(Screen.Downloads.route) {
|
||||
DownloadsScreen(
|
||||
viewModel = downloadsViewModel,
|
||||
navController = navController,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
@@ -281,536 +237,8 @@ fun MainScreen() {
|
||||
) { backStackEntry ->
|
||||
val encodedPath = backStackEntry.arguments?.getString("filePath") ?: ""
|
||||
val filePath = URLDecoder.decode(encodedPath, StandardCharsets.UTF_8.toString())
|
||||
EpubReaderScreen(File(filePath), navController)
|
||||
ReaderScreen(File(filePath), readerViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
fun AO3WebView(
|
||||
url: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onWebViewCreated: (WebView) -> Unit = {},
|
||||
onCanGoBackChanged: (Boolean) -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var webView: WebView? by remember { mutableStateOf(null) }
|
||||
var canGoBack by remember { mutableStateOf(false) }
|
||||
|
||||
// This handles the back gesture/button for the WebView
|
||||
BackHandler(enabled = canGoBack) {
|
||||
webView?.goBack()
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
factory = { ctx ->
|
||||
WebView(ctx).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
CookieManager.getInstance().flush()
|
||||
url?.let {
|
||||
ctx.getSharedPreferences("ao3_prefs", Context.MODE_PRIVATE)
|
||||
.edit().putString("last_url", it).apply()
|
||||
}
|
||||
val canBack = view?.canGoBack() ?: false
|
||||
canGoBack = canBack
|
||||
onCanGoBackChanged(canBack)
|
||||
}
|
||||
|
||||
override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
|
||||
super.doUpdateVisitedHistory(view, url, isReload)
|
||||
val canBack = view?.canGoBack() ?: false
|
||||
canGoBack = canBack
|
||||
onCanGoBackChanged(canBack)
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||
val requestUrl = request?.url?.toString() ?: ""
|
||||
return if (requestUrl.contains("archiveofourown.org")) {
|
||||
false
|
||||
} else {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(requestUrl))
|
||||
ctx.startActivity(intent)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setDownloadListener { downloadUrl, userAgent, contentDisposition, mimetype, _ ->
|
||||
val fileName = URLUtil.guessFileName(downloadUrl, contentDisposition, mimetype)
|
||||
val request = DownloadManager.Request(Uri.parse(downloadUrl)).apply {
|
||||
val cookie = CookieManager.getInstance().getCookie(downloadUrl)
|
||||
addRequestHeader("Cookie", cookie)
|
||||
addRequestHeader("User-Agent", userAgent)
|
||||
setTitle(fileName)
|
||||
setDescription("Downloading from AO3...")
|
||||
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
setDestinationInExternalFilesDir(ctx, Environment.DIRECTORY_DOWNLOADS, fileName)
|
||||
}
|
||||
|
||||
val dm = ctx.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
dm.enqueue(request)
|
||||
Toast.makeText(ctx, "Download started: $fileName", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
@Suppress("DEPRECATION")
|
||||
databaseEnabled = true
|
||||
setSupportZoom(true)
|
||||
builtInZoomControls = true
|
||||
displayZoomControls = false
|
||||
useWideViewPort = true
|
||||
loadWithOverviewMode = true
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||
}
|
||||
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
||||
loadUrl(url)
|
||||
onWebViewCreated(this)
|
||||
webView = this
|
||||
}
|
||||
},
|
||||
update = {
|
||||
// No-op to avoid reloading on every recomposition
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
enum class SortOrder { NAME, DATE, SIZE }
|
||||
|
||||
@Composable
|
||||
fun DownloadsScreen(navController: NavController, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val baseDir = remember { context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)!! }
|
||||
var currentDir by remember { mutableStateOf(baseDir) }
|
||||
var items by remember { mutableStateOf(listOf<File>()) }
|
||||
var fileToDelete by remember { mutableStateOf<File?>(null) }
|
||||
var fileToMove by remember { mutableStateOf<File?>(null) }
|
||||
var isOrganizing by remember { mutableStateOf(false) }
|
||||
|
||||
// Search and Filter State
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var sortOrder by remember { mutableStateOf(SortOrder.NAME) }
|
||||
var showSortMenu by remember { mutableStateOf(false) }
|
||||
var isSearchExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
fun refreshFiles() {
|
||||
val allItems = currentDir.listFiles()?.toList() ?: emptyList()
|
||||
val filtered = if (searchQuery.isBlank()) {
|
||||
allItems
|
||||
} else {
|
||||
allItems.filter { it.name.contains(searchQuery, ignoreCase = true) }
|
||||
}
|
||||
|
||||
items = filtered.sortedWith { f1, f2 ->
|
||||
if (f1.isDirectory && !f2.isDirectory) -1
|
||||
else if (!f1.isDirectory && f2.isDirectory) 1
|
||||
else {
|
||||
when (sortOrder) {
|
||||
SortOrder.NAME -> f1.name.lowercase().compareTo(f2.name.lowercase())
|
||||
SortOrder.DATE -> f2.lastModified().compareTo(f1.lastModified())
|
||||
SortOrder.SIZE -> f2.length().compareTo(f1.length())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentDir, searchQuery, sortOrder) {
|
||||
refreshFiles()
|
||||
}
|
||||
|
||||
BackHandler(enabled = currentDir != baseDir || isSearchExpanded) {
|
||||
if (isSearchExpanded) {
|
||||
isSearchExpanded = false
|
||||
searchQuery = ""
|
||||
} else {
|
||||
currentDir = currentDir.parentFile ?: baseDir
|
||||
}
|
||||
}
|
||||
|
||||
fun organizeFiles() {
|
||||
isOrganizing = true
|
||||
val allFiles = baseDir.walkTopDown().filter { it.isFile && it.extension.lowercase() == "epub" }.toList()
|
||||
var movedCount = 0
|
||||
|
||||
// Regex to match "Series: Part (#) of (Series Name)"
|
||||
val seriesRegex = Regex("Series:\\s*Part\\s*(\\d+)\\s*of\\s*([^\\n\\r<]+)", RegexOption.IGNORE_CASE)
|
||||
|
||||
allFiles.forEach { file ->
|
||||
try {
|
||||
FileInputStream(file).use { fis ->
|
||||
val book = EpubReader().readEpub(fis)
|
||||
val author = book.metadata.authors.firstOrNull()?.let { "${it.firstname} ${it.lastname}".trim() } ?: "Unknown Author"
|
||||
|
||||
var seriesName: String? = null
|
||||
|
||||
// Look through all resources in the book to find the text "Series: Part ..."
|
||||
for (resource in book.contents) {
|
||||
val text = String(resource.data, StandardCharsets.UTF_8)
|
||||
// Strip HTML tags for cleaner searching
|
||||
val cleanText = text.replace(Regex("<[^>]*>"), " ")
|
||||
val match = seriesRegex.find(cleanText)
|
||||
if (match != null) {
|
||||
seriesName = match.groupValues[2].trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Clean folder names
|
||||
val cleanAuthor = author.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
||||
val targetDir = if (!seriesName.isNullOrBlank()) {
|
||||
val cleanSeries = seriesName.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
||||
File(baseDir, "$cleanAuthor/$cleanSeries")
|
||||
} else {
|
||||
File(baseDir, cleanAuthor)
|
||||
}
|
||||
|
||||
if (!targetDir.exists()) targetDir.mkdirs()
|
||||
|
||||
val targetFile = File(targetDir, file.name)
|
||||
if (file.absolutePath != targetFile.absolutePath) {
|
||||
if (file.renameTo(targetFile)) {
|
||||
movedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
isOrganizing = false
|
||||
refreshFiles()
|
||||
Toast.makeText(context, "Organized $movedCount files", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
if (fileToDelete != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { fileToDelete = null },
|
||||
title = { Text("Delete ${if (fileToDelete?.isDirectory == true) "Folder" else "Work"}") },
|
||||
text = { Text("Are you sure you want to delete '${fileToDelete?.name}'?") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
fileToDelete?.deleteRecursively()
|
||||
fileToDelete = null
|
||||
refreshFiles()
|
||||
Toast.makeText(context, "Deleted", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
) {
|
||||
Text("Delete")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { fileToDelete = null }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
if (isSearchExpanded) {
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
placeholder = { Text("Search files...") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
isSearchExpanded = false
|
||||
searchQuery = ""
|
||||
}) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close search")
|
||||
}
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Downloads",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
if (currentDir != baseDir) {
|
||||
val pathDisplay = currentDir.absolutePath.removePrefix(baseDir.absolutePath)
|
||||
if (pathDisplay.isNotEmpty()) {
|
||||
Text(
|
||||
text = pathDisplay,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = { isSearchExpanded = true }) {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
||||
}
|
||||
|
||||
Box {
|
||||
IconButton(onClick = { showSortMenu = true }) {
|
||||
Icon(Icons.Default.FilterList, contentDescription = "Sort")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showSortMenu,
|
||||
onDismissRequest = { showSortMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Sort by Name") },
|
||||
onClick = { sortOrder = SortOrder.NAME; showSortMenu = false }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Sort by Date") },
|
||||
onClick = { sortOrder = SortOrder.DATE; showSortMenu = false }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Sort by Size") },
|
||||
onClick = { sortOrder = SortOrder.SIZE; showSortMenu = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (fileToMove != null) {
|
||||
IconButton(onClick = {
|
||||
val targetFile = File(currentDir, fileToMove?.name ?: "")
|
||||
if (fileToMove?.renameTo(targetFile) == true) {
|
||||
Toast.makeText(context, "Moved ${fileToMove?.name}", Toast.LENGTH_SHORT).show()
|
||||
fileToMove = null
|
||||
refreshFiles()
|
||||
} else {
|
||||
Toast.makeText(context, "Error moving file", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Default.ContentPaste, contentDescription = "Paste Here")
|
||||
}
|
||||
IconButton(onClick = { fileToMove = null }) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Cancel Move")
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = { organizeFiles() }, enabled = !isOrganizing) {
|
||||
Icon(Icons.Default.AutoFixHigh, contentDescription = "Organize")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(if (searchQuery.isNotEmpty()) "No results matching '$searchQuery'" else "No files found.")
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
if (currentDir != baseDir && !isSearchExpanded) {
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("..") },
|
||||
leadingContent = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) },
|
||||
modifier = Modifier.clickable { currentDir = currentDir.parentFile ?: baseDir }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
items(items) { item ->
|
||||
ListItem(
|
||||
headlineContent = { Text(item.name) },
|
||||
supportingContent = {
|
||||
if (!item.isDirectory) {
|
||||
Text("${item.length() / 1024} KB")
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
if (item.isDirectory) Icons.Default.Folder else Icons.Default.Download,
|
||||
contentDescription = null,
|
||||
tint = if (item == fileToMove) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Row {
|
||||
IconButton(onClick = { fileToMove = item }) {
|
||||
Icon(Icons.AutoMirrored.Filled.DriveFileMove, contentDescription = "Move")
|
||||
}
|
||||
IconButton(onClick = { fileToDelete = item }) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Delete")
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
if (item.isDirectory) {
|
||||
currentDir = item
|
||||
isSearchExpanded = false
|
||||
searchQuery = ""
|
||||
} else {
|
||||
if (item.extension.lowercase() == "epub") {
|
||||
val encodedPath = URLEncoder.encode(item.absolutePath, StandardCharsets.UTF_8.toString())
|
||||
navController.navigate("reader/$encodedPath")
|
||||
} else {
|
||||
openFile(context, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EpubReaderScreen(file: File, navController: NavController) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val prefs = remember { context.getSharedPreferences("ao3_prefs", Context.MODE_PRIVATE) }
|
||||
val scrollKey = "scroll_${file.absolutePath}"
|
||||
|
||||
var htmlContent by remember { mutableStateOf("") }
|
||||
var title by remember { mutableStateOf("Reading...") }
|
||||
var webViewRef by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
// Helper function to save scroll
|
||||
fun saveScroll(wv: WebView?) {
|
||||
wv?.let {
|
||||
val scrollY = it.scrollY
|
||||
if (scrollY > 0) {
|
||||
prefs.edit().putInt(scrollKey, scrollY).apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save scroll position on pause or dispose
|
||||
DisposableEffect(lifecycleOwner, file.absolutePath, webViewRef) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_PAUSE) {
|
||||
saveScroll(webViewRef)
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
saveScroll(webViewRef)
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(file) {
|
||||
try {
|
||||
val book: Book = EpubReader().readEpub(FileInputStream(file))
|
||||
title = book.title ?: file.name
|
||||
val sb = StringBuilder()
|
||||
// Updated style for dark mode
|
||||
sb.append("<html><head><style>")
|
||||
sb.append("body { font-family: sans-serif; padding: 16px; line-height: 1.6; color: #E0E0E0; background-color: #121212; }")
|
||||
sb.append("hr { border: 0; border-top: 1px solid #333; margin: 20px 0; }")
|
||||
sb.append("a { color: #BB86FC; }")
|
||||
sb.append("</style></head><body>")
|
||||
|
||||
book.contents.forEach { resource: Resource ->
|
||||
val content = String(resource.data, StandardCharsets.UTF_8)
|
||||
val bodyContent = if (content.contains("<body")) {
|
||||
content.substringAfter("<body").substringAfter(">").substringBeforeLast("</body>")
|
||||
} else {
|
||||
content
|
||||
}
|
||||
|
||||
if (bodyContent.isNotBlank()) {
|
||||
sb.append(bodyContent)
|
||||
sb.append("<hr/>")
|
||||
}
|
||||
}
|
||||
sb.append("</body></html>")
|
||||
htmlContent = sb.toString()
|
||||
} catch (e: Exception) {
|
||||
htmlContent = "<html><body style='background-color:#121212; color:white;'><h3>Error loading EPUB</h3><p>${e.message}</p></body></html>"
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = Color(0xFF121212), // Match HTML background
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(title, maxLines = 1) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color(0xFF1F1F1F),
|
||||
titleContentColor = Color.White,
|
||||
navigationIconContentColor = Color.White
|
||||
),
|
||||
windowInsets = TopAppBarDefaults.windowInsets
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
AndroidView(
|
||||
modifier = Modifier.padding(padding).fillMaxSize(),
|
||||
factory = { ctx ->
|
||||
WebView(ctx).apply {
|
||||
setBackgroundColor(android.graphics.Color.parseColor("#121212"))
|
||||
settings.javaScriptEnabled = true
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
val lastScroll = prefs.getInt(scrollKey, 0)
|
||||
if (lastScroll > 0) {
|
||||
// Try scrolling after several delays to ensure layout is done
|
||||
view?.postDelayed({ view.scrollTo(0, lastScroll) }, 100)
|
||||
view?.postDelayed({ view.scrollTo(0, lastScroll) }, 300)
|
||||
view?.postDelayed({ view.scrollTo(0, lastScroll) }, 600)
|
||||
}
|
||||
}
|
||||
}
|
||||
setOnScrollChangeListener { _, _, _, scrollY, _ ->
|
||||
if (scrollY > 0) {
|
||||
prefs.edit().putInt(scrollKey, scrollY).apply()
|
||||
}
|
||||
}
|
||||
webViewRef = this
|
||||
}
|
||||
},
|
||||
update = { webView ->
|
||||
// Avoid reloading if content is the same
|
||||
if (webView.tag != htmlContent && htmlContent.isNotBlank()) {
|
||||
webView.loadDataWithBaseURL(null, htmlContent, "text/html", "UTF-8", null)
|
||||
webView.tag = htmlContent
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun openFile(context: Context, file: File) {
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, context.contentResolver.getType(uri))
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
try {
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, "No app found to open this file", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package net.pythonsden.ao3_.browser
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.URLUtil
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
fun AO3WebView(
|
||||
url: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onUrlChanged: (String) -> Unit = {},
|
||||
onCanGoBackChanged: (Boolean) -> Unit = {},
|
||||
onWebViewCreated: (WebView) -> Unit = {}
|
||||
) {
|
||||
var webView: WebView? by remember { mutableStateOf(null) }
|
||||
var canGoBack by remember { mutableStateOf(false) }
|
||||
|
||||
BackHandler(enabled = canGoBack) {
|
||||
webView?.goBack()
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
factory = { ctx ->
|
||||
WebView(ctx).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
CookieManager.getInstance().flush()
|
||||
url?.let { onUrlChanged(it) }
|
||||
val canBack = view?.canGoBack() ?: false
|
||||
canGoBack = canBack
|
||||
onCanGoBackChanged(canBack)
|
||||
}
|
||||
|
||||
override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
|
||||
super.doUpdateVisitedHistory(view, url, isReload)
|
||||
val canBack = view?.canGoBack() ?: false
|
||||
canGoBack = canBack
|
||||
onCanGoBackChanged(canBack)
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||
val requestUrl = request?.url?.toString() ?: ""
|
||||
return if (requestUrl.contains("archiveofourown.org")) {
|
||||
false
|
||||
} else {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(requestUrl))
|
||||
ctx.startActivity(intent)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setDownloadListener { downloadUrl, userAgent, contentDisposition, mimetype, _ ->
|
||||
val fileName = URLUtil.guessFileName(downloadUrl, contentDisposition, mimetype)
|
||||
val request = DownloadManager.Request(Uri.parse(downloadUrl)).apply {
|
||||
val cookie = CookieManager.getInstance().getCookie(downloadUrl)
|
||||
addRequestHeader("Cookie", cookie)
|
||||
addRequestHeader("User-Agent", userAgent)
|
||||
setTitle(fileName)
|
||||
setDescription("Downloading from AO3...")
|
||||
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
setDestinationInExternalFilesDir(ctx, Environment.DIRECTORY_DOWNLOADS, fileName)
|
||||
}
|
||||
|
||||
val dm = ctx.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
dm.enqueue(request)
|
||||
Toast.makeText(ctx, "Download started: $fileName", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
// Safe settings
|
||||
allowFileAccess = false
|
||||
allowContentAccess = false
|
||||
@Suppress("DEPRECATION")
|
||||
databaseEnabled = true
|
||||
setSupportZoom(true)
|
||||
builtInZoomControls = true
|
||||
displayZoomControls = false
|
||||
useWideViewPort = true
|
||||
loadWithOverviewMode = true
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
// Minimize mixed content
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
||||
}
|
||||
|
||||
// Only enable third-party cookies if absolutely necessary.
|
||||
// AO3 might need them for some features, but let's try disabling or keeping it as it was if it worked.
|
||||
// The original code had it enabled.
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
||||
|
||||
loadUrl(url)
|
||||
onWebViewCreated(this)
|
||||
webView = this
|
||||
}
|
||||
},
|
||||
update = {
|
||||
// Usually we don't want to re-load on every update
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package net.pythonsden.ao3_.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||
|
||||
class SettingsRepository(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private val OFFLINE_MODE = booleanPreferencesKey("offline_mode")
|
||||
private val LAST_ROUTE = stringPreferencesKey("last_route")
|
||||
private val LAST_URL = stringPreferencesKey("last_url")
|
||||
private val LAST_EPUB_PATH = stringPreferencesKey("last_epub_path")
|
||||
}
|
||||
|
||||
val offlineModeFlow: Flow<Boolean> = context.dataStore.data.map { preferences ->
|
||||
preferences[OFFLINE_MODE] ?: false
|
||||
}
|
||||
|
||||
val lastRouteFlow: Flow<String?> = context.dataStore.data.map { preferences ->
|
||||
preferences[LAST_ROUTE]
|
||||
}
|
||||
|
||||
val lastUrlFlow: Flow<String> = context.dataStore.data.map { preferences ->
|
||||
preferences[LAST_URL] ?: "https://archiveofourown.org/"
|
||||
}
|
||||
|
||||
val lastEpubPathFlow: Flow<String?> = context.dataStore.data.map { preferences ->
|
||||
preferences[LAST_EPUB_PATH]
|
||||
}
|
||||
|
||||
suspend fun updateOfflineMode(enabled: Boolean) {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[OFFLINE_MODE] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateLastRoute(route: String) {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[LAST_ROUTE] = route
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateLastUrl(url: String) {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[LAST_URL] = url
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateLastEpubPath(path: String) {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[LAST_EPUB_PATH] = path
|
||||
}
|
||||
}
|
||||
|
||||
fun getReaderScrollFlow(filePath: String): Flow<Int> = context.dataStore.data.map { preferences ->
|
||||
preferences[intPreferencesKey("scroll_$filePath")] ?: 0
|
||||
}
|
||||
|
||||
suspend fun updateReaderScroll(filePath: String, scrollY: Int) {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[intPreferencesKey("scroll_$filePath")] = scrollY
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package net.pythonsden.ao3_.data.model
|
||||
|
||||
data class EpubMetadata(
|
||||
val title: String,
|
||||
val author: String,
|
||||
val seriesName: String?,
|
||||
val seriesPart: Int?,
|
||||
val summary: String? = null,
|
||||
val tags: List<String> = emptyList(),
|
||||
val chapterCount: Int = 0
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
package net.pythonsden.ao3_.data.repository
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.pythonsden.ao3_.data.model.EpubMetadata
|
||||
import nl.siegmann.epublib.domain.Book
|
||||
import nl.siegmann.epublib.domain.Resource
|
||||
import nl.siegmann.epublib.epub.EpubReader
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class EpubRepository {
|
||||
|
||||
private val seriesRegex = Regex("Series:\\s*Part\\s*(\\d+)\\s*of\\s*([^\\n\\r<]+)", RegexOption.IGNORE_CASE)
|
||||
|
||||
suspend fun parseMetadata(file: File): EpubMetadata = withContext(Dispatchers.IO) {
|
||||
FileInputStream(file).use { fis ->
|
||||
val book = EpubReader().readEpub(fis)
|
||||
val title = book.metadata.titles.firstOrNull() ?: file.nameWithoutExtension
|
||||
val author = book.metadata.authors.firstOrNull()?.let { "${it.firstname} ${it.lastname}".trim() } ?: "Unknown Author"
|
||||
|
||||
var seriesName: String? = null
|
||||
var seriesPart: Int? = null
|
||||
|
||||
// Search in metadata first if available (epublib might not expose custom ones easily)
|
||||
// Fallback to content scanning as in original code, but improved
|
||||
for (resource in book.contents) {
|
||||
val text = String(resource.data, StandardCharsets.UTF_8)
|
||||
val cleanText = text.replace(Regex("<[^>]*>"), " ")
|
||||
val match = seriesRegex.find(cleanText)
|
||||
if (match != null) {
|
||||
seriesPart = match.groupValues[1].toIntOrNull()
|
||||
seriesName = match.groupValues[2].trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
EpubMetadata(
|
||||
title = title,
|
||||
author = author,
|
||||
seriesName = seriesName,
|
||||
seriesPart = seriesPart,
|
||||
chapterCount = book.spine.spineReferences.size
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getBookContent(file: File): Result<Pair<String, String>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
FileInputStream(file).use { fis ->
|
||||
val book = EpubReader().readEpub(fis)
|
||||
val title = book.title ?: file.name
|
||||
val sb = StringBuilder()
|
||||
|
||||
sb.append("<html><head><style>")
|
||||
sb.append("body { font-family: sans-serif; padding: 16px; line-height: 1.6; color: #E0E0E0; background-color: #121212; }")
|
||||
sb.append("hr { border: 0; border-top: 1px solid #333; margin: 20px 0; }")
|
||||
sb.append("a { color: #BB86FC; }")
|
||||
sb.append("img { max-width: 100%; height: auto; }")
|
||||
sb.append("</style></head><body>")
|
||||
|
||||
book.contents.forEach { resource: Resource ->
|
||||
val content = String(resource.data, StandardCharsets.UTF_8)
|
||||
val bodyContent = if (content.contains("<body", ignoreCase = true)) {
|
||||
content.substringAfter("<body").substringAfter(">").substringBeforeLast("</body>")
|
||||
} else {
|
||||
content
|
||||
}
|
||||
|
||||
if (bodyContent.isNotBlank()) {
|
||||
sb.append(bodyContent)
|
||||
sb.append("<hr/>")
|
||||
}
|
||||
}
|
||||
sb.append("</body></html>")
|
||||
Result.success(Pair(title, sb.toString()))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package net.pythonsden.ao3_.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
class FileRepository(private val context: Context) {
|
||||
|
||||
fun getDownloadsDir(): File {
|
||||
return context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)!!
|
||||
}
|
||||
|
||||
suspend fun listFiles(directory: File): List<File> = withContext(Dispatchers.IO) {
|
||||
directory.listFiles()?.toList() ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun deleteFile(file: File): Boolean = withContext(Dispatchers.IO) {
|
||||
if (file.isDirectory) {
|
||||
file.deleteRecursively()
|
||||
} else {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun moveFile(source: File, target: File): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (target.exists()) {
|
||||
// Simple collision handling: append timestamp if it's a file
|
||||
if (target.isFile) {
|
||||
val newTarget = File(target.parent, "${target.nameWithoutExtension}_${System.currentTimeMillis()}.${target.extension}")
|
||||
source.renameTo(newTarget)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
val parent = target.parentFile
|
||||
if (parent != null && !parent.exists()) {
|
||||
parent.mkdirs()
|
||||
}
|
||||
source.renameTo(target)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun safeMoveFile(source: File, targetDir: File): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (!targetDir.exists()) {
|
||||
targetDir.mkdirs()
|
||||
}
|
||||
var targetFile = File(targetDir, source.name)
|
||||
if (targetFile.exists() && targetFile.absolutePath != source.absolutePath) {
|
||||
val name = source.nameWithoutExtension
|
||||
val ext = source.extension
|
||||
var counter = 1
|
||||
while (targetFile.exists()) {
|
||||
targetFile = File(targetDir, "${name}_$counter.$ext")
|
||||
counter++
|
||||
}
|
||||
}
|
||||
|
||||
if (source.absolutePath == targetFile.absolutePath) return@withContext true
|
||||
|
||||
val success = source.renameTo(targetFile)
|
||||
if (!success) {
|
||||
// Fallback to copy and delete if rename fails (e.g. across partitions, though unlikely here)
|
||||
try {
|
||||
source.inputStream().use { input ->
|
||||
targetFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
source.delete()
|
||||
true
|
||||
} catch (e: IOException) {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package net.pythonsden.ao3_.downloads
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.DriveFileMove
|
||||
import androidx.compose.material.icons.filled.AutoFixHigh
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ContentPaste
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.navigation.NavController
|
||||
import net.pythonsden.ao3_.viewmodel.DownloadsViewModel
|
||||
import net.pythonsden.ao3_.viewmodel.SortOrder
|
||||
import java.io.File
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
@Composable
|
||||
fun DownloadsScreen(
|
||||
viewModel: DownloadsViewModel,
|
||||
navController: NavController,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val currentDir by viewModel.currentDir.collectAsState()
|
||||
val items by viewModel.items.collectAsState()
|
||||
val searchQuery by viewModel.searchQuery.collectAsState()
|
||||
val sortOrder by viewModel.sortOrder.collectAsState()
|
||||
val isOrganizing by viewModel.isOrganizing.collectAsState()
|
||||
|
||||
var fileToDelete by remember { mutableStateOf<File?>(null) }
|
||||
var fileToMove by remember { mutableStateOf<File?>(null) }
|
||||
var showSortMenu by remember { mutableStateOf(false) }
|
||||
var isSearchExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
BackHandler(enabled = currentDir != viewModel.baseDir || isSearchExpanded) {
|
||||
if (isSearchExpanded) {
|
||||
isSearchExpanded = false
|
||||
viewModel.setSearchQuery("")
|
||||
} else {
|
||||
viewModel.navigateUp()
|
||||
}
|
||||
}
|
||||
|
||||
if (fileToDelete != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { fileToDelete = null },
|
||||
title = { Text("Delete ${if (fileToDelete?.isDirectory == true) "Folder" else "Work"}") },
|
||||
text = { Text("Are you sure you want to delete '${fileToDelete?.name}'? This action cannot be undone.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.deleteFile(fileToDelete!!) {
|
||||
Toast.makeText(context, "Deleted", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
fileToDelete = null
|
||||
}
|
||||
) {
|
||||
Text("Delete")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { fileToDelete = null }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
if (isSearchExpanded) {
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { viewModel.setSearchQuery(it) },
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
placeholder = { Text("Search files...") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
isSearchExpanded = false
|
||||
viewModel.setSearchQuery("")
|
||||
}) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close search")
|
||||
}
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Downloads",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
if (currentDir != viewModel.baseDir) {
|
||||
val pathDisplay = currentDir.absolutePath.removePrefix(viewModel.baseDir.absolutePath)
|
||||
if (pathDisplay.isNotEmpty()) {
|
||||
Text(
|
||||
text = pathDisplay,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = { isSearchExpanded = true }) {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
||||
}
|
||||
|
||||
Box {
|
||||
IconButton(onClick = { showSortMenu = true }) {
|
||||
Icon(Icons.Default.FilterList, contentDescription = "Sort")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showSortMenu,
|
||||
onDismissRequest = { showSortMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Sort by Name") },
|
||||
onClick = { viewModel.setSortOrder(SortOrder.NAME); showSortMenu = false }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Sort by Date") },
|
||||
onClick = { viewModel.setSortOrder(SortOrder.DATE); showSortMenu = false }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Sort by Size") },
|
||||
onClick = { viewModel.setSortOrder(SortOrder.SIZE); showSortMenu = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (fileToMove != null) {
|
||||
IconButton(onClick = {
|
||||
viewModel.moveFile(fileToMove!!, currentDir) {
|
||||
Toast.makeText(context, "Moved ${fileToMove?.name}", Toast.LENGTH_SHORT).show()
|
||||
fileToMove = null
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Default.ContentPaste, contentDescription = "Paste Here")
|
||||
}
|
||||
IconButton(onClick = { fileToMove = null }) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Cancel Move")
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = {
|
||||
viewModel.organizeFiles { count ->
|
||||
Toast.makeText(context, "Organized $count files", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}, enabled = !isOrganizing) {
|
||||
Icon(Icons.Default.AutoFixHigh, contentDescription = "Organize")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(if (searchQuery.isNotEmpty()) "No results matching '$searchQuery'" else "No files found.")
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
if (currentDir != viewModel.baseDir && !isSearchExpanded) {
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("..") },
|
||||
leadingContent = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) },
|
||||
modifier = Modifier.clickable { viewModel.navigateUp() }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
items(items) { item ->
|
||||
ListItem(
|
||||
headlineContent = { Text(item.name) },
|
||||
supportingContent = {
|
||||
if (!item.isDirectory) {
|
||||
Text("${item.length() / 1024} KB")
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
if (item.isDirectory) Icons.Default.Folder else Icons.Default.Download,
|
||||
contentDescription = null,
|
||||
tint = if (item == fileToMove) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Row {
|
||||
IconButton(onClick = { fileToMove = item }) {
|
||||
Icon(Icons.AutoMirrored.Filled.DriveFileMove, contentDescription = "Move")
|
||||
}
|
||||
IconButton(onClick = { fileToDelete = item }) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Delete")
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
if (item.isDirectory) {
|
||||
viewModel.navigateTo(item)
|
||||
isSearchExpanded = false
|
||||
} else {
|
||||
if (item.extension.lowercase() == "epub") {
|
||||
val encodedPath = URLEncoder.encode(item.absolutePath, StandardCharsets.UTF_8.toString())
|
||||
navController.navigate("reader/$encodedPath")
|
||||
} else {
|
||||
openFile(context, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openFile(context: Context, file: File) {
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, context.contentResolver.getType(uri))
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
try {
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, "No app found to open this file", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package net.pythonsden.ao3_.reader
|
||||
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.navigation.NavController
|
||||
import net.pythonsden.ao3_.viewmodel.ReaderUiState
|
||||
import net.pythonsden.ao3_.viewmodel.ReaderViewModel
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ReaderScreen(
|
||||
file: File,
|
||||
viewModel: ReaderViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
LaunchedEffect(file) {
|
||||
viewModel.loadEpub(file)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = Color(0xFF121212),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = when (val state = uiState) {
|
||||
is ReaderUiState.Success -> state.title
|
||||
is ReaderUiState.Loading -> "Loading..."
|
||||
else -> "Reader"
|
||||
},
|
||||
maxLines = 1
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color(0xFF1F1F1F),
|
||||
titleContentColor = Color.White,
|
||||
navigationIconContentColor = Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Box(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||
when (val state = uiState) {
|
||||
is ReaderUiState.Loading -> {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
is ReaderUiState.Success -> {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { ctx ->
|
||||
WebView(ctx).apply {
|
||||
setBackgroundColor(android.graphics.Color.parseColor("#121212"))
|
||||
// Security: Disable JavaScript for EPUB reading unless strictly needed
|
||||
settings.javaScriptEnabled = false
|
||||
settings.allowFileAccess = false
|
||||
settings.allowContentAccess = false
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
if (state.initialScroll > 0) {
|
||||
// Restore scroll position
|
||||
view?.scrollTo(0, state.initialScroll)
|
||||
// Multiple attempts to ensure it works after layout
|
||||
view?.postDelayed({ view.scrollTo(0, state.initialScroll) }, 100)
|
||||
view?.postDelayed({ view.scrollTo(0, state.initialScroll) }, 300)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setOnScrollChangeListener { _, _, _, scrollY, _ ->
|
||||
viewModel.saveScrollPosition(file.absolutePath, scrollY)
|
||||
}
|
||||
}
|
||||
},
|
||||
update = { webView ->
|
||||
if (webView.tag != state.content) {
|
||||
webView.loadDataWithBaseURL(null, state.content, "text/html", "UTF-8", null)
|
||||
webView.tag = state.content
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
is ReaderUiState.Error -> {
|
||||
Text(
|
||||
text = "Error: ${state.message}",
|
||||
color = Color.White,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package net.pythonsden.ao3_.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import net.pythonsden.ao3_.data.SettingsRepository
|
||||
|
||||
class BrowserViewModel(private val settingsRepository: SettingsRepository) : ViewModel() {
|
||||
|
||||
val lastUrl: StateFlow<String> = settingsRepository.lastUrlFlow
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "https://archiveofourown.org/")
|
||||
|
||||
private val _canGoBack = MutableStateFlow(false)
|
||||
val canGoBack: StateFlow<Boolean> = _canGoBack.asStateFlow()
|
||||
|
||||
fun updateLastUrl(url: String) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.updateLastUrl(url)
|
||||
}
|
||||
}
|
||||
|
||||
fun setCanGoBack(canGoBack: Boolean) {
|
||||
_canGoBack.value = canGoBack
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package net.pythonsden.ao3_.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import net.pythonsden.ao3_.data.repository.EpubRepository
|
||||
import net.pythonsden.ao3_.data.repository.FileRepository
|
||||
import java.io.File
|
||||
|
||||
enum class SortOrder { NAME, DATE, SIZE }
|
||||
|
||||
class DownloadsViewModel(
|
||||
private val fileRepository: FileRepository,
|
||||
private val epubRepository: EpubRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _currentDir = MutableStateFlow(fileRepository.getDownloadsDir())
|
||||
val currentDir: StateFlow<File> = _currentDir.asStateFlow()
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
private val _sortOrder = MutableStateFlow(SortOrder.NAME)
|
||||
val sortOrder: StateFlow<SortOrder> = _sortOrder.asStateFlow()
|
||||
|
||||
private val _isOrganizing = MutableStateFlow(false)
|
||||
val isOrganizing: StateFlow<Boolean> = _isOrganizing.asStateFlow()
|
||||
|
||||
private val _items = MutableStateFlow<List<File>>(emptyList())
|
||||
val items: StateFlow<List<File>> = _items.asStateFlow()
|
||||
|
||||
val baseDir: File = fileRepository.getDownloadsDir()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
combine(_currentDir, _searchQuery, _sortOrder) { dir, query, sort ->
|
||||
Triple(dir, query, sort)
|
||||
}.collect { (dir, query, sort) ->
|
||||
refreshFiles(dir, query, sort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshFiles(dir: File, query: String, sort: SortOrder) {
|
||||
val allItems = fileRepository.listFiles(dir)
|
||||
val filtered = if (query.isBlank()) {
|
||||
allItems
|
||||
} else {
|
||||
allItems.filter { it.name.contains(query, ignoreCase = true) }
|
||||
}
|
||||
|
||||
_items.value = filtered.sortedWith { f1, f2 ->
|
||||
if (f1.isDirectory && !f2.isDirectory) -1
|
||||
else if (!f1.isDirectory && f2.isDirectory) 1
|
||||
else {
|
||||
when (sort) {
|
||||
SortOrder.NAME -> f1.name.lowercase().compareTo(f2.name.lowercase())
|
||||
SortOrder.DATE -> f2.lastModified().compareTo(f1.lastModified())
|
||||
SortOrder.SIZE -> f2.length().compareTo(f1.length())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateTo(dir: File) {
|
||||
_currentDir.value = dir
|
||||
}
|
||||
|
||||
fun navigateUp(): Boolean {
|
||||
val current = _currentDir.value
|
||||
return if (current != baseDir) {
|
||||
_currentDir.value = current.parentFile ?: baseDir
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun setSearchQuery(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun setSortOrder(order: SortOrder) {
|
||||
_sortOrder.value = order
|
||||
}
|
||||
|
||||
fun deleteFile(file: File, onComplete: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
fileRepository.deleteFile(file)
|
||||
refreshFiles(_currentDir.value, _searchQuery.value, _sortOrder.value)
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
|
||||
fun moveFile(file: File, targetDir: File, onComplete: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
fileRepository.moveFile(file, File(targetDir, file.name))
|
||||
refreshFiles(_currentDir.value, _searchQuery.value, _sortOrder.value)
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
|
||||
fun organizeFiles(onComplete: (Int) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
_isOrganizing.value = true
|
||||
val allFiles = baseDir.walkTopDown().filter { it.isFile && it.extension.lowercase() == "epub" }.toList()
|
||||
var movedCount = 0
|
||||
|
||||
allFiles.forEach { file ->
|
||||
try {
|
||||
val metadata = epubRepository.parseMetadata(file)
|
||||
val author = metadata.author.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
||||
|
||||
val targetDir = if (!metadata.seriesName.isNullOrBlank()) {
|
||||
val seriesName = metadata.seriesName.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
||||
File(baseDir, "$author/$seriesName")
|
||||
} else {
|
||||
File(baseDir, author)
|
||||
}
|
||||
|
||||
if (fileRepository.safeMoveFile(file, targetDir)) {
|
||||
movedCount++
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
_isOrganizing.value = false
|
||||
refreshFiles(_currentDir.value, _searchQuery.value, _sortOrder.value)
|
||||
onComplete(movedCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package net.pythonsden.ao3_.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import net.pythonsden.ao3_.data.SettingsRepository
|
||||
|
||||
class MainViewModel(private val settingsRepository: SettingsRepository) : ViewModel() {
|
||||
|
||||
val offlineMode: StateFlow<Boolean> = settingsRepository.offlineModeFlow
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
val lastRoute: StateFlow<String?> = settingsRepository.lastRouteFlow
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||
|
||||
val lastEpubPath: StateFlow<String?> = settingsRepository.lastEpubPathFlow
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||
|
||||
fun toggleOfflineMode(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.updateOfflineMode(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLastRoute(route: String) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.updateLastRoute(route)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLastEpubPath(path: String) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.updateLastEpubPath(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package net.pythonsden.ao3_.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import net.pythonsden.ao3_.data.SettingsRepository
|
||||
import net.pythonsden.ao3_.data.repository.EpubRepository
|
||||
import java.io.File
|
||||
|
||||
sealed class ReaderUiState {
|
||||
object Loading : ReaderUiState()
|
||||
data class Success(val title: String, val content: String, val initialScroll: Int) : ReaderUiState()
|
||||
data class Error(val message: String) : ReaderUiState()
|
||||
}
|
||||
|
||||
class ReaderViewModel(
|
||||
private val epubRepository: EpubRepository,
|
||||
private val settingsRepository: SettingsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow<ReaderUiState>(ReaderUiState.Loading)
|
||||
val uiState: StateFlow<ReaderUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadEpub(file: File) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = ReaderUiState.Loading
|
||||
val initialScroll = settingsRepository.getReaderScrollFlow(file.absolutePath).first()
|
||||
epubRepository.getBookContent(file).onSuccess { (title, content) ->
|
||||
_uiState.value = ReaderUiState.Success(title, content, initialScroll)
|
||||
settingsRepository.updateLastEpubPath(file.absolutePath)
|
||||
}.onFailure {
|
||||
_uiState.value = ReaderUiState.Error(it.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveScrollPosition(filePath: String, scrollY: Int) {
|
||||
viewModelScope.launch {
|
||||
if (scrollY > 0) {
|
||||
settingsRepository.updateReaderScroll(filePath, scrollY)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package net.pythonsden.ao3_.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import net.pythonsden.ao3_.data.SettingsRepository
|
||||
import net.pythonsden.ao3_.data.repository.EpubRepository
|
||||
import net.pythonsden.ao3_.data.repository.FileRepository
|
||||
|
||||
class ViewModelFactory(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val fileRepository: FileRepository,
|
||||
private val epubRepository: EpubRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return when {
|
||||
modelClass.isAssignableFrom(MainViewModel::class.java) -> {
|
||||
MainViewModel(settingsRepository) as T
|
||||
}
|
||||
modelClass.isAssignableFrom(BrowserViewModel::class.java) -> {
|
||||
BrowserViewModel(settingsRepository) as T
|
||||
}
|
||||
modelClass.isAssignableFrom(DownloadsViewModel::class.java) -> {
|
||||
DownloadsViewModel(fileRepository, epubRepository) as T
|
||||
}
|
||||
modelClass.isAssignableFrom(ReaderViewModel::class.java) -> {
|
||||
ReaderViewModel(epubRepository, settingsRepository) as T
|
||||
}
|
||||
else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ kotlin = "2.2.10"
|
||||
composeBom = "2024.09.00"
|
||||
navigationCompose = "2.7.7"
|
||||
slf4j = "1.7.36"
|
||||
datastore = "1.1.1"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -32,6 +33,7 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio
|
||||
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||
epublib = { group = "com.positiondev.epublib", name = "epublib-core", version = "3.1" }
|
||||
slf4j-android = { group = "org.slf4j", name = "slf4j-android", version.ref = "slf4j" }
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
Reference in New Issue
Block a user