First working version

This commit is contained in:
sNice/Python
2026-03-22 17:26:33 -05:00
commit 8a59fe6455
53 changed files with 2053 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/app_icon"
android:label="@string/app_name"
android:roundIcon="@drawable/app_icon"
android:supportsRtl="true"
android:theme="@style/Theme.AO3client">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.AO3client">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,789 @@
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.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.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
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_.ui.theme.AO3clientTheme
import java.io.File
import java.io.FileInputStream
import java.net.URLDecoder
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setupCookieManager()
enableEdgeToEdge()
setContent {
AO3clientTheme {
MainScreen()
}
}
}
private fun setupCookieManager() {
val cookieManager = CookieManager.getInstance()
cookieManager.setAcceptCookie(true)
}
override fun onPause() {
super.onPause()
CookieManager.getInstance().flush()
}
override fun onDestroy() {
CookieManager.getInstance().flush()
super.onDestroy()
}
}
sealed class Screen(val route: String, val label: String, val icon: @Composable () -> Unit) {
object Browser : Screen("browser", "Browse", { Icon(Icons.Default.Language, contentDescription = null) })
object Downloads : Screen("downloads", "Downloads", { Icon(Icons.Default.Download, contentDescription = null) })
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
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) }
var isOfflineMode by remember {
mutableStateOf(prefs.getBoolean("offline_mode", false))
}
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
var isRestored by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
if (!isRestored) {
val lastRoute = prefs.getString("last_route", null)
val startRoute = if (isOfflineMode) Screen.Downloads.route else Screen.Browser.route
if (lastRoute != null && lastRoute != startRoute) {
if (lastRoute == "reader/{filePath}") {
val lastEpubPath = prefs.getString("last_epub_path", null)
if (lastEpubPath != null) {
navController.navigate("reader/$lastEpubPath")
}
} else if (!isOfflineMode || lastRoute == Screen.Downloads.route) {
navController.navigate(lastRoute) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
}
isRestored = true
}
}
Scaffold(
topBar = {
if (currentDestination?.route != "reader/{filePath}") {
TopAppBar(
title = { Text("AO3 Client") },
navigationIcon = {
if (currentDestination?.route == Screen.Browser.route && canGoBackInBrowser) {
IconButton(onClick = { webViewRef?.goBack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
},
actions = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(end = 8.dp)
) {
Text("Offline Mode", style = MaterialTheme.typography.labelLarge)
Switch(
checked = isOfflineMode,
onCheckedChange = { checked ->
isOfflineMode = checked
prefs.edit().putBoolean("offline_mode", checked).apply()
if (checked && currentDestination?.route == Screen.Browser.route) {
navController.navigate(Screen.Downloads.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
},
modifier = Modifier.padding(start = 8.dp)
)
}
}
)
}
},
bottomBar = {
if (currentDestination?.route != "reader/{filePath}") {
NavigationBar {
val screens = if (isOfflineMode) listOf(Screen.Downloads) else listOf(Screen.Browser, Screen.Downloads)
screens.forEach { screen ->
NavigationBarItem(
icon = screen.icon,
label = { Text(screen.label) },
selected = currentDestination?.route == screen.route,
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
}
) { innerPadding ->
NavHost(
navController = navController,
startDestination = if (isOfflineMode) Screen.Downloads.route else Screen.Browser.route,
modifier = Modifier.fillMaxSize()
) {
composable(Screen.Browser.route) {
if (isOfflineMode) {
Box(modifier = Modifier.padding(innerPadding).fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Offline mode is enabled.")
}
} else {
val lastUrl = prefs.getString("last_url", "https://archiveofourown.org/") ?: "https://archiveofourown.org/"
AO3WebView(
url = lastUrl,
modifier = Modifier.padding(innerPadding),
onWebViewCreated = { webViewRef = it },
onCanGoBackChanged = { canGoBackInBrowser = it }
)
}
}
composable(Screen.Downloads.route) {
DownloadsScreen(
navController = navController,
modifier = Modifier.padding(innerPadding)
)
}
composable(
route = "reader/{filePath}",
arguments = listOf(navArgument("filePath") { type = NavType.StringType })
) { backStackEntry ->
val encodedPath = backStackEntry.arguments?.getString("filePath") ?: ""
val filePath = URLDecoder.decode(encodedPath, StandardCharsets.UTF_8.toString())
EpubReaderScreen(File(filePath), 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 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) }
// Save scroll position
DisposableEffect(file.absolutePath) {
onDispose {
webViewRef?.let { wv ->
val scrollY = wv.scrollY
if (scrollY > 0) {
prefs.edit().putInt(scrollKey, scrollY).apply()
}
}
}
}
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) {
view?.post {
view.scrollTo(0, lastScroll)
}
}
}
}
webViewRef = this
}
},
update = { webView ->
webView.loadDataWithBaseURL(null, htmlContent, "text/html", "UTF-8", null)
}
)
}
}
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,11 @@
package net.pythonsden.ao3_.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,58 @@
package net.pythonsden.ao3_.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun AO3clientTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,34 @@
package net.pythonsden.ao3_.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">AO3-client</string>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.AO3client" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="my_downloads" path="Download" />
</paths>