Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add share functionality to imageviewer #1144

Merged
merged 1 commit into from
Aug 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,6 @@ dependencies {
implementation("com.google.accompanist:accompanist-pager:$accompanistVersion")
implementation("com.google.accompanist:accompanist-pager-indicators:$accompanistVersion")
implementation("com.google.accompanist:accompanist-flowlayout:$accompanistVersion")
implementation("com.google.accompanist:accompanist-permissions:$accompanistVersion")
implementation("com.google.accompanist:accompanist-navigation-animation:$accompanistVersion")
implementation("com.google.accompanist:accompanist-systemuicontroller:$accompanistVersion")

Expand Down
10 changes: 10 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@
android:name="com.crazylegend.crashyreporter.initializer.CrashyInitializer"
android:value="androidx.startup" />
</provider>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>

</application>

</manifest>
2 changes: 1 addition & 1 deletion app/src/main/java/com/jerboa/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -690,7 +690,7 @@ class MainActivity : AppCompatActivity() {
) {
val args = Route.ViewArgs(it)

ImageViewer(url = args.url, onBackRequest = appState::popBackStack)
ImageViewer(url = args.url, appState = appState)
}

composable(
Expand Down
35 changes: 35 additions & 0 deletions app/src/main/java/com/jerboa/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import android.os.Environment
import android.provider.MediaStore
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.appcompat.app.AppCompatDelegate
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.ExperimentalFoundationApi
Expand Down Expand Up @@ -56,6 +60,8 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras
import androidx.navigation.NavController
import arrow.core.compareTo
import coil.annotation.ExperimentalCoilApi
import coil.imageLoader
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.jerboa.api.API
Expand All @@ -77,6 +83,8 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import org.ocpsoft.prettytime.PrettyTime
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
Expand Down Expand Up @@ -1516,3 +1524,30 @@ fun ConnectivityManager?.isCurrentlyConnected(): Boolean =
?.let(::getNetworkCapabilities)
?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
?: false

/**
* When calling this, you must call ActivityResultLauncher.unregister()
* on the returned ActivityResultLauncher when the launcher is no longer
* needed to release any values that might be captured in the registered callback.
*/
fun <I, O> ComponentActivity.registerActivityResultLauncher(
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>,
): ActivityResultLauncher<I> {
val key = UUID.randomUUID().toString()
return activityResultRegistry.register(key, contract, callback)
}

/**
* Returns a [InputStream] for the data of the URL, but it also checks the cache first!
*/
@OptIn(ExperimentalCoilApi::class)
fun Context.getInputStream(url: String): InputStream {
val snapshot = this.imageLoader.diskCache?.openSnapshot(url)

return snapshot?.use {
it.data.toFile().inputStream()
} ?: API.httpClient.newCall(Request(url.toHttpUrl())).execute().use { response ->
response.body.byteStream()
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
package com.jerboa.ui.components.imageviewer

import android.app.Activity
import android.content.Context
import android.net.Uri
import android.os.Build.VERSION.SDK_INT
import android.util.Log
import android.view.WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS
import android.view.WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
Expand All @@ -26,6 +21,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
Expand All @@ -41,7 +37,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
Expand All @@ -56,26 +51,21 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.request.ImageRequest
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.jerboa.JerboaAppState
import com.jerboa.JerboaApplication
import com.jerboa.R
import com.jerboa.saveBitmap
import com.jerboa.saveBitmapP
import com.jerboa.rememberJerboaAppState
import com.jerboa.ui.components.common.LoadingBar
import com.jerboa.util.downloadprogress.DownloadProgress
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.jerboa.util.shareImage
import com.jerboa.util.storeImage
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
import java.io.IOException
import java.net.URL

const val backFadeTime = 300

@Composable
fun ImageViewer(url: String, onBackRequest: () -> Unit) {
fun ImageViewer(url: String, appState: JerboaAppState) {
val ctx = LocalContext.current
val backColor = MaterialTheme.colorScheme.scrim
var showTopBar by remember { mutableStateOf(true) }
Expand Down Expand Up @@ -145,7 +135,7 @@ fun ImageViewer(url: String, onBackRequest: () -> Unit) {

Scaffold(
topBar = {
ViewerHeader(showTopBar, onBackRequest, url)
ViewerHeader(showTopBar, url, appState)
},
content = {
Box(
Expand All @@ -157,7 +147,7 @@ fun ImageViewer(url: String, onBackRequest: () -> Unit) {
consumeScrollDelta = {
if (it < -70 && !debounce) {
debounce = true
onBackRequest()
appState.navigateUp()
}
it
},
Expand Down Expand Up @@ -221,77 +211,28 @@ fun ImageViewer(url: String, onBackRequest: () -> Unit) {
)
}

// Needs to check for permission before this for API 29 and below
suspend fun saveImage(url: String, context: Context) {
Toast.makeText(context, context.getString(R.string.saving_image), Toast.LENGTH_SHORT).show()

val fileName = Uri.parse(url).pathSegments.last()

val extension = MimeTypeMap.getFileExtensionFromUrl(url)
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)

try {
withContext(Dispatchers.IO) {
URL(url).openStream().use {
if (SDK_INT < 29) {
saveBitmapP(context, it, mimeType, fileName)
} else {
saveBitmap(context, it, mimeType, fileName)
}
}
}
Toast.makeText(context, context.getString(R.string.saved_image), Toast.LENGTH_SHORT).show()
} catch (e: IOException) {
Log.d("image", "failed saving image", e)
Toast.makeText(context, R.string.failed_saving_image, Toast.LENGTH_SHORT).show()
}
}

@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ViewerHeader(
showTopBar: Boolean = true,
onBackRequest: () -> Unit = {},
url: String = "",
appState: JerboaAppState,
) {
val topBarAlpha by animateFloatAsState(
targetValue = if (showTopBar) 1f else 0f,
animationSpec = tween(backFadeTime),
label = "topBarAlpha",
)

val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()

val onTap: () -> Unit = if (SDK_INT < 29) {
val storagePermissionState = rememberPermissionState(
android.Manifest.permission.WRITE_EXTERNAL_STORAGE,
) {
if (it) {
coroutineScope.launch {
saveImage(url, context)
}
} else {
Toast.makeText(context, context.getString(R.string.permission_denied), Toast.LENGTH_SHORT).show()
}
}

storagePermissionState::launchPermissionRequest
} else {
{
coroutineScope.launch {
saveImage(url, context)
}
}
}
val ctx = LocalContext.current

TopAppBar(
colors = topAppBarColors(containerColor = MaterialTheme.colorScheme.scrim.copy(alpha = 0.2f)),
modifier = Modifier.alpha(topBarAlpha),
title = {},
navigationIcon = {
IconButton(
onClick = onBackRequest,
onClick = appState::navigateUp,
) {
Icon(
Icons.Outlined.ArrowBack,
Expand All @@ -302,7 +243,22 @@ fun ViewerHeader(
},
actions = {
IconButton(
onClick = onTap,
onClick = {
shareImage(appState.coroutineScope, ctx, url)
},
) {
Icon(
Icons.Outlined.Share,
tint = Color.White,
contentDescription = stringResource(R.string.share),
)
}

IconButton(
// TODO disable once it is busy
onClick = {
storeImage(appState.coroutineScope, ctx, url)
},
) {
Icon(
Icons.Outlined.Download,
Expand All @@ -317,7 +273,7 @@ fun ViewerHeader(
@Composable
@Preview
fun ImageActivityPreview() {
ImageViewer(url = "", onBackRequest = { })
ImageViewer(url = "", appState = rememberJerboaAppState())
}

enum class ImageState {
Expand Down
105 changes: 105 additions & 0 deletions app/src/main/java/com/jerboa/util/UserActions.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package com.jerboa.util

import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build.VERSION.SDK_INT
import android.util.Log
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.FileProvider
import com.jerboa.MainActivity
import com.jerboa.R
import com.jerboa.getInputStream
import com.jerboa.registerActivityResultLauncher
import com.jerboa.saveBitmap
import com.jerboa.saveBitmapP
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.IOException

fun storeImage(scope: CoroutineScope, ctx: Context, url: String) {
if (SDK_INT < 29 && ctx.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
val appCompat = (ctx as MainActivity)

appCompat.registerActivityResultLauncher(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
actualStoreImage(scope, ctx, url)
} else {
Toast.makeText(ctx, ctx.getString(R.string.permission_denied), Toast.LENGTH_SHORT).show()
}
}.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
} else {
actualStoreImage(scope, ctx, url)
}
}

private fun actualStoreImage(scope: CoroutineScope, ctx: Context, url: String) {
scope.launch {
saveImage(url, ctx)
}
}

// Needs to check for permission before this for API 29 and below
private suspend fun saveImage(url: String, context: Context) {
Toast.makeText(context, context.getString(R.string.saving_image), Toast.LENGTH_SHORT).show()

val fileName = Uri.parse(url).pathSegments.last()
val extension = MimeTypeMap.getFileExtensionFromUrl(url)
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)

try {
withContext(Dispatchers.IO) {
context.getInputStream(url).use {
if (SDK_INT < 29) {
saveBitmapP(context, it, mimeType, fileName)
} else {
saveBitmap(context, it, mimeType, fileName)
}
}
}
Toast.makeText(context, context.getString(R.string.saved_image), Toast.LENGTH_SHORT).show()
} catch (e: IOException) {
Log.d("image", "failed saving image", e)
Toast.makeText(context, R.string.failed_saving_image, Toast.LENGTH_SHORT).show()
} catch (e: IllegalArgumentException) {
Log.d("image", "invalid URL", e)
Toast.makeText(context, R.string.failed_saving_image, Toast.LENGTH_SHORT).show()
}
}

fun shareImage(scope: CoroutineScope, ctx: Context, url: String) {
try {
val fileName = Uri.parse(url).pathSegments.last()

val file = File(ctx.cacheDir, fileName)

scope.launch(Dispatchers.IO) {
ctx.getInputStream(url).use { input ->
file.outputStream().use {
input.copyTo(it)
}
}
}

val uri = FileProvider.getUriForFile(ctx, ctx.packageName + ".provider", file)
val shareIntent = Intent()
shareIntent.setAction(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_STREAM, uri)
shareIntent.setType("image/*")
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
ctx.startActivity(Intent.createChooser(shareIntent, ctx.getString(R.string.share)))
} catch (e: IOException) {
Log.d("share", "failed", e)
Toast.makeText(ctx, R.string.failed_saving_image, Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Log.d("image", "invalid URL", e)
Toast.makeText(ctx, R.string.failed_saving_image, Toast.LENGTH_SHORT).show()
}
}
Loading