diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index a9442a5..41c059e 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
id("com.google.devtools.ksp")
+ id("kotlin-parcelize")
}
android {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index eacb4b2..9d5874d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,9 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/paulcoding/hviewer/helper/File.kt b/app/src/main/java/com/paulcoding/hviewer/helper/File.kt
index 2f5bffe..75bb367 100644
--- a/app/src/main/java/com/paulcoding/hviewer/helper/File.kt
+++ b/app/src/main/java/com/paulcoding/hviewer/helper/File.kt
@@ -1,6 +1,7 @@
package com.paulcoding.hviewer.helper
import android.content.Context
+import android.os.Environment
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.io.File
@@ -19,9 +20,21 @@ val Context.crashLogDir
val Context.configFile
get() = File(scriptsDir, CONFIG_FILE)
+val downloadDir: File = File(
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "HViewer"
+)
+
fun Context.setupPaths() {
scriptsDir.mkdir()
crashLogDir.mkdir()
+
+ if (!downloadDir.exists()) {
+ downloadDir.mkdirs()
+ val nomediaFile = File(downloadDir, ".nomedia")
+ if (!nomediaFile.exists()) {
+ nomediaFile.createNewFile()
+ }
+ }
}
fun Context.writeFile(data: String, fileName: String, fileDir: File = scriptsDir): File {
diff --git a/app/src/main/java/com/paulcoding/hviewer/helper/ImageDownloader.kt b/app/src/main/java/com/paulcoding/hviewer/helper/ImageDownloader.kt
new file mode 100644
index 0000000..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/AppEntry.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/AppEntry.kt
index 571cb4e..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
@@ -31,6 +34,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
@@ -82,6 +86,7 @@ fun AppEntry(intent: Intent?) {
}
LaunchedEffect(updatedIntent) {
+
updatedIntent?.apply {
when (action) {
Intent.ACTION_SEND -> {
@@ -93,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 -> {
@@ -120,6 +131,9 @@ fun AppEntry(intent: Intent?) {
navToHistory = {
navController.navigate(Route.HISTORY)
},
+ navToDownloads = {
+ navController.navigate("downloads/")
+ },
goBack = { navController.popBackStack() })
}
animatedComposable(Route.SETTINGS) {
@@ -250,6 +264,17 @@ fun AppEntry(intent: Intent?) {
appViewModel = appViewModel, siteConfigs = siteConfigs
)
}
+ animatedComposable(
+ route = "downloads/{path}",
+ arguments = listOf(navArgument("path") { type = NavType.StringType }),
+ deepLinks = listOf(navDeepLink { uriPattern = "hviewer://downloads/{path}" })
+ ) { backStackEntry ->
+ val path = backStackEntry.arguments?.getString("path")
+ DownloadsPage(
+ goBack = navController::popBackStack,
+ initialDir = path
+ )
+ }
}
}
diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/downloads/DownloadsPage.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/downloads/DownloadsPage.kt
new file mode 100644
index 0000000..ef5e1a7
--- /dev/null
+++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/downloads/DownloadsPage.kt
@@ -0,0 +1,132 @@
+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,
+ ) { 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
new file mode 100644
index 0000000..ff0e305
--- /dev/null
+++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/post/DownloadService.kt
@@ -0,0 +1,216 @@
+package com.paulcoding.hviewer.ui.page.post
+
+import android.annotation.SuppressLint
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.app.TaskStackBuilder
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import androidx.core.net.toUri
+import com.paulcoding.hviewer.MainActivity
+import com.paulcoding.hviewer.helper.ImageDownloader
+import com.paulcoding.hviewer.helper.SCRIPTS_DIR
+import com.paulcoding.hviewer.helper.downloadDir
+import com.paulcoding.hviewer.model.PostData
+import com.paulcoding.hviewer.model.SiteConfig
+import com.paulcoding.js.JS
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+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
+
+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 fun reset() {
+ postPage = 1
+ postTotalPage = 1
+ nextPage = null
+ images = mutableListOf()
+
+ _downloadStatusFlow.update { DownloadStatus.IDLE }
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ notificationManager = getSystemService(NotificationManager::class.java)
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
+ reset()
+
+ val postUrl = intent.getStringExtra("postUrl")
+ val postName = intent.getStringExtra("postName")
+ val siteConfig = intent.getParcelableExtra("siteConfig")
+ if (postUrl != null && postName != null && siteConfig != null) {
+ startForeground(notificationId, createNotification("Downloading $postName"))
+ download(postUrl, postName, siteConfig)
+ } else {
+ throw IllegalArgumentException("Missing required parameters")
+ }
+ return START_NOT_STICKY
+ }
+
+ override fun onBind(intent: Intent?): IBinder? {
+ return null
+ }
+
+ private suspend fun getImages(js: JS, url: String, page: Int = 1) {
+ js.callFunction("getImages", arrayOf(url, page)).onSuccess { postData ->
+ postTotalPage = postData.total
+ nextPage = postData.next
+ images = (images + postData.images).toMutableList()
+ }.onFailure {
+ throw (it)
+ }
+ }
+
+ private fun download(postUrl: String, postName: String, siteConfig: SiteConfig) {
+ val js = JS(
+ fileRelativePath = SCRIPTS_DIR + "/${siteConfig.scriptFile}",
+ properties = mapOf("baseUrl" to siteConfig.baseUrl)
+ )
+
+ nextPage = postUrl
+
+ try {
+ // fetch image urls
+ _downloadStatusFlow.update { DownloadStatus.DOWNLOADING }
+ 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
+ } finally {
+ _downloadStatusFlow.update { DownloadStatus.IDLE }
+ }
+ }
+
+ private suspend fun downloadImagesParallel(postName: String, onFinish: () -> Unit = {}) {
+ val outputName = postName.replace(Regex("[^a-zA-Z0-9._]"), "_")
+ coroutineScope {
+ val outputDir = File(downloadDir, outputName).apply {
+ if (!exists()) {
+ mkdirs()
+ }
+ }
+ val paddingLength = images.size.toString().length
+ val downloadJobs = images.mapIndexed { index, url ->
+ 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")
+ }
+ }
+ }
+ var totalProgress = 0
+ downloadJobs.chunked(5).forEach { chunkedJobs ->
+ chunkedJobs.awaitAll()
+ delay(500)
+ totalProgress += chunkedJobs.size
+ updateNotification("${totalProgress}/${images.size} images")
+ }
+ println("✅ All images downloaded successfully!")
+ showDownloadCompleteNotification(outputDir)
+ onFinish()
+ }
+ }
+
+
+ private fun createNotification(title: String): Notification {
+ notificationBuilder =
+ NotificationCompat.Builder(this, channelId)
+ .setContentTitle(title)
+ .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 deepLinkIntent = Intent(
+ Intent.ACTION_VIEW,
+ "hviewer://downloads/${Uri.encode(file.absolutePath)}".toUri(),
+ this,
+ MainActivity::class.java
+ )
+
+ val deepLinkPendingIntent: PendingIntent? = TaskStackBuilder.create(this).run {
+ addNextIntentWithParentStack(deepLinkIntent)
+ getPendingIntent(0, PendingIntent.FLAG_MUTABLE)
+ }
+
+
+ val completedNotification =
+ NotificationCompat.Builder(this, channelId).setContentTitle("Download Complete")
+ .setContentText("Tap to open")
+ .setSmallIcon(android.R.drawable.stat_sys_download_done)
+ .setContentIntent(deepLinkPendingIntent)
+ .setAutoCancel(true)
+ .build()
+
+ notificationManager.notify(notificationId, completedNotification)
+ }
+
+ companion object {
+ private val _downloadStatusFlow = MutableStateFlow(DownloadStatus.IDLE)
+ val downloadStatusFlow = _downloadStatusFlow.asStateFlow()
+ }
+}
+
+enum class DownloadStatus {
+ IDLE,
+ DOWNLOADING,
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/post/Images.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/post/Images.kt
index c0c0b51..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
@@ -1,5 +1,8 @@
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
@@ -21,6 +24,8 @@ 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
@@ -35,13 +40,20 @@ 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
import com.paulcoding.hviewer.ui.component.HLoading
@@ -49,28 +61,47 @@ 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(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 {
Toast.makeText(appContext, it.message ?: it.toString(), Toast.LENGTH_SHORT).show()
@@ -90,6 +121,17 @@ 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)
@@ -151,6 +193,22 @@ 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/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/sites/SitesPage.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/sites/SitesPage.kt
index 0d90fd4..aca94e7 100644
--- a/app/src/main/java/com/paulcoding/hviewer/ui/page/sites/SitesPage.kt
+++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/sites/SitesPage.kt
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -47,6 +48,7 @@ fun SitesPage(
siteConfigs: SiteConfigs,
navToSettings: () -> Unit,
navToHistory: () -> Unit,
+ navToDownloads: () -> Unit,
refresh: () -> Unit,
navToFavorite: () -> Unit,
) {
@@ -60,6 +62,9 @@ fun SitesPage(
Scaffold(topBar = {
TopAppBar(title = { Text(stringResource(R.string.sites)) }, actions = {
+ HIcon(Icons.Outlined.Download) {
+ navToDownloads()
+ }
HIcon(Icons.Outlined.History) {
navToHistory()
}
diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/tabs/TabsPage.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/tabs/TabsPage.kt
index ad9ecf4..55c6dc2 100644
--- a/app/src/main/java/com/paulcoding/hviewer/ui/page/tabs/TabsPage.kt
+++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/tabs/TabsPage.kt
@@ -81,7 +81,7 @@ fun TabsPage(
if (siteConfig != null) {
ImageList(
- tab.url,
+ tab,
siteConfig = siteConfig,
goBack = goBack,
bottomRowActions = {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d30b9ab..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
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