Skip to content

Commit

Permalink
Add long click link popup menu, including actions (#1189)
Browse files Browse the repository at this point in the history
* Add long click link popup menu, including actions

* Remove remnants

* Trigger woodpecker

* Trigger woodpecker
  • Loading branch information
MV-GH authored Aug 20, 2023
1 parent ace82a0 commit 5cbb7c1
Show file tree
Hide file tree
Showing 23 changed files with 886 additions and 169 deletions.
12 changes: 11 additions & 1 deletion app/src/main/java/com/jerboa/JerboaAppState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
Expand Down Expand Up @@ -48,6 +49,7 @@ class JerboaAppState(
val navController: NavHostController,
val coroutineScope: CoroutineScope,
) {
val linkDropdownExpanded = mutableStateOf<String?>(null)

fun toPrivateMessageReply(
channel: RouteChannel<PrivateMessageDeps>,
Expand Down Expand Up @@ -75,7 +77,7 @@ class JerboaAppState(

fun toCrashLogs() = navController.navigate(Route.CRASH_LOGS)

fun toView(url: String) {
fun openImageViewer(url: String) {
val encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8.name())
navController.navigate(Route.ViewArgs.makeRoute(encodedUrl))
}
Expand Down Expand Up @@ -181,6 +183,14 @@ class JerboaAppState(
fun toCreatePrivateMessage(id: Int, name: String) {
navController.navigate(Route.CreatePrivateMessageArgs.makeRoute(personId = "$id", personName = name))
}

fun hideLinkPopup() {
linkDropdownExpanded.value = null
}

fun showLinkPopup(url: String) {
linkDropdownExpanded.value = url
}
}

// A view model stored higher up the tree used for moving navigation arguments from one route
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/com/jerboa/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import android.os.Build
import android.os.Bundle
import android.util.Log
import android.util.Patterns
import android.widget.TextView
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.viewModels
Expand Down Expand Up @@ -51,6 +52,7 @@ import com.jerboa.model.ReplyItem
import com.jerboa.model.SiteViewModel
import com.jerboa.ui.components.comment.edit.CommentEditActivity
import com.jerboa.ui.components.comment.reply.CommentReplyActivity
import com.jerboa.ui.components.common.LinkDropDownMenu
import com.jerboa.ui.components.common.MarkdownHelper
import com.jerboa.ui.components.common.Route
import com.jerboa.ui.components.common.ShowChangelog
Expand Down Expand Up @@ -78,6 +80,7 @@ import com.jerboa.ui.components.settings.account.AccountSettingsActivity
import com.jerboa.ui.components.settings.crashlogs.CrashLogsActivity
import com.jerboa.ui.components.settings.lookandfeel.LookAndFeelActivity
import com.jerboa.ui.theme.JerboaTheme
import com.jerboa.util.markwon.BetterLinkMovementMethod

class MainActivity : AppCompatActivity() {
val siteViewModel by viewModels<SiteViewModel>(factoryProducer = { SiteViewModel.Factory })
Expand Down Expand Up @@ -149,6 +152,20 @@ class MainActivity : AppCompatActivity() {
appState,
appSettings.useCustomTabs,
appSettings.usePrivateTabs,
object : BetterLinkMovementMethod.OnLinkLongClickListener {
override fun onLongClick(textView: TextView, url: String): Boolean {
appState.showLinkPopup(url)
return true
}
},
)

LinkDropDownMenu(
appState.linkDropdownExpanded.value,
appState::hideLinkPopup,
appState,
appSettings.useCustomTabs,
appSettings.usePrivateTabs,
)

ShowChangelog(appSettingsViewModel = appSettingsViewModel)
Expand Down
93 changes: 70 additions & 23 deletions app/src/main/java/com/jerboa/Utils.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.jerboa

import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentValues
Expand All @@ -13,15 +14,18 @@ import android.media.MediaScannerConnection
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build
import android.os.Build.VERSION.SDK_INT
import android.os.Environment
import android.provider.MediaStore
import android.util.Log
import android.webkit.MimeTypeMap.getFileExtensionFromUrl
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.annotation.RequiresApi
import androidx.appcompat.app.AppCompatDelegate
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.ExperimentalFoundationApi
Expand Down Expand Up @@ -383,19 +387,6 @@ fun looksLikeUserUrl(url: String): Pair<String, String>? {
return null
}

/**
* Open a sharesheet for the given URL.
*/
fun shareLink(url: String, ctx: Context) {
val intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, url)
type = "text/plain"
}
val shareIntent = Intent.createChooser(intent, null)
ctx.startActivity(shareIntent)
}

// Current logic is that if the url matches a community url or user url then it confirms
// if the host is an actual lemmy instance unless it was originally formatted in a user/community format

Expand Down Expand Up @@ -427,7 +418,7 @@ fun openLinkRaw(url: String, navController: NavController, useCustomTab: Boolean
intent.launchUrl(navController.context, Uri.parse(url))
} else {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
navController.context.startActivity(intent)
navController.context.startActivitySafe(intent)
}
}

Expand Down Expand Up @@ -851,9 +842,32 @@ enum class PostType {

/**
* A Video. Should open the built-in video viewer.
* Also matches audio only
* (Not currently available).
*/
Video,

;

companion object {
fun fromURL(url: String): PostType {
return if (isImage(url)) {
Image
} else if (isVideo(url)) {
Video
} else {
Link
}
}
}

fun toMediaDir(): String {
return when (this) {
Image -> Environment.DIRECTORY_PICTURES
Video -> Environment.DIRECTORY_MOVIES
Link -> Environment.DIRECTORY_DOCUMENTS
}
}
}

@OptIn(ExperimentalFoundationApi::class)
Expand Down Expand Up @@ -925,24 +939,32 @@ fun nsfwCheck(postView: PostView): Boolean {
return postView.post.nsfw || postView.community.nsfw
}

@RequiresApi(Build.VERSION_CODES.Q)
@Throws(IOException::class)
fun saveBitmap(
fun saveMediaQ(
ctx: Context,
inputStream: InputStream,
mimeType: String?,
displayName: String,
mediaType: PostType,
): Uri {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/Jerboa")
put(MediaStore.MediaColumns.RELATIVE_PATH, mediaType.toMediaDir() + "/Jerboa")
}

val resolver = ctx.contentResolver
var uri: Uri? = null

try {
uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
val insert = when (mediaType) {
PostType.Image -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
PostType.Video -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
PostType.Link -> MediaStore.Downloads.EXTERNAL_CONTENT_URI
}

uri = resolver.insert(insert, values)
?: throw IOException("Failed to create new MediaStore record.")

resolver.openOutputStream(uri)?.use {
Expand All @@ -960,18 +982,19 @@ fun saveBitmap(
}
}

// saveBitmap that works for Android 9 and below
fun saveBitmapP(
// saveMedia that works for Android 9 and below
fun saveMediaP(
context: Context,
inputStream: InputStream,
mimeType: String?,
displayName: String,
mediaType: PostType, // Link is here more like other media (think of PDF, doc, txt)
) {
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
val picsDir = File(dir, "Jerboa")
val dest = File(picsDir, displayName)
val dir = Environment.getExternalStoragePublicDirectory(mediaType.toMediaDir())
val mediaDir = File(dir, "Jerboa")
val dest = File(mediaDir, displayName)

picsDir.mkdirs() // make if not exist
mediaDir.mkdirs() // make if not exist

inputStream.use { input ->
dest.outputStream().use {
Expand Down Expand Up @@ -1511,3 +1534,27 @@ fun Context.getInputStream(url: String): InputStream {
response.body.byteStream()
}
}

val videoRgx = Regex(
pattern = "(http)?s?:?(//[^\"']*\\.(?:mp4|mp3|ogg|flv|m4a|3gp|mkv|mpeg|mov))",
)
fun isVideo(url: String): Boolean {
return url.matches(videoRgx)
}

val nonMediaExt = setOf("html", "htm", "xhtml", "")

// Fast guess at checking if the link could be a file that we consider as Media
fun isMedia(url: String): Boolean {
val ext = getFileExtensionFromUrl(url)
return !nonMediaExt.contains(ext)
}

fun Context.startActivitySafe(intent: Intent) {
try {
this.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Log.d("jerboa", "failed open activity", e)
Toast.makeText(this, this.getText(R.string.no_activity_found), Toast.LENGTH_SHORT).show()
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.jerboa.util
package com.jerboa.feat

import android.Manifest
import android.content.Context
Expand All @@ -12,43 +12,46 @@ import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.FileProvider
import com.jerboa.MainActivity
import com.jerboa.PostType
import com.jerboa.R
import com.jerboa.getInputStream
import com.jerboa.registerActivityResultLauncher
import com.jerboa.saveBitmap
import com.jerboa.saveBitmapP
import com.jerboa.saveMediaP
import com.jerboa.saveMediaQ
import com.jerboa.startActivitySafe
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) {
fun storeMedia(scope: CoroutineScope, ctx: Context, url: String, mediaType: PostType) {
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)
actualStoreImage(scope, ctx, url, mediaType)
} 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)
actualStoreImage(scope, ctx, url, mediaType)
}
}

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

// 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()
private suspend fun saveMedia(url: String, context: Context, mediaType: PostType) {
val toastId = if (mediaType == PostType.Image) R.string.saving_image else R.string.saving_media
Toast.makeText(context, context.getString(toastId), Toast.LENGTH_SHORT).show()

val fileName = Uri.parse(url).pathSegments.last()
val extension = MimeTypeMap.getFileExtensionFromUrl(url)
Expand All @@ -58,23 +61,27 @@ private suspend fun saveImage(url: String, context: Context) {
withContext(Dispatchers.IO) {
context.getInputStream(url).use {
if (SDK_INT < 29) {
saveBitmapP(context, it, mimeType, fileName)
saveMediaP(context, it, mimeType, fileName, mediaType)
} else {
saveBitmap(context, it, mimeType, fileName)
saveMediaQ(context, it, mimeType, fileName, mediaType)
}
}
}
Toast.makeText(context, context.getString(R.string.saved_image), Toast.LENGTH_SHORT).show()
val toastId2 = if (mediaType == PostType.Image) R.string.saved_image else R.string.saved_media
Toast.makeText(context, context.getString(toastId2), 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()
Log.d("saveMedia", "failed saving media", e)
Toast.makeText(context, R.string.failed_saving_media, 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()
Log.d("saveMedia", "invalid URL", e)
Toast.makeText(context, R.string.failed_saving_media, Toast.LENGTH_SHORT).show()
}
}

fun shareImage(scope: CoroutineScope, ctx: Context, url: String) {
/**
* Shares the actual file from the link
*/
fun shareMedia(scope: CoroutineScope, ctx: Context, url: String, mediaType: PostType) {
try {
val fileName = Uri.parse(url).pathSegments.last()

Expand All @@ -92,14 +99,31 @@ fun shareImage(scope: CoroutineScope, ctx: Context, url: String) {
val shareIntent = Intent()
shareIntent.setAction(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_STREAM, uri)
shareIntent.setType("image/*")
when (mediaType) {
PostType.Image -> shareIntent.setType("image/*")
PostType.Video -> shareIntent.setType("video/*")
PostType.Link -> shareIntent.setType("text/*")
}
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
ctx.startActivity(Intent.createChooser(shareIntent, ctx.getString(R.string.share)))
ctx.startActivitySafe(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()
Log.d("shareMedia", "failed", e)
Toast.makeText(ctx, R.string.failed_saving_media, 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()
Log.d("shareMedia", "invalid URL", e)
Toast.makeText(ctx, R.string.failed_saving_media, Toast.LENGTH_SHORT).show()
}
}

/**
* Just shares the link
*/
fun shareLink(url: String, ctx: Context) {
val intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, url)
type = "text/plain"
}
val shareIntent = Intent.createChooser(intent, ctx.getString(R.string.share))
ctx.startActivitySafe(shareIntent)
}
Loading

0 comments on commit 5cbb7c1

Please sign in to comment.