From 67e965157576258e30c2e918f1bace6c4117bc4f Mon Sep 17 00:00:00 2001 From: paulcoding810 Date: Sun, 9 Mar 2025 10:35:50 +0700 Subject: [PATCH 01/16] Download images --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 4 + .../hviewer/helper/ImageDownloader.kt | 35 ++++ .../com/paulcoding/hviewer/model/SiteModel.kt | 4 +- .../hviewer/ui/page/post/DownloadService.kt | 190 ++++++++++++++++++ .../paulcoding/hviewer/ui/page/post/Images.kt | 46 ++++- .../hviewer/ui/page/post/PostPage.kt | 2 +- .../hviewer/ui/page/tabs/TabsPage.kt | 2 +- 8 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/paulcoding/hviewer/helper/ImageDownloader.kt create mode 100644 app/src/main/java/com/paulcoding/hviewer/ui/page/post/DownloadService.kt 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..b4686a7 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/ImageDownloader.kt b/app/src/main/java/com/paulcoding/hviewer/helper/ImageDownloader.kt new file mode 100644 index 0000000..748aae5 --- /dev/null +++ b/app/src/main/java/com/paulcoding/hviewer/helper/ImageDownloader.kt @@ -0,0 +1,35 @@ +package com.paulcoding.hviewer.helper + +import kotlinx.coroutines.* +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream + +object ImageDownloader { + private val client = OkHttpClient() + + suspend fun downloadImage(url: String, outputFile: File): Boolean { + return withContext(Dispatchers.IO) { + try { + 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 + } catch (e: Exception) { + e.printStackTrace() + return@withContext false + } + } + } +} 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/page/post/DownloadService.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/post/DownloadService.kt new file mode 100644 index 0000000..92c71c9 --- /dev/null +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/post/DownloadService.kt @@ -0,0 +1,190 @@ +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.Service +import android.content.Intent +import android.os.Build +import android.os.Environment +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.paulcoding.hviewer.helper.ImageDownloader +import com.paulcoding.hviewer.helper.SCRIPTS_DIR +import com.paulcoding.hviewer.model.PostData +import com.paulcoding.hviewer.model.SiteConfig +import com.paulcoding.js.JS +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.io.File + +class DownloadService : Service() { + private lateinit var notificationManager: NotificationManager + private lateinit var notificationBuilder: NotificationCompat.Builder + + 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 var downloadDir: File = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "HViewer" + ) + + init { + if (!downloadDir.exists()) { + downloadDir.mkdirs() + val nomediaFile = File(downloadDir, ".nomedia") + if (!nomediaFile.exists()) { + nomediaFile.createNewFile() + } + } + } + + private fun reset() { + postPage = 1 + postTotalPage = 1 + nextPage = null + images = mutableListOf() + } + + override fun onCreate() { + super.onCreate() + notificationManager = getSystemService(NotificationManager::class.java) + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + reset() + + startForeground(notificationId, createNotification("Downloading...")) + val postUrl = intent.getStringExtra("postUrl") + val postName = intent.getStringExtra("postName") + val siteConfig = intent.getParcelableExtra("siteConfig") + if (postUrl != null && postName != null && siteConfig != null) { + download(postUrl, postName, siteConfig) + } else { + throw IllegalArgumentException("Missing required parameters") + } + return START_NOT_STICKY + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + 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 + createNotification("Downloading $postName") + + try { + // fetch image urls + CoroutineScope(Dispatchers.IO).launch { + 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) + } + } catch (e: Exception) { + e.printStackTrace() + stopSelf() // stop the service if an exception occurs + } + } + + private suspend fun downloadImagesParallel(postName: String, onFinish: () -> Unit = {}) { + coroutineScope { + val outputDir = File(downloadDir, postName.replace(':', '_')).apply { + if (!exists()) { + mkdirs() + } + } + val downloadJobs = images.mapIndexed { index, url -> + async { + val file = + File(outputDir, "img_%0${images.size / 10}d.jpg".format(index)) + val success = ImageDownloader.downloadImage(url, file) + if (!success) { + println("❌ Failed to download: $url") + } + } + } + var totalProgress = 0 + downloadJobs.chunked(5).forEach { chunkedJobs -> + chunkedJobs.awaitAll() + totalProgress += chunkedJobs.size + updateNotification("Downloading ${totalProgress}/${images.size} images") + } + println("✅ All images downloaded successfully!") + showDownloadCompleteNotification(downloadDir) + onFinish() + } + } + + + private fun createNotification(content: String): Notification { + notificationBuilder = + NotificationCompat.Builder(this, channelId) + .setContentTitle(this.application.applicationInfo.name) + .setContentText(content).setSmallIcon(android.R.drawable.stat_sys_download) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setProgress(0, 0, true) + 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 completedNotification = + NotificationCompat.Builder(this, channelId).setContentTitle("Download Complete") + .setContentText("Tap to open") + .setSmallIcon(android.R.drawable.stat_sys_download_done) + // .setContentIntent(pendingIntent) // TODO: Add the pending intent here + .setAutoCancel(true).build() + + notificationManager.notify(notificationId, completedNotification) + } +} \ 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..309de87 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 @@ -1,5 +1,7 @@ package com.paulcoding.hviewer.ui.page.post +import android.content.Intent +import android.os.Build import android.widget.Toast import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween @@ -21,6 +23,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.KeyboardArrowUp import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -35,13 +38,19 @@ 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.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.rememberPermissionState import com.paulcoding.hviewer.MainApp.Companion.appContext import com.paulcoding.hviewer.helper.BasePaginationHelper import com.paulcoding.hviewer.helper.LoadMoreHandler +import com.paulcoding.hviewer.helper.makeToast +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 @@ -49,22 +58,29 @@ import com.paulcoding.hviewer.ui.component.SystemBar import kotlinx.coroutines.launch +@OptIn(ExperimentalPermissionsApi::class) @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 storagePermission = + rememberPermissionState(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) { granted -> + if (!granted) + makeToast("Permission Denied!") + } val uiState by viewModel.stateFlow.collectAsState() var selectedImage by remember { mutableStateOf(null) } val listState = rememberLazyListState() val scope = rememberCoroutineScope() + val context = LocalContext.current val translationY by animateDpAsState( targetValue = if (uiState.isSystemBarHidden) (-100).dp else 0.dp, @@ -90,6 +106,14 @@ fun ImageList( ) } + fun checkPermissionOrDownload(block: () -> Unit) { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q || storagePermission.status == PermissionStatus.Granted) { + block() + } else { + storagePermission.launchPermissionRequest() + } + } + LoadMoreHandler(uiState.images.size, listState, paginationHelper) SystemBar(uiState.isSystemBarHidden) @@ -151,6 +175,22 @@ fun ImageList( horizontalArrangement = Arrangement.SpaceBetween ) { bottomRowActions() + HIcon( + Icons.Outlined.Download, + size = 32, + rounded = true, + enabled = true, + onClick = { + checkPermissionOrDownload { + val intent = Intent(context, DownloadService::class.java).apply { + putExtra("postUrl", post.url) + putExtra("postName", post.name) + putExtra("siteConfig", siteConfig) + } + context.startForegroundService(intent) + } + } + ) Spacer(modifier = Modifier.weight(1f)) HIcon( Icons.Outlined.KeyboardArrowUp, 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/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 = { From 5f52c29114a44cf92dbdaacfe677189ef7c2a2cb Mon Sep 17 00:00:00 2001 From: paulcoding810 Date: Sun, 9 Mar 2025 13:54:22 +0700 Subject: [PATCH 02/16] Request notification permission --- .../hviewer/ui/page/post/DownloadService.kt | 15 ++++++++------- .../paulcoding/hviewer/ui/page/post/Images.kt | 17 ++++++++++++++++- 2 files changed, 24 insertions(+), 8 deletions(-) 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 index 92c71c9..23a7fa4 100644 --- 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 @@ -66,11 +66,11 @@ class DownloadService : Service() { override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { reset() - startForeground(notificationId, createNotification("Downloading...")) 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") @@ -99,7 +99,6 @@ class DownloadService : Service() { ) nextPage = postUrl - createNotification("Downloading $postName") try { // fetch image urls @@ -125,10 +124,11 @@ class DownloadService : Service() { mkdirs() } } + val paddingLength = images.size.toString().length val downloadJobs = images.mapIndexed { index, url -> async { val file = - File(outputDir, "img_%0${images.size / 10}d.jpg".format(index)) + File(outputDir, "img_${index.toString().padStart(paddingLength, '0')}.jpg") val success = ImageDownloader.downloadImage(url, file) if (!success) { println("❌ Failed to download: $url") @@ -138,8 +138,9 @@ class DownloadService : Service() { var totalProgress = 0 downloadJobs.chunked(5).forEach { chunkedJobs -> chunkedJobs.awaitAll() + delay(500) totalProgress += chunkedJobs.size - updateNotification("Downloading ${totalProgress}/${images.size} images") + updateNotification("${totalProgress}/${images.size} images") } println("✅ All images downloaded successfully!") showDownloadCompleteNotification(downloadDir) @@ -148,11 +149,11 @@ class DownloadService : Service() { } - private fun createNotification(content: String): Notification { + private fun createNotification(title: String): Notification { notificationBuilder = NotificationCompat.Builder(this, channelId) - .setContentTitle(this.application.applicationInfo.name) - .setContentText(content).setSmallIcon(android.R.drawable.stat_sys_download) + .setContentTitle(title) + .setSmallIcon(android.R.drawable.stat_sys_download) .setPriority(NotificationCompat.PRIORITY_LOW) .setProgress(0, 0, true) val notification = notificationBuilder.build() 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 309de87..f8af584 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 @@ -1,5 +1,6 @@ package com.paulcoding.hviewer.ui.page.post +import android.Manifest import android.content.Intent import android.os.Build import android.widget.Toast @@ -45,6 +46,7 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel 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.MainApp.Companion.appContext import com.paulcoding.hviewer.helper.BasePaginationHelper @@ -71,11 +73,21 @@ fun ImageList( factory = PostViewModelFactory(post.url, siteConfig = siteConfig) ) val storagePermission = - rememberPermissionState(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) { granted -> + 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 + } + val uiState by viewModel.stateFlow.collectAsState() var selectedImage by remember { mutableStateOf(null) } val listState = rememberLazyListState() @@ -107,6 +119,9 @@ fun ImageList( } 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 { From 1cf0f4aa85d2c893fbad57b212da5d0a29518995 Mon Sep 17 00:00:00 2001 From: paulcoding810 Date: Sun, 9 Mar 2025 15:29:37 +0700 Subject: [PATCH 03/16] Open folder on download completed --- app/src/main/AndroidManifest.xml | 13 ++++++++++++- .../hviewer/ui/page/post/DownloadService.kt | 17 ++++++++++++++++- app/src/main/res/xml/provider_paths.xml | 6 ++++++ 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 app/src/main/res/xml/provider_paths.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b4686a7..33209d4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -46,7 +46,18 @@ - + + + + \ 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 index 23a7fa4..071036e 100644 --- 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 @@ -4,12 +4,14 @@ 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.content.Intent import android.os.Build import android.os.Environment import android.os.IBinder import androidx.core.app.NotificationCompat +import androidx.core.content.FileProvider import com.paulcoding.hviewer.helper.ImageDownloader import com.paulcoding.hviewer.helper.SCRIPTS_DIR import com.paulcoding.hviewer.model.PostData @@ -129,6 +131,7 @@ class DownloadService : Service() { async { val file = File(outputDir, "img_${index.toString().padStart(paddingLength, '0')}.jpg") + val success = ImageDownloader.downloadImage(url, file) if (!success) { println("❌ Failed to download: $url") @@ -179,11 +182,23 @@ class DownloadService : Service() { } private fun showDownloadCompleteNotification(file: File) { + val uri = FileProvider.getUriForFile( + this, + "${applicationContext.packageName}.fileprovider", + file + ) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "*/*") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val pendingIntent = + PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE) + val completedNotification = NotificationCompat.Builder(this, channelId).setContentTitle("Download Complete") .setContentText("Tap to open") .setSmallIcon(android.R.drawable.stat_sys_download_done) - // .setContentIntent(pendingIntent) // TODO: Add the pending intent here + .setContentIntent(pendingIntent) .setAutoCancel(true).build() notificationManager.notify(notificationId, completedNotification) 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 From 882ef1d9bc6b4d0de3216eec0c3a11b280b35de5 Mon Sep 17 00:00:00 2001 From: paulcoding810 Date: Mon, 10 Mar 2025 10:52:51 +0700 Subject: [PATCH 04/16] Add downloads page --- .../com/paulcoding/hviewer/helper/File.kt | 13 ++ .../paulcoding/hviewer/ui/page/AppEntry.kt | 9 ++ .../com/paulcoding/hviewer/ui/page/Route.kt | 1 + .../ui/page/downloads/DownloadsPage.kt | 133 ++++++++++++++++++ .../hviewer/ui/page/post/DownloadService.kt | 16 +-- .../hviewer/ui/page/sites/SitesPage.kt | 5 + app/src/main/res/values/strings.xml | 1 + 7 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/paulcoding/hviewer/ui/page/downloads/DownloadsPage.kt 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/ui/page/AppEntry.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/AppEntry.kt index 571cb4e..a089fb3 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 @@ -31,6 +31,7 @@ import com.paulcoding.hviewer.model.Tag import com.paulcoding.hviewer.network.Github import com.paulcoding.hviewer.preference.Preferences 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 @@ -120,6 +121,9 @@ fun AppEntry(intent: Intent?) { navToHistory = { navController.navigate(Route.HISTORY) }, + navToDownloads = { + navController.navigate(Route.DOWNLOADS) + }, goBack = { navController.popBackStack() }) } animatedComposable(Route.SETTINGS) { @@ -250,6 +254,11 @@ fun AppEntry(intent: Intent?) { appViewModel = appViewModel, siteConfigs = siteConfigs ) } + animatedComposable(Route.DOWNLOADS) { + DownloadsPage( + goBack = navController::popBackStack + ) + } } } diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/Route.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/Route.kt index 8334bef..e7f3592 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/Route.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/Route.kt @@ -16,4 +16,5 @@ object Route { const val HISTORY = "history" const val WEBVIEW = "webview" const val TABS = "tabs" + const val DOWNLOADS = "downloads" } \ No newline at end of file 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..7bde5aa --- /dev/null +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/downloads/DownloadsPage.kt @@ -0,0 +1,133 @@ +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.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.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) } + + LaunchedEffect(Unit) { + downloadDir.listFiles()?.filter { it.isDirectory }?.toList()?.let { + dirs = it + } + 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, + rounded = true, + ) { 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) + ) + } + } + if (dirs.isEmpty()) item { + HEmpty() + } + } + else { + ImageList(selectedDir!!) + } + } + } +} + +@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 index 071036e..5c43b16 100644 --- 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 @@ -8,12 +8,12 @@ import android.app.PendingIntent import android.app.Service import android.content.Intent import android.os.Build -import android.os.Environment import android.os.IBinder import androidx.core.app.NotificationCompat import androidx.core.content.FileProvider 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 @@ -38,20 +38,6 @@ class DownloadService : Service() { private var nextPage: String? = null private var images: MutableList = mutableListOf() - private var downloadDir: File = File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "HViewer" - ) - - init { - if (!downloadDir.exists()) { - downloadDir.mkdirs() - val nomediaFile = File(downloadDir, ".nomedia") - if (!nomediaFile.exists()) { - nomediaFile.createNewFile() - } - } - } - private fun reset() { postPage = 1 postTotalPage = 1 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index d30b9ab..4206666 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,4 +31,5 @@ Edit local scripts Invalid URL %1$s Invalid Repo + Downloads \ No newline at end of file From e4cf7c22dc6fbe0bafb0bebbbb345f81820fbb64 Mon Sep 17 00:00:00 2001 From: paulcoding810 Date: Mon, 10 Mar 2025 15:51:44 +0700 Subject: [PATCH 05/16] Deeplink to downloads page --- app/src/main/AndroidManifest.xml | 11 +++++++ .../paulcoding/hviewer/ui/page/AppEntry.kt | 24 ++++++++++++--- .../com/paulcoding/hviewer/ui/page/Route.kt | 1 - .../hviewer/ui/page/post/DownloadService.kt | 29 +++++++++++-------- 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 33209d4..9d5874d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -40,6 +40,17 @@ + + + + + + + + + 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 a089fb3..0d32dfc 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 @@ -20,9 +20,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 @@ -83,6 +86,7 @@ fun AppEntry(intent: Intent?) { } LaunchedEffect(updatedIntent) { + updatedIntent?.apply { when (action) { Intent.ACTION_SEND -> { @@ -94,7 +98,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 -> { @@ -122,7 +132,7 @@ fun AppEntry(intent: Intent?) { navController.navigate(Route.HISTORY) }, navToDownloads = { - navController.navigate(Route.DOWNLOADS) + navController.navigate("downloads/") }, goBack = { navController.popBackStack() }) } @@ -254,9 +264,15 @@ fun AppEntry(intent: Intent?) { appViewModel = appViewModel, siteConfigs = siteConfigs ) } - animatedComposable(Route.DOWNLOADS) { + 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 + goBack = navController::popBackStack, + initialDir = path ) } } diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/Route.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/Route.kt index e7f3592..8334bef 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/Route.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/Route.kt @@ -16,5 +16,4 @@ object Route { const val HISTORY = "history" const val WEBVIEW = "webview" const val TABS = "tabs" - const val DOWNLOADS = "downloads" } \ 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 index 5c43b16..bd5d38f 100644 --- 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 @@ -6,11 +6,14 @@ 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.content.FileProvider +import androidx.core.net.toUri +import com.paulcoding.hviewer.MainActivity import com.paulcoding.hviewer.helper.ImageDownloader import com.paulcoding.hviewer.helper.SCRIPTS_DIR import com.paulcoding.hviewer.helper.downloadDir @@ -132,7 +135,7 @@ class DownloadService : Service() { updateNotification("${totalProgress}/${images.size} images") } println("✅ All images downloaded successfully!") - showDownloadCompleteNotification(downloadDir) + showDownloadCompleteNotification(outputDir) onFinish() } } @@ -168,24 +171,26 @@ class DownloadService : Service() { } private fun showDownloadCompleteNotification(file: File) { - val uri = FileProvider.getUriForFile( + val deepLinkIntent = Intent( + Intent.ACTION_VIEW, + "hviewer://downloads/${Uri.encode(file.absolutePath)}".toUri(), this, - "${applicationContext.packageName}.fileprovider", - file + MainActivity::class.java ) - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, "*/*") - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + + val deepLinkPendingIntent: PendingIntent? = TaskStackBuilder.create(this).run { + addNextIntentWithParentStack(deepLinkIntent) + getPendingIntent(0, PendingIntent.FLAG_MUTABLE) } - val pendingIntent = - PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE) + val completedNotification = NotificationCompat.Builder(this, channelId).setContentTitle("Download Complete") .setContentText("Tap to open") .setSmallIcon(android.R.drawable.stat_sys_download_done) - .setContentIntent(pendingIntent) - .setAutoCancel(true).build() + .setContentIntent(deepLinkPendingIntent) + .setAutoCancel(true) + .build() notificationManager.notify(notificationId, completedNotification) } From 603e7f9d31fd0c763019c0d79787ab9f59233f4c Mon Sep 17 00:00:00 2001 From: paulcoding810 Date: Mon, 10 Mar 2025 16:01:29 +0700 Subject: [PATCH 06/16] Fix invalid download directory name --- .../com/paulcoding/hviewer/ui/page/post/DownloadService.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index bd5d38f..cf02ffb 100644 --- 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 @@ -109,8 +109,9 @@ class DownloadService : Service() { } private suspend fun downloadImagesParallel(postName: String, onFinish: () -> Unit = {}) { + val outputName = postName.replace(Regex("[^a-zA-Z0-9._]"), "_") coroutineScope { - val outputDir = File(downloadDir, postName.replace(':', '_')).apply { + val outputDir = File(downloadDir, outputName).apply { if (!exists()) { mkdirs() } From 8fdd4922437afa30ff5569b8ca592b2d326ce432 Mon Sep 17 00:00:00 2001 From: paulcoding810 Date: Mon, 10 Mar 2025 16:05:12 +0700 Subject: [PATCH 07/16] Update close image list button --- .../com/paulcoding/hviewer/ui/page/downloads/DownloadsPage.kt | 1 - 1 file changed, 1 deletion(-) 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 index 7bde5aa..ef5e1a7 100644 --- 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 @@ -60,7 +60,6 @@ fun DownloadsPage( }, actions = { if (selectedDir != null) HIcon( Icons.Outlined.Close, - rounded = true, ) { selectedDir = null } }) }) { paddings -> From 5cc2ad6ab4be9ff607c3bbd552e02f5d38301ec9 Mon Sep 17 00:00:00 2001 From: paulcoding810 Date: Mon, 10 Mar 2025 16:42:26 +0700 Subject: [PATCH 08/16] Disable download button when downloading --- .../hviewer/ui/page/post/DownloadService.kt | 18 ++++++++++++++++++ .../paulcoding/hviewer/ui/page/post/Images.kt | 7 +++++-- 2 files changed, 23 insertions(+), 2 deletions(-) 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 index cf02ffb..ff0e305 100644 --- 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 @@ -26,6 +26,9 @@ 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 @@ -46,6 +49,8 @@ class DownloadService : Service() { postTotalPage = 1 nextPage = null images = mutableListOf() + + _downloadStatusFlow.update { DownloadStatus.IDLE } } override fun onCreate() { @@ -93,6 +98,7 @@ class DownloadService : Service() { try { // fetch image urls + _downloadStatusFlow.update { DownloadStatus.DOWNLOADING } CoroutineScope(Dispatchers.IO).launch { while (postPage <= postTotalPage) { delay(1000) // add some delay to avoid getting blocked by the server @@ -105,6 +111,8 @@ class DownloadService : Service() { } catch (e: Exception) { e.printStackTrace() stopSelf() // stop the service if an exception occurs + } finally { + _downloadStatusFlow.update { DownloadStatus.IDLE } } } @@ -195,4 +203,14 @@ class DownloadService : Service() { notificationManager.notify(notificationId, completedNotification) } + + companion object { + private val _downloadStatusFlow = MutableStateFlow(DownloadStatus.IDLE) + val downloadStatusFlow = _downloadStatusFlow.asStateFlow() + } +} + +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 f8af584..46b84bd 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 @@ -25,6 +25,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.Downloading import androidx.compose.material.icons.outlined.KeyboardArrowUp import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -99,6 +100,8 @@ fun ImageList( animationSpec = tween(200) ) + val downloadState by DownloadService.downloadStatusFlow.collectAsState() + LaunchedEffect(uiState.error) { uiState.error?.let { Toast.makeText(appContext, it.message ?: it.toString(), Toast.LENGTH_SHORT).show() @@ -191,10 +194,10 @@ fun ImageList( ) { bottomRowActions() HIcon( - Icons.Outlined.Download, + imageVector = if (downloadState == DownloadStatus.IDLE) Icons.Outlined.Download else Icons.Outlined.Downloading, size = 32, rounded = true, - enabled = true, + enabled = downloadState == DownloadStatus.IDLE, onClick = { checkPermissionOrDownload { val intent = Intent(context, DownloadService::class.java).apply { From 7c4e8225aab55f82ac1e4605a8c58160fb34eaa3 Mon Sep 17 00:00:00 2001 From: paulcoding810 Date: Tue, 11 Mar 2025 09:10:55 +0700 Subject: [PATCH 09/16] Fix downloadStatusFlow not updated outside coroutine scope --- .../com/paulcoding/hviewer/ui/page/post/DownloadService.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index ff0e305..ab1e33e 100644 --- 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 @@ -98,8 +98,8 @@ class DownloadService : Service() { try { // fetch image urls - _downloadStatusFlow.update { DownloadStatus.DOWNLOADING } CoroutineScope(Dispatchers.IO).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) } @@ -107,6 +107,7 @@ class DownloadService : Service() { postPage++ } downloadImagesParallel(postName) + _downloadStatusFlow.update { DownloadStatus.IDLE } } } catch (e: Exception) { e.printStackTrace() From 87d9d8a45a3103633468eb9d6d0754260fde2180 Mon Sep 17 00:00:00 2001 From: paulcoding810 Date: Tue, 11 Mar 2025 09:14:19 +0700 Subject: [PATCH 10/16] Update download output name pattern --- .../java/com/paulcoding/hviewer/ui/page/post/DownloadService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index ab1e33e..81fc36a 100644 --- 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 @@ -118,7 +118,7 @@ class DownloadService : Service() { } private suspend fun downloadImagesParallel(postName: String, onFinish: () -> Unit = {}) { - val outputName = postName.replace(Regex("[^a-zA-Z0-9._]"), "_") + val outputName = postName.trim().replace(Regex("[^\\p{L}0-9._]"), "_") coroutineScope { val outputDir = File(downloadDir, outputName).apply { if (!exists()) { From 27316799691df3ca93b92b501dd55e9e1a4b00f6 Mon Sep 17 00:00:00 2001 From: paulcoding810 Date: Tue, 11 Mar 2025 10:03:04 +0700 Subject: [PATCH 11/16] Make download feature accessible everywhere --- .../hviewer/helper/DownloadState.kt | 82 +++++ .../hviewer/ui/CompositionLocals.kt | 6 + .../paulcoding/hviewer/ui/page/AppEntry.kt | 320 +++++++++--------- .../paulcoding/hviewer/ui/page/post/Images.kt | 57 ---- .../hviewer/ui/page/posts/PostCard.kt | 25 +- app/src/main/res/values/strings.xml | 1 + 6 files changed, 274 insertions(+), 217 deletions(-) create mode 100644 app/src/main/java/com/paulcoding/hviewer/helper/DownloadState.kt create mode 100644 app/src/main/java/com/paulcoding/hviewer/ui/CompositionLocals.kt 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/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/page/AppEntry.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/AppEntry.kt index 0d32dfc..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 @@ -33,6 +34,7 @@ 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 @@ -54,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 @@ -112,168 +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) - }, - navToDownloads = { - navController.navigate("downloads/") - }, - 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 = "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 - ) + }) + } + 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/post/Images.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/post/Images.kt index 46b84bd..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 @@ -1,8 +1,5 @@ package com.paulcoding.hviewer.ui.page.post -import android.Manifest -import android.content.Intent -import android.os.Build import android.widget.Toast import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween @@ -24,8 +21,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack -import androidx.compose.material.icons.outlined.Download -import androidx.compose.material.icons.outlined.Downloading import androidx.compose.material.icons.outlined.KeyboardArrowUp import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -40,19 +35,13 @@ 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.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel -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.MainApp.Companion.appContext import com.paulcoding.hviewer.helper.BasePaginationHelper import com.paulcoding.hviewer.helper.LoadMoreHandler -import com.paulcoding.hviewer.helper.makeToast import com.paulcoding.hviewer.model.PostItem import com.paulcoding.hviewer.model.SiteConfig import com.paulcoding.hviewer.ui.component.HIcon @@ -60,8 +49,6 @@ import com.paulcoding.hviewer.ui.component.HLoading import com.paulcoding.hviewer.ui.component.SystemBar import kotlinx.coroutines.launch - -@OptIn(ExperimentalPermissionsApi::class) @Composable fun ImageList( post: PostItem, @@ -73,34 +60,17 @@ fun ImageList( key = post.url, factory = PostViewModelFactory(post.url, siteConfig = siteConfig) ) - 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 - } val uiState by viewModel.stateFlow.collectAsState() var selectedImage by remember { mutableStateOf(null) } val listState = rememberLazyListState() val scope = rememberCoroutineScope() - val context = LocalContext.current val translationY by animateDpAsState( targetValue = if (uiState.isSystemBarHidden) (-100).dp else 0.dp, animationSpec = tween(200) ) - val downloadState by DownloadService.downloadStatusFlow.collectAsState() LaunchedEffect(uiState.error) { uiState.error?.let { @@ -121,17 +91,6 @@ fun ImageList( ) } - 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() - } - } - LoadMoreHandler(uiState.images.size, listState, paginationHelper) SystemBar(uiState.isSystemBarHidden) @@ -193,22 +152,6 @@ fun ImageList( horizontalArrangement = Arrangement.SpaceBetween ) { bottomRowActions() - HIcon( - imageVector = if (downloadState == DownloadStatus.IDLE) Icons.Outlined.Download else Icons.Outlined.Downloading, - size = 32, - rounded = true, - enabled = downloadState == DownloadStatus.IDLE, - onClick = { - checkPermissionOrDownload { - val intent = Intent(context, DownloadService::class.java).apply { - putExtra("postUrl", post.url) - putExtra("postName", post.name) - putExtra("siteConfig", siteConfig) - } - context.startForegroundService(intent) - } - } - ) Spacer(modifier = Modifier.weight(1f)) HIcon( Icons.Outlined.KeyboardArrowUp, 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4206666..5dd45c7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,4 +32,5 @@ Invalid URL %1$s Invalid Repo Downloads + Downloading %1$s \ No newline at end of file From 218ea3e9b5e4e8008538d0142e46b39294d11d3f Mon Sep 17 00:00:00 2001 From: paulcoding810 Date: Tue, 11 Mar 2025 10:39:11 +0700 Subject: [PATCH 12/16] Update string resources --- .../com/paulcoding/hviewer/ui/page/post/DownloadService.kt | 5 +++-- app/src/main/res/values/strings.xml | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) 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 index 81fc36a..38aa8f1 100644 --- 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 @@ -14,6 +14,7 @@ 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 @@ -195,8 +196,8 @@ class DownloadService : Service() { val completedNotification = - NotificationCompat.Builder(this, channelId).setContentTitle("Download Complete") - .setContentText("Tap to open") + 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) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5dd45c7..ee65531 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,4 +33,6 @@ Invalid Repo Downloads Downloading %1$s + Tap to open + Download Complete \ No newline at end of file From ce2b621d8106ea74212a6f96beb120c839474282 Mon Sep 17 00:00:00 2001 From: paulcoding810 Date: Tue, 11 Mar 2025 11:15:29 +0700 Subject: [PATCH 13/16] Enable stop downloading --- .../hviewer/helper/ImageDownloader.kt | 31 ++++++------ .../hviewer/ui/page/post/DownloadService.kt | 47 ++++++++++++++++--- 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/paulcoding/hviewer/helper/ImageDownloader.kt b/app/src/main/java/com/paulcoding/hviewer/helper/ImageDownloader.kt index 748aae5..3cc3492 100644 --- a/app/src/main/java/com/paulcoding/hviewer/helper/ImageDownloader.kt +++ b/app/src/main/java/com/paulcoding/hviewer/helper/ImageDownloader.kt @@ -1,35 +1,32 @@ package com.paulcoding.hviewer.helper -import kotlinx.coroutines.* +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(url: String, outputFile: File): Boolean { - return withContext(Dispatchers.IO) { - try { - val request = Request.Builder().url(url).build() - val response = client.newCall(request).execute() + 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 + if (!response.isSuccessful) return@withContext false - val inputStream: InputStream? = response.body?.byteStream() - val outputStream = FileOutputStream(outputFile) + val inputStream: InputStream? = response.body?.byteStream() + val outputStream = FileOutputStream(outputFile) - inputStream?.copyTo(outputStream) - outputStream.close() - inputStream?.close() + inputStream?.copyTo(outputStream) + outputStream.close() + inputStream?.close() - return@withContext true - } catch (e: Exception) { - e.printStackTrace() - return@withContext false - } + return@withContext true } } } 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 index 38aa8f1..21d0fdc 100644 --- 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 @@ -22,7 +22,9 @@ 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 @@ -32,11 +34,16 @@ 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 @@ -54,6 +61,13 @@ class DownloadService : Service() { _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) @@ -61,6 +75,11 @@ class DownloadService : Service() { } 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") @@ -79,6 +98,11 @@ class DownloadService : Service() { 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 @@ -99,7 +123,7 @@ class DownloadService : Service() { try { // fetch image urls - CoroutineScope(Dispatchers.IO).launch { + CoroutineScope(coroutineContext).launch { _downloadStatusFlow.update { DownloadStatus.DOWNLOADING } while (postPage <= postTotalPage) { delay(1000) // add some delay to avoid getting blocked by the server @@ -128,13 +152,14 @@ class DownloadService : Service() { } val paddingLength = images.size.toString().length val downloadJobs = images.mapIndexed { index, url -> - async { + async(context = coroutineContext, start = CoroutineStart.LAZY) { val file = File(outputDir, "img_${index.toString().padStart(paddingLength, '0')}.jpg") - - val success = ImageDownloader.downloadImage(url, file) - if (!success) { - println("❌ Failed to download: $url") + try { + ImageDownloader.downloadImage(coroutineContext, url, file) + } catch (e: Exception) { + println("❌ Failed to download image: $url") + e.printStackTrace() } } } @@ -153,12 +178,18 @@ class DownloadService : Service() { 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 @@ -196,7 +227,8 @@ class DownloadService : Service() { val completedNotification = - NotificationCompat.Builder(this, channelId).setContentTitle(getString(R.string.download_complete)) + 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) @@ -209,6 +241,7 @@ class DownloadService : Service() { companion object { private val _downloadStatusFlow = MutableStateFlow(DownloadStatus.IDLE) val downloadStatusFlow = _downloadStatusFlow.asStateFlow() + const val ACTION_STOP_SERVICE = "STOP_FOREGROUND_SERVICE" } } From 6f1d08a03e339bae3fb0a17dc006c942ac258e9a Mon Sep 17 00:00:00 2001 From: paulcoding810 Date: Tue, 11 Mar 2025 14:17:05 +0700 Subject: [PATCH 14/16] Update image & output dir name --- .../hviewer/ui/page/post/DownloadService.kt | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) 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 index 21d0fdc..efef3ad 100644 --- 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 @@ -143,18 +143,17 @@ class DownloadService : Service() { } private suspend fun downloadImagesParallel(postName: String, onFinish: () -> Unit = {}) { - val outputName = postName.trim().replace(Regex("[^\\p{L}0-9._]"), "_") + val outputName = postName.replace(Regex("[^\\p{L}0-9._]+"), " ").trim() coroutineScope { val outputDir = File(downloadDir, outputName).apply { if (!exists()) { mkdirs() } } - val paddingLength = images.size.toString().length val downloadJobs = images.mapIndexed { index, url -> async(context = coroutineContext, start = CoroutineStart.LAZY) { - val file = - File(outputDir, "img_${index.toString().padStart(paddingLength, '0')}.jpg") + + val file = File(outputDir, getImgName(url, index)) try { ImageDownloader.downloadImage(coroutineContext, url, file) } catch (e: Exception) { @@ -176,6 +175,20 @@ class DownloadService : Service() { } } + 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 { From 7783fcb3b663fa4c0ae5a56ed061eafa7f34c9f3 Mon Sep 17 00:00:00 2001 From: paulcoding810 Date: Tue, 11 Mar 2025 16:50:43 +0700 Subject: [PATCH 15/16] Delete downloaded folder --- .../hviewer/ui/component/ConfirmDialog.kt | 37 +++++++++++++++++++ .../ui/page/downloads/DownloadsPage.kt | 30 ++++++++++++++- app/src/main/res/values/strings.xml | 1 + 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/paulcoding/hviewer/ui/component/ConfirmDialog.kt 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/downloads/DownloadsPage.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/downloads/DownloadsPage.kt index ef5e1a7..569f29a 100644 --- 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 @@ -11,6 +11,7 @@ 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 @@ -30,6 +31,8 @@ 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 @@ -45,11 +48,16 @@ fun DownloadsPage( ) { var dirs by remember { mutableStateOf(emptyList()) } var selectedDir by remember { mutableStateOf(null) } + var dirWillBeDeleted by remember { mutableStateOf(null) } - LaunchedEffect(Unit) { + fun fetchDirs() { downloadDir.listFiles()?.filter { it.isDirectory }?.toList()?.let { dirs = it } + } + + LaunchedEffect(Unit) { + fetchDirs() initialDir?.let { if (File(it).exists()) selectedDir = File(it) } @@ -79,8 +87,13 @@ fun DownloadsPage( ) { Icon(Icons.Outlined.Folder, it.name) Text( - it.name, modifier = Modifier.padding(12.dp) + it.name, modifier = Modifier + .padding(12.dp) + .weight(1f) ) + HIcon(Icons.Outlined.Delete) { + dirWillBeDeleted = it + } } } if (dirs.isEmpty()) item { @@ -91,6 +104,19 @@ fun DownloadsPage( ImageList(selectedDir!!) } } + ConfirmDialog( + showDialog = dirWillBeDeleted != null, + title = "Confirm Delete", + text = "Are you sure you want to delete ${dirWillBeDeleted?.name}?\nThis process cannot be undone.", + onDismiss = { + dirWillBeDeleted = null + }, onConfirm = { + if (dirWillBeDeleted?.deleteRecursively() == true) { + makeToast("Deleted ${dirWillBeDeleted?.name}") + fetchDirs() + dirWillBeDeleted = null + } + }) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ee65531..00d88d2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -35,4 +35,5 @@ Downloading %1$s Tap to open Download Complete + Confirm \ No newline at end of file From 3aa083f9dca73f602f1cd96e2ab32b787a3167c4 Mon Sep 17 00:00:00 2001 From: paulcoding810 Date: Tue, 11 Mar 2025 16:54:34 +0700 Subject: [PATCH 16/16] Update string resources --- .../paulcoding/hviewer/ui/page/downloads/DownloadsPage.kt | 7 +++++-- app/src/main/res/values/strings.xml | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) 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 index 569f29a..07dd4b9 100644 --- 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 @@ -106,8 +106,11 @@ fun DownloadsPage( } ConfirmDialog( showDialog = dirWillBeDeleted != null, - title = "Confirm Delete", - text = "Are you sure you want to delete ${dirWillBeDeleted?.name}?\nThis process cannot be undone.", + title = stringResource(R.string.confirm_delete), + text = stringResource( + R.string.are_you_sure_you_want_to_delete_folder, + dirWillBeDeleted?.name ?: "" + ), onDismiss = { dirWillBeDeleted = null }, onConfirm = { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 00d88d2..b63ab26 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,4 +36,6 @@ 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