Compare commits

...
5 Commits
11 changed files with 381 additions and 49 deletions
+9 -3
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 2
versionName "0.2"
versionCode 3
versionName "0.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -90,7 +90,13 @@ dependencies {
// link preview
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha03'
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha04'
// upload pictures:
implementation "com.google.accompanist:accompanist-permissions:0.28.0"
// view videos
implementation 'com.google.android.exoplayer:exoplayer:2.18.2'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
+1
View File
@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<application
android:allowBackup="true"
@@ -31,7 +31,7 @@ object NostrAccountDataSource: NostrDataSource("AccountData") {
fun createAccountFilter(): JsonFilter {
return JsonFilter(
authors = listOf(account.userProfile().pubkeyHex),
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 4), // 4 days
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7), // 4 days
)
}
@@ -8,6 +8,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.android.exoplayer2.util.Util
import com.vitorpamplona.amethyst.KeyStorage
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
@@ -0,0 +1,57 @@
package com.vitorpamplona.amethyst.ui.actions
import android.graphics.Bitmap
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.UUID
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
object ImageUploader {
private fun encodeImage(bitmap: Bitmap): ByteArray {
val byteArrayOutPutStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutPutStream)
return byteArrayOutPutStream.toByteArray()
}
fun uploadImage(bitmap: Bitmap, onSuccess: (String) -> Unit) {
val client = OkHttpClient.Builder().build()
val body: RequestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"image",
"${UUID.randomUUID()}.png",
encodeImage(bitmap).toRequestBody("image/png".toMediaType())
)
.build()
val request: Request = Request.Builder()
.url("https://api.imgur.com/3/image")
.header("Authorization", "Client-ID e6aea87296f3f96")
.post(body)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
val tree = jacksonObjectMapper().readTree(response.body!!.string())
val url = tree.get("data").get("link").asText()
onSuccess(url)
}
}
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
}
})
}
}
@@ -17,48 +17,73 @@ import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.TagLink
import com.vitorpamplona.amethyst.ui.components.UrlPreview
import com.vitorpamplona.amethyst.ui.components.imageExtension
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import com.vitorpamplona.amethyst.ui.components.tagIndex
import com.vitorpamplona.amethyst.ui.navigation.UploadFromGallery
import kotlinx.coroutines.delay
import nostr.postr.events.TextNoteEvent
class PostViewModel: ViewModel() {
var account: Account? = null
var message by mutableStateOf("")
var replyingTo: Note? = null
fun sendPost() {
account?.sendPost(message, replyingTo)
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account) {
val postViewModel: PostViewModel = viewModel<PostViewModel>().apply {
val postViewModel: NewPostViewModel = viewModel<NewPostViewModel>().apply {
this.replyingTo = replyingTo
this.account = account
}
val dialogProperties = DialogProperties()
val context = LocalContext.current
// initialize focus reference to be able to request focus programmatically
val focusRequester = FocusRequester()
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
delay(100)
focusRequester.requestFocus()
}
Dialog(
onDismissRequest = { onClose() }, properties = dialogProperties
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false
)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.5f)
.fillMaxHeight()
) {
Column(
modifier = Modifier.padding(10.dp)
@@ -69,7 +94,14 @@ fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account)
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = onClose)
CloseButton(onCancel = {
postViewModel.cancel()
onClose()
})
UploadFromGallery {
postViewModel.upload(it, context)
}
PostButton(
onPost = {
@@ -96,16 +128,26 @@ fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account)
OutlinedTextField(
value = postViewModel.message,
onValueChange = { postViewModel.message = it },
onValueChange = {
postViewModel.message = it
postViewModel.urlPreview = postViewModel.findUrlInMessage()
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
modifier = Modifier.fillMaxWidth().fillMaxHeight()
modifier = Modifier
.fillMaxWidth()
.border(
width = 1.dp,
color = MaterialTheme.colors.surface,
shape = RoundedCornerShape(8.dp)
),
)
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
}
},
placeholder = {
Text(
text = "What's on your mind?",
@@ -117,8 +159,32 @@ fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account)
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
)
)
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Column(modifier = Modifier.padding(top = 5.dp)) {
val removedParamsFromUrl = myUrlPreview.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
AsyncImage(
model = myUrlPreview,
contentDescription = myUrlPreview,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
)
} else {
UrlPreview("https://$myUrlPreview", myUrlPreview, false)
}
}
}
}
}
}
@@ -0,0 +1,58 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
class NewPostViewModel: ViewModel() {
var account: Account? = null
var replyingTo: Note? = null
var message by mutableStateOf("")
var urlPreview by mutableStateOf<String?>(null)
fun sendPost() {
account?.sendPost(message, replyingTo)
message = ""
urlPreview = null
}
fun upload(it: Uri, context: Context) {
val img = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(context.contentResolver, it)
} else {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, it))
}
img?.let {
ImageUploader.uploadImage(img) {
message = message + "\n\n" + it
urlPreview = findUrlInMessage()
}
}
}
fun cancel() {
message = ""
urlPreview = null
}
fun findUrlInMessage(): String? {
return message.split('\n').firstNotNullOfOrNull { paragraph ->
paragraph.split(' ').firstOrNull { word: String ->
isValidURL(word) || noProtocolUrlValidator.matcher(word).matches()
}
}
}
}
@@ -0,0 +1,85 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
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.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun UploadFromGallery(onImageChosen: (Uri) -> Unit) {
val cameraPermissionState = rememberPermissionState(
android.Manifest.permission.READ_MEDIA_IMAGES
)
if (cameraPermissionState.status.isGranted) {
var showGallerySelect by remember { mutableStateOf(false) }
if (showGallerySelect) {
GallerySelect(
onImageUri = { uri ->
showGallerySelect = false
if (uri != null)
onImageChosen(uri)
}
)
} else {
Box() {
Button(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(4.dp),
onClick = {
showGallerySelect = true
}
) {
Text("Add Image from Gallery")
}
}
}
} else {
Column {
Button(onClick = { cameraPermissionState.launchPermissionRequest() }) {
Text("Add Image from Gallery")
}
}
}
}
@Composable
fun GallerySelect(
onImageUri: (Uri?) -> Unit = { }
) {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
onResult = { uri: Uri? ->
onImageUri(uri)
}
)
@Composable
fun LaunchGallery() {
SideEffect {
launcher.launch("image/*")
}
}
LaunchGallery()
}
@@ -23,6 +23,7 @@ import java.net.URL
import java.util.regex.Pattern
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp)$")
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm)$")
val noProtocolUrlValidator = Pattern.compile("^[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$")
val tagIndex = Pattern.compile("\\#\\[([0-9]*)\\]")
@@ -59,6 +60,8 @@ fun RichTextViewer(content: String, tags: List<List<String>>?) {
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
)
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
VideoView(word)
} else {
UrlPreview(word, word)
}
@@ -34,31 +34,27 @@ import com.baha.url.preview.UrlInfoItem
@Composable
fun UrlPreview(url: String, urlText: String) {
fun UrlPreview(url: String, urlText: String, showUrlIfError: Boolean = true) {
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(UrlPreviewState.Loading) }
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
LaunchedEffect(urlPreviewState) {
if (urlPreviewState == UrlPreviewState.Loading) {
val urlPreview = BahaUrlPreview(url, object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
if (urlInfo.allFetchComplete() && urlInfo.url == url)
urlPreviewState = UrlPreviewState.Loaded(urlInfo)
else
urlPreviewState = UrlPreviewState.Empty
}
override fun onFailed(throwable: Throwable) {
urlPreviewState = UrlPreviewState.Error("Error parsing preview for ${url}: ${throwable.message}")
}
})
urlPreview.fetchUrlPreview()
}
}
val uri = LocalUriHandler.current
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
LaunchedEffect(url) {
BahaUrlPreview(url, object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
if (urlInfo.allFetchComplete() && urlInfo.url == url)
urlPreviewState = UrlPreviewState.Loaded(urlInfo)
else
urlPreviewState = UrlPreviewState.Empty
}
override fun onFailed(throwable: Throwable) {
urlPreviewState = UrlPreviewState.Error("Error parsing preview for ${url}: ${throwable.message}")
}
}).fetchUrlPreview()
}
Crossfade(targetState = urlPreviewState) { state ->
when (state) {
is UrlPreviewState.Loaded -> {
@@ -97,11 +93,13 @@ fun UrlPreview(url: String, urlText: String) {
}
}
else -> {
ClickableText(
text = AnnotatedString("$urlText "),
onClick = { runCatching { uri.openUri(url) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
)
if (showUrlIfError) {
ClickableText(
text = AnnotatedString("$urlText "),
onClick = { runCatching { uri.openUri(url) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
)
}
}
}
}
@@ -0,0 +1,57 @@
package com.vitorpamplona.amethyst.ui.components
import android.media.browse.MediaBrowser
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView
@Composable
fun VideoView(videoUri: String) {
val context = LocalContext.current
val exoPlayer = ExoPlayer.Builder(LocalContext.current).build().apply {
playWhenReady = true
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
setMediaItem(MediaItem.fromUri(videoUri))
prepare()
}
DisposableEffect(exoPlayer) {
onDispose {
exoPlayer.release()
}
}
println("CCC"+exoPlayer.videoFormat?.width)
AndroidView(
modifier = Modifier
.fillMaxWidth(),
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
}
})
}