From 035ed020e461eacb0597e35872940c5242419cad Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 01:03:41 +0000 Subject: [PATCH] refactor: composer seams for location, expiration, message field and imeta - DeviceLocation port in commons (LocationState implements it), reached through AccountViewModelHost and AppServices; composers and geohash screens stop reading Amethyst.instance.locationManager. - rememberCoarseLocationPermission expect/actual (accompanist on Android, denied elsewhere) replaces direct permission-state calls. - ExpirationDatePicker drops java.time/android.text for LocalClock's new utcOffsetSeconds and a rememberIs24HourClock expect. - MessageField takes MediaUri; PreviewState moves to commonMain. - MediaUploader.remoteFileHeader replaces IMetaAttachments' MimeTypeMap + OkHttp download. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P --- .../service/location/LocationState.kt | 9 ++-- .../service/uploads/AndroidMediaUploader.kt | 13 +++++ .../topbars/AroundMeLocationLabel.kt | 4 +- .../expiration/ExpirationDatePicker.kt | 29 ++++++----- .../location/DisplayLocationObserver.kt | 1 + .../location/GeohashLocationPickerDialog.kt | 5 +- .../creators/location/ILocationGrabber.kt | 4 +- .../note/creators/location/LocationAsHash.kt | 19 +++---- .../creators/messagefield/MessageField.kt | 4 +- .../nip22Comments/CommentPostViewModel.kt | 9 ++-- .../ui/platform/AndroidAppServices.kt | 3 ++ .../loggedIn/AndroidAccountViewModelHost.kt | 3 ++ .../ui/screen/loggedIn/LoggedInPage.kt | 10 ++-- .../chats/geohashChat/GeohashChatScreen.kt | 19 +++---- .../chats/geohashChat/NewGeohashChatScreen.kt | 21 ++++---- .../privateDM/send/ChatNewMessageViewModel.kt | 9 ++-- .../chats/privateDM/send/IMetaAttachments.kt | 15 ++---- .../send/ChannelNewMessageViewModel.kt | 9 ++-- .../nip23LongForm/LongFormPostViewModel.kt | 9 ++-- .../nip99Classifieds/NewProductViewModel.kt | 9 ++-- .../ui/screen/loggedIn/home/HomeScreen.kt | 4 +- .../loggedIn/home/ShortNotePostViewModel.kt | 8 ++- .../room/chat/NestNewMessageViewModel.kt | 9 ++-- .../NewPublicMessageViewModel.kt | 9 ++-- .../commons/model/composer/PreviewState.kt | 1 + .../commons/model/location/DeviceLocation.kt | 49 +++++++++++++++++++ .../commons/search/calendar/LocalClock.kt | 3 ++ .../amethyst/commons/service/AppServices.kt | 4 ++ .../commons/service/uploads/MediaUploader.kt | 17 +++++++ .../viewmodels/AccountViewModelHost.kt | 4 ++ .../commons/search/calendar/LocalClock.ios.kt | 2 + .../search/calendar/LocalClock.jvmAndroid.kt | 7 +++ .../components/LocationPermission.android.kt | 41 ++++++++++++++++ .../ui/note/PlatformDateFormat.android.kt | 6 +++ .../ui/components/LocationPermission.kt | 30 ++++++++++++ .../ui/components/PlatformPermission.kt | 32 ++++++++++++ .../commons/ui/note/PlatformDateFormat.kt | 4 ++ .../ui/components/LocationPermission.ios.kt | 33 +++++++++++++ .../commons/ui/note/PlatformDateFormat.ios.kt | 5 ++ .../ui/components/LocationPermission.jvm.kt | 33 +++++++++++++ .../commons/ui/note/PlatformDateFormat.jvm.kt | 11 +++++ 41 files changed, 390 insertions(+), 126 deletions(-) rename commons/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/model/composer/PreviewState.kt (98%) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/location/DeviceLocation.kt create mode 100644 commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.android.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/PlatformPermission.kt create mode 100644 commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.ios.kt create mode 100644 commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.jvm.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt index 34743eeada..74d92448ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.location import android.content.Context import android.location.Location import android.location.LocationManager +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision @@ -75,7 +76,7 @@ class LocationState( LocationFlow(context.getSystemService(Context.LOCATION_SERVICE) as LocationManager) .get(minTimeMs, minDistanceM, onListening) }, -) { +) : DeviceLocation { companion object { /** A 5 km cell takes 2.5 minutes to cross at 120 km/h; 60s/500m is ample. */ const val COARSE_MIN_TIME: Long = 60_000L @@ -111,7 +112,7 @@ class LocationState( private val latestPreciseLocation = MutableStateFlow(LocationResult.Loading) - fun setLocationPermission(newValue: Boolean) { + override fun setLocationPermission(newValue: Boolean) { if (newValue != hasLocationPermission.value) { hasLocationPermission.tryEmit(newValue) } @@ -195,7 +196,7 @@ class LocationState( } }.stateIn(scope, SharingStarted.WhileSubscribed(SUBSCRIPTION_STOP_TIMEOUT_MS), cache.value) - val geohashStateFlow: StateFlow by lazy { + override val geohashStateFlow: StateFlow by lazy { buildGeohashStateFlow( tag = "GeohashStateFlow", charsCount = GeohashPrecision.KM_5_X_5.digits, @@ -217,7 +218,7 @@ class LocationState( * building-level today. The profile is kept so the intent survives if the * app ever requests `ACCESS_FINE_LOCATION`. */ - val preciseGeohashStateFlow: StateFlow by lazy { + override val preciseGeohashStateFlow: StateFlow by lazy { buildGeohashStateFlow( tag = "PreciseGeohashStateFlow", charsCount = GeohashChannelLevel.BUILDING.chars, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt index 2e670493a4..9615badac2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt @@ -22,11 +22,13 @@ package com.vitorpamplona.amethyst.service.uploads import android.content.Context import android.net.Uri +import android.webkit.MimeTypeMap import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType import com.vitorpamplona.amethyst.commons.model.mediaServers.blossomUploadOrder +import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException import com.vitorpamplona.amethyst.commons.service.upload.FileHeader @@ -52,6 +54,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import java.io.File +import java.util.Locale import kotlin.coroutines.cancellation.CancellationException /** @@ -592,4 +595,14 @@ class AndroidMediaUploader( } } } + + override suspend fun remoteFileHeader( + url: String, + httpClients: IRoleBasedHttpClientBuilder, + ): FileHeader? = + withContext(Dispatchers.IO) { + val fileExtension: String = MimeTypeMap.getFileExtensionFromUrl(url) + val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension.lowercase(Locale.getDefault())) + FileHeader.prepare(url, mimeType, null) { httpClients.okHttpClientForImage(it) }.getOrNull() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/AroundMeLocationLabel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/AroundMeLocationLabel.kt index dae3a52c12..37716f40e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/AroundMeLocationLabel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/AroundMeLocationLabel.kt @@ -34,13 +34,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.lack_location_permissions import com.vitorpamplona.amethyst.commons.resources.loading_location import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName +import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer @@ -61,7 +61,7 @@ fun AndroidAroundMeLocationLabel() { overflow = TextOverflow.Ellipsis, ) } else { - val location by Amethyst.instance.locationManager.geohashStateFlow + val location by LocalAppServices.current.deviceLocation.geohashStateFlow .collectAsStateWithLifecycle() when (val myLocation = location) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt index 2e6e2421d0..714bb5defb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.expiration -import android.text.format.DateFormat import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -49,7 +48,6 @@ 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.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -64,14 +62,15 @@ import com.vitorpamplona.amethyst.commons.resources.expiration_date_select import com.vitorpamplona.amethyst.commons.resources.expiration_expires_in import com.vitorpamplona.amethyst.commons.resources.expiration_time import com.vitorpamplona.amethyst.commons.resources.next +import com.vitorpamplona.amethyst.commons.search.calendar.LocalClock +import com.vitorpamplona.amethyst.commons.search.calendar.SearchDate +import com.vitorpamplona.amethyst.commons.ui.note.rememberIs24HourClock import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.quartz.utils.TimeUtils -import java.time.Instant -import java.time.ZoneId -import java.time.ZoneOffset +import com.vitorpamplona.quartz.utils.currentTimeMillis @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -79,24 +78,26 @@ fun ExpirationDatePicker(model: IExpiration) { var showDatePicker by remember { mutableStateOf(false) } var showTimePicker by remember { mutableStateOf(false) } - val currentTime = Instant.ofEpochMilli(model.expirationDate * 1000).atZone(ZoneId.systemDefault()).toLocalDateTime() + // The expiration as a local wall-clock time: seconds since the epoch shifted by this zone's offset. + val localSeconds = model.expirationDate + LocalClock.utcOffsetSeconds(model.expirationDate) + val localSecondOfDay = localSeconds.mod(TimeUtils.ONE_DAY) + val currentYear = SearchDate.civilFromDays(localSeconds.floorDiv(TimeUtils.ONE_DAY)).year val datePickerState = rememberDatePickerState( initialSelectedDateMillis = model.expirationDate * 1000, - yearRange = currentTime.year..2050, + yearRange = currentYear..2050, selectableDates = object : SelectableDates { - override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis >= System.currentTimeMillis() - 86400000 + override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis >= currentTimeMillis() - 86400000 }, ) - val context = LocalContext.current val timePickerState = rememberTimePickerState( - initialHour = currentTime.hour, - initialMinute = currentTime.minute, - is24Hour = DateFormat.is24HourFormat(context), + initialHour = localSecondOfDay / TimeUtils.ONE_HOUR, + initialMinute = (localSecondOfDay % TimeUtils.ONE_HOUR) / TimeUtils.ONE_MINUTE, + is24Hour = rememberIs24HourClock(), ) Column(Modifier.fillMaxWidth()) { @@ -183,9 +184,7 @@ fun ExpirationDatePicker(model: IExpiration) { (timePickerState.minute * TimeUtils.ONE_MINUTE) } ?: TimeUtils.oneDayAhead() - val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now()) - - model.expirationDate = datetimeLocalTimeZone - offset.totalSeconds + model.expirationDate = datetimeLocalTimeZone - LocalClock.utcOffsetSeconds(TimeUtils.now()) showTimePicker = false }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt index 222befd9b0..39f808170d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.lack_location_permissions import com.vitorpamplona.amethyst.commons.resources.loading_location import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation +import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt index 02445f15df..fe2f920d0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt @@ -82,7 +82,6 @@ import androidx.compose.ui.window.DialogProperties import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.location.LocationResult @@ -97,6 +96,8 @@ import com.vitorpamplona.amethyst.commons.resources.location_picker_search_hint import com.vitorpamplona.amethyst.commons.resources.location_picker_title import com.vitorpamplona.amethyst.commons.resources.location_picker_use_mine import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe +import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName +import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.service.location.ForwardGeolocation import com.vitorpamplona.amethyst.service.location.LocationState @@ -206,7 +207,7 @@ fun GeohashLocationPickerContent( val context = LocalContext.current val keyboard = LocalSoftwareKeyboardController.current val scope = rememberCoroutineScope() - val locationManager = Amethyst.instance.locationManager + val locationManager = LocalAppServices.current.deviceLocation val seed = remember(initialGeohash) { initialGeohash?.takeIf { it.isNotBlank() }?.let { GeoHash.decode(it) } } val seedLen = initialGeohash?.trim()?.length ?: 0 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/ILocationGrabber.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/ILocationGrabber.kt index 9f78db5852..89945df357 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/ILocationGrabber.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/ILocationGrabber.kt @@ -20,12 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.location +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.model.location.LocationResult -import com.vitorpamplona.amethyst.service.location.LocationState import kotlinx.coroutines.flow.StateFlow interface ILocationGrabber { - fun locationManager(): LocationState + fun locationManager(): DeviceLocation fun locationFlow(): StateFlow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationAsHash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationAsHash.kt index 4a74cc7907..73dc3a764e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationAsHash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationAsHash.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.location -import android.Manifest import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -39,37 +38,31 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.geohash_explainer import com.vitorpamplona.amethyst.commons.resources.geohash_title +import com.vitorpamplona.amethyst.commons.ui.components.rememberCoarseLocationPermission import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText -@OptIn(ExperimentalPermissionsApi::class) @Composable fun LocationAsHash( postViewModel: ILocationGrabber, innerContent: @Composable () -> Unit = {}, ) { - val locationPermissionState = - rememberPermissionState( - Manifest.permission.ACCESS_COARSE_LOCATION, - ) + val locationPermission = rememberCoarseLocationPermission() - LaunchedEffect(locationPermissionState.status.isGranted) { - postViewModel.locationManager().setLocationPermission(locationPermissionState.status.isGranted) + LaunchedEffect(locationPermission.isGranted) { + postViewModel.locationManager().setLocationPermission(locationPermission.isGranted) } - if (locationPermissionState.status.isGranted) { + if (locationPermission.isGranted) { DisplayLocationBox(postViewModel, innerContent) } else { - LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() } + LaunchedEffect(locationPermission) { locationPermission.launchRequest() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt index bb58f52b24..768421cb6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.messagefield -import android.net.Uri import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.LocalTextStyle @@ -40,6 +39,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.service.uploads.MediaUri import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.IMessageField import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.text.MentionPreservingInputTransformation @@ -55,7 +55,7 @@ fun MessageField( placeholder: StringResource, viewModel: IMessageField, requestFocus: Boolean = true, - onContentReceived: ((Uri, String?) -> Unit)? = null, + onContentReceived: ((MediaUri, String?) -> Unit)? = null, ) { val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index bb649a217a..5b05122de6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -30,7 +30,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note @@ -43,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger import com.vitorpamplona.amethyst.commons.model.composer.PreviewState import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder import com.vitorpamplona.amethyst.commons.model.composer.toZapSplitSetup +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState @@ -73,7 +73,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.commons.ui.text.setTextAndPlaceCursorAtBeginning import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor @@ -1006,9 +1005,7 @@ open class CommentPostViewModel : val wordToInsert = item.link + " " viewModelScope.launch(Dispatchers.IO) { - iMetaAttachments.downloadAndPrepare(item.link) { - accountViewModel.httpClientBuilder.okHttpClientForImage(item.link) - } + iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder) } message.replaceCurrentWord(wordToInsert) @@ -1081,5 +1078,5 @@ open class CommentPostViewModel : return location!! } - override fun locationManager(): LocationState = Amethyst.instance.locationManager + override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/platform/AndroidAppServices.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/platform/AndroidAppServices.kt index 6d66399b4a..41062f32af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/platform/AndroidAppServices.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/platform/AndroidAppServices.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserHistoryRegistry import com.vitorpamplona.amethyst.commons.browser.BrowserIconRegistry import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppsRegistry +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.service.AppServices import com.vitorpamplona.amethyst.commons.service.BlossomServerFinder @@ -52,6 +53,8 @@ object AndroidAppServices : AppServices { override val namecoinResolver: NamecoinNameResolver get() = Amethyst.instance.namecoinResolver override val blossomServerFinder: BlossomServerFinder = AndroidBlossomServerFinder + + override val deviceLocation: DeviceLocation get() = Amethyst.instance.locationManager } /** [BlossomServerFinder] over the app's BUD-10 resolver, read lazily (main process only). */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AndroidAccountViewModelHost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AndroidAccountViewModelHost.kt index 3f7a1d7466..4aedc816af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AndroidAccountViewModelHost.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AndroidAccountViewModelHost.kt @@ -25,6 +25,7 @@ import androidx.core.content.ContextCompat import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.AppModules import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.service.lnurl.LnurlHttpTransport import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlTransport import com.vitorpamplona.amethyst.commons.service.pow.PoWJobFailure @@ -74,6 +75,8 @@ class AndroidAccountViewModelHost( override val mediaUploader: MediaUploader by lazy { AndroidMediaUploader(modules.appContext) } + override val deviceLocation: DeviceLocation get() = modules.locationManager + override val savedAccounts: Flow> = LocalPreferences .accountsFlow() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt index 8312cf5c02..8150638e42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt @@ -45,6 +45,8 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.error_opening_external_signer import com.vitorpamplona.amethyst.commons.resources.error_opening_external_signer_description +import com.vitorpamplona.amethyst.commons.ui.components.rememberCoarseLocationPermission +import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreload import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils @@ -133,13 +135,13 @@ fun LoggedInPage( ) } -@OptIn(ExperimentalPermissionsApi::class) @Composable fun WatchLocationPermissions() { - val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION) + val locationPermissionState = rememberCoarseLocationPermission() - LaunchedEffect(locationPermissionState.status.isGranted) { - Amethyst.instance.locationManager.setLocationPermission(locationPermissionState.status.isGranted) + val locationSource = LocalAppServices.current.deviceLocation + LaunchedEffect(locationPermissionState.isGranted) { + locationSource.setLocationPermission(locationPermissionState.isGranted) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt index e3b13ec255..51c8e98202 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat -import android.Manifest import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -57,16 +56,13 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserPicture import com.vitorpamplona.amethyst.commons.search.SearchSeed import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.commons.ui.components.rememberCoarseLocationPermission import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold @@ -74,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SearchTopBarAction import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName +import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.LocalChatActingIdentities import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.LocalChatDisplayNameResolver @@ -121,7 +118,6 @@ fun GeohashChatScreen( } } -@OptIn(ExperimentalPermissionsApi::class) @Composable private fun GeohashChatRoom( channel: GeohashChatChannel, @@ -159,13 +155,14 @@ private fun GeohashChatRoom( // otherwise we fall back to how the user arrived — the map picker passes teleported=true, "near me" // and manual entry default to false. This drives the ["t","teleport"] tag honestly. rememberPermissionState // only reads the current grant (it never prompts), so opening a location chat can't trigger a GPS dialog. - val locationPermission = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION) - LaunchedEffect(locationPermission.status.isGranted) { - if (locationPermission.status.isGranted) { - Amethyst.instance.locationManager.setLocationPermission(true) + val locationPermission = rememberCoarseLocationPermission() + val locationSource = LocalAppServices.current.deviceLocation + LaunchedEffect(locationPermission.isGranted) { + if (locationPermission.isGranted) { + locationSource.setLocationPermission(true) } } - val deviceLocation by Amethyst.instance.locationManager.preciseGeohashStateFlow + val deviceLocation by locationSource.preciseGeohashStateFlow .collectAsStateWithLifecycle() val isTeleported = remember(deviceLocation, geohash, teleported) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/NewGeohashChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/NewGeohashChatScreen.kt index 7e8c436be2..544216c5d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/NewGeohashChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/NewGeohashChatScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat -import android.Manifest import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -56,18 +55,16 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.ui.components.rememberCoarseLocationPermission import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName +import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash @@ -274,18 +271,18 @@ private fun ManualEntrySection(onOpen: (String) -> Unit) { } } -@OptIn(ExperimentalPermissionsApi::class) @Composable private fun NearMeSection(onOpen: (String) -> Unit) { Column { SectionHeader(MaterialSymbols.Explore, "Near me") - val permission = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION) - LaunchedEffect(permission.status.isGranted) { - Amethyst.instance.locationManager.setLocationPermission(permission.status.isGranted) + val permission = rememberCoarseLocationPermission() + val locationSource = LocalAppServices.current.deviceLocation + LaunchedEffect(permission.isGranted) { + locationSource.setLocationPermission(permission.isGranted) } - if (!permission.status.isGranted) { + if (!permission.isGranted) { Card( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), modifier = Modifier.fillMaxWidth(), @@ -297,7 +294,7 @@ private fun NearMeSection(onOpen: (String) -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 12.dp), ) - FilledTonalButton(onClick = { permission.launchPermissionRequest() }) { + FilledTonalButton(onClick = { permission.launchRequest() }) { SymbolIcon(symbol = MaterialSymbols.LocationOn, contentDescription = null, modifier = Modifier.size(18.dp)) Text(" Use my location") } @@ -306,7 +303,7 @@ private fun NearMeSection(onOpen: (String) -> Unit) { return } - val location by Amethyst.instance.locationManager.preciseGeohashStateFlow + val location by LocalAppServices.current.deviceLocation.preciseGeohashStateFlow .collectAsStateWithLifecycle() when (val loc = location) { is LocationResult.Success -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 5059cfe763..27d164978e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -30,7 +30,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note @@ -43,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger import com.vitorpamplona.amethyst.commons.model.composer.PreviewState import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder import com.vitorpamplona.amethyst.commons.model.composer.toZapSplitSetup +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState @@ -57,7 +57,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.ui.text.onUiThread import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileSender import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploader @@ -798,9 +797,7 @@ class ChatNewMessageViewModel : val wordToInsert = item.link + " " viewModelScope.launch(Dispatchers.IO) { - iMetaAttachments.downloadAndPrepare(item.link) { - accountViewModel.httpClientBuilder.okHttpClientForImage(item.link) - } + iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder) } message.replaceCurrentWord(wordToInsert) @@ -870,5 +867,5 @@ class ChatNewMessageViewModel : draftTag.newVersion() } - override fun locationManager(): LocationState = Amethyst.instance.locationManager + override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt index e04b6ce484..420543551e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt @@ -20,13 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send -import android.webkit.MimeTypeMap import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import com.vitorpamplona.amethyst.commons.service.upload.FileHeader +import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder +import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator -import com.vitorpamplona.amethyst.service.uploads.prepare import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip92IMeta.imetaTagBuilder @@ -40,21 +39,17 @@ import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash -import okhttp3.OkHttpClient -import java.util.Locale class IMetaAttachments { var iMetaAttachments by mutableStateOf>(emptyList()) suspend fun downloadAndPrepare( url: String, - okHttpClient: (String) -> OkHttpClient, + uploader: MediaUploader, + httpClients: IRoleBasedHttpClientBuilder, ) { - val fileExtension: String = MimeTypeMap.getFileExtensionFromUrl(url) - val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension.lowercase(Locale.getDefault())) - val imeta = - FileHeader.prepare(url, mimeType, null, okHttpClient).getOrNull()?.let { + uploader.remoteFileHeader(url, httpClients)?.let { IMetaTagBuilder(url) .apply { hash(it.hash) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index da332f094c..c5a59ffdf0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -30,7 +30,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Channel @@ -44,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel @@ -71,7 +71,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode -import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState @@ -1016,9 +1015,7 @@ open class ChannelNewMessageViewModel : val wordToInsert = item.link + " " viewModelScope.launch(Dispatchers.IO) { - iMetaAttachments.downloadAndPrepare(item.link) { - accountViewModel.httpClientBuilder.okHttpClientForImage(item.link) - } + iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder) } message.replaceCurrentWord(wordToInsert) @@ -1041,7 +1038,7 @@ open class ChannelNewMessageViewModel : message.insertUrlAtCursor(newElement) } - override fun locationManager(): LocationState = Amethyst.instance.locationManager + override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation override fun locationFlow(): StateFlow { if (location == null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 527a64cf98..f121e543ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -31,7 +31,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note @@ -43,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.IZapRaiser import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder import com.vitorpamplona.amethyst.commons.model.composer.toZapSplitSetup +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia @@ -72,7 +72,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource import com.vitorpamplona.amethyst.commons.ui.uploads.uploadToDefaultServer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor @@ -686,9 +685,7 @@ class LongFormPostViewModel : val wordToInsert = item.link + " " viewModelScope.launch(Dispatchers.IO) { - iMetaAttachments.downloadAndPrepare(item.link) { - accountViewModel.httpClientBuilder.okHttpClientForImage(item.link) - } + iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder) } message.replaceCurrentWord(wordToInsert) @@ -762,5 +759,5 @@ class LongFormPostViewModel : draftTag.newVersion() } - override fun locationManager(): LocationState = Amethyst.instance.locationManager + override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index a5b3dfdad5..c0bd0a7c17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -30,7 +30,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note @@ -43,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger import com.vitorpamplona.amethyst.commons.model.composer.PreviewState import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder import com.vitorpamplona.amethyst.commons.model.composer.toZapSplitSetup +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState @@ -67,7 +67,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.onUiThread import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor @@ -579,9 +578,7 @@ open class NewProductViewModel : val wordToInsert = item.link + " " viewModelScope.launch(Dispatchers.IO) { - iMetaDescription.downloadAndPrepare(item.link) { - accountViewModel.httpClientBuilder.okHttpClientForImage(item.link) - } + iMetaDescription.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder) } message.replaceCurrentWord(wordToInsert) @@ -681,5 +678,5 @@ open class NewProductViewModel : draftTag.newVersion() } - override fun locationManager(): LocationState = Amethyst.instance.locationManager + override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 8ff65412ac..aed1eef237 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -59,7 +59,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.commons.feeds.ChannelFeedState import com.vitorpamplona.amethyst.commons.feeds.FeedContentState @@ -92,6 +91,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.zonedDrawerSwipeIfModal import com.vitorpamplona.amethyst.commons.ui.note.NoteCompose import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar +import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geohash.NewGeoPostButton import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.hashtag.NewHashtagPostButton import com.vitorpamplona.amethyst.commons.ui.stringRes @@ -334,7 +334,7 @@ fun HomeScreenFloatingButton( when (val filter = list.value) { TopFilter.AroundMe -> { - val location by Amethyst.instance.locationManager.geohashStateFlow + val location by LocalAppServices.current.deviceLocation.geohashStateFlow .collectAsStateWithLifecycle() when (val myLocation = location) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 629fb3cc2d..d8f4703e56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger import com.vitorpamplona.amethyst.commons.model.composer.PreviewState import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder import com.vitorpamplona.amethyst.commons.model.composer.toZapSplitSetup +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia @@ -91,7 +92,6 @@ import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.service.ai.WritingAssistantFactory -import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationController import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber @@ -1822,9 +1822,7 @@ open class ShortNotePostViewModel : val wordToInsert = item.link + " " viewModelScope.launch(Dispatchers.IO) { - iMetaAttachments.downloadAndPrepare(item.link) { - accountViewModel.httpClientBuilder.okHttpClientForImage(item.link) - } + iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder) } message.replaceCurrentWord(wordToInsert) @@ -2120,5 +2118,5 @@ open class ShortNotePostViewModel : draftTag.newVersion() } - override fun locationManager(): LocationState = Amethyst.instance.locationManager + override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt index 378d7a8aa3..670e8b49ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt @@ -30,7 +30,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note @@ -40,6 +39,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.DraftTagState import com.vitorpamplona.amethyst.commons.model.composer.IExpiration import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState @@ -61,7 +61,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.onUiThread import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState @@ -571,9 +570,7 @@ open class NestNewMessageViewModel : val wordToInsert = item.link + " " viewModelScope.launch(Dispatchers.IO) { - iMetaAttachments.downloadAndPrepare(item.link) { - accountViewModel.httpClientBuilder.okHttpClientForImage(item.link) - } + iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder) } message.replaceCurrentWord(wordToInsert) @@ -596,7 +593,7 @@ open class NestNewMessageViewModel : message.insertUrlAtCursor(newElement) } - override fun locationManager(): LocationState = Amethyst.instance.locationManager + override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation override fun locationFlow(): StateFlow { if (location == null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index 4031174597..ccdf7f60ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -30,7 +30,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note @@ -43,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger import com.vitorpamplona.amethyst.commons.model.composer.PreviewState import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder import com.vitorpamplona.amethyst.commons.model.composer.toZapSplitSetup +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState @@ -69,7 +69,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.onUiThread import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor @@ -643,9 +642,7 @@ class NewPublicMessageViewModel : val wordToInsert = item.link + " " viewModelScope.launch(Dispatchers.IO) { - iMetaAttachments.downloadAndPrepare(item.link) { - accountViewModel.httpClientBuilder.okHttpClientForImage(item.link) - } + iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder) } message.replaceCurrentWord(wordToInsert) @@ -715,5 +712,5 @@ class NewPublicMessageViewModel : draftTag.newVersion() } - override fun locationManager(): LocationState = Amethyst.instance.locationManager + override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/composer/PreviewState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/composer/PreviewState.kt similarity index 98% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/composer/PreviewState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/composer/PreviewState.kt index b5c748ecd5..b8f4939fd9 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/composer/PreviewState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/composer/PreviewState.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.richtext.CachedUrlParser import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/location/DeviceLocation.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/location/DeviceLocation.kt new file mode 100644 index 0000000000..bc21155841 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/location/DeviceLocation.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.location + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * The device's position as geohashes, for "around me" feeds, location chats and the composers' + * location tag. The platform decides when it actually listens; readers only collect. + */ +interface DeviceLocation { + /** A roughly 5 km geohash cell, or why there is none. */ + val geohashStateFlow: StateFlow + + /** The finest geohash the platform offers, for location channels that truncate it per level. */ + val preciseGeohashStateFlow: StateFlow + + /** Tells the source whether the user has granted location access, so it can start or stop. */ + fun setLocationPermission(newValue: Boolean) + + /** No location source on this platform: always reports a missing permission. */ + object None : DeviceLocation { + private val lacking = MutableStateFlow(LocationResult.LackPermission) + + override val geohashStateFlow: StateFlow = lacking + override val preciseGeohashStateFlow: StateFlow = lacking + + override fun setLocationPermission(newValue: Boolean) = Unit + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.kt index c776a8d4d2..cc21649e3d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.kt @@ -41,6 +41,9 @@ expect object LocalClock { /** Today, in the reader's timezone. */ fun today(): SearchDate + /** How many seconds this zone is ahead of UTC at [unixSeconds]; a clock change moves it. */ + fun utcOffsetSeconds(unixSeconds: Long): Long + /** * Which local day a unix second falls on, with the zone read once for the whole counter * rather than per timestamp: for grouping a batch of events by day in one pass. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/AppServices.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/AppServices.kt index d13b4f0e0e..ffa7fcea27 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/AppServices.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/AppServices.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserHistoryRegistry import com.vitorpamplona.amethyst.commons.browser.BrowserIconRegistry import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppsRegistry +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver @@ -56,4 +57,7 @@ interface AppServices { /** Finds the server behind a `blossom:` URI (BUD-10). */ val blossomServerFinder: BlossomServerFinder get() = BlossomServerFinder.None + + /** The device's position as geohashes. */ + val deviceLocation: DeviceLocation get() = DeviceLocation.None } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUploader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUploader.kt index a20a1de7bc..285902e8a0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUploader.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUploader.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.commons.service.uploads import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName +import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder +import com.vitorpamplona.amethyst.commons.service.upload.FileHeader import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.ciphers.NostrCipher @@ -74,6 +76,16 @@ interface MediaUploader { useH265: Boolean, convertGifToMp4: Boolean, ): MediaCompressorResult + + /** + * Downloads the already-hosted file at [url] and computes its [FileHeader] (hash, size, + * dimensions, preview hashes) for an `imeta` tag, guessing the MIME type from the URL's + * extension. Null when it cannot be downloaded or decoded. + */ + suspend fun remoteFileHeader( + url: String, + httpClients: IRoleBasedHttpClientBuilder, + ): FileHeader? } /** No media pipeline on this front end yet: every upload fails with a clear message. */ @@ -121,4 +133,9 @@ object UnavailableMediaUploader : MediaUploader { useH265: Boolean, convertGifToMp4: Boolean, ): MediaCompressorResult = MediaCompressorResult(uri, mimeType, null) + + override suspend fun remoteFileHeader( + url: String, + httpClients: IRoleBasedHttpClientBuilder, + ): FileHeader? = null } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModelHost.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModelHost.kt index 8f497e176e..4cbef6742e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModelHost.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModelHost.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.viewmodels +import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation import com.vitorpamplona.amethyst.commons.service.lnurl.LnurlHttpTransport import com.vitorpamplona.amethyst.commons.service.pow.PoWJobFailure import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader @@ -70,6 +71,9 @@ interface AccountViewModelHost { /** Compresses, strips, encrypts and uploads picked media for the composers. */ val mediaUploader: MediaUploader + /** The device's position as geohashes. */ + val deviceLocation: DeviceLocation get() = DeviceLocation.None + /** Public keys of every account saved on this device. */ val savedAccounts: Flow> diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.ios.kt index cfebd03c92..f535752b38 100644 --- a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.ios.kt +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.ios.kt @@ -67,6 +67,8 @@ actual object LocalClock { actual fun today(): SearchDate = ZoneMath.dayAt(currentTimeSeconds(), localZone) + actual fun utcOffsetSeconds(unixSeconds: Long): Long = localZone.at(unixSeconds) + actual fun epochDayCounter(): EpochDayCounter = EpochDayCounter { ZoneMath.epochDayAt(it, localZone) } /** diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.jvmAndroid.kt index f7288c0cad..cd836faa47 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.jvmAndroid.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.jvmAndroid.kt @@ -54,6 +54,13 @@ actual object LocalClock { SearchDate(it.year, it.monthValue, it.dayOfMonth) } + actual fun utcOffsetSeconds(unixSeconds: Long): Long = + zone() + .rules + .getOffset(Instant.ofEpochSecond(unixSeconds)) + .totalSeconds + .toLong() + actual fun epochDayCounter(): EpochDayCounter { val rules = zone().rules val offsets = ZoneOffsets { rules.getOffset(Instant.ofEpochSecond(it)).totalSeconds.toLong() } diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.android.kt new file mode 100644 index 0000000000..6e23c715b5 --- /dev/null +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.android.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import android.Manifest +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +actual fun rememberCoarseLocationPermission(): PlatformPermission { + val state = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION) + return remember(state) { + object : PlatformPermission { + override val isGranted: Boolean get() = state.status.isGranted + + override fun launchRequest() = state.launchPermissionRequest() + } + } +} diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.android.kt index f4c13f3478..f33865d82e 100644 --- a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.android.kt +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.android.kt @@ -70,6 +70,12 @@ actual fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String { return remember(context) { { epochMillis -> DateFormat.getTimeFormat(context).format(Date(epochMillis)) } } } +@Composable +actual fun rememberIs24HourClock(): Boolean { + val context = LocalContext.current + return remember(context) { DateFormat.is24HourFormat(context) } +} + actual fun relativeTimeSpanShortOrNull( epochMillis: Long, nowMillis: Long, diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.kt new file mode 100644 index 0000000000..d49f8e84ab --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import androidx.compose.runtime.Composable + +/** + * The approximate-location permission the geohash features need. Android asks for + * `ACCESS_COARSE_LOCATION`; desktop and iOS have no location source wired yet and report it denied. + */ +@Composable +expect fun rememberCoarseLocationPermission(): PlatformPermission diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/PlatformPermission.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/PlatformPermission.kt new file mode 100644 index 0000000000..d2ebb6b6a2 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/PlatformPermission.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import androidx.compose.runtime.Stable + +/** A runtime permission the user may grant or deny. [isGranted] is observable from composition. */ +@Stable +interface PlatformPermission { + val isGranted: Boolean + + /** Asks the user for the permission; [isGranted] updates when they answer. */ + fun launchRequest() +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.kt index 4b88cbbbff..d8962cc592 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.kt @@ -45,6 +45,10 @@ expect fun calendarYearAndDay(epochMillis: Long): Int @Composable expect fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String +/** Whether times should be shown on a 24-hour clock, under the same rules as [rememberTimeOfDayFormatter]. */ +@Composable +expect fun rememberIs24HourClock(): Boolean + /** * The platform's abbreviated relative span ("5 min. ago", "Yesterday"), [nowLabel] for an * instant that rounds to now or lies ahead, or null where the platform has no such formatter. diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.ios.kt new file mode 100644 index 0000000000..1dc27c0ede --- /dev/null +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.ios.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import androidx.compose.runtime.Composable + +private object NoLocationPermission : PlatformPermission { + override val isGranted: Boolean = false + + override fun launchRequest() = Unit +} + +/** No location source on this platform yet. */ +@Composable +actual fun rememberCoarseLocationPermission(): PlatformPermission = NoLocationPermission diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.ios.kt index 3a03a0d9e7..77cede0810 100644 --- a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.ios.kt +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.ios.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.ui.note import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import platform.Foundation.NSCalendar import platform.Foundation.NSCalendarUnitDay import platform.Foundation.NSCalendarUnitYear @@ -88,6 +89,10 @@ private val timeOfDay: (Long) -> String = { epochMillis -> timeOfDayFormatter.st @Composable actual fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String = timeOfDay +// The "j" template asks for the locale's preferred hour; an "a" in the result means an AM/PM clock. +@Composable +actual fun rememberIs24HourClock(): Boolean = remember { NSDateFormatter.dateFormatFromTemplate("j", 0u, NSLocale.currentLocale)?.contains('a') != true } + actual fun relativeTimeSpanShortOrNull( epochMillis: Long, nowMillis: Long, diff --git a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.jvm.kt new file mode 100644 index 0000000000..1dc27c0ede --- /dev/null +++ b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LocationPermission.jvm.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import androidx.compose.runtime.Composable + +private object NoLocationPermission : PlatformPermission { + override val isGranted: Boolean = false + + override fun launchRequest() = Unit +} + +/** No location source on this platform yet. */ +@Composable +actual fun rememberCoarseLocationPermission(): PlatformPermission = NoLocationPermission diff --git a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.jvm.kt index 3ad02fe674..b95d0d321a 100644 --- a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.jvm.kt +++ b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.jvm.kt @@ -21,9 +21,12 @@ package com.vitorpamplona.amethyst.commons.ui.note import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import java.time.Instant import java.time.ZoneId +import java.time.chrono.IsoChronology import java.time.format.DateTimeFormatter +import java.time.format.DateTimeFormatterBuilder import java.time.format.FormatStyle import java.util.Locale @@ -80,6 +83,14 @@ private val timeOfDay: (Long) -> String = { epochMillis -> @Composable actual fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String = timeOfDay +@Composable +actual fun rememberIs24HourClock(): Boolean = + remember { + DateTimeFormatterBuilder + .getLocalizedDateTimePattern(null, FormatStyle.SHORT, IsoChronology.INSTANCE, Locale.getDefault()) + .contains('H') + } + actual fun relativeTimeSpanShortOrNull( epochMillis: Long, nowMillis: Long,