Major overhaul of project files, including reworking of app file management. May be broken, but pushing to stave progress.

This commit is contained in:
sNice/Python
2026-03-31 08:24:24 -05:00
parent 895f129b81
commit 271651ff02
18 changed files with 1154 additions and 632 deletions
BIN
View File
Binary file not shown.
+3 -6
View File
@@ -5,16 +5,12 @@ plugins {
android { android {
namespace = "net.pythonsden.ao3_" namespace = "net.pythonsden.ao3_"
compileSdk { compileSdk = 35
version = release(36) {
minorApiLevel = 1
}
}
defaultConfig { defaultConfig {
applicationId = "net.pythonsden.ao3_" applicationId = "net.pythonsden.ao3_"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
@@ -51,6 +47,7 @@ dependencies {
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.datastore.preferences)
implementation(libs.epublib) { implementation(libs.epublib) {
exclude(group = "org.slf4j", module = "slf4j-simple") exclude(group = "org.slf4j", module = "slf4j-simple")
+1
View File
@@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<application <application
android:name=".AO3Application"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_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_ 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.Bundle
import android.os.Environment
import android.view.ViewGroup
import android.webkit.CookieManager 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.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.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.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.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.core.content.FileProvider import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@@ -83,12 +43,16 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import nl.siegmann.epublib.domain.Book import net.pythonsden.ao3_.browser.AO3WebView
import nl.siegmann.epublib.domain.Resource import net.pythonsden.ao3_.downloads.DownloadsScreen
import nl.siegmann.epublib.epub.EpubReader import net.pythonsden.ao3_.reader.ReaderScreen
import net.pythonsden.ao3_.ui.theme.AO3clientTheme 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.File
import java.io.FileInputStream
import java.net.URLDecoder import java.net.URLDecoder
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
@@ -99,8 +63,14 @@ class MainActivity : ComponentActivity() {
setupCookieManager() setupCookieManager()
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
val app = application as AO3Application
val factory = ViewModelFactory(
app.settingsRepository,
app.fileRepository,
app.epubRepository
)
AO3clientTheme { AO3clientTheme {
MainScreen() MainScreen(factory)
} }
} }
} }
@@ -128,54 +98,35 @@ sealed class Screen(val route: String, val label: String, val icon: @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen() { fun MainScreen(factory: ViewModelFactory) {
val navController = rememberNavController() val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination 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 { val isOfflineMode by mainViewModel.offlineMode.collectAsState()
mutableStateOf(prefs.getBoolean("offline_mode", false)) 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) } // Handle Restoration
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
var isRestored by remember { mutableStateOf(false) } var isRestored by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(lastRoute, isOfflineMode) {
if (!isRestored) { if (!isRestored && lastRoute != null) {
val lastRoute = prefs.getString("last_route", null)
val startRoute = if (isOfflineMode) Screen.Downloads.route else Screen.Browser.route val startRoute = if (isOfflineMode) Screen.Downloads.route else Screen.Browser.route
if (lastRoute != startRoute) {
if (lastRoute != null && lastRoute != startRoute) {
if (lastRoute == "reader/{filePath}") { if (lastRoute == "reader/{filePath}") {
val lastEpubPath = prefs.getString("last_epub_path", null) lastEpubPath?.let { path ->
if (lastEpubPath != null) { val encoded = URLEncoder.encode(path, StandardCharsets.UTF_8.toString())
val encoded = URLEncoder.encode(lastEpubPath, StandardCharsets.UTF_8.toString())
navController.navigate("reader/$encoded") navController.navigate("reader/$encoded")
} }
} else if (!isOfflineMode || lastRoute == Screen.Downloads.route) { } else if (!isOfflineMode || lastRoute == Screen.Downloads.route) {
navController.navigate(lastRoute) { navController.navigate(lastRoute!!) {
popUpTo(navController.graph.findStartDestination().id) { popUpTo(navController.graph.findStartDestination().id) { saveState = true }
saveState = true
}
launchSingleTop = true launchSingleTop = true
restoreState = true restoreState = true
} }
@@ -185,6 +136,13 @@ fun MainScreen() {
} }
} }
// Save current route
LaunchedEffect(navBackStackEntry) {
navBackStackEntry?.destination?.route?.let { route ->
mainViewModel.updateLastRoute(route)
}
}
Scaffold( Scaffold(
topBar = { topBar = {
if (currentDestination?.route != "reader/{filePath}") { if (currentDestination?.route != "reader/{filePath}") {
@@ -192,9 +150,8 @@ fun MainScreen() {
title = { Text("AO3 Client") }, title = { Text("AO3 Client") },
navigationIcon = { navigationIcon = {
if (currentDestination?.route == Screen.Browser.route && canGoBackInBrowser) { if (currentDestination?.route == Screen.Browser.route && canGoBackInBrowser) {
IconButton(onClick = { webViewRef?.goBack() }) { // This would ideally be handled by a message to the browser component
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") // For now, Browser handles back via BackHandler internally
}
} }
}, },
actions = { actions = {
@@ -206,13 +163,10 @@ fun MainScreen() {
Switch( Switch(
checked = isOfflineMode, checked = isOfflineMode,
onCheckedChange = { checked -> onCheckedChange = { checked ->
isOfflineMode = checked mainViewModel.toggleOfflineMode(checked)
prefs.edit().putBoolean("offline_mode", checked).apply()
if (checked && currentDestination?.route == Screen.Browser.route) { if (checked && currentDestination?.route == Screen.Browser.route) {
navController.navigate(Screen.Downloads.route) { navController.navigate(Screen.Downloads.route) {
popUpTo(navController.graph.findStartDestination().id) { popUpTo(navController.graph.findStartDestination().id) { saveState = true }
saveState = true
}
launchSingleTop = true launchSingleTop = true
restoreState = true restoreState = true
} }
@@ -230,10 +184,11 @@ fun MainScreen() {
NavigationBar { NavigationBar {
val screens = if (isOfflineMode) listOf(Screen.Downloads) else listOf(Screen.Browser, Screen.Downloads) val screens = if (isOfflineMode) listOf(Screen.Downloads) else listOf(Screen.Browser, Screen.Downloads)
screens.forEach { screen -> screens.forEach { screen ->
val selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true
NavigationBarItem( NavigationBarItem(
icon = screen.icon, icon = screen.icon,
label = { Text(screen.label) }, label = { Text(screen.label) },
selected = currentDestination?.route == screen.route, selected = selected,
onClick = { onClick = {
navController.navigate(screen.route) { navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) { popUpTo(navController.graph.findStartDestination().id) {
@@ -260,17 +215,18 @@ fun MainScreen() {
Text("Offline mode is enabled.") Text("Offline mode is enabled.")
} }
} else { } else {
val lastUrl = prefs.getString("last_url", "https://archiveofourown.org/") ?: "https://archiveofourown.org/" val lastUrl by browserViewModel.lastUrl.collectAsState()
AO3WebView( AO3WebView(
url = lastUrl, url = lastUrl,
modifier = Modifier.padding(innerPadding), modifier = Modifier.padding(innerPadding),
onWebViewCreated = { webViewRef = it }, onUrlChanged = { browserViewModel.updateLastUrl(it) },
onCanGoBackChanged = { canGoBackInBrowser = it } onCanGoBackChanged = { browserViewModel.setCanGoBack(it) }
) )
} }
} }
composable(Screen.Downloads.route) { composable(Screen.Downloads.route) {
DownloadsScreen( DownloadsScreen(
viewModel = downloadsViewModel,
navController = navController, navController = navController,
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding)
) )
@@ -281,536 +237,8 @@ fun MainScreen() {
) { backStackEntry -> ) { backStackEntry ->
val encodedPath = backStackEntry.arguments?.getString("filePath") ?: "" val encodedPath = backStackEntry.arguments?.getString("filePath") ?: ""
val filePath = URLDecoder.decode(encodedPath, StandardCharsets.UTF_8.toString()) 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}")
}
}
}
+2
View File
@@ -11,6 +11,7 @@ kotlin = "2.2.10"
composeBom = "2024.09.00" composeBom = "2024.09.00"
navigationCompose = "2.7.7" navigationCompose = "2.7.7"
slf4j = "1.7.36" slf4j = "1.7.36"
datastore = "1.1.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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" } androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
epublib = { group = "com.positiondev.epublib", name = "epublib-core", version = "3.1" } epublib = { group = "com.positiondev.epublib", name = "epublib-core", version = "3.1" }
slf4j-android = { group = "org.slf4j", name = "slf4j-android", version.ref = "slf4j" } slf4j-android = { group = "org.slf4j", name = "slf4j-android", version.ref = "slf4j" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }