diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a9442a5..41c059e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) id("com.google.devtools.ksp") + id("kotlin-parcelize") } android { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index eacb4b2..9d5874d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,9 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/paulcoding/hviewer/helper/DownloadState.kt b/app/src/main/java/com/paulcoding/hviewer/helper/DownloadState.kt new file mode 100644 index 0000000..755c0ca --- /dev/null +++ b/app/src/main/java/com/paulcoding/hviewer/helper/DownloadState.kt @@ -0,0 +1,82 @@ +package com.paulcoding.hviewer.helper + +import android.Manifest +import android.content.Intent +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.paulcoding.hviewer.R +import com.paulcoding.hviewer.model.PostItem +import com.paulcoding.hviewer.ui.LocalHostsMap +import com.paulcoding.hviewer.ui.page.post.DownloadService +import com.paulcoding.hviewer.ui.page.post.DownloadStatus + + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun rememberDownloadState(post: PostItem): DownloadState { + val hostsMap = LocalHostsMap.current + + val downloadState by DownloadService.downloadStatusFlow.collectAsState() + val context = LocalContext.current + + val storagePermission = + rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { granted -> + if (!granted) + makeToast("Permission Denied!") + } + + val notificationPermission = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) { granted -> + if (!granted) + makeToast("Notification permission Denied!") + } + } else { + null + } + + fun checkPermissionOrDownload(block: () -> Unit) { + if (notificationPermission != null && !notificationPermission.status.isGranted) { + notificationPermission.launchPermissionRequest() + } + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q || storagePermission.status == PermissionStatus.Granted) { + block() + } else { + storagePermission.launchPermissionRequest() + } + } + + fun download() { + val siteConfig = + post.getSiteConfig(hostsMap) ?: throw Exception("Site config not found: ${post.url}") + + checkPermissionOrDownload { + makeToast(context.getString(R.string.downloading_post, post.name)) + val intent = Intent(context, DownloadService::class.java).apply { + putExtra("postUrl", post.url) + putExtra("postName", post.name) + putExtra("siteConfig", siteConfig) + } + context.startForegroundService(intent) + } + } + + return DownloadState( + status = downloadState, + isDownloading = downloadState == DownloadStatus.DOWNLOADING, + download = ::download + ) +} + +class DownloadState( + val status: DownloadStatus, + val isDownloading: Boolean, + val download: () -> Unit +) diff --git a/app/src/main/java/com/paulcoding/hviewer/helper/File.kt b/app/src/main/java/com/paulcoding/hviewer/helper/File.kt index 2f5bffe..75bb367 100644 --- a/app/src/main/java/com/paulcoding/hviewer/helper/File.kt +++ b/app/src/main/java/com/paulcoding/hviewer/helper/File.kt @@ -1,6 +1,7 @@ package com.paulcoding.hviewer.helper import android.content.Context +import android.os.Environment import com.google.gson.Gson import com.google.gson.reflect.TypeToken import java.io.File @@ -19,9 +20,21 @@ val Context.crashLogDir val Context.configFile get() = File(scriptsDir, CONFIG_FILE) +val downloadDir: File = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "HViewer" +) + fun Context.setupPaths() { scriptsDir.mkdir() crashLogDir.mkdir() + + if (!downloadDir.exists()) { + downloadDir.mkdirs() + val nomediaFile = File(downloadDir, ".nomedia") + if (!nomediaFile.exists()) { + nomediaFile.createNewFile() + } + } } fun Context.writeFile(data: String, fileName: String, fileDir: File = scriptsDir): File { diff --git a/app/src/main/java/com/paulcoding/hviewer/helper/ImageDownloader.kt b/app/src/main/java/com/paulcoding/hviewer/helper/ImageDownloader.kt new file mode 100644 index 0000000..3cc3492 --- /dev/null +++ b/app/src/main/java/com/paulcoding/hviewer/helper/ImageDownloader.kt @@ -0,0 +1,32 @@ +package com.paulcoding.hviewer.helper + +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream +import kotlin.coroutines.CoroutineContext + +object ImageDownloader { + private val client = OkHttpClient() + + suspend fun downloadImage(context: CoroutineContext, url: String, outputFile: File): Boolean { + return withContext(context) { + println("🔵 Downloading image: $url") + val request = Request.Builder().url(url).build() + val response = client.newCall(request).execute() + + if (!response.isSuccessful) return@withContext false + + val inputStream: InputStream? = response.body?.byteStream() + val outputStream = FileOutputStream(outputFile) + + inputStream?.copyTo(outputStream) + outputStream.close() + inputStream?.close() + + return@withContext true + } + } +} diff --git a/app/src/main/java/com/paulcoding/hviewer/model/SiteModel.kt b/app/src/main/java/com/paulcoding/hviewer/model/SiteModel.kt index 7fc7920..f610a35 100644 --- a/app/src/main/java/com/paulcoding/hviewer/model/SiteModel.kt +++ b/app/src/main/java/com/paulcoding/hviewer/model/SiteModel.kt @@ -1,5 +1,6 @@ package com.paulcoding.hviewer.model +import android.os.Parcelable import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable @@ -12,11 +13,12 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.paulcoding.hviewer.R +@kotlinx.parcelize.Parcelize data class SiteConfig( val baseUrl: String = "", val scriptFile: String = "", val tags: Map = mapOf(), -) { +) : Parcelable { private val icon get() = "https://www.google.com/s2/favicons?sz=64&domain=$baseUrl" diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/CompositionLocals.kt b/app/src/main/java/com/paulcoding/hviewer/ui/CompositionLocals.kt new file mode 100644 index 0000000..adabe6b --- /dev/null +++ b/app/src/main/java/com/paulcoding/hviewer/ui/CompositionLocals.kt @@ -0,0 +1,6 @@ +package com.paulcoding.hviewer.ui + +import androidx.compose.runtime.staticCompositionLocalOf +import com.paulcoding.hviewer.model.SiteConfig + +val LocalHostsMap = staticCompositionLocalOf> { mapOf() } diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/component/ConfirmDialog.kt b/app/src/main/java/com/paulcoding/hviewer/ui/component/ConfirmDialog.kt new file mode 100644 index 0000000..97a982b --- /dev/null +++ b/app/src/main/java/com/paulcoding/hviewer/ui/component/ConfirmDialog.kt @@ -0,0 +1,37 @@ +package com.paulcoding.hviewer.ui.component + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import com.paulcoding.hviewer.R + + +@Composable +fun ConfirmDialog( + showDialog: Boolean, + title: String = "", + text: String = "", + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + if (showDialog) { + AlertDialog( + onDismissRequest = { onDismiss() }, + title = { Text(text = title) }, + text = { Text(text = text) }, + confirmButton = { + TextButton(onClick = { onConfirm() }) { + Text(stringResource(R.string.confirm)) + } + }, + dismissButton = { + TextButton(onClick = { onDismiss() }) { + Text(stringResource(R.string.cancel), color = Color.Red) + } + } + ) + } +} diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/AppEntry.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/AppEntry.kt index 571cb4e..f6dce98 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/AppEntry.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/AppEntry.kt @@ -8,6 +8,7 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -20,9 +21,12 @@ import androidx.navigation.NamedNavArgument import androidx.navigation.NavBackStackEntry import androidx.navigation.NavDeepLink import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import androidx.navigation.navDeepLink import com.paulcoding.hviewer.R import com.paulcoding.hviewer.helper.makeToast import com.paulcoding.hviewer.model.PostItem @@ -30,7 +34,9 @@ import com.paulcoding.hviewer.model.SiteConfigs import com.paulcoding.hviewer.model.Tag import com.paulcoding.hviewer.network.Github import com.paulcoding.hviewer.preference.Preferences +import com.paulcoding.hviewer.ui.LocalHostsMap import com.paulcoding.hviewer.ui.favorite.FavoritePage +import com.paulcoding.hviewer.ui.page.downloads.DownloadsPage import com.paulcoding.hviewer.ui.page.editor.EditorPage import com.paulcoding.hviewer.ui.page.editor.ListScriptPage import com.paulcoding.hviewer.ui.page.history.HistoryPage @@ -50,6 +56,7 @@ fun AppEntry(intent: Intent?) { val githubState by Github.stateFlow.collectAsState() val siteConfigs by remember { derivedStateOf { githubState.siteConfigs ?: SiteConfigs() } } + val hostsMap by remember { derivedStateOf { siteConfigs.toHostsMap() } } val appViewModel: AppViewModel = viewModel() val appState by appViewModel.stateFlow.collectAsState() val context = LocalContext.current @@ -82,6 +89,7 @@ fun AppEntry(intent: Intent?) { } LaunchedEffect(updatedIntent) { + updatedIntent?.apply { when (action) { Intent.ACTION_SEND -> { @@ -93,7 +101,13 @@ fun AppEntry(intent: Intent?) { } Intent.ACTION_VIEW -> { - handleIntentUrl(data.toString()) + // TODO: why deeplink not working + if (data.toString().startsWith("hviewer://")) { + val route = data.toString().substringAfter("hviewer://") + navController.navigate(route) + } else { + handleIntentUrl(data.toString()) + } } else -> { @@ -101,154 +115,169 @@ fun AppEntry(intent: Intent?) { } } } - - NavHost(navController, startDestination = startDestination) { - animatedComposable(Route.SITES) { - SitesPage( - isDevMode = appState.isDevMode, - siteConfigs = siteConfigs, - refresh = { Github.refreshLocalConfigs() }, - navToTopics = { siteConfig -> - appViewModel.setCurrentPost(PostItem(siteConfig.baseUrl)) - navController.navigate(Route.POSTS) - }, navToSettings = { - navController.navigate(Route.SETTINGS) - }, - navToFavorite = { - navController.navigate(Route.FAVORITE) - }, - navToHistory = { - navController.navigate(Route.HISTORY) - }, - goBack = { navController.popBackStack() }) - } - animatedComposable(Route.SETTINGS) { - SettingsPage(appViewModel = appViewModel, - navToListScript = { - navController.navigate(Route.LIST_SCRIPT + "/script") - }, - navToListCrashLog = { - navController.navigate(Route.LIST_SCRIPT + "/crash_log") - }, - onLockEnabled = { - navController.navigate(Route.LOCK) { - popUpTo(navController.graph.startDestinationId) { inclusive = true } - launchSingleTop = true - restoreState = false - } - }, goBack = { navController.popBackStack() }) - } - animatedComposable(Route.POSTS) { - PostsPage( - appViewModel, - navToImages = { post: PostItem -> - navToImages(post) - }, - navToSearch = { navController.navigate(Route.SEARCH) }, - navToCustomTag = { postItem, tag -> navToCustomTag(postItem, tag) }, - navToTabs = { navController.navigate(Route.TABS) }, - goBack = { navController.popBackStack() }, - ) - } - animatedComposable(Route.CUSTOM_TAG) { - CustomTagPage( - appViewModel, - navToCustomTag = { postItem, tag -> navToCustomTag(postItem, tag) }, - goBack = { navController.popBackStack() } - ) { - navToImages(it) + CompositionLocalProvider(LocalHostsMap provides hostsMap) { + NavHost(navController, startDestination = startDestination) { + animatedComposable(Route.SITES) { + SitesPage( + isDevMode = appState.isDevMode, + siteConfigs = siteConfigs, + refresh = { Github.refreshLocalConfigs() }, + navToTopics = { siteConfig -> + appViewModel.setCurrentPost(PostItem(siteConfig.baseUrl)) + navController.navigate(Route.POSTS) + }, navToSettings = { + navController.navigate(Route.SETTINGS) + }, + navToFavorite = { + navController.navigate(Route.FAVORITE) + }, + navToHistory = { + navController.navigate(Route.HISTORY) + }, + navToDownloads = { + navController.navigate("downloads/") + }, + goBack = { navController.popBackStack() }) } - } - animatedComposable(Route.POST) { - PostPage( - appViewModel, - navToWebView = { - appViewModel.setWebViewUrl(it) - navController.navigate(Route.WEBVIEW) - }, - hostMap = siteConfigs.toHostsMap(), - goBack = { - navController.popBackStack() - }) - } - animatedComposable(Route.SEARCH) { - SearchPage( - appViewModel = appViewModel, - navToImages = { post: PostItem -> - navToImages(post) - }, - navToCustomTag = { postItem, tag -> navToCustomTag(postItem, tag) }, - goBack = { navController.popBackStack() }, - ) - } - animatedComposable(Route.FAVORITE) { - FavoritePage( - appViewModel = appViewModel, - navToImages = { post: PostItem -> - navToImages(post) - }, - navToCustomTag = { post, tag -> - navToCustomTag(post, tag) - }, - goBack = { navController.popBackStack() } - ) - } - animatedComposable(Route.LIST_SCRIPT + "/{type}") { backStackEntry -> - val type = backStackEntry.arguments?.getString("type")!! + animatedComposable(Route.SETTINGS) { + SettingsPage(appViewModel = appViewModel, + navToListScript = { + navController.navigate(Route.LIST_SCRIPT + "/script") + }, + navToListCrashLog = { + navController.navigate(Route.LIST_SCRIPT + "/crash_log") + }, + onLockEnabled = { + navController.navigate(Route.LOCK) { + popUpTo(navController.graph.startDestinationId) { inclusive = true } + launchSingleTop = true + restoreState = false + } + }, goBack = { navController.popBackStack() }) + } + animatedComposable(Route.POSTS) { + PostsPage( + appViewModel, + navToImages = { post: PostItem -> + navToImages(post) + }, + navToSearch = { navController.navigate(Route.SEARCH) }, + navToCustomTag = { postItem, tag -> navToCustomTag(postItem, tag) }, + navToTabs = { navController.navigate(Route.TABS) }, + goBack = { navController.popBackStack() }, + ) + } + animatedComposable(Route.CUSTOM_TAG) { + CustomTagPage( + appViewModel, + navToCustomTag = { postItem, tag -> navToCustomTag(postItem, tag) }, + goBack = { navController.popBackStack() } + ) { + navToImages(it) + } + } + animatedComposable(Route.POST) { + PostPage( + appViewModel, + navToWebView = { + appViewModel.setWebViewUrl(it) + navController.navigate(Route.WEBVIEW) + }, + hostMap = siteConfigs.toHostsMap(), + goBack = { + navController.popBackStack() + }) + } + animatedComposable(Route.SEARCH) { + SearchPage( + appViewModel = appViewModel, + navToImages = { post: PostItem -> + navToImages(post) + }, + navToCustomTag = { postItem, tag -> navToCustomTag(postItem, tag) }, + goBack = { navController.popBackStack() }, + ) + } + animatedComposable(Route.FAVORITE) { + FavoritePage( + appViewModel = appViewModel, + navToImages = { post: PostItem -> + navToImages(post) + }, + navToCustomTag = { post, tag -> + navToCustomTag(post, tag) + }, + goBack = { navController.popBackStack() } + ) + } + animatedComposable(Route.LIST_SCRIPT + "/{type}") { backStackEntry -> + val type = backStackEntry.arguments?.getString("type")!! - ListScriptPage( - appViewModel = appViewModel, - type = type, - goBack = { navController.popBackStack() }, - navToEditor = { - navController.navigate(Route.EDITOR + "/$type" + "/$it") - }) - } - animatedComposable(Route.EDITOR + "/{type}" + "/{scriptFile}") { backStackEntry -> - val type = backStackEntry.arguments?.getString("type")!! - val scriptFile = backStackEntry.arguments?.getString("scriptFile")!! + ListScriptPage( + appViewModel = appViewModel, + type = type, + goBack = { navController.popBackStack() }, + navToEditor = { + navController.navigate(Route.EDITOR + "/$type" + "/$it") + }) + } + animatedComposable(Route.EDITOR + "/{type}" + "/{scriptFile}") { backStackEntry -> + val type = backStackEntry.arguments?.getString("type")!! + val scriptFile = backStackEntry.arguments?.getString("scriptFile")!! - EditorPage( - appViewModel = appViewModel, - type = type, - scriptFile = scriptFile, - goBack = { navController.popBackStack() }) - } - animatedComposable(Route.LOCK) { - LockPage(onUnlocked = { - navController.navigate(Route.SITES) - { - popUpTo(Route.LOCK) { - inclusive = true + EditorPage( + appViewModel = appViewModel, + type = type, + scriptFile = scriptFile, + goBack = { navController.popBackStack() }) + } + animatedComposable(Route.LOCK) { + LockPage(onUnlocked = { + navController.navigate(Route.SITES) + { + popUpTo(Route.LOCK) { + inclusive = true + } } - } - }) - } - animatedComposable(Route.HISTORY) { - HistoryPage( - goBack = { navController.popBackStack() }, appViewModel = appViewModel, - navToImages = { post: PostItem -> - navToImages(post) - }, - navToCustomTag = { post, tag -> - navToCustomTag(post, tag) - }, - deleteHistory = appViewModel::deleteHistory - ) - } - animatedComposable(Route.WEBVIEW) { - val url = appViewModel.getWebViewUrl() - WebPage(goBack = { navController.popBackStack() }, url = url) - } - animatedComposable(Route.TABS) { - TabsPage( - goBack = { - navController.popBackStack() - appViewModel.clearTabs() - }, - navToCustomTag = { postItem, tag -> navToCustomTag(postItem, tag) }, - appViewModel = appViewModel, siteConfigs = siteConfigs - ) + }) + } + animatedComposable(Route.HISTORY) { + HistoryPage( + goBack = { navController.popBackStack() }, appViewModel = appViewModel, + navToImages = { post: PostItem -> + navToImages(post) + }, + navToCustomTag = { post, tag -> + navToCustomTag(post, tag) + }, + deleteHistory = appViewModel::deleteHistory + ) + } + animatedComposable(Route.WEBVIEW) { + val url = appViewModel.getWebViewUrl() + WebPage(goBack = { navController.popBackStack() }, url = url) + } + animatedComposable(Route.TABS) { + TabsPage( + goBack = { + navController.popBackStack() + appViewModel.clearTabs() + }, + navToCustomTag = { postItem, tag -> navToCustomTag(postItem, tag) }, + appViewModel = appViewModel, siteConfigs = siteConfigs + ) + } + animatedComposable( + route = "downloads/{path}", + arguments = listOf(navArgument("path") { type = NavType.StringType }), + deepLinks = listOf(navDeepLink { uriPattern = "hviewer://downloads/{path}" }) + ) { backStackEntry -> + val path = backStackEntry.arguments?.getString("path") + DownloadsPage( + goBack = navController::popBackStack, + initialDir = path + ) + } } } } diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/downloads/DownloadsPage.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/downloads/DownloadsPage.kt new file mode 100644 index 0000000..07dd4b9 --- /dev/null +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/downloads/DownloadsPage.kt @@ -0,0 +1,161 @@ +package com.paulcoding.hviewer.ui.page.downloads + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +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.res.stringResource +import androidx.compose.ui.unit.dp +import com.paulcoding.hviewer.R +import com.paulcoding.hviewer.helper.downloadDir +import com.paulcoding.hviewer.helper.makeToast +import com.paulcoding.hviewer.ui.component.ConfirmDialog +import com.paulcoding.hviewer.ui.component.HBackIcon +import com.paulcoding.hviewer.ui.component.HEmpty +import com.paulcoding.hviewer.ui.component.HIcon +import com.paulcoding.hviewer.ui.page.post.ImageModal +import com.paulcoding.hviewer.ui.page.post.PostImage +import java.io.File + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DownloadsPage( + goBack: () -> Unit, + initialDir: String? = null, +) { + var dirs by remember { mutableStateOf(emptyList()) } + var selectedDir by remember { mutableStateOf(null) } + var dirWillBeDeleted by remember { mutableStateOf(null) } + + fun fetchDirs() { + downloadDir.listFiles()?.filter { it.isDirectory }?.toList()?.let { + dirs = it + } + } + + LaunchedEffect(Unit) { + fetchDirs() + initialDir?.let { + if (File(it).exists()) selectedDir = File(it) + } + } + Scaffold(topBar = { + TopAppBar(title = { Text(stringResource(R.string.downloads)) }, navigationIcon = { + HBackIcon { goBack() } + }, actions = { + if (selectedDir != null) HIcon( + Icons.Outlined.Close, + ) { selectedDir = null } + }) + }) { paddings -> + Column(modifier = Modifier.padding(paddings)) { + if (selectedDir == null) LazyColumn( + modifier = Modifier.padding(horizontal = 12.dp), + contentPadding = PaddingValues(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(dirs) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + selectedDir = it + }, + ) { + Icon(Icons.Outlined.Folder, it.name) + Text( + it.name, modifier = Modifier + .padding(12.dp) + .weight(1f) + ) + HIcon(Icons.Outlined.Delete) { + dirWillBeDeleted = it + } + } + } + if (dirs.isEmpty()) item { + HEmpty() + } + } + else { + ImageList(selectedDir!!) + } + } + ConfirmDialog( + showDialog = dirWillBeDeleted != null, + title = stringResource(R.string.confirm_delete), + text = stringResource( + R.string.are_you_sure_you_want_to_delete_folder, + dirWillBeDeleted?.name ?: "" + ), + onDismiss = { + dirWillBeDeleted = null + }, onConfirm = { + if (dirWillBeDeleted?.deleteRecursively() == true) { + makeToast("Deleted ${dirWillBeDeleted?.name}") + fetchDirs() + dirWillBeDeleted = null + } + }) + } +} + +@Composable +internal fun ImageList(selectedDir: File) { + var selectedImage by remember { mutableStateOf(null) } + var isSystemBarHidden by remember { mutableStateOf(false) } + val listState = rememberLazyListState() + + val images by remember { + derivedStateOf { + selectedDir.listFiles()?.map { it.absolutePath } ?: emptyList() + } + } + + LazyColumn( + state = listState, verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(images, key = { it }) { image -> + PostImage( + url = image, + onDoubleTap = { + selectedImage = image + }, + onTap = { + isSystemBarHidden = !isSystemBarHidden + }, + ) + } + if (images.isEmpty()) item { + HEmpty() + } + } + if (selectedImage != null) { + ImageModal(url = selectedImage!!) { + selectedImage = null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/post/DownloadService.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/post/DownloadService.kt new file mode 100644 index 0000000..efef3ad --- /dev/null +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/post/DownloadService.kt @@ -0,0 +1,264 @@ +package com.paulcoding.hviewer.ui.page.post + +import android.annotation.SuppressLint +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.app.TaskStackBuilder +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.net.toUri +import com.paulcoding.hviewer.MainActivity +import com.paulcoding.hviewer.R +import com.paulcoding.hviewer.helper.ImageDownloader +import com.paulcoding.hviewer.helper.SCRIPTS_DIR +import com.paulcoding.hviewer.helper.downloadDir +import com.paulcoding.hviewer.model.PostData +import com.paulcoding.hviewer.model.SiteConfig +import com.paulcoding.js.JS +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.io.File +import kotlin.coroutines.CoroutineContext + +class DownloadService : Service() { + private lateinit var notificationManager: NotificationManager + private lateinit var notificationBuilder: NotificationCompat.Builder + + private val job = SupervisorJob() + private val coroutineContext: CoroutineContext + get() = Dispatchers.IO + job + + private val channelId = "DownloadChannel" + private val notificationId = 1 + + private var postPage: Int = 1 + private var postTotalPage: Int = 1 + private var nextPage: String? = null + private var images: MutableList = mutableListOf() + + private fun reset() { + postPage = 1 + postTotalPage = 1 + nextPage = null + images = mutableListOf() + + _downloadStatusFlow.update { DownloadStatus.IDLE } + } + + private fun stopService() { + job.cancel() + reset() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + override fun onCreate() { + super.onCreate() + notificationManager = getSystemService(NotificationManager::class.java) + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + if (intent.action == ACTION_STOP_SERVICE) { + stopService() + return START_NOT_STICKY + } + + reset() + + val postUrl = intent.getStringExtra("postUrl") + val postName = intent.getStringExtra("postName") + val siteConfig = intent.getParcelableExtra("siteConfig") + if (postUrl != null && postName != null && siteConfig != null) { + startForeground(notificationId, createNotification("Downloading $postName")) + download(postUrl, postName, siteConfig) + } else { + throw IllegalArgumentException("Missing required parameters") + } + return START_NOT_STICKY + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + override fun onDestroy() { + job.cancel() + super.onDestroy() + } + + private suspend fun getImages(js: JS, url: String, page: Int = 1) { + js.callFunction("getImages", arrayOf(url, page)).onSuccess { postData -> + postTotalPage = postData.total + nextPage = postData.next + images = (images + postData.images).toMutableList() + }.onFailure { + throw (it) + } + } + + private fun download(postUrl: String, postName: String, siteConfig: SiteConfig) { + val js = JS( + fileRelativePath = SCRIPTS_DIR + "/${siteConfig.scriptFile}", + properties = mapOf("baseUrl" to siteConfig.baseUrl) + ) + + nextPage = postUrl + + try { + // fetch image urls + CoroutineScope(coroutineContext).launch { + _downloadStatusFlow.update { DownloadStatus.DOWNLOADING } + while (postPage <= postTotalPage) { + delay(1000) // add some delay to avoid getting blocked by the server + nextPage?.let { getImages(js, it, postPage) } + updateNotification("Fetching ($postPage/$postTotalPage) pages") + postPage++ + } + downloadImagesParallel(postName) + _downloadStatusFlow.update { DownloadStatus.IDLE } + } + } catch (e: Exception) { + e.printStackTrace() + stopSelf() // stop the service if an exception occurs + } finally { + _downloadStatusFlow.update { DownloadStatus.IDLE } + } + } + + private suspend fun downloadImagesParallel(postName: String, onFinish: () -> Unit = {}) { + val outputName = postName.replace(Regex("[^\\p{L}0-9._]+"), " ").trim() + coroutineScope { + val outputDir = File(downloadDir, outputName).apply { + if (!exists()) { + mkdirs() + } + } + val downloadJobs = images.mapIndexed { index, url -> + async(context = coroutineContext, start = CoroutineStart.LAZY) { + + val file = File(outputDir, getImgName(url, index)) + try { + ImageDownloader.downloadImage(coroutineContext, url, file) + } catch (e: Exception) { + println("❌ Failed to download image: $url") + e.printStackTrace() + } + } + } + var totalProgress = 0 + downloadJobs.chunked(5).forEach { chunkedJobs -> + chunkedJobs.awaitAll() + delay(500) + totalProgress += chunkedJobs.size + updateNotification("${totalProgress}/${images.size} images") + } + println("✅ All images downloaded successfully!") + showDownloadCompleteNotification(outputDir) + onFinish() + } + } + + private fun getImgName(url: String, index: Int): String { + val paddingLength = images.size.toString().length + + val extension = when { + url.contains(".webp", ignoreCase = true) -> "webp" + url.contains(".png", ignoreCase = true) -> "png" + url.contains(".gif", ignoreCase = true) -> "gif" + url.contains(".jpeg", ignoreCase = true) -> "jpeg" + url.contains(".jpg", ignoreCase = true) -> "jpg" + else -> "jpg" + } + + return "img_${(index + 1).toString().padStart(paddingLength, '0')}.$extension" + } + + private fun createNotification(title: String): Notification { + val stopIntent = Intent(this, DownloadService::class.java).apply { + action = ACTION_STOP_SERVICE + } + val stopPendingIntent = + PendingIntent.getService(this, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE) + notificationBuilder = + NotificationCompat.Builder(this, channelId) + .setContentTitle(title) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setProgress(0, 0, true) + .addAction(android.R.drawable.ic_delete, "Cancel", stopPendingIntent) + val notification = notificationBuilder.build() + notificationManager.notify(notificationId, notification) + return notification + } + + private fun updateNotification(msg: String) { + notificationBuilder.setContentText(msg) + notificationManager.notify(notificationId, notificationBuilder.build()) + } + + @SuppressLint("ObsoleteSdkInt") + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val serviceChannel = NotificationChannel( + channelId, "Download Service Channel", NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel( + serviceChannel + ) + } + } + + private fun showDownloadCompleteNotification(file: File) { + val deepLinkIntent = Intent( + Intent.ACTION_VIEW, + "hviewer://downloads/${Uri.encode(file.absolutePath)}".toUri(), + this, + MainActivity::class.java + ) + + val deepLinkPendingIntent: PendingIntent? = TaskStackBuilder.create(this).run { + addNextIntentWithParentStack(deepLinkIntent) + getPendingIntent(0, PendingIntent.FLAG_MUTABLE) + } + + + val completedNotification = + NotificationCompat.Builder(this, channelId) + .setContentTitle(getString(R.string.download_complete)) + .setContentText(getString(R.string.tap_to_open)) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setContentIntent(deepLinkPendingIntent) + .setAutoCancel(true) + .build() + + notificationManager.notify(notificationId, completedNotification) + } + + companion object { + private val _downloadStatusFlow = MutableStateFlow(DownloadStatus.IDLE) + val downloadStatusFlow = _downloadStatusFlow.asStateFlow() + const val ACTION_STOP_SERVICE = "STOP_FOREGROUND_SERVICE" + } +} + +enum class DownloadStatus { + IDLE, + DOWNLOADING, +} \ No newline at end of file diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/post/Images.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/post/Images.kt index c0c0b51..ce5032c 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/post/Images.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/post/Images.kt @@ -42,23 +42,23 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.paulcoding.hviewer.MainApp.Companion.appContext import com.paulcoding.hviewer.helper.BasePaginationHelper import com.paulcoding.hviewer.helper.LoadMoreHandler +import com.paulcoding.hviewer.model.PostItem import com.paulcoding.hviewer.model.SiteConfig import com.paulcoding.hviewer.ui.component.HIcon import com.paulcoding.hviewer.ui.component.HLoading import com.paulcoding.hviewer.ui.component.SystemBar import kotlinx.coroutines.launch - @Composable fun ImageList( - postUrl: String, + post: PostItem, siteConfig: SiteConfig, goBack: () -> Unit, bottomRowActions: @Composable (RowScope.() -> Unit) = {}, ) { val viewModel: PostViewModel = viewModel( - key = postUrl, - factory = PostViewModelFactory(postUrl, siteConfig = siteConfig) + key = post.url, + factory = PostViewModelFactory(post.url, siteConfig = siteConfig) ) val uiState by viewModel.stateFlow.collectAsState() @@ -71,6 +71,7 @@ fun ImageList( animationSpec = tween(200) ) + LaunchedEffect(uiState.error) { uiState.error?.let { Toast.makeText(appContext, it.message ?: it.toString(), Toast.LENGTH_SHORT).show() diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/post/PostPage.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/post/PostPage.kt index 7538d0a..3fd0088 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/post/PostPage.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/post/PostPage.kt @@ -18,7 +18,7 @@ fun PostPage( post.getSiteConfig(hostMap)?.let { ImageList( - postUrl = post.url, siteConfig = it, + post = post, siteConfig = it, goBack = goBack ) } diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/posts/PostCard.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/posts/PostCard.kt index 0c81b46..f6856c3 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/posts/PostCard.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/posts/PostCard.kt @@ -18,6 +18,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.Downloading import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -33,6 +35,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset @@ -45,6 +48,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.paulcoding.hviewer.R import com.paulcoding.hviewer.extensions.openInBrowser +import com.paulcoding.hviewer.helper.rememberDownloadState import com.paulcoding.hviewer.model.PostItem import com.paulcoding.hviewer.model.Tag import com.paulcoding.hviewer.ui.component.HFavoriteIcon @@ -158,6 +162,7 @@ fun InfoBottomSheet( ) { val bottomSheetState = rememberModalBottomSheetState() val activity = LocalActivity.current + val downloadState = rememberDownloadState(postItem) LaunchedEffect(visible) { if (visible) { @@ -177,8 +182,24 @@ fun InfoBottomSheet( .padding(16.dp), ) { postItem.run { - SelectionContainer { - Text(text = name, fontSize = 20.sp) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box(modifier = Modifier.weight(1f)) { + SelectionContainer { + Text(text = name, fontSize = 16.sp) + } + } + HIcon( + imageVector = if (downloadState.isDownloading) Icons.Outlined.Downloading else Icons.Outlined.Download, + enabled = !downloadState.isDownloading, + onClick = { + onDismissRequest() + downloadState.download() + } + ) } TextButton(onClick = { onDismissRequest() diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/sites/SitesPage.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/sites/SitesPage.kt index 0d90fd4..aca94e7 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/sites/SitesPage.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/sites/SitesPage.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.ExperimentalMaterial3Api @@ -47,6 +48,7 @@ fun SitesPage( siteConfigs: SiteConfigs, navToSettings: () -> Unit, navToHistory: () -> Unit, + navToDownloads: () -> Unit, refresh: () -> Unit, navToFavorite: () -> Unit, ) { @@ -60,6 +62,9 @@ fun SitesPage( Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.sites)) }, actions = { + HIcon(Icons.Outlined.Download) { + navToDownloads() + } HIcon(Icons.Outlined.History) { navToHistory() } diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/tabs/TabsPage.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/tabs/TabsPage.kt index ad9ecf4..55c6dc2 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/tabs/TabsPage.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/tabs/TabsPage.kt @@ -81,7 +81,7 @@ fun TabsPage( if (siteConfig != null) { ImageList( - tab.url, + tab, siteConfig = siteConfig, goBack = goBack, bottomRowActions = { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d30b9ab..b63ab26 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,4 +31,11 @@ Edit local scripts Invalid URL %1$s Invalid Repo + Downloads + Downloading %1$s + Tap to open + Download Complete + Confirm + Confirm Delete + Are you sure you want to delete %1$s?\nThis process cannot be undone. \ No newline at end of file diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml new file mode 100644 index 0000000..4cb486c --- /dev/null +++ b/app/src/main/res/xml/provider_paths.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file