From 67e965157576258e30c2e918f1bace6c4117bc4f Mon Sep 17 00:00:00 2001 From: paulcoding810 Date: Sun, 9 Mar 2025 10:35:50 +0700 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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 {