diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d0b1e44b8a..0b01178648 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ configurations.configureEach { exclude(module = "commons-logging") } -val canonicalVersionCode = 436 -val canonicalVersionName = "1.30.3" +val canonicalVersionCode = 437 +val canonicalVersionName = "1.31.0" val postFixSize = 10 val abiPostFix = mapOf( @@ -151,8 +151,12 @@ android { buildTypes { getByName("release") { - isMinifyEnabled = false - + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + file("proguard-rules.pro") + ) devNetDefaultOn(false) enablePermissiveNetworkSecurityConfig(false) setAlternativeAppName(null) @@ -188,7 +192,6 @@ android { getByName("debug") { isDefault = true - isMinifyEnabled = false enableUnitTestCoverage = false signingConfig = signingConfigs.getByName("debug") @@ -373,6 +376,11 @@ dependencies { if (huaweiEnabled) { val huaweiImplementation = configurations.maybeCreate("huaweiImplementation") huaweiImplementation(libs.huawei.push) + + // These are compileOnly on the Huawei flavor so R8 can resolve optional HMS classes + // referenced by HMS Push during minification. + compileOnly(libs.huawei.hianalytics) + compileOnly(libs.huawei.availableupdate) } implementation(libs.androidx.media3.exoplayer) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000000..1db2e7d18e --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,213 @@ +########## BASELINE / ATTRIBUTES ########## +# Core attrs (serialization/DI/reflective access often rely on these) +-keepattributes *Annotation*,Signature,InnerClasses,EnclosingMethod,MethodParameters,Record + +# Honor @Keep if present +-keep @androidx.annotation.Keep class * { *; } +-keepclasseswithmembers class * { @androidx.annotation.Keep *; } + +########## OPTIONAL GOOGLE BITS (SUPPRESSED WARNINGS) ########## +-dontwarn com.google.android.gms.common.annotation.** +-dontwarn com.google.firebase.analytics.connector.** + +########## ANDROID / DI ########## +# Workers constructed by class name +-keep class ** extends androidx.work.ListenableWorker + +########## KOTLINX SERIALIZATION ########## +-keepclassmembers class ** { + @kotlinx.serialization.Serializable *; + *** Companion; + kotlinx.serialization.KSerializer serializer(...); +} + +########## JACKSON (CORE + ANNOTATIONS + DTOs) ########## +# Keep Jackson packages and common annotated members +-keep class com.fasterxml.jackson.** { *; } +-keepclassmembers class ** { + @com.fasterxml.jackson.annotation.JsonCreator (...); + @com.fasterxml.jackson.annotation.JsonProperty *; +} + +-keep class ** extends com.fasterxml.jackson.core.type.TypeReference { *; } +-keep class * implements com.fasterxml.jackson.databind.util.Converter { public (); public *; } +-keep class * extends com.fasterxml.jackson.databind.JsonDeserializer { public (); public *; } + +-dontwarn com.fasterxml.jackson.databind.** + +# Jackson DTO used by OpenGroupApi (reactions map values) +-keep class org.session.libsession.messaging.open_groups.OpenGroupApi$Reaction { *; } +-keepnames class org.session.libsession.messaging.open_groups.OpenGroupApi$Reaction +-keepclassmembers class org.session.libsession.messaging.open_groups.OpenGroupApi$Reaction { + ; + *** get*(); + void set*(***); + + # keep the default constructor too: + public (***, int, kotlin.jvm.internal.DefaultConstructorMarker); + # and a bare no-arg constructor if it exists + public (); +} + +# DTO used by OpenGroupApi +-keep class org.session.libsession.messaging.open_groups.OpenGroupApi$Capabilities { *; } +-keepclassmembers class org.session.libsession.messaging.open_groups.OpenGroupApi$Capabilities { (); } +-keepnames class org.session.libsession.messaging.open_groups.OpenGroupApi$Capabilities + +# Project models referenced via Jackson (from crashes) +-keep class org.thoughtcrime.securesms.crypto.KeyStoreHelper$SealedData { *; } +-keep class org.thoughtcrime.securesms.crypto.KeyStoreHelper$SealedData$* { *; } +-keep class org.thoughtcrime.securesms.crypto.AttachmentSecret { *; } +-keep class org.thoughtcrime.securesms.crypto.AttachmentSecret$* { *; } + +# Keep names + bean-style accessors for OpenGroupApi models +-keepnames class org.session.libsession.messaging.open_groups.** +-keepclassmembers class org.session.libsession.messaging.open_groups.** { + ; + *** get*(); + void set*(***); +} + +# Keep names + bean-style accessors for snode models +-keepnames class org.session.libsession.snode.** +-keepclassmembers class org.session.libsession.snode.** { + ; + *** get*(); + void set*(***); +} + +# Converters / Deserializers +-keep class org.session.libsession.snode.model.RetrieveMessageConverter { public (); public *; } + +########## JNI LOGGER / NATIVE ENTRYPOINTS ########## +# Logging interface & implementations (JNI looks up log(String,String,int)) +-keep interface network.loki.messenger.libsession_util.util.Logger { *; } +-keepnames class * implements network.loki.messenger.libsession_util.util.Logger +-keepclassmembers class * implements network.loki.messenger.libsession_util.util.Logger { + public void log(java.lang.String, java.lang.String, int); +} + +# JNI: ConfigPush constructors (exact signatures preserved) +-keepnames class network.loki.messenger.libsession_util.util.ConfigPush +-keepclassmembers class network.loki.messenger.libsession_util.util.ConfigPush { + public (java.util.List, long, java.util.List); + public (java.util.List, long, java.util.List, int, kotlin.jvm.internal.DefaultConstructorMarker); +} + +# JNI: specific getter used from native +-keepnames class network.loki.messenger.libsession_util.util.UserPic +-keepclassmembers class network.loki.messenger.libsession_util.util.UserPic { + public byte[] getKeyAsByteArray(); +} + +-keep class network.loki.messenger.libsession_util.util.GroupInfo$ClosedGroupInfo { *; } +-keepnames class network.loki.messenger.libsession_util.util.GroupInfo$ClosedGroupInfo +-keepclassmembers class network.loki.messenger.libsession_util.util.GroupInfo$ClosedGroupInfo { + public byte[] getAdminKeyAsByteArray(); + public byte[] getAuthDataAsByteArray(); +} + +########## WEBRTC / CHROMIUM JNI ########## +# WebRTC public Java APIs (kept for JNI_OnLoad registration) +-keep class org.webrtc.** { *; } + +# Chromium-based bits +-keep class org.chromium.base.** { *; } +-keep class org.chromium.net.** { *; } + +# Keep all native bridges everywhere +-keepclasseswithmembers,includedescriptorclasses class * { + native ; +} + +########## WEBRTC / CHROMIUM jni_zero ########## +# Ensure jni_zero Java side is discoverable by native +-keep class org.jni_zero.** { *; } +-keepnames class org.jni_zero.** + +########## CONVERSATION / MODELS (JNI + REFLECTION) ########## +# Conversation.* types constructed via JNI with (String,long,boolean) +-keepclassmembers class network.loki.messenger.libsession_util.util.Conversation$* { + public (java.lang.String, long, boolean); +} + +# Keep names and members of Conversation/Community models (JNI searches by name) +-keep class network.loki.messenger.libsession_util.util.Conversation$Community { *; } +-keep class network.loki.messenger.libsession_util.util.Conversation$OneToOne { *; } +-keep class network.loki.messenger.libsession_util.util.Conversation$ClosedGroup { *; } +-keep class network.loki.messenger.libsession_util.util.BaseCommunityInfo { *; } + +-keepclassmembers class network.loki.messenger.libsession_util.util.Conversation$Community { public (...); } +-keepclassmembers class network.loki.messenger.libsession_util.util.Conversation$OneToOne { public (...); } +-keepclassmembers class network.loki.messenger.libsession_util.util.Conversation$ClosedGroup { public (...); } + +-keepnames class network.loki.messenger.libsession_util.util.Conversation$Community +-keepnames class network.loki.messenger.libsession_util.util.Conversation$OneToOne +-keepnames class network.loki.messenger.libsession_util.util.Conversation$ClosedGroup +-keepnames class network.loki.messenger.libsession_util.util.BaseCommunityInfo + +# Group members (JNI constructor with long) +-keep class network.loki.messenger.libsession_util.GroupMembersConfig { *; } +-keep class network.loki.messenger.libsession_util.util.GroupMember { *; } +-keepclassmembers class network.loki.messenger.libsession_util.util.GroupMember { public (long); } +-keepnames class network.loki.messenger.libsession_util.util.GroupMember + +# Broad safety net for long-arg ctors in util package +-keepclassmembers class network.loki.messenger.libsession_util.util.** { public (long); } + +########## EMOJI SEARCH (JACKSON / POLYMORPHIC) ########## +# Keep names if @JsonTypeInfo uses CLASS/MINIMAL_CLASS +-keepnames class org.thoughtcrime.securesms.database.model.** +# Preserve abstract base + nested types for property/creator names +-keep class org.thoughtcrime.securesms.database.model.EmojiSearchData { *; } +-keep class org.thoughtcrime.securesms.database.model.EmojiSearchData$* { *; } + +########## KRYO (SERIALIZATION OF DESTINATIONS) ########## +# No-arg contructors required at runtime for these sealed subclasses +-keepclassmembers class org.session.libsession.messaging.messages.Destination$ClosedGroup { (); } +-keepclassmembers class org.session.libsession.messaging.messages.Destination$Contact { (); } +-keepclassmembers class org.session.libsession.messaging.messages.Destination$LegacyClosedGroup { (); } +-keepclassmembers class org.session.libsession.messaging.messages.Destination$LegacyOpenGroup { (); } +-keepclassmembers class org.session.libsession.messaging.messages.Destination$OpenGroup { (); } +-keepclassmembers class org.session.libsession.messaging.messages.Destination$OpenGroupInbox { (); } + +# Keep the Enum serializer contructor Kryo reflects on +-keepclassmembers class com.esotericsoftware.kryo.serializers.** { + public (...); +} + +# Prevent enum unboxing/renaming for the enum field being serialized +-keep class org.session.libsession.messaging.messages.control.TypingIndicator$Kind { *; } + +# Preserve class names for Kryo +-keepnames class org.session.libsession.messaging.messages.Destination$** + +########## OPEN GROUP API (MESSAGES) ########## +-keep class org.session.libsession.messaging.open_groups.OpenGroupApi$Message { *; } +-keepclassmembers class org.session.libsession.messaging.open_groups.OpenGroupApi$Message { (); } +-keepnames class org.session.libsession.messaging.open_groups.OpenGroupApi$Message +-keepclassmembers class org.session.libsession.messaging.open_groups.OpenGroupApi$Message { + *** get*(); + void set*(***); +} + +-keep class org.session.libsession.messaging.utilities.UpdateMessageData { *; } +-keep class org.session.libsession.messaging.utilities.UpdateMessageData$* { *; } +-keepnames class org.session.libsession.messaging.utilities.UpdateMessageData$* + +########## HUAWEI / HMS (minified builds) ########## +# Device-only classes referenced by HMS internals — not present on Maven. +-dontwarn android.telephony.HwTelephonyManager +-dontwarn com.huawei.android.os.BuildEx$VERSION +-dontwarn com.huawei.libcore.io.** +-dontwarn com.huawei.hianalytics.** +-dontwarn com.huawei.hms.availableupdate.** + +# Misc suppressed warnings +-dontwarn java.beans.BeanInfo +-dontwarn java.beans.IntrospectionException +-dontwarn java.beans.Introspector +-dontwarn java.beans.PropertyDescriptor +-dontwarn java.lang.management.ManagementFactory +-dontwarn java.lang.management.RuntimeMXBean +-dontwarn sun.nio.ch.DirectBuffer \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/BasePoller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/BasePoller.kt new file mode 100644 index 0000000000..424ce51a43 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/BasePoller.kt @@ -0,0 +1,179 @@ +package org.session.libsession.messaging.sending_receiving.pollers + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.util.AppVisibilityManager +import org.thoughtcrime.securesms.util.NetworkConnectivity +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * Base class for pollers that perform periodic polling operations. These poller will: + * + * 1. Run periodically when the app is in the foreground and there is network. + * 2. Adjust the polling interval based on success/failure of previous polls. + * 3. Expose the current polling state via [pollState] + * 4. Allow manual polling via [manualPollOnce] + * + * @param T The type of the result returned by the single polling. + */ +abstract class BasePoller( + private val networkConnectivity: NetworkConnectivity, + appVisibilityManager: AppVisibilityManager, + private val scope: CoroutineScope, +) { + protected val logTag: String = this::class.java.simpleName + private val pollMutex = Mutex() + + private val mutablePollState = MutableStateFlow>(PollState.Idle) + + /** + * The current state of the poller. + */ + val pollState: StateFlow> get() = mutablePollState + + init { + scope.launch { + var numConsecutiveFailures = 0 + + while (true) { + // Wait until the app is in the foreground and we have network connectivity + combine( + appVisibilityManager.isAppVisible.filter { visible -> + if (visible) { + true + } else { + Log.d(logTag, "Polling paused - app in background") + false + } + }, + networkConnectivity.networkAvailable.filter { hasNetwork -> + if (hasNetwork) { + true + } else { + Log.d(logTag, "Polling paused - no network connectivity") + false + } + }, + transform = { _, _ -> } + ).first() + + try { + pollOnce("routine") + numConsecutiveFailures = 0 + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + numConsecutiveFailures += 1 + } + + val nextPollSeconds = nextPollDelaySeconds(numConsecutiveFailures) + Log.d(logTag, "Next poll in ${nextPollSeconds}s") + delay(nextPollSeconds * 1000L) + } + } + } + + protected open val successfulPollIntervalSeconds: Int get() = 2 + protected open val maxRetryIntervalSeconds: Int get() = 10 + + /** + * Returns the delay until the next poll should be performed. + * + * @param numConsecutiveFailures The number of consecutive polling failures that have occurred. + * 0 indicates the last poll was successful. + */ + private fun nextPollDelaySeconds( + numConsecutiveFailures: Int, + ): Int { + val delay = successfulPollIntervalSeconds * (numConsecutiveFailures + 1) + return delay.coerceAtMost(maxRetryIntervalSeconds) + } + + /** + * Performs a single polling operation. A failed poll should throw an exception. + * + * @param isFirstPollSinceApoStarted True if this is the first poll since the app started. + * @return The result of the polling operation. + */ + protected abstract suspend fun doPollOnce(isFirstPollSinceApoStarted: Boolean): T + + private suspend fun pollOnce(reason: String): T { + pollMutex.withLock { + val lastState = mutablePollState.value + mutablePollState.value = + PollState.Polling(reason, lastPolledResult = lastState.lastPolledResult) + Log.d(logTag, "Start $reason polling") + val result = runCatching { + doPollOnce(isFirstPollSinceApoStarted = lastState is PollState.Idle) + } + + if (result.isSuccess) { + Log.d(logTag, "$reason polling succeeded") + } else if (result.exceptionOrNull() !is CancellationException) { + Log.e(logTag, "$reason polling failed", result.exceptionOrNull()) + } + + mutablePollState.value = PollState.Polled( + at = Clock.System.now(), + result = result, + ) + + return result.getOrThrow() + } + } + + /** + * Manually triggers a single polling operation. + * + * Note: + * * If a polling operation is already in progress, this will wait for it to complete first. + * * This method does not check for app foreground/background state or network connectivity. + * * This method will throw if the polling operation fails. + */ + suspend fun manualPollOnce(): T { + val resultChannel = Channel>() + + scope.launch { + resultChannel.trySend(runCatching { + pollOnce("manual") + }) + } + + return resultChannel.receive().getOrThrow() + } + + + sealed interface PollState { + val lastPolledResult: Result? + + object Idle : PollState { + override val lastPolledResult: Result? + get() = null + } + + data class Polled( + val at: Instant, + val result: Result, + ) : PollState { + override val lastPolledResult: Result + get() = result + } + + data class Polling( + val reason: String, + override val lastPolledResult: Result?, + ) : PollState + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt index 0869536168..dbaa4e6e32 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt @@ -4,15 +4,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit @@ -38,10 +30,9 @@ import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.CommunityDatabase import org.thoughtcrime.securesms.util.AppVisibilityManager +import org.thoughtcrime.securesms.util.NetworkConnectivity import javax.inject.Provider -private typealias PollRequestToken = Channel>> - /** * A [OpenGroupPoller] is responsible for polling all communities on a particular server. * @@ -52,7 +43,6 @@ private typealias PollRequestToken = Channel>> */ class OpenGroupPoller @AssistedInject constructor( private val storage: StorageProtocol, - private val appVisibilityManager: AppVisibilityManager, private val configFactory: ConfigFactoryProtocol, private val trimThreadJobFactory: TrimThreadJob.Factory, private val openGroupDeleteJobFactory: OpenGroupDeleteJob.Factory, @@ -63,76 +53,22 @@ class OpenGroupPoller @AssistedInject constructor( private val getDirectMessageFactory: GetDirectMessagesApi.Factory, private val pollRoomInfoFactory: PollRoomApi.Factory, private val getCapsApi: Provider, + networkConnectivity: NetworkConnectivity, + appVisibilityManager: AppVisibilityManager, private val json: Json, @Assisted private val server: String, @Assisted private val scope: CoroutineScope, @Assisted private val pollerSemaphore: Semaphore, +): BasePoller( + networkConnectivity = networkConnectivity, + scope = scope, + appVisibilityManager = appVisibilityManager ) { - companion object { - private const val POLL_INTERVAL_MILLS: Long = 4000L - const val MAX_INACTIVITIY_PERIOD_MILLS = 14 * 24 * 60 * 60 * 1000L // 14 days - - private const val TAG = "OpenGroupPoller" - } - - private val pendingPollRequest = Channel() + override val successfulPollIntervalSeconds: Int + get() = 4 - @OptIn(ExperimentalCoroutinesApi::class) - val pollState: StateFlow = flow { - val tokens = arrayListOf() - - while (true) { - // Wait for next request(s) to come in - tokens.clear() - tokens.add(pendingPollRequest.receive()) - tokens.addAll(generateSequence { pendingPollRequest.tryReceive().getOrNull() }) - - Log.d(TAG, "Polling open group messages for server: $server") - emit(PollState.Polling) - val pollResult = runCatching { - pollerSemaphore.withPermit { - pollOnce() - } - } - tokens.forEach { it.trySend(pollResult) } - emit(PollState.Idle(pollResult)) - - pollResult.exceptionOrNull()?.let { - Log.e(TAG, "Error while polling open groups for $server", it) - } - - } - }.stateIn(scope, SharingStarted.Eagerly, PollState.Idle(null)) - - init { - // Start a periodic polling request when the app becomes visible - scope.launch { - appVisibilityManager.isAppVisible - .collectLatest { visible -> - if (visible) { - while (true) { - val r = requestPollAndAwait() - if (r.isSuccess) { - delay(POLL_INTERVAL_MILLS) - } else { - delay(2000L) - } - } - } - } - } - } - - /** - * Requests a poll and await for the result. - * - * The result will be a list of room tokens that were polled. - */ - suspend fun requestPollAndAwait(): Result> { - val token: PollRequestToken = Channel() - pendingPollRequest.send(token) - return token.receive() - } + override val maxRetryIntervalSeconds: Int + get() = 30 private fun handleRoomPollInfo( address: Address.Community, @@ -147,7 +83,7 @@ class OpenGroupPoller @AssistedInject constructor( * * @return A list of rooms that were polled. */ - private suspend fun pollOnce(): List { + override suspend fun doPollOnce(isFirstPollSinceApoStarted: Boolean): Unit = pollerSemaphore.withPermit { val allCommunities = configFactory.withUserConfigs { it.userGroups.allCommunityInfo() } val rooms = allCommunities @@ -158,7 +94,7 @@ class OpenGroupPoller @AssistedInject constructor( }?.community?.pubKeyHex if (rooms.isEmpty() || serverKey.isNullOrBlank()) { - return emptyList() + return } coroutineScope { @@ -255,8 +191,6 @@ class OpenGroupPoller @AssistedInject constructor( } } } - - return rooms } @@ -285,7 +219,7 @@ class OpenGroupPoller @AssistedInject constructor( ) } catch (e: Exception) { Log.e( - TAG, + logTag, "Error processing open group message ${msg.id} in ${threadAddress.debugString}", e ) @@ -317,7 +251,7 @@ class OpenGroupPoller @AssistedInject constructor( val serverPubKeyHex = storage.getOpenGroupPublicKey(server) ?: run { - Log.e(TAG, "No community server public key cannot process inbox messages") + Log.e(logTag, "No community server public key cannot process inbox messages") return } @@ -334,7 +268,7 @@ class OpenGroupPoller @AssistedInject constructor( ) } catch (e: Exception) { - Log.e(TAG, "Error processing inbox message", e) + Log.e(logTag, "Error processing inbox message", e) } } } @@ -351,7 +285,7 @@ class OpenGroupPoller @AssistedInject constructor( val serverPubKeyHex = storage.getOpenGroupPublicKey(server) ?: run { - Log.e(TAG, "No community server public key cannot process inbox messages") + Log.e(logTag, "No community server public key cannot process inbox messages") return } @@ -368,18 +302,12 @@ class OpenGroupPoller @AssistedInject constructor( ) } catch (e: Exception) { - Log.e(TAG, "Error processing outbox message", e) + Log.e(logTag, "Error processing outbox message", e) } } } } - - sealed interface PollState { - data class Idle(val lastPolled: Result>?) : PollState - data object Polling : PollState - } - @AssistedFactory interface Factory { fun create( diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt index 99b24fe59f..04709336a2 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt @@ -1,5 +1,6 @@ package org.session.libsession.messaging.sending_receiving.pollers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel @@ -89,7 +90,7 @@ class OpenGroupPollerManager @Inject constructor( val isAllCaughtUp: Boolean get() = pollers.value.values.all { - (it.poller.pollState.value as? OpenGroupPoller.PollState.Idle)?.lastPolled != null + it.poller.pollState.value is BasePoller.PollState.Polled } @@ -99,9 +100,11 @@ class OpenGroupPollerManager @Inject constructor( pollers.value.map { (server, handle) -> handle.pollerScope.launch { runCatching { - handle.poller.requestPollAndAwait() + handle.poller.manualPollOnce() }.onFailure { - Log.e(TAG, "Error polling open group ${server}", it) + if (it !is CancellationException) { + Log.e(TAG, "Error polling open group $server", it) + } } } }.joinAll() diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt index 2c9d6fb254..7098b775a2 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt @@ -1,27 +1,10 @@ package org.session.libsession.messaging.sending_receiving.pollers -import android.os.SystemClock import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import network.loki.messenger.libsession_util.Namespace @@ -31,7 +14,6 @@ import org.session.libsession.messaging.messages.Message.Companion.senderOrSync import org.session.libsession.messaging.sending_receiving.MessageParser import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor import org.session.libsession.network.SnodeClock -import org.session.libsession.network.snode.SwarmDirectory import org.session.libsession.snode.model.RetrieveMessageResponse import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress @@ -54,17 +36,12 @@ import org.thoughtcrime.securesms.util.AppVisibilityManager import org.thoughtcrime.securesms.util.NetworkConnectivity import kotlin.time.Duration.Companion.days -private const val TAG = "Poller" - -typealias PollerRequestToken = Channel> - class Poller @AssistedInject constructor( private val configFactory: ConfigFactoryProtocol, private val storage: StorageProtocol, private val lokiApiDatabase: LokiAPIDatabaseProtocol, private val preferences: TextSecurePreferences, - private val appVisibilityManager: AppVisibilityManager, - private val networkConnectivity: NetworkConnectivity, + networkConnectivity: NetworkConnectivity, private val snodeClock: SnodeClock, private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, private val processor: ReceivedMessageProcessor, @@ -73,56 +50,25 @@ class Poller @AssistedInject constructor( private val alterTtlApiFactory: AlterTtlApi.Factory, private val swarmApiExecutor: SwarmApiExecutor, private val swarmSnodeSelector: SwarmSnodeSelector, + appVisibilityManager: AppVisibilityManager, @Assisted scope: CoroutineScope +) : BasePoller( + networkConnectivity = networkConnectivity, + scope = scope, + appVisibilityManager = appVisibilityManager ) { private val userPublicKey: String get() = storage.getUserPublicKey().orEmpty() - private val manualRequestTokens: SendChannel - val pollState: StateFlow - - init { - val tokenChannel = Channel() - - manualRequestTokens = tokenChannel - pollState = flow { setUpPolling(this, tokenChannel) } - .stateIn(scope, SharingStarted.Eagerly, PollState.Idle) - } @AssistedFactory interface Factory { fun create(scope: CoroutineScope): Poller } - enum class PollState { - Idle, - Polling, - } - - // region Settings - companion object { - private const val RETRY_INTERVAL_MS: Long = 2 * 1000 - private const val MAX_RETRY_INTERVAL_MS: Long = 15 * 1000 - private const val NEXT_RETRY_MULTIPLIER: Float = 1.2f // If we fail to poll we multiply our current retry interval by this (up to the above max) then try again - } - // endregion - - /** - * Request to do a poll from the poller. If it happens to have other requests pending, they - * will be batched together and processed at once. - * - * Note that if there's any error during the poll, this method will throw the same error. - */ - suspend fun requestPollOnce() { - val token = Channel>() - manualRequestTokens.send(token) - token.receive().getOrThrow() - } - - // region Private API - private suspend fun setUpPolling(collector: FlowCollector, tokenReceiver: ReceiveChannel) { + override suspend fun doPollOnce(isFirstPollSinceApoStarted: Boolean) { // Migrate to multipart config when needed - if (!preferences.migratedToMultiPartConfig) { + if (isFirstPollSinceApoStarted && !preferences.migratedToMultiPartConfig) { val allConfigNamespaces = intArrayOf(Namespace.USER_PROFILE(), Namespace.USER_GROUPS(), Namespace.CONTACTS(), @@ -139,87 +85,26 @@ class Poller @AssistedInject constructor( preferences.migratedToMultiPartConfig = true } - var retryScalingFactor = 1.0f // We increment the retry interval by NEXT_RETRY_MULTIPLIER times this value, which we bump on each failure - - var scheduledNextPoll = 0L - var hasPolledUserProfileOnce = false - - while (true) { - val requestTokens = merge( - combine( - appVisibilityManager.isAppVisible.filter { it }, - networkConnectivity.networkAvailable.filter { it }, - ) { _, _ -> - // If the app is visible and we have network, we can poll but need to stick to - // the scheduled next poll time - val delayMills = scheduledNextPoll - SystemClock.elapsedRealtime() - if (delayMills > 0) { - Log.d(TAG, "Delaying next poll by $delayMills ms") - delay(delayMills) - } - - mutableListOf() - }, - - tokenReceiver.receiveAsFlow().map { mutableListOf(it) } - ).first() - - // Drain the request tokens channel so we can process all pending requests at once - generateSequence { tokenReceiver.tryReceive().getOrNull() } - .mapTo(requestTokens) { it } + // When we are only just starting to set up the account, we want to poll only the user + // profile config so the user can see their name/avatar ASAP. Once this is done, we + // will do a full poll immediately. + val pollOnlyUserProfileConfig = isFirstPollSinceApoStarted && + configFactory.withUserConfigs { it.userProfile.activeHashes().isEmpty() } - // When we are only just starting to set up the account, we want to poll only the user - // profile config so the user can see their name/avatar ASAP. Once this is done, we - // will do a full poll immediately. - val pollOnlyUserProfileConfig = !hasPolledUserProfileOnce && - configFactory.withUserConfigs { it.userProfile.activeHashes().isEmpty() } - - Log.d(TAG, "Polling...manualTokenSize=${requestTokens.size}, " + - "pollOnlyUserProfileConfig=$pollOnlyUserProfileConfig") - - var pollDelay = RETRY_INTERVAL_MS - collector.emit(PollState.Polling) - try { - val currentNode = swarmSnodeSelector.selectSnode(userPublicKey) - - poll(currentNode, pollOnlyUserProfileConfig) - retryScalingFactor = 1f - - requestTokens.forEach { it.trySend(Result.success(Unit)) } - - if (pollOnlyUserProfileConfig) { - pollDelay = 0L // If we only polled the user profile config, we need to poll again immediately - } - - hasPolledUserProfileOnce = true - } catch (e: CancellationException) { - Log.w(TAG, "Polling cancelled", e) - requestTokens.forEach { it.trySend(Result.failure(e)) } - throw e - } catch (e: Exception) { - Log.e(TAG, "Error while polling:", e) - - pollDelay = minOf( - MAX_RETRY_INTERVAL_MS, - (RETRY_INTERVAL_MS * (NEXT_RETRY_MULTIPLIER * retryScalingFactor)).toLong() - ) - retryScalingFactor++ - requestTokens.forEach { it.trySend(Result.failure(e)) } - } finally { - collector.emit(PollState.Idle) - } - - scheduledNextPoll = SystemClock.elapsedRealtime() + pollDelay - } + poll( + snode = swarmSnodeSelector.selectSnode(userPublicKey), + pollOnlyUserProfileConfig = pollOnlyUserProfileConfig + ) } + // region Private API private fun processPersonalMessages(messages: List) { if (messages.isEmpty()) { - Log.d(TAG, "No personal messages to process") + Log.d(logTag, "No personal messages to process") return } - Log.d(TAG, "Received ${messages.size} personal messages from snode") + Log.d(logTag, "Received ${messages.size} personal messages from snode") processor.startProcessing("Poller") { ctx -> for (message in messages) { @@ -228,7 +113,7 @@ class Poller @AssistedInject constructor( namespace = Namespace.DEFAULT(), hash = message.hash )) { - Log.d(TAG, "Skipping duplicated message ${message.hash}") + Log.d(logTag, "Skipping duplicated message ${message.hash}") continue } @@ -249,7 +134,7 @@ class Poller @AssistedInject constructor( ) } catch (ec: Exception) { Log.e( - TAG, + logTag, "Error while processing personal message with hash ${message.hash}", ec ) @@ -260,7 +145,7 @@ class Poller @AssistedInject constructor( private fun processConfig(messages: List, forConfig: UserConfigType) { if (messages.isEmpty()) { - Log.d(TAG, "No messages to process for $forConfig") + Log.d(logTag, "No messages to process for $forConfig") return } @@ -289,11 +174,11 @@ class Poller @AssistedInject constructor( messages = newMessages ) } catch (e: Exception) { - Log.e(TAG, "Error while merging user configs for $forConfig", e) + Log.e(logTag, "Error while merging user configs for $forConfig", e) } } - Log.d(TAG, "Processed ${newMessages.size} new messages for config $forConfig") + Log.d(logTag, "Processed ${newMessages.size} new messages for config $forConfig") } @@ -382,7 +267,7 @@ class Poller @AssistedInject constructor( ) ) } catch (e: Exception) { - Log.e(TAG, "Error while extending TTL for hashes", e) + Log.e(logTag, "Error while extending TTL for hashes", e) } } } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/PollerManager.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/PollerManager.kt index 83cea4a8d8..31d08d758f 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/PollerManager.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/PollerManager.kt @@ -28,7 +28,7 @@ class PollerManager @Inject constructor( val isPolling: Boolean - get() = currentPoller.value?.pollState?.value == Poller.PollState.Polling + get() = currentPoller.value?.pollState?.value is BasePoller.PollState.Polling /** * Requests a poll from the current poller. @@ -36,6 +36,6 @@ class PollerManager @Inject constructor( * If there's none, it will suspend until one is created. */ suspend fun pollOnce() { - currentPoller.filterNotNull().first().requestPollOnce() + currentPoller.filterNotNull().first().manualPollOnce() } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt index 162078b51a..e19e0ec5c3 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt @@ -154,20 +154,25 @@ class SnodeDirectory @Inject constructor( for (target in seeds) { Log.d("SnodeDirectory", "Fetching snode pool using seed node: $target") @Suppress("OPT_IN_USAGE") val result = runCatching { - httpExecutor.get().send( + val body = httpExecutor.get().send( ctx = ApiExecutorContext(), req = HttpRequest.createFromJson( url = target.resolve("/json_rpc")!!, method = "POST", - jsonText = json.encodeToString(SnodeJsonRequest( - method = "get_n_service_nodes", - params = ListSnodeApi.buildRequestJson() - )) + jsonText = json.encodeToString( + SnodeJsonRequest( + method = "get_n_service_nodes", + params = ListSnodeApi.buildRequestJson() + ) + ) ) - ).throwIfNotSuccessful() - .body + ).throwIfNotSuccessful().body + + body .asInputStream() - .use { json.decodeFromStream(it) } + .use { + json.decodeFromStream(it) + } .result }.onFailure { e -> lastError = e diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/snode/ListSnodeApi.kt b/app/src/main/java/org/thoughtcrime/securesms/api/snode/ListSnodeApi.kt index 50b3504ab6..db0fe7da31 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/api/snode/ListSnodeApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/api/snode/ListSnodeApi.kt @@ -100,9 +100,9 @@ class ListSnodeApi @Inject constructor( @Serializable class SnodeInfo( @SerialName(KEY_IP) - val ip: String, + val ip: String? = null, @SerialName(KEY_PORT) - val port: Int, + val port: Int? = null, @SerialName(KEY_ED25519) val ed25519PubKey: String, @SerialName(KEY_X25519) @@ -110,9 +110,9 @@ class ListSnodeApi @Inject constructor( ) { fun toSnode(): Snode? { return Snode( - ip.takeUnless { it == "0.0.0.0" || it == "255.255.255.255" }?.let { "https://$it" } ?: return null, - port, - Snode.KeySet(ed25519PubKey, x25519PubKey), + address = ip.takeUnless { it == "0.0.0.0" || it == "255.255.255.255" }?.let { "https://$it" } ?: return null, + port = port ?: return null, + publicKeySet = Snode.KeySet(ed25519PubKey, x25519PubKey), ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java index 1abe337271..e776b735f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java @@ -24,7 +24,6 @@ import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; -import android.content.pm.PackageManager; import android.database.Cursor; import android.net.Uri; import android.os.AsyncTask; @@ -35,8 +34,6 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; import com.bumptech.glide.RequestManager; import com.squareup.phrase.Phrase; @@ -349,13 +346,13 @@ public static void selectGallery(Activity activity, int requestCode, @NonNull Ad .execute(); } - public static boolean hasFullAccess(Activity activity) { + public static boolean hasFullAccess(Context c) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - return Permissions.hasAll(activity, + return Permissions.hasAll(c, Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO); } else { - return Permissions.hasAll(activity, android.Manifest.permission.READ_EXTERNAL_STORAGE); + return Permissions.hasAll(c, android.Manifest.permission.READ_EXTERNAL_STORAGE); } } @@ -386,9 +383,9 @@ public static void managePhotoAccess(@NonNull Activity activity, @Nullable Runna } } - public static boolean shouldShowManagePhoto(@NonNull Activity activity){ + public static boolean shouldShowManagePhoto(@NonNull Context c){ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE){ - return !hasFullAccess(activity) && hasPartialAccess(activity); + return !hasFullAccess(c) && hasPartialAccess(c); }else{ // No partial access for <= API 33 return false; diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt index 6ac07309f0..3081b4943b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt @@ -28,7 +28,6 @@ class OnAppStartupComponents private constructor( } @Inject constructor( - appVisibilityManager: AppVisibilityManager, groupPollerManager: GroupPollerManager, expiredGroupManager: ExpiredGroupManager, openGroupPollerManager: OpenGroupPollerManager, @@ -48,7 +47,6 @@ class OnAppStartupComponents private constructor( subscriptionManagers: Set<@JvmSuppressWildcards SubscriptionManager>, ): this( components = listOf( - appVisibilityManager, groupPollerManager, expiredGroupManager, openGroupPollerManager, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ExpiredGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ExpiredGroupManager.kt index a061c0c8d4..23a8280736 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ExpiredGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ExpiredGroupManager.kt @@ -29,7 +29,7 @@ class ExpiredGroupManager @Inject constructor( @Suppress("OPT_IN_USAGE") val expiredGroups: StateFlow> = pollerManager.watchAllGroupPollingState() .mapNotNull { (groupId, state) -> - val expired = state.lastPoll?.groupExpired + val expired = state.lastPolledResult?.getOrNull()?.groupExpired if (expired == null) { // Poller doesn't know about the expiration state yet, so we skip diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index 4ccecc0a6a..9ac9c8b34f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -622,39 +622,35 @@ class GroupManagerV2Impl @Inject constructor( .mapNotNull { (member, result) -> configs.groupMembers.get(member.hexString)?.apply { if (result.isFailure) { - configs.groupMembers.get(member.hexString)?.let { member -> - member.setPromotionFailed() - configs.groupMembers.set(member) - } + setPromotionFailed() } } } .forEach(configs.groupMembers::set) } + if (!isRepromote) { + messageSender.sendAndAwait(message, Address.fromSerialized(group.hexString)) + } + val failedMembers = promotedByMemberIDs .filterValues { it.isFailure } .keys - .toList() + .map { it.hexString } if (failedMembers.isNotEmpty()) { val cause = promotedByMemberIDs.values - .firstOrNull { it.isFailure } - ?.exceptionOrNull() + .firstOrNull { it.isFailure }?.exceptionOrNull() ?: RuntimeException("Failed to promote ${failedMembers.size} member(s)") throw GroupInviteException( isPromotion = true, - inviteeAccountIds = failedMembers.map { it.hexString }, + inviteeAccountIds = failedMembers, groupName = groupName ?: "", isReinvite = isRepromote, underlying = cause ) } - - if (!isRepromote) { - messageSender.sendAndAwait(message, Address.fromSerialized(group.hexString)) - } } } @@ -749,7 +745,7 @@ class GroupManagerV2Impl @Inject constructor( groupPollerManager.pollOnce(groupId) groupPollerManager.watchGroupPollingState(groupId) - .filter { it.hadAtLeastOneSuccessfulPoll } + .filter { it.lastPolledResult?.isSuccess == true } .first() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt index 119917a535..927509440e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt @@ -7,21 +7,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import network.loki.messenger.libsession_util.Namespace import org.session.libsession.messaging.sending_receiving.MessageParser import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor +import org.session.libsession.messaging.sending_receiving.pollers.BasePoller import org.session.libsession.network.SnodeClock import org.session.libsession.snode.model.RetrieveMessageResponse import org.session.libsession.utilities.Address @@ -42,7 +34,7 @@ import org.thoughtcrime.securesms.api.swarm.SwarmSnodeSelector import org.thoughtcrime.securesms.api.swarm.execute import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.util.AppVisibilityManager -import java.time.Instant +import org.thoughtcrime.securesms.util.NetworkConnectivity import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.days @@ -53,7 +45,6 @@ class GroupPoller @AssistedInject constructor( private val configFactoryProtocol: ConfigFactoryProtocol, private val lokiApiDatabase: LokiAPIDatabaseProtocol, private val clock: SnodeClock, - private val appVisibilityManager: AppVisibilityManager, private val groupRevokedMessageHandler: GroupRevokedMessageHandler, private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, private val messageParser: MessageParser, @@ -62,125 +53,18 @@ class GroupPoller @AssistedInject constructor( private val alterTtlApiApiFactory: AlterTtlApi.Factory, private val swarmApiExecutor: SwarmApiExecutor, private val swarmSnodeSelector: SwarmSnodeSelector, + networkConnectivity: NetworkConnectivity, + appVisibilityManager: AppVisibilityManager, +): BasePoller( + networkConnectivity = networkConnectivity, + appVisibilityManager = appVisibilityManager, + scope = scope ) { - companion object { - private const val POLL_INTERVAL = 3_000L - private const val SWARM_FETCH_INTERVAL = 1800_000L // Every 30 minutes - - private const val TAG = "GroupPoller" - } - - data class State( - val hadAtLeastOneSuccessfulPoll: Boolean = false, - val lastPoll: PollResult? = null, - val inProgress: Boolean = false, - ) - - data class PollResult( - val startedAt: Instant, - val finishedAt: Instant, - val result: Result, + data class GroupPollResult( val groupExpired: Boolean? - ) { - fun hasNonRetryableError(): Boolean { - val e = result.exceptionOrNull() - return e != null && (e is NonRetryableException || e is CancellationException) - } - } - - - // A channel to send tokens to trigger a poll - private val pollOnceTokens = Channel() - - // A flow that represents the state of the poller. - val state: StateFlow = flow { - var lastState = State() - val pendingTokens = mutableListOf() - - while (true) { - pendingTokens.add(pollOnceTokens.receive()) - - // Drain all the tokens we've received up to this point, so we can reply them all at once - while (true) { - val result = pollOnceTokens.tryReceive() - result.getOrNull()?.let(pendingTokens::add) ?: break - } - - lastState = lastState.copy(inProgress = true).also { emit(it) } - - val pollResult = pollSemaphore.withPermit { - doPollOnce() - } - - lastState = lastState.copy( - hadAtLeastOneSuccessfulPoll = lastState.hadAtLeastOneSuccessfulPoll || pollResult.result.isSuccess, - lastPoll = pollResult, - inProgress = false - ).also { emit(it) } - - // Notify all pending tokens - pendingTokens.forEach { - it.resultCallback.trySend(pollResult) - } - pendingTokens.clear() - } - }.stateIn(scope, SharingStarted.Eagerly, State()) - - init { - // This coroutine is here to periodically request polling the group when the app - // becomes visible - scope.launch { - while (true) { - // Wait for the app becomes visible - appVisibilityManager.isAppVisible.first { visible -> visible } - - // As soon as the app becomes visible, start polling - Log.d(TAG, "Requesting routine poll for group($groupId)") - if (requestPollOnce().hasNonRetryableError()) { - Log.v(TAG, "Error polling group $groupId and stopped polling") - break - } - Log.d(TAG, "Routine poll done once for group($groupId)") - - // As long as the app is visible, keep polling - while (true) { - // Wait POLL_INTERVAL - delay(POLL_INTERVAL) - - val appInBackground = !appVisibilityManager.isAppVisible.value - - if (appInBackground) { - Log.d(TAG, "App became invisible, stopping polling group $groupId") - break - } - - Log.d(TAG, "Requesting routine poll for group($groupId)") - - if (requestPollOnce().hasNonRetryableError()) { - Log.v(TAG, "Error polling group $groupId and stopped polling") - return@launch - } - - Log.d(TAG, "Routine poll done once for group($groupId)") - } - } - } - } - - /** - * Request to poll the group once and return the result. It's guaranteed that - * the poll will be run AT LEAST once after the request is sent, but it's not guaranteed - * that one request will result in one poll, as the poller may choose to batch multiple requests - * together. - */ - suspend fun requestPollOnce(): PollResult { - val resultChannel = Channel() - pollOnceTokens.send(PollOnceToken(resultChannel)) - return resultChannel.receive() - } + ) - private suspend fun doPollOnce(): PollResult { - val pollStartedAt = Instant.now() + override suspend fun doPollOnce(isFirstPollSinceApoStarted: Boolean): GroupPollResult = pollSemaphore.withPermit { var groupExpired: Boolean? = null val result = runCatching { @@ -206,7 +90,7 @@ class GroupPoller @AssistedInject constructor( throw NonRetryableException("Group has been kicked") } - Log.v(TAG, "Start polling group($groupId) message snode = ${snode.ip}") + Log.v(logTag, "Start polling group($groupId) message snode = ${snode.ip}") val adminKey = group.adminKey @@ -351,21 +235,13 @@ class GroupPoller @AssistedInject constructor( } } - Log.d(TAG, "Group($groupId) polling completed, success = ${result.isSuccess}") + Log.d(logTag, "Group($groupId) polling completed, success = ${result.isSuccess}") - if (result.isFailure) { - val error = result.exceptionOrNull() - Log.e(TAG, "Error polling group", error) - } + result.getOrThrow() - val pollResult = PollResult( - startedAt = pollStartedAt, - finishedAt = Instant.now(), - result = result, + GroupPollResult( groupExpired = groupExpired ) - - return pollResult } private fun RetrieveMessageResponse.Message.toConfigMessage(): ConfigMessage { @@ -401,7 +277,7 @@ class GroupPoller @AssistedInject constructor( } Log.d( - TAG, "Handling group config messages(" + + logTag, "Handling group config messages(" + "info = ${infoResponse.size}, " + "keys = ${keysResponse.size}, " + "members = ${membersResponse.size})" @@ -430,7 +306,7 @@ class GroupPoller @AssistedInject constructor( namespace = Namespace.GROUP_MESSAGES(), hash = message.hash )) { - Log.v(TAG, "Skipping duplicated group message ${message.hash} for group $groupId") + Log.v(logTag, "Skipping duplicated group message ${message.hash} for group $groupId") continue } @@ -451,20 +327,14 @@ class GroupPoller @AssistedInject constructor( pro = result.pro, ) } catch (e: Exception) { - Log.e(TAG, "Error handling group message", e) + Log.e(logTag, "Error handling group message", e) } } } - Log.d(TAG, "Handled ${messages.size} group messages for $groupId in ${System.currentTimeMillis() - start}ms") + Log.d(logTag, "Handled ${messages.size} group messages for $groupId in ${System.currentTimeMillis() - start}ms") } - /** - * A token to poll a group once and receive the result. Note that it's not guaranteed that - * one token will trigger one poll, as the poller may batch multiple requests together. - */ - private data class PollOnceToken(val resultCallback: SendChannel) - @AssistedFactory interface Factory { fun create(scope: CoroutineScope, groupId: AccountId, pollSemaphore: Semaphore): GroupPoller diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPollerManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPollerManager.kt index 8ea0ef88ea..3efa436ade 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPollerManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPollerManager.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.sync.Semaphore +import org.session.libsession.messaging.sending_receiving.pollers.BasePoller import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.userConfigsChanged import org.session.libsession.utilities.withUserConfigs @@ -128,22 +129,22 @@ class GroupPollerManager @Inject constructor( @Suppress("OPT_IN_USAGE") - fun watchGroupPollingState(groupId: AccountId): Flow { + fun watchGroupPollingState(groupId: AccountId): Flow> { return groupPollers .flatMapLatest { pollers -> - pollers[groupId]?.poller?.state ?: flowOf(GroupPoller.State()) + pollers[groupId]?.poller?.pollState ?: flowOf(BasePoller.PollState.Idle) } .distinctUntilChanged() } @OptIn(ExperimentalCoroutinesApi::class) - fun watchAllGroupPollingState(): Flow> { + fun watchAllGroupPollingState(): Flow>> { return groupPollers .flatMapLatest { pollers -> // Merge all poller states into a single flow of (groupId, state) pairs merge( *pollers - .map { (id, poller) -> poller.poller.state.map { state -> id to state } } + .map { (id, poller) -> poller.poller.pollState.map { state -> id to state } } .toTypedArray() ) } @@ -153,7 +154,7 @@ class GroupPollerManager @Inject constructor( supervisorScope { groupPollers.value.values.map { async { - it.poller.requestPollOnce() + it.poller.manualPollOnce() } }.awaitAll() } @@ -165,11 +166,11 @@ class GroupPollerManager @Inject constructor( * Note that if the group is not supposed to be polled (kicked, destroyed, etc) then * this function will hang forever. It's your responsibility to set a timeout if needed. */ - suspend fun pollOnce(groupId: AccountId): GroupPoller.PollResult { + suspend fun pollOnce(groupId: AccountId): GroupPoller.GroupPollResult { return groupPollers.mapNotNull { it[groupId] } .first() .poller - .requestPollOnce() + .manualPollOnce() } data class GroupPollerHandle( diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/InviteMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/InviteMembersViewModel.kt index 66d045f47f..737d773b28 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/InviteMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/InviteMembersViewModel.kt @@ -90,7 +90,8 @@ class InviteMembersViewModel @AssistedInject constructor( selected: Set, ): InviteContactsDialogState { val count = selected.size - val firstMember = selected.firstOrNull() + val sortedMembers = selected.sortedBy { it.address } + val firstMember = sortedMembers.firstOrNull() val body: CharSequence = when (count) { 1 -> { @@ -103,7 +104,7 @@ class InviteMembersViewModel @AssistedInject constructor( } } 2 -> { - val secondMember = selected.elementAtOrNull(1)?.name + val secondMember = sortedMembers.elementAtOrNull(1)?.name Phrase.from(context, R.string.membersInviteShareDescriptionTwo) .put(NAME_KEY, firstMember?.name) .put(OTHER_NAME_KEY, secondMember) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt index eceee18611..c2180aff0f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -303,7 +303,8 @@ class ManageGroupMembersViewModel @AssistedInject constructor( group: String ): RemoveMembersDialogState { val count = selected.size - val firstMember = selected.firstOrNull() + val sortedMembers = selected.sortedBy { it.accountId } + val firstMember = sortedMembers.firstOrNull() val body: CharSequence = when (count) { 1 -> Phrase.from(context, R.string.groupRemoveDescription) @@ -312,7 +313,7 @@ class ManageGroupMembersViewModel @AssistedInject constructor( .format() 2 -> { - val secondMember = selected.elementAtOrNull(1)?.name + val secondMember = sortedMembers.elementAtOrNull(1)?.name Phrase.from(context, R.string.groupRemoveDescriptionTwo) .put(NAME_KEY, firstMember?.name) .put(OTHER_NAME_KEY, secondMember) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt index 4c6d01a227..92b3a06c19 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt @@ -64,7 +64,7 @@ class OpenGroupManager @Inject constructor( .mapNotNull { it[server] } .first() .poller - .requestPollAndAwait() + .manualPollOnce() } fun delete(server: String, room: String) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt index fafdb4c9a9..fc21477ae3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt @@ -137,7 +137,8 @@ class PromoteMembersViewModel @AssistedInject constructor( selected: Set ): String { val count = selected.size - val firstMember = selected.firstOrNull() + val sortedMembers = selected.sortedBy { it.accountId } + val firstMember = sortedMembers.firstOrNull() val body: CharSequence = when (count) { 1 -> { @@ -147,7 +148,7 @@ class PromoteMembersViewModel @AssistedInject constructor( } 2 -> { - val secondMember = selected.elementAtOrNull(1)?.name + val secondMember = sortedMembers.elementAtOrNull(1)?.name Phrase.from(context, R.string.adminPromoteTwoDescription) .put(NAME_KEY, firstMember?.name) .put(OTHER_NAME_KEY, secondMember) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.java deleted file mode 100644 index b84ebfd276..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.java +++ /dev/null @@ -1,42 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import android.net.Uri; -import androidx.annotation.NonNull; - -/** - * Represents a folder that's shown in {@link MediaPickerFolderFragment}. - */ -public class MediaFolder { - - private final Uri thumbnailUri; - private final String title; - private final int itemCount; - private final String bucketId; - - MediaFolder(@NonNull Uri thumbnailUri, @NonNull String title, int itemCount, @NonNull String bucketId) { - this.thumbnailUri = thumbnailUri; - this.title = title; - this.itemCount = itemCount; - this.bucketId = bucketId; - } - - Uri getThumbnailUri() { - return thumbnailUri; - } - - public String getTitle() { - return title; - } - - int getItemCount() { - return itemCount; - } - - public String getBucketId() { - return bucketId; - } - - enum FolderType { - NORMAL, CAMERA - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.kt new file mode 100644 index 0000000000..038af1d22e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.kt @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.mediasend + +import android.net.Uri + + +/** + * Represents a folder that's shown in MediaPickerFolderFragment. + */ +data class MediaFolder( + val thumbnailUri: Uri?, + val title: String, + val itemCount: Int, + val bucketId: String, +) { + enum class FolderType { + NORMAL, CAMERA + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java deleted file mode 100644 index 1973ad1700..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java +++ /dev/null @@ -1,97 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import com.bumptech.glide.load.engine.DiskCacheStrategy; -import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; - - - -import network.loki.messenger.R; -import com.bumptech.glide.RequestManager; - -import java.util.ArrayList; -import java.util.List; - -class MediaPickerFolderAdapter extends RecyclerView.Adapter { - - private final RequestManager glideRequests; - private final EventListener eventListener; - private final List folders; - - MediaPickerFolderAdapter(@NonNull RequestManager glideRequests, @NonNull EventListener eventListener) { - this.glideRequests = glideRequests; - this.eventListener = eventListener; - this.folders = new ArrayList<>(); - } - - @NonNull - @Override - public FolderViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { - return new FolderViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.mediapicker_folder_item, viewGroup, false)); - } - - @Override - public void onBindViewHolder(@NonNull FolderViewHolder folderViewHolder, int i) { - folderViewHolder.bind(folders.get(i), glideRequests, eventListener); - } - - @Override - public void onViewRecycled(@NonNull FolderViewHolder holder) { - holder.recycle(); - } - - @Override - public int getItemCount() { - return folders.size(); - } - - void setFolders(@NonNull List folders) { - this.folders.clear(); - this.folders.addAll(folders); - notifyDataSetChanged(); - } - - static class FolderViewHolder extends RecyclerView.ViewHolder { - - private final ImageView thumbnail; - private final ImageView icon; - private final TextView title; - private final TextView count; - - FolderViewHolder(@NonNull View itemView) { - super(itemView); - - thumbnail = itemView.findViewById(R.id.mediapicker_folder_item_thumbnail); - icon = itemView.findViewById(R.id.mediapicker_folder_item_icon); - title = itemView.findViewById(R.id.mediapicker_folder_item_title); - count = itemView.findViewById(R.id.mediapicker_folder_item_count); - } - - void bind(@NonNull MediaFolder folder, @NonNull RequestManager glideRequests, @NonNull EventListener eventListener) { - title.setText(folder.getTitle()); - count.setText(String.valueOf(folder.getItemCount())); - - glideRequests.load(folder.getThumbnailUri()) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .transition(DrawableTransitionOptions.withCrossFade()) - .into(thumbnail); - - itemView.setOnClickListener(v -> eventListener.onFolderClicked(folder)); - } - - void recycle() { - itemView.setOnClickListener(null); - } - } - - interface EventListener { - void onFolderClicked(@NonNull MediaFolder mediaFolder); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java deleted file mode 100644 index 3bdf73322b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java +++ /dev/null @@ -1,197 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import static org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY; - -import android.content.Context; -import android.content.res.Configuration; -import android.graphics.Point; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.widget.Toolbar; -import androidx.core.view.MenuHost; -import androidx.core.view.MenuProvider; -import androidx.fragment.app.Fragment; -import androidx.lifecycle.Lifecycle; -import androidx.lifecycle.ViewModelProvider; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.bumptech.glide.Glide; -import com.squareup.phrase.Phrase; - -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsession.utilities.recipients.RecipientNamesKt; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager; -import org.thoughtcrime.securesms.util.ViewUtilitiesKt; - -import dagger.hilt.android.AndroidEntryPoint; -import network.loki.messenger.R; - -/** - * Allows the user to select a media folder to explore. - */ -@AndroidEntryPoint -public class MediaPickerFolderFragment extends Fragment implements MediaPickerFolderAdapter.EventListener { - - private static final String KEY_RECIPIENT_NAME = "recipient_name"; - - private String recipientName; - private MediaSendViewModel viewModel; - private Controller controller; - private GridLayoutManager layoutManager; - - MediaPickerFolderAdapter adapter; - - private MenuProvider manageMenuProvider; - - public static @NonNull MediaPickerFolderFragment newInstance(@NonNull Recipient recipient) { - Bundle args = new Bundle(); - args.putString(KEY_RECIPIENT_NAME, RecipientNamesKt.displayName(recipient)); - - MediaPickerFolderFragment fragment = new MediaPickerFolderFragment(); - fragment.setArguments(args); - - return fragment; - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - recipientName = getArguments().getString(KEY_RECIPIENT_NAME); - viewModel = new ViewModelProvider(requireActivity()).get(MediaSendViewModel.class); - } - - @Override - public void onAttach(Context context) { - super.onAttach(context); - - if (!(getActivity() instanceof Controller)) { - throw new IllegalStateException("Parent activity must implement controller class."); - } - - controller = (Controller) getActivity(); - } - - @Override - public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.mediapicker_folder_fragment, container, false); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - ViewUtilitiesKt.applySafeInsetsPaddings(view); - - RecyclerView list = view.findViewById(R.id.mediapicker_folder_list); - adapter = new MediaPickerFolderAdapter(Glide.with(this), this); - - layoutManager = new GridLayoutManager(requireContext(), 2); - onScreenWidthChanged(getScreenWidth()); - - list.setLayoutManager(layoutManager); - list.setAdapter(adapter); - - viewModel.getFolders(requireContext()).observe(getViewLifecycleOwner(), adapter::setFolders); - - initToolbar(view.findViewById(R.id.mediapicker_toolbar)); - } - - @Override - public void onResume() { - super.onResume(); - - viewModel.onFolderPickerStarted(); - } - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - onScreenWidthChanged(getScreenWidth()); - } - - private void initToolbar(Toolbar toolbar) { - ((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar); - ActionBar actionBar = ((AppCompatActivity) requireActivity()).getSupportActionBar(); - if (actionBar == null) { - Log.w("MediaPickerFolderFragment", "ActionBar is null in initToolbar - cannot continue."); - } else { - CharSequence txt = Phrase.from(requireContext(), R.string.attachmentsSendTo).put(NAME_KEY, recipientName).format(); - actionBar.setTitle(txt); - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setHomeButtonEnabled(true); - toolbar.setNavigationOnClickListener(v -> requireActivity().getOnBackPressedDispatcher().onBackPressed()); - } - - initToolbarOptions(); - } - - private void initToolbarOptions() { - MenuHost menuHost = (MenuHost) requireActivity(); - - // Always remove current provider first (if any) - if (manageMenuProvider != null) { - menuHost.removeMenuProvider(manageMenuProvider); - manageMenuProvider = null; - } - - if (AttachmentManager.shouldShowManagePhoto(requireActivity())) { - manageMenuProvider = new MenuProvider() { - @Override - public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { - inflater.inflate(R.menu.menu_media_add, menu); - } - - @Override - public boolean onMenuItemSelected(@NonNull MenuItem item) { - if (item.getItemId() == R.id.mediapicker_menu_add) { - AttachmentManager.managePhotoAccess(requireActivity(), () -> { - if (!isAdded()) return; - - viewModel.getFolders(requireContext()) - .observe(getViewLifecycleOwner(), adapter::setFolders); - - initToolbarOptions(); - }); - return true; - } - return false; - } - }; - - menuHost.addMenuProvider(manageMenuProvider, getViewLifecycleOwner(), Lifecycle.State.STARTED); - } - } - - private void onScreenWidthChanged(int newWidth) { - if (layoutManager != null) { - layoutManager.setSpanCount(newWidth / getResources().getDimensionPixelSize(R.dimen.media_picker_folder_width)); - } - } - - private int getScreenWidth() { - Point size = new Point(); - requireActivity().getWindowManager().getDefaultDisplay().getSize(size); - return size.x; - } - - @Override - public void onFolderClicked(@NonNull MediaFolder folder) { - controller.onFolderSelected(folder); - } - - public interface Controller { - void onFolderSelected(@NonNull MediaFolder folder); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java deleted file mode 100644 index b184197fe1..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java +++ /dev/null @@ -1,173 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import com.bumptech.glide.load.engine.DiskCacheStrategy; -import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; - -import network.loki.messenger.R; -import com.bumptech.glide.RequestManager; - -import org.thoughtcrime.securesms.util.MediaUtil; -import org.thoughtcrime.securesms.util.StableIdGenerator; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.LinkedList; -import java.util.List; - -public class MediaPickerItemAdapter extends RecyclerView.Adapter { - - private final RequestManager glideRequests; - private final EventListener eventListener; - private final List media; - private final List selected; - private final int maxSelection; - private final StableIdGenerator stableIdGenerator; - - private boolean forcedMultiSelect; - - public MediaPickerItemAdapter(@NonNull RequestManager glideRequests, @NonNull EventListener eventListener, int maxSelection) { - this.glideRequests = glideRequests; - this.eventListener = eventListener; - this.media = new ArrayList<>(); - this.maxSelection = maxSelection; - this.stableIdGenerator = new StableIdGenerator<>(); - this.selected = new LinkedList<>(); - - setHasStableIds(true); - } - - @Override - public @NonNull ItemViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { - return new ItemViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.mediapicker_media_item, viewGroup, false)); - } - - @Override - public void onBindViewHolder(@NonNull ItemViewHolder holder, int i) { - holder.bind(media.get(i), forcedMultiSelect, selected, maxSelection, glideRequests, eventListener); - } - - @Override - public void onViewRecycled(@NonNull ItemViewHolder holder) { - holder.recycle(); - } - - @Override - public int getItemCount() { - return media.size(); - } - - @Override - public long getItemId(int position) { - return stableIdGenerator.getId(media.get(position)); - } - - void setMedia(@NonNull List media) { - this.media.clear(); - this.media.addAll(media); - notifyDataSetChanged(); - } - - void setSelected(@NonNull Collection selected) { - this.selected.clear(); - this.selected.addAll(selected); - notifyDataSetChanged(); - } - - List getSelected() { - return selected; - } - - void setForcedMultiSelect(boolean forcedMultiSelect) { - this.forcedMultiSelect = forcedMultiSelect; - notifyDataSetChanged(); - } - - static class ItemViewHolder extends RecyclerView.ViewHolder { - - private final ImageView thumbnail; - private final View playOverlay; - private final View selectOn; - private final View selectOff; - private final View selectOverlay; - private final TextView selectOrder; - - ItemViewHolder(@NonNull View itemView) { - super(itemView); - thumbnail = itemView.findViewById(R.id.mediapicker_image_item_thumbnail); - playOverlay = itemView.findViewById(R.id.mediapicker_play_overlay); - selectOn = itemView.findViewById(R.id.mediapicker_select_on); - selectOff = itemView.findViewById(R.id.mediapicker_select_off); - selectOverlay = itemView.findViewById(R.id.mediapicker_select_overlay); - selectOrder = itemView.findViewById(R.id.mediapicker_select_order); - } - - void bind(@NonNull Media media, boolean multiSelect, List selected, int maxSelection, @NonNull RequestManager glideRequests, @NonNull EventListener eventListener) { - glideRequests.load(media.getUri()) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .transition(DrawableTransitionOptions.withCrossFade()) - .into(thumbnail); - - playOverlay.setVisibility(MediaUtil.isVideoType(media.getMimeType()) ? View.VISIBLE : View.GONE); - - if (selected.isEmpty() && !multiSelect) { - itemView.setOnClickListener(v -> eventListener.onMediaChosen(media)); - selectOn.setVisibility(View.GONE); - selectOff.setVisibility(View.GONE); - selectOverlay.setVisibility(View.GONE); - - if (maxSelection > 1) { - itemView.setOnLongClickListener(v -> { - selected.add(media); - eventListener.onMediaSelectionStarted(); - eventListener.onMediaSelectionChanged(new ArrayList<>(selected)); - return true; - }); - } - } else if (selected.contains(media)) { - selectOff.setVisibility(View.VISIBLE); - selectOn.setVisibility(View.VISIBLE); - selectOverlay.setVisibility(View.VISIBLE); - selectOrder.setText(String.valueOf(selected.indexOf(media) + 1)); - itemView.setOnLongClickListener(null); - itemView.setOnClickListener(v -> { - selected.remove(media); - eventListener.onMediaSelectionChanged(new ArrayList<>(selected)); - }); - } else { - selectOff.setVisibility(View.VISIBLE); - selectOn.setVisibility(View.GONE); - selectOverlay.setVisibility(View.GONE); - itemView.setOnLongClickListener(null); - itemView.setOnClickListener(v -> { - if (selected.size() < maxSelection) { - selected.add(media); - eventListener.onMediaSelectionChanged(new ArrayList<>(selected)); - } else { - eventListener.onMediaSelectionOverflow(maxSelection); - } - }); - } - } - - void recycle() { - itemView.setOnClickListener(null); - } - - - } - - interface EventListener { - void onMediaChosen(@NonNull Media media); - void onMediaSelectionStarted(); - void onMediaSelectionChanged(@NonNull List media); - void onMediaSelectionOverflow(int maxSelection); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java deleted file mode 100644 index 15d6a8c7a3..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java +++ /dev/null @@ -1,203 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import androidx.appcompat.app.ActionBar; -import androidx.lifecycle.ViewModelProvider; -import android.content.Context; -import android.content.res.Configuration; -import android.graphics.Point; -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.appcompat.app.AppCompatActivity; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.appcompat.widget.Toolbar; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowManager; -import android.widget.Toast; - -import com.bumptech.glide.Glide; -import org.session.libsession.utilities.Util; -import org.thoughtcrime.securesms.util.ViewUtilitiesKt; - -import java.util.ArrayList; -import java.util.List; - -import dagger.hilt.android.AndroidEntryPoint; -import network.loki.messenger.R; - -/** - * Allows the user to select a set of media items from a specified folder. - */ -@AndroidEntryPoint -public class MediaPickerItemFragment extends Fragment implements MediaPickerItemAdapter.EventListener { - - private static final String KEY_BUCKET_ID = "bucket_id"; - private static final String KEY_FOLDER_TITLE = "folder_title"; - private static final String KEY_MAX_SELECTION = "max_selection"; - - private String bucketId; - private String folderTitle; - private int maxSelection; - private MediaSendViewModel viewModel; - private MediaPickerItemAdapter adapter; - private Controller controller; - private GridLayoutManager layoutManager; - - public static MediaPickerItemFragment newInstance(@NonNull String bucketId, @NonNull String folderTitle, int maxSelection) { - Bundle args = new Bundle(); - args.putString(KEY_BUCKET_ID, bucketId); - args.putString(KEY_FOLDER_TITLE, folderTitle); - args.putInt(KEY_MAX_SELECTION, maxSelection); - - MediaPickerItemFragment fragment = new MediaPickerItemFragment(); - fragment.setArguments(args); - - return fragment; - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setHasOptionsMenu(true); - - bucketId = getArguments().getString(KEY_BUCKET_ID); - folderTitle = getArguments().getString(KEY_FOLDER_TITLE); - maxSelection = getArguments().getInt(KEY_MAX_SELECTION); - viewModel = new ViewModelProvider(requireActivity()).get(MediaSendViewModel.class); - } - - @Override - public void onAttach(Context context) { - super.onAttach(context); - - if (!(getActivity() instanceof Controller)) { - throw new IllegalStateException("Parent activity must implement controller class."); - } - - controller = (Controller) getActivity(); - } - - @Override - public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.mediapicker_item_fragment, container, false); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - ViewUtilitiesKt.applySafeInsetsPaddings(view); - - RecyclerView imageList = view.findViewById(R.id.mediapicker_item_list); - - adapter = new MediaPickerItemAdapter(Glide.with(this), this, maxSelection); - layoutManager = new GridLayoutManager(requireContext(), 4); - - imageList.setLayoutManager(layoutManager); - imageList.setAdapter(adapter); - - initToolbar(view.findViewById(R.id.mediapicker_toolbar)); - onScreenWidthChanged(getScreenWidth()); - - if (!Util.isEmpty(viewModel.getSelectedMedia().getValue())) { - adapter.setSelected(viewModel.getSelectedMedia().getValue()); - onMediaSelectionChanged(new ArrayList<>(viewModel.getSelectedMedia().getValue())); - } - - viewModel.getMediaInBucket(requireContext(), bucketId).observe(getViewLifecycleOwner(), adapter::setMedia); - - initMediaObserver(viewModel); - } - - @Override - public void onResume() { - super.onResume(); - - viewModel.onItemPickerStarted(); - } - - @Override - public void onPrepareOptionsMenu(Menu menu) { - requireActivity().getMenuInflater().inflate(R.menu.mediapicker_default, menu); - - if (viewModel.getCountButtonState().getValue() != null && viewModel.getCountButtonState().getValue().isVisible()) { - menu.findItem(R.id.mediapicker_menu_add).setVisible(false); - } - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - if (item.getItemId() == R.id.mediapicker_menu_add) { - adapter.setForcedMultiSelect(true); - viewModel.onMultiSelectStarted(); - return true; - } - return false; - } - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - onScreenWidthChanged(getScreenWidth()); - } - - @Override - public void onMediaChosen(@NonNull Media media) { - controller.onMediaSelected(media); - } - - @Override - public void onMediaSelectionStarted() { - viewModel.onMultiSelectStarted(); - } - - @Override - public void onMediaSelectionChanged(@NonNull List selected) { - adapter.notifyDataSetChanged(); - viewModel.onSelectedMediaChanged(requireContext(), selected); - } - - @Override - public void onMediaSelectionOverflow(int maxSelection) { - Toast.makeText(requireContext(), getString(R.string.attachmentsErrorNumber), Toast.LENGTH_SHORT).show(); - } - - private void initToolbar(Toolbar toolbar) { - AppCompatActivity activity = (AppCompatActivity) requireActivity(); - activity.setSupportActionBar(toolbar); - ActionBar actionBar = activity.getSupportActionBar(); - actionBar.setTitle(folderTitle); - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setHomeButtonEnabled(true); - - toolbar.setNavigationOnClickListener(v -> requireActivity().getOnBackPressedDispatcher().onBackPressed()); - } - - private void initMediaObserver(@NonNull MediaSendViewModel viewModel) { - viewModel.getCountButtonState().observe(getViewLifecycleOwner(), media -> { - requireActivity().invalidateOptionsMenu(); - }); - } - - private void onScreenWidthChanged(int newWidth) { - if (layoutManager != null) { - layoutManager.setSpanCount(newWidth / getResources().getDimensionPixelSize(R.dimen.media_picker_item_width)); - } - } - - private int getScreenWidth() { - Point size = new Point(); - requireActivity().getWindowManager().getDefaultDisplay().getSize(size); - return size.x; - } - - public interface Controller { - void onMediaSelected(@NonNull Media media); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt index 67956e8753..bd5ed3b00f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt @@ -19,7 +19,9 @@ import androidx.activity.viewModels import androidx.core.view.ViewGroupCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @@ -37,6 +39,8 @@ import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.mediasend.CameraXActivity.Companion.KEY_MEDIA_SEND_COUNT import org.thoughtcrime.securesms.mediasend.MediaSendViewModel.CountButtonState +import org.thoughtcrime.securesms.mediasend.compose.MediaPickerFolderComposeFragment +import org.thoughtcrime.securesms.mediasend.compose.MediaPickerItemComposeFragment import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.scribbles.ImageEditorFragment import org.thoughtcrime.securesms.util.FilenameUtils.constructPhotoFilename @@ -51,8 +55,8 @@ import javax.inject.Inject * It will return the [Media] that the user decided to send. */ @AndroidEntryPoint -class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragment.Controller, - MediaPickerItemFragment.Controller, MediaSendFragment.Controller, +class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderComposeFragment.Controller, + MediaPickerItemComposeFragment.Controller, MediaSendFragment.Controller, ImageEditorFragment.Controller { private var recipient: Recipient? = null @@ -105,7 +109,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme if (isCamera) { navigateToCamera() } else if (!isEmpty(media)) { - viewModel.onSelectedMediaChanged(this, media!!) + viewModel.onSelectedMediaChanged(media!!) lastEntryFromCameraCapture = false @@ -115,7 +119,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme .replace(R.id.mediasend_fragment_container, fragment, TAG_SEND) .commit() } else { - val fragment = MediaPickerFolderFragment.newInstance( + val fragment = MediaPickerFolderComposeFragment.newInstance( recipient!! ) supportFragmentManager.beginTransaction() @@ -126,7 +130,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme initializeCountButtonObserver() initializeCameraButtonObserver() - initializeErrorObserver() + collectEffects() binding.mediasendCameraButton.setOnClickListener { v: View? -> val maxSelection = MediaSendViewModel.MAX_SELECTED_FILES @@ -147,7 +151,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme // and we're on the editor as the only fragment. if (lastEntryFromCameraCapture && isCameraFlow && fm.backStackEntryCount == 1) { fm.popBackStackImmediate() // remove the editor fragment - viewModel.onImageCaptureUndo(this@MediaSendActivity) + viewModel.onImageCaptureUndo() lastEntryFromCameraCapture = false navigateToCamera() return @@ -177,10 +181,9 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme override fun onFolderSelected(folder: MediaFolder) { viewModel.onFolderSelected(folder.bucketId) - val fragment = MediaPickerItemFragment.newInstance( + val fragment = MediaPickerItemComposeFragment.newInstance( folder.bucketId, - folder.title, - MediaSendViewModel.MAX_SELECTED_FILES + folder.title ) supportFragmentManager.beginTransaction() .setCustomAnimations( @@ -206,11 +209,11 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme } override fun onAddMediaClicked(bucketId: String) { - val folderFragment = MediaPickerFolderFragment.newInstance( + val folderFragment = MediaPickerFolderComposeFragment.newInstance( recipient!! ) val itemFragment = - MediaPickerItemFragment.newInstance(bucketId, "", MediaSendViewModel.MAX_SELECTED_FILES) + MediaPickerItemComposeFragment.newInstance(bucketId, "") supportFragmentManager.beginTransaction() .setCustomAnimations( @@ -300,44 +303,63 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme } } - private fun initializeErrorObserver() { - viewModel.getError().observe( - this - ) { error: MediaSendViewModel.Error? -> - if (error == null) return@observe - when (error) { - MediaSendViewModel.Error.INVALID_TYPE_ONLY -> Toast.makeText( - this, - Phrase.from( - this, - R.string.sharingSupportMultipleMedia - ).put(APP_NAME_KEY, getString(R.string.app_name)).format().toString(), - Toast.LENGTH_LONG - ).show() - - MediaSendViewModel.Error.MIXED_TYPE -> Toast.makeText( - this, - R.string.sharingSupportMultipleMediaExcluded, - Toast.LENGTH_LONG - ).show() - - MediaSendViewModel.Error.ITEM_TOO_LARGE -> Toast.makeText( - this, - R.string.attachmentsErrorSize, - Toast.LENGTH_LONG - ).show() - MediaSendViewModel.Error.TOO_MANY_ITEMS -> // In modern session we'll say you can't sent more than 32 items, but if we ever want - // the exact count of how many items the user attempted to send it's: viewModel.getMaxSelection() - Toast.makeText( - this, - getString(R.string.attachmentsErrorNumber), - Toast.LENGTH_SHORT - ).show() + private fun collectEffects() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.effects.collect { effect -> + when (effect) { + is MediaSendViewModel.MediaSendEffect.ShowError -> showError(effect.error) + is MediaSendViewModel.MediaSendEffect.Toast -> + Toast.makeText( + this@MediaSendActivity, + effect.messageRes, + Toast.LENGTH_LONG + ).show() + + is MediaSendViewModel.MediaSendEffect.ToastText -> + Toast.makeText( + this@MediaSendActivity, + effect.message, + Toast.LENGTH_LONG + ).show() + } + } } } } + private fun showError(error: MediaSendViewModel.Error) { + when (error) { + MediaSendViewModel.Error.INVALID_TYPE_ONLY -> Toast.makeText( + this, + Phrase.from(this, R.string.sharingSupportMultipleMedia) + .put(APP_NAME_KEY, getString(R.string.app_name)) + .format() + .toString(), + Toast.LENGTH_LONG + ).show() + + MediaSendViewModel.Error.MIXED_TYPE -> Toast.makeText( + this, + R.string.sharingSupportMultipleMediaExcluded, + Toast.LENGTH_LONG + ).show() + + MediaSendViewModel.Error.ITEM_TOO_LARGE -> Toast.makeText( + this, + R.string.attachmentsErrorSize, + Toast.LENGTH_LONG + ).show() + + MediaSendViewModel.Error.TOO_MANY_ITEMS -> Toast.makeText( + this, + getString(R.string.attachmentsErrorNumber), + Toast.LENGTH_SHORT + ).show() + } + } + private fun navigateToMediaSend(recipient: Address) { val fragment = MediaSendFragment.newInstance(recipient) var backstackTag: String? = null @@ -372,7 +394,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme .request(Manifest.permission.CAMERA) .withPermanentDenialDialog(permanentDenialTxt) .onAllGranted { - val countNow = viewModel.getCountButtonState().value?.count ?: 0 + val countNow = viewModel.uiState.value.count val intent = Intent(this@MediaSendActivity, CameraXActivity::class.java) .putExtra(KEY_MEDIA_SEND_COUNT, countNow) cameraLauncher.launch(intent) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt index 9581143f42..25e89eaf29 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt @@ -210,10 +210,7 @@ class MediaSendFragment : Fragment(), RailItemListener, InputBarDelegate { override fun onRailItemDeleteClicked(distanceFromActive: Int) { val currentItem = binding?.mediasendPager?.currentItem ?: return - viewModel?.onMediaItemRemoved( - requireContext(), - currentItem + distanceFromActive - ) + viewModel?.onMediaItemRemoved(currentItem + distanceFromActive) } fun onTouchEventsNeeded(needed: Boolean) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt index 899bf57b5c..89709a8b6e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt @@ -3,45 +3,48 @@ package org.thoughtcrime.securesms.mediasend import android.app.Application import android.content.Context import android.net.Uri +import android.os.Build import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.asLiveData import com.annimon.stream.Stream import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import org.session.libsession.utilities.Util.equals import org.session.libsession.utilities.Util.runOnMain import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.InputbarViewModel +import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager.hasFullAccess +import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager.hasPartialAccess import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.providers.BlobUtils import org.thoughtcrime.securesms.util.MediaUtil -import org.thoughtcrime.securesms.util.SingleLiveEvent -import java.util.LinkedList import javax.inject.Inject /** * Manages the observable datasets available in [MediaSendActivity]. */ @HiltViewModel -internal class MediaSendViewModel @Inject constructor( +class MediaSendViewModel @Inject constructor( private val application: Application, proStatusManager: ProStatusManager, recipientRepository: RecipientRepository, + private val context: ApplicationContext, ) : InputbarViewModel( application = application, proStatusManager = proStatusManager, recipientRepository = recipientRepository, ) { - private val selectedMedia: MutableLiveData?> - private val bucketMedia: MutableLiveData> - private val position: MutableLiveData - private val bucketId: MutableLiveData - private val folders: MutableLiveData> - private val countButtonState: MutableLiveData - private val cameraButtonVisibility: MutableLiveData - private val error: SingleLiveEvent private val savedDrawState: MutableMap private val mediaConstraints: MediaConstraints = MediaConstraints.getPushMediaConstraints() @@ -49,72 +52,120 @@ internal class MediaSendViewModel @Inject constructor( var body: CharSequence private set - private var countButtonVisibility: CountButtonState.Visibility + private var sentMedia: Boolean = false private var lastImageCapture: Optional + private val _uiState = MutableStateFlow(MediaSendUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _effects = MutableSharedFlow(extraBufferCapacity = 1) + val effects: SharedFlow = _effects.asSharedFlow() + + // Legacy LiveData bridges (delete later once all UI is Flow/Compose) + private val selectedMediaLiveData: LiveData?> = + uiState.map { it.selectedMedia.ifEmpty { null } }.asLiveData() + + private val bucketIdLiveData: LiveData = + uiState.map { it.bucketId }.asLiveData() + + private val positionLiveData: LiveData = + uiState.map { it.position }.asLiveData() + + private val foldersLiveData: LiveData> = + uiState.map { it.folders }.asLiveData() + + private val countButtonStateLiveData: LiveData = + uiState.map { CountButtonState(it.count, it.countVisibility) } + .asLiveData() + + private val cameraButtonVisibilityLiveData: LiveData = + uiState.map { it.showCameraButton }.asLiveData() + init { - this.selectedMedia = MutableLiveData() - this.bucketMedia = MutableLiveData() - this.position = MutableLiveData() - this.bucketId = MutableLiveData() - this.folders = MutableLiveData() - this.countButtonState = MutableLiveData() - this.cameraButtonVisibility = MutableLiveData() - this.error = SingleLiveEvent() this.savedDrawState = HashMap() - this.countButtonVisibility = CountButtonState.Visibility.FORCED_OFF this.lastImageCapture = Optional.absent() this.body = "" - position.value = -1 - countButtonState.value = CountButtonState(0, countButtonVisibility) - cameraButtonVisibility.value = false + _uiState.value = MediaSendUiState( + position = -1, + countVisibility = CountButtonState.Visibility.FORCED_OFF, + showCameraButton = false + ) } - fun onSelectedMediaChanged(context: Context, newMedia: List) { + fun onMediaSelected(media: Media) { + val updatedList = run { + val current = uiState.value.selectedMedia + val exists = current.any { it.uri == media.uri } + + if (exists) { + current.filterNot { it.uri == media.uri } + } else { + if (current.size >= MAX_SELECTED_FILES) { + _effects.tryEmit(MediaSendEffect.ShowError(Error.TOO_MANY_ITEMS)) + current + } else { + current + media + } + } + } + + onSelectedMediaChanged(updatedList) + } + + fun onSelectedMediaChanged(newMedia: List) { repository.getPopulatedMedia(context, newMedia) { populatedMedia: List -> runOnMain { // Use the new filter function that returns valid items AND errors - var (filteredMedia, errors) = getFilteredMedia(context, populatedMedia, mediaConstraints) + var (filteredMedia, errors) = getFilteredMedia( + context, + populatedMedia, + mediaConstraints + ) // Report errors if they occurred if (errors.contains(Error.ITEM_TOO_LARGE)) { - error.setValue(Error.ITEM_TOO_LARGE) + _effects.tryEmit(MediaSendEffect.ShowError(Error.ITEM_TOO_LARGE)) } else if (errors.contains(Error.INVALID_TYPE_ONLY)) { - error.setValue(Error.INVALID_TYPE_ONLY) - }else if (errors.contains(Error.MIXED_TYPE)) { - error.setValue(Error.MIXED_TYPE) + _effects.tryEmit(MediaSendEffect.ShowError(Error.INVALID_TYPE_ONLY)) + } else if (errors.contains(Error.MIXED_TYPE)) { + _effects.tryEmit(MediaSendEffect.ShowError(Error.MIXED_TYPE)) } if (filteredMedia.size > MAX_SELECTED_FILES) { filteredMedia = filteredMedia.subList(0, MAX_SELECTED_FILES) - error.setValue(Error.TOO_MANY_ITEMS) + _effects.tryEmit(MediaSendEffect.ShowError(Error.TOO_MANY_ITEMS)) } - if (filteredMedia.isNotEmpty()) { - val computedId: String = Stream.of(filteredMedia) - .skip(1) - .reduce(filteredMedia[0].bucketId ?: Media.ALL_MEDIA_BUCKET_ID) { id: String?, m: Media -> - if (equals(id, m.bucketId ?: Media.ALL_MEDIA_BUCKET_ID)) { - id - } else { - Media.ALL_MEDIA_BUCKET_ID + val computedId: String = + if (filteredMedia.isNotEmpty()) { + Stream.of(filteredMedia) + .skip(1) + .reduce( + filteredMedia[0].bucketId ?: Media.ALL_MEDIA_BUCKET_ID + ) { id: String?, m: Media -> + if (equals(id, m.bucketId ?: Media.ALL_MEDIA_BUCKET_ID)) { + id + } else { + Media.ALL_MEDIA_BUCKET_ID + } } - } - bucketId.setValue(computedId) - } else { - bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID) - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL - } + } else { + Media.ALL_MEDIA_BUCKET_ID + } + + val newVisibility = + if (filteredMedia.isEmpty()) CountButtonState.Visibility.CONDITIONAL + else _uiState.value.countVisibility - selectedMedia.setValue(filteredMedia) - countButtonState.setValue( - CountButtonState( - filteredMedia.size, - countButtonVisibility + _uiState.update { + it.copy( + selectedMedia = filteredMedia, + bucketId = computedId, + countVisibility = newVisibility, ) - ) + } } } } @@ -122,66 +173,79 @@ internal class MediaSendViewModel @Inject constructor( fun onSingleMediaSelected(context: Context, media: Media) { repository.getPopulatedMedia(context, listOf(media)) { populatedMedia: List -> runOnMain { - val (filteredMedia, errors) = getFilteredMedia(context, populatedMedia, mediaConstraints) + val (filteredMedia, errors) = getFilteredMedia( + context, + populatedMedia, + mediaConstraints + ) if (filteredMedia.isEmpty()) { if (errors.contains(Error.ITEM_TOO_LARGE)) { - error.setValue(Error.ITEM_TOO_LARGE) + _effects.tryEmit(MediaSendEffect.ShowError(Error.ITEM_TOO_LARGE)) } else if (errors.contains(Error.INVALID_TYPE_ONLY)) { - error.setValue(Error.INVALID_TYPE_ONLY) - }else if (errors.contains(Error.MIXED_TYPE)) { - error.setValue(Error.MIXED_TYPE) + _effects.tryEmit(MediaSendEffect.ShowError(Error.INVALID_TYPE_ONLY)) + } else if (errors.contains(Error.MIXED_TYPE)) { + _effects.tryEmit(MediaSendEffect.ShowError(Error.MIXED_TYPE)) } - bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID) - } else { - bucketId.setValue(filteredMedia[0].bucketId ?: Media.ALL_MEDIA_BUCKET_ID) } - countButtonVisibility = CountButtonState.Visibility.FORCED_OFF + val newBucketId = + if (filteredMedia.isEmpty()) Media.ALL_MEDIA_BUCKET_ID + else (filteredMedia[0].bucketId ?: Media.ALL_MEDIA_BUCKET_ID) - selectedMedia.value = filteredMedia - countButtonState.setValue( - CountButtonState( - filteredMedia.size, - countButtonVisibility + _uiState.update { + it.copy( + selectedMedia = filteredMedia, + bucketId = newBucketId, + countVisibility = CountButtonState.Visibility.FORCED_OFF, ) - ) + } } } } fun onMultiSelectStarted() { - countButtonVisibility = CountButtonState.Visibility.FORCED_ON - countButtonState.value = - CountButtonState(selectedMediaOrDefault.size, countButtonVisibility) + _uiState.update { + it.copy( + countVisibility = CountButtonState.Visibility.FORCED_ON + ) + } } fun onImageEditorStarted() { - countButtonVisibility = CountButtonState.Visibility.FORCED_OFF - countButtonState.value = - CountButtonState(selectedMediaOrDefault.size, countButtonVisibility) - cameraButtonVisibility.value = false + _uiState.update { + it.copy( + countVisibility = CountButtonState.Visibility.FORCED_OFF, + showCameraButton = false + ) + } } fun onCameraStarted() { - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL - countButtonState.value = - CountButtonState(selectedMediaOrDefault.size, countButtonVisibility) - cameraButtonVisibility.value = false + _uiState.update { + it.copy( + countVisibility = CountButtonState.Visibility.CONDITIONAL, + showCameraButton = false + ) + } } fun onItemPickerStarted() { - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL - countButtonState.value = - CountButtonState(selectedMediaOrDefault.size, countButtonVisibility) - cameraButtonVisibility.value = true + _uiState.update { + it.copy( + countVisibility = CountButtonState.Visibility.CONDITIONAL, + showCameraButton = true + ) + } } fun onFolderPickerStarted() { - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL - countButtonState.value = - CountButtonState(selectedMediaOrDefault.size, countButtonVisibility) - cameraButtonVisibility.value = true + _uiState.update { + it.copy( + countVisibility = CountButtonState.Visibility.CONDITIONAL, + showCameraButton = true + ) + } } fun onBodyChanged(body: CharSequence) { @@ -189,78 +253,99 @@ internal class MediaSendViewModel @Inject constructor( } fun onFolderSelected(bucketId: String) { - this.bucketId.value = bucketId - bucketMedia.value = - emptyList() + _uiState.update { it.copy(bucketId = bucketId, bucketMedia = emptyList()) } } fun onPageChanged(position: Int) { - if (position < 0 || position >= selectedMediaOrDefault.size) { - Log.w(TAG, - "Tried to move to an out-of-bounds item. Size: " + selectedMediaOrDefault.size + ", position: " + position + if (position !in selectedMedia.indices) { + Log.w( + TAG, + "Tried to move to an out-of-bounds item. Size: " + selectedMedia.size + ", position: " + position ) return } - this.position.value = position + _uiState.update { it.copy(position = position) } } - fun onMediaItemRemoved(context: Context, position: Int) { - if (position < 0 || position >= selectedMediaOrDefault.size) { + fun onMediaItemRemoved(position: Int) { + val current = selectedMedia + if (position < 0 || position >= current.size) { Log.w( TAG, - "Tried to remove an out-of-bounds item. Size: " + selectedMediaOrDefault.size + ", position: " + position + "Tried to remove an out-of-bounds item. Size: ${current.size}, position: $position" ) return } - val updatedList = selectedMediaOrDefault.toMutableList() + val updatedList = current.toMutableList() val removed: Media = updatedList.removeAt(position) if (BlobUtils.isAuthority(removed.uri)) { BlobUtils.getInstance().delete(context, removed.uri) } - selectedMedia.setValue(updatedList) + _uiState.update { state -> + val newPos = + if (updatedList.isEmpty()) -1 + else state.position.coerceIn(0, updatedList.lastIndex) + state.copy(selectedMedia = updatedList, position = newPos) + } } fun onImageCaptured(media: Media) { - var selected: MutableList? = selectedMedia.value?.toMutableList() - - if (selected == null) { - selected = LinkedList() - } + val selected: MutableList = selectedMedia.toMutableList() if (selected.size >= MAX_SELECTED_FILES) { - error.setValue(Error.TOO_MANY_ITEMS) + _effects.tryEmit(MediaSendEffect.ShowError(Error.TOO_MANY_ITEMS)) return } lastImageCapture = Optional.of(media) selected.add(media) - selectedMedia.setValue(selected) - position.setValue(selected.size - 1) - bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID) - - if (selected.size == 1) { - countButtonVisibility = CountButtonState.Visibility.FORCED_OFF - } else { - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL - } - countButtonState.setValue(CountButtonState(selected.size, countButtonVisibility)) + val newVisibility = + if (selected.size == 1) CountButtonState.Visibility.FORCED_OFF else CountButtonState.Visibility.CONDITIONAL + + _uiState.update { + it.copy( + selectedMedia = selected, + position = selected.size - 1, + bucketId = Media.ALL_MEDIA_BUCKET_ID, + countVisibility = newVisibility + ) + } } - fun onImageCaptureUndo(context: Context) { - val selected: MutableList = selectedMediaOrDefault.toMutableList() + fun onImageCaptureUndo() { + val last = if (lastImageCapture.isPresent) lastImageCapture.get() else return + val current = selectedMedia + + if (!(current.size == 1 && current.contains(last))) return - if (lastImageCapture.isPresent && selected.contains(lastImageCapture.get()) && selected.size == 1) { - selected.remove(lastImageCapture.get()) - selectedMedia.value = selected - countButtonState.value = CountButtonState(selected.size, countButtonVisibility) - BlobUtils.getInstance().delete(context, lastImageCapture.get().uri) + val updated = current.toMutableList().apply { remove(last) } + + _uiState.update { state -> + state.copy( + selectedMedia = updated, + position = -1 + ) } + + if (BlobUtils.isAuthority(last.uri)) { + BlobUtils.getInstance().delete(context, last.uri) + } + + lastImageCapture = Optional.absent() + } + + fun refreshPhotoAccessUi() { + val show = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && + !hasFullAccess(context) && + hasPartialAccess(context) + + _uiState.update { it.copy(showManagePhotoAccess = show) } } fun saveDrawState(state: Map) { @@ -276,42 +361,49 @@ internal class MediaSendViewModel @Inject constructor( get() = savedDrawState fun getSelectedMedia(): LiveData?> { - return selectedMedia + return selectedMediaLiveData } - fun getMediaInBucket(context: Context, bucketId: String): LiveData> { - repository.getMediaInBucket(context, bucketId) { value: List -> bucketMedia.postValue(value) } - return bucketMedia + fun getMediaInBucket(bucketId: String): LiveData> { + // refresh data, but state is stored in uiState + repository.getMediaInBucket(context, bucketId) { value -> + _uiState.update { it.copy(bucketMedia = value) } + } + return uiState.map { it.bucketMedia }.asLiveData() } - fun getFolders(context: Context): LiveData> { - repository.getFolders(context) { value: List -> folders.postValue(value) } - return folders + fun getFolders(): LiveData> { + repository.getFolders(context) { value -> + _uiState.update { it.copy(folders = value) } + } + return foldersLiveData } fun getCountButtonState(): LiveData { - return countButtonState + return countButtonStateLiveData } fun getCameraButtonVisibility(): LiveData { - return cameraButtonVisibility + return cameraButtonVisibilityLiveData } fun getPosition(): LiveData { - return position + return positionLiveData } fun getBucketId(): LiveData { - return bucketId + return bucketIdLiveData } - fun getError(): LiveData { - return error - } + private val selectedMedia: List + get() = _uiState.value.selectedMedia - private val selectedMediaOrDefault: List - get() = if (selectedMedia.value == null) emptyList() else - selectedMedia.value!! + // Same as getFolders but does not return LiveData + fun refreshFolders() { + repository.getFolders(context) { value -> + _uiState.update { it.copy(folders = value) } + } + } /** * Filters the input list of media. @@ -324,6 +416,11 @@ internal class MediaSendViewModel @Inject constructor( media: List, mediaConstraints: MediaConstraints ): Pair, Set> { + + if (media.isEmpty()) { + return Pair(emptyList(), emptySet()) + } + val validMedia = ArrayList() val errors = HashSet() @@ -336,12 +433,11 @@ internal class MediaSendViewModel @Inject constructor( } // if there are no valid types at all, return early - if(validMultiMediaCount == 0){ + if (validMultiMediaCount == 0) { errors.add(Error.INVALID_TYPE_ONLY) return Pair(validMedia, errors) } - for (m in media) { val isGif = MediaUtil.isGif(m.mimeType) val isVideo = MediaUtil.isVideoType(m.mimeType) @@ -373,7 +469,7 @@ internal class MediaSendViewModel @Inject constructor( override fun onCleared() { if (!sentMedia) { - Stream.of(selectedMediaOrDefault) + Stream.of(selectedMedia) .map { obj: Media -> obj.uri } .filter { uri: Uri? -> BlobUtils.isAuthority( @@ -381,18 +477,16 @@ internal class MediaSendViewModel @Inject constructor( ) } .forEach { uri: Uri? -> - BlobUtils.getInstance().delete( - application.applicationContext, uri!! - ) + BlobUtils.getInstance().delete(context, uri!!) } } } - internal enum class Error { + enum class Error { ITEM_TOO_LARGE, TOO_MANY_ITEMS, INVALID_TYPE_ONLY, MIXED_TYPE } - internal class CountButtonState(val count: Int, private val visibility: Visibility) { + class CountButtonState(val count: Int, private val visibility: Visibility) { val isVisible: Boolean get() { return when (visibility) { @@ -402,11 +496,44 @@ internal class MediaSendViewModel @Inject constructor( } } - internal enum class Visibility { + enum class Visibility { CONDITIONAL, FORCED_ON, FORCED_OFF } } + data class MediaSendUiState( + val recipientName: String = "", + val folders: List = emptyList(), + val bucketId: String = Media.ALL_MEDIA_BUCKET_ID, + val bucketMedia: List = emptyList(), + val selectedMedia: List = emptyList(), + val position: Int = -1, + val countVisibility: CountButtonState.Visibility = CountButtonState.Visibility.FORCED_OFF, + val showCameraButton: Boolean = false, + val showManagePhotoAccess : Boolean = false + ) { + val count: Int get() = selectedMedia.size + + val isMultiSelect: Boolean + get() = selectedMedia.isNotEmpty() || countVisibility == CountButtonState.Visibility.FORCED_ON + + val canLongPress: Boolean + get() = selectedMedia.isEmpty() && !isMultiSelect + val showCountButton: Boolean + get() = + when (countVisibility) { + CountButtonState.Visibility.FORCED_ON -> true + CountButtonState.Visibility.FORCED_OFF -> false + CountButtonState.Visibility.CONDITIONAL -> count > 0 + } + } + + sealed interface MediaSendEffect { + data class ShowError(val error: Error) : MediaSendEffect + data class Toast(val messageRes: Int) : MediaSendEffect + data class ToastText(val message: String) : MediaSendEffect + } + companion object { private val TAG: String = MediaSendViewModel::class.java.simpleName diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt new file mode 100644 index 0000000000..72449650d6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt @@ -0,0 +1,321 @@ +package org.thoughtcrime.securesms.mediasend.compose + +import android.net.Uri +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.Crossfade +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.innerShadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import network.loki.messenger.R +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.ui.AnimateFade +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.util.MediaUtil + +@Composable +fun MediaFolderCell( + title: String, + count: Int, + thumbnailUri: Uri?, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clickable(onClick = onClick) + ) { + AsyncImage( + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Crop, + model = ImageRequest.Builder(LocalContext.current) + .data(thumbnailUri) + .build(), + contentDescription = null, + ) + + // Bottom row + Box( + modifier = Modifier.fillMaxSize() + .innerShadow( + shape = RectangleShape, + shadow = Shadow( + radius = 8.dp, + color = Color.Black.copy(alpha = 0.4f), + offset = DpOffset(x = 0.dp, (-40).dp) // shadow appears form the bottom + ) + ) + ) { + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding( + horizontal = LocalDimensions.current.smallSpacing, + vertical = LocalDimensions.current.xxsSpacing + ), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.ic_baseline_folder_24), + contentDescription = null, + modifier = Modifier.size(LocalDimensions.current.iconSmall), + colorFilter = ColorFilter.tint(Color.White) + ) + + Spacer(Modifier.width(LocalDimensions.current.xxsSpacing)) + + Text( + text = title, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) + + Spacer(Modifier.width(LocalDimensions.current.xxsSpacing)) + + Text( + text = count.toString(), + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } +} + + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +fun MediaPickerItemCell( + media: Media, + isSelected: Boolean = false, + selectedIndex: Int = 1, + isMultiSelect: Boolean, + onMediaChosen: (Media) -> Unit, + onSelectionStarted: () -> Unit, + onSelectionChanged: (selectedMedia: Media) -> Unit, + modifier: Modifier = Modifier, + showSelectionOn: Boolean = false, + canLongPress: Boolean = true +) { + Box( + modifier = modifier + .aspectRatio(1f) + .combinedClickable( + onClick = { + if (!isMultiSelect) { + onMediaChosen(media) // Choosing a single media + } else { + onSelectionChanged(media) // Selecting/unselecting media + } + }, + onLongClick = if (canLongPress) { + { + // long press starts selection, adds this item + onSelectionChanged(media) + onSelectionStarted() + } + } else null + ) + ) { + // Thumbnail + AsyncImage( + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + model = ImageRequest.Builder(LocalContext.current) + .data(media.uri) + .build(), + contentDescription = null, + ) + + // Play overlay (center) for video + if (MediaUtil.isVideoType(media.mimeType)) { + Box( + modifier = Modifier + .align(Alignment.Center) + .size(36.dp) + .clip(CircleShape) + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.drawable.triangle_right), + contentDescription = null, + modifier = Modifier.size(LocalDimensions.current.iconMedium), + colorFilter = ColorFilter.tint(LocalColors.current.accent) // match @color/core_blue-ish + ) + } + } + + // Selection overlay + AnimateFade(isSelected, modifier = Modifier.matchParentSize()) { + Box( + Modifier + .matchParentSize() + .background(Color.Black.copy(alpha = 0.80f)) + ) + } + + + val state: BadgeState = + when { + !isMultiSelect -> BadgeState.Hidden + selectedIndex < 0 -> BadgeState.Off + else -> BadgeState.On(selectedIndex + 1) + } + + Crossfade( + targetState = state, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(LocalDimensions.current.xxsSpacing), + ) { s -> + when (s) { + BadgeState.Hidden -> Unit + BadgeState.Off -> IndicatorOff() + is BadgeState.On -> Box(contentAlignment = Alignment.Center) { + IndicatorOn() + Text( + text = s.number.toString(), + color = LocalColors.current.textOnAccent, + style = LocalType.current.base, + textAlign = TextAlign.Center + ) + } + } + } + } +} + +private sealed interface BadgeState { + data object Hidden : BadgeState + data object Off : BadgeState + data class On(val number: Int) : BadgeState +} + +@Composable +private fun IndicatorOff(modifier: Modifier = Modifier, size: Dp = 26.dp ) { + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .border( + width = LocalDimensions.current.borderStroke, + color = LocalColors.current.text, + shape = CircleShape + ) + ) +} + +@Composable +private fun IndicatorOn(modifier: Modifier = Modifier, size: Dp = 26.dp) { + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background( + color = LocalColors.current.accent, + shape = CircleShape + ) + ) +} + +@Preview +@Composable +private fun PreviewMediaFolderCell() { + MediaFolderCell( + title = "Test Title", + count = 100, + thumbnailUri = null + ) { } +} + +@Preview(name = "MediaPickerItemCell - Not selected") +@Composable +private fun Preview_MediaPickerItemCell_NotSelected() { + val media = previewMedia("content://preview/media/1", "image/jpeg") + + MediaPickerItemCell( + media = media, + isMultiSelect = false, + canLongPress = true, + onMediaChosen = {}, + onSelectionStarted = {}, + onSelectionChanged = {}, + ) +} + +@Preview(name = "MediaPickerItemCell - Selected (order 1)") +@Composable +private fun Preview_MediaPickerItemCell_Selected() { + val media = previewMedia("content://preview/media/2", "image/jpeg") + + MediaPickerItemCell( + media = media, + isMultiSelect = true, + canLongPress = true, + onMediaChosen = {}, + onSelectionStarted = {}, + onSelectionChanged = {}, + ) +} + +private fun previewMedia(uri: String, mime: String): Media { + return Media( + uri.toUri(), + /* filename = */ "preview", + /* mimeType = */ mime, + /* date = */ 0L, + /* width = */ 100, + /* height = */ 100, + /* size = */ 1234L, + /* bucketId = */ "preview", + /* caption = */ null + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderComposeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderComposeFragment.kt new file mode 100644 index 0000000000..9c8c9667a6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderComposeFragment.kt @@ -0,0 +1,101 @@ +package org.thoughtcrime.securesms.mediasend.compose + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import com.squareup.phrase.Phrase +import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.displayName +import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager +import org.thoughtcrime.securesms.mediasend.MediaFolder +import org.thoughtcrime.securesms.mediasend.MediaSendViewModel +import org.thoughtcrime.securesms.ui.setThemedContent + +@AndroidEntryPoint +class MediaPickerFolderComposeFragment : Fragment() { + + private val viewModel: MediaSendViewModel by activityViewModels() + + private var recipientName: String? = null + private var controller: Controller? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + controller = activity as? Controller + ?: throw IllegalStateException("Parent activity must implement controller class.") + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + recipientName = requireArguments().getString(KEY_RECIPIENT_NAME) + } + + override fun onResume() { + super.onResume() + viewModel.onFolderPickerStarted() + viewModel.refreshPhotoAccessUi() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setThemedContent { + val ctx = LocalContext.current + // Same title as the old toolbar + val title = remember(recipientName) { + Phrase.from(ctx, R.string.attachmentsSendTo) + .put(StringSubstitutionConstants.NAME_KEY, recipientName ?: "") + .format() + .toString() + } + + MediaPickerFolderScreen( + viewModel = viewModel, + title = title, + handleBack = { + requireActivity().onBackPressedDispatcher.onBackPressed() + }, + onFolderClick = { folder -> + controller?.onFolderSelected(folder) + }, + manageMediaAccess = ::manageMediaAccess + ) + } + } + } + + fun manageMediaAccess() { + AttachmentManager.managePhotoAccess(requireActivity()) { + viewModel.refreshFolders() + } + } + + companion object { + private const val KEY_RECIPIENT_NAME = "recipient_name" + + fun newInstance(recipient: Recipient): MediaPickerFolderComposeFragment { + return MediaPickerFolderComposeFragment().apply { + arguments = Bundle().apply { + putString(KEY_RECIPIENT_NAME, recipient.displayName(false)) + } + } + } + } + + interface Controller { + fun onFolderSelected(folder: MediaFolder) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt new file mode 100644 index 0000000000..0e7be107b3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt @@ -0,0 +1,156 @@ +package org.thoughtcrime.securesms.mediasend.compose + +import android.annotation.SuppressLint +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import org.thoughtcrime.securesms.mediasend.MediaFolder +import org.thoughtcrime.securesms.mediasend.MediaSendViewModel +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions + +@Composable +fun MediaPickerFolderScreen( + viewModel: MediaSendViewModel, + onFolderClick: (MediaFolder) -> Unit, + title: String, + handleBack: () -> Unit, + manageMediaAccess: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.refreshFolders() + viewModel.onFolderPickerStarted() + } + + MediaPickerFolder( + folders = uiState.folders, + onFolderClick = onFolderClick, + title = title, + handleBack = handleBack, + showManageMediaAccess = uiState.showManagePhotoAccess, + manageMediaAccess = manageMediaAccess + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@SuppressLint("ConfigurationScreenWidthHeight") +@Composable +private fun MediaPickerFolder( + folders: List, + onFolderClick: (folder: MediaFolder) -> Unit, + title: String, + handleBack: () -> Unit, + showManageMediaAccess: Boolean, + manageMediaAccess : () -> Unit +) { + + // span logic: screenWidth / media_picker_folder_width + val folderWidth = 175.dp + val columns = maxOf(1, (LocalConfiguration.current.screenWidthDp.dp / folderWidth).toInt()) + + Scaffold( + topBar = { + BackAppBar( + title = title, + onBack = handleBack, + actions = { + if (showManageMediaAccess) { + IconButton( + onClick = { + manageMediaAccess() + } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_plus), + contentDescription = null + ) + } + } + } + ) + }, + contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues)) { + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + modifier = Modifier + .padding(LocalDimensions.current.tinySpacing) + .fillMaxSize() + .background(LocalColors.current.background), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.tinySpacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.tinySpacing) + ) { + items( + items = folders, + key = { folder -> folder.bucketId } + ) { folder -> + MediaFolderCell( + title = folder.title, + count = folder.itemCount, + thumbnailUri = folder.thumbnailUri, + onClick = { onFolderClick(folder) }, + ) + } + } + } + } +} + +@Preview +@Composable +private fun MediaPickerFolderPreview() { + MediaPickerFolder( + folders = listOf( + MediaFolder( + title = "Camera", + itemCount = 0, + thumbnailUri = null, + bucketId = "camera" + ), + MediaFolder( + title = "Daily Bugle", + itemCount = 122, + thumbnailUri = null, + bucketId = "daily_bugle" + ), + MediaFolder( + title = "Screenshots", + itemCount = 42, + thumbnailUri = null, + bucketId = "screenshots" + ) + ), + onFolderClick = {}, + title = "Folders", + handleBack = {}, + showManageMediaAccess = true, + manageMediaAccess = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemComposeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemComposeFragment.kt new file mode 100644 index 0000000000..a891f9f7b8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemComposeFragment.kt @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.mediasend.compose + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import dagger.hilt.android.AndroidEntryPoint +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.mediasend.MediaSendViewModel +import org.thoughtcrime.securesms.ui.setThemedContent + +@AndroidEntryPoint +class MediaPickerItemComposeFragment : Fragment() { + + private val viewModel: MediaSendViewModel by activityViewModels() + + private var controller: Controller? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + controller = activity as? Controller + ?: throw IllegalStateException("Parent activity must implement controller class.") + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val bucketId = requireArguments().getString(ARG_BUCKET_ID)!! + val title = requireArguments().getString(ARG_TITLE)!! + + return ComposeView(requireContext()).apply { + setThemedContent { + MediaPickerItemScreen( + viewModel = viewModel, + bucketId = bucketId, + title = title, + onBack = { requireActivity().onBackPressedDispatcher.onBackPressed() }, + onMediaSelected = { media -> + // Exact same path as old fragment -> Activity + controller?.onMediaSelected(media) + } + ) + } + } + } + + companion object { + private const val ARG_BUCKET_ID = "bucket_id" + private const val ARG_TITLE = "title" + private const val ARG_MAX_SELECTION = "max_selection" + + @JvmStatic + fun newInstance(bucketId: String, title: String) = + MediaPickerItemComposeFragment().apply { + arguments = bundleOf( + ARG_BUCKET_ID to bucketId, + ARG_TITLE to title, + ) + } + } + + interface Controller { + fun onMediaSelected(media: Media) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt new file mode 100644 index 0000000000..746af8ff64 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt @@ -0,0 +1,190 @@ +package org.thoughtcrime.securesms.mediasend.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import network.loki.messenger.R +import org.session.libsession.utilities.MediaTypes +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.mediasend.Media.Companion.ALL_MEDIA_BUCKET_ID +import org.thoughtcrime.securesms.mediasend.MediaSendViewModel +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions + +@Composable +fun MediaPickerItemScreen( + viewModel: MediaSendViewModel, + bucketId: String, + title: String, + onBack: () -> Unit, + onMediaSelected: (Media) -> Unit, // navigate to send screen +) { + val uiState = viewModel.uiState.collectAsState().value + + LaunchedEffect(bucketId) { + viewModel.getMediaInBucket(bucketId) // triggers repository + updates uiState.bucketMedia + viewModel.onItemPickerStarted() + } + + MediaPickerItem( + title = title, + media = uiState.bucketMedia, + selectedMedia = uiState.selectedMedia, + canLongPress = uiState.canLongPress, + showMultiSelectAction = !uiState.showCountButton, + onBack = onBack, + onStartMultiSelect = { + viewModel.onMultiSelectStarted() + }, + onToggleSelection = { nextSelected -> + viewModel.onMediaSelected(nextSelected) // List + }, + onSinglePick = { media -> + onMediaSelected(media) + }, + isMultiSelect = uiState.isMultiSelect + ) + +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MediaPickerItem( + title: String, + media: List, + selectedMedia: List, + canLongPress: Boolean, + showMultiSelectAction: Boolean, + onBack: () -> Unit, + onStartMultiSelect: () -> Unit, + onToggleSelection: (selectedMedia: Media) -> Unit, + onSinglePick: (Media) -> Unit, + isMultiSelect: Boolean = false +) { + + val itemWidth = 85.dp + val screenWidth = LocalConfiguration.current.screenWidthDp.dp + val columns = maxOf(1, (screenWidth / itemWidth).toInt()) + + Scaffold( + modifier = Modifier.background(LocalColors.current.background), + topBar = { + BackAppBar( + title = title, + onBack = onBack, + actions = { + if (showMultiSelectAction) { + IconButton( + onClick = { + onStartMultiSelect() + } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_images), + contentDescription = null + ) + } + } + } + ) + }, + ) { padding -> + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + modifier = Modifier + .padding(padding) + .padding(LocalDimensions.current.tinySpacing) + .fillMaxSize() + .background(LocalColors.current.background), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.tinySpacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.tinySpacing) + ) { + items(media, key = { it.uri }) { item -> + val isSelected = selectedMedia.any { it.uri == item.uri } + MediaPickerItemCell( + media = item, + isSelected = isSelected, + selectedIndex = selectedMedia.indexOfFirst { it.uri == item.uri }, + isMultiSelect = isMultiSelect, + canLongPress = canLongPress, + showSelectionOn = isSelected, + onMediaChosen = { onSinglePick(it) }, + onSelectionStarted = onStartMultiSelect, + onSelectionChanged = onToggleSelection, + ) + } + } + } +} + + +@Preview(name = "Picker - no selection") +@Composable +private fun Preview_MediaPickerItem_NoSelection() { + val media = previewMediaList() + MediaPickerItem( + title = "Screenshots", + media = media, + selectedMedia = emptyList(), + canLongPress = true, + showMultiSelectAction = true, + onBack = {}, + onStartMultiSelect = {}, + onToggleSelection = {}, + onSinglePick = {}, + ) +} + +@Preview(name = "Picker - multi-select with 2 selected") +@Composable +private fun Preview_MediaPickerItem_WithSelection() { + val media = previewMediaList() + val selected = listOf(media[1], media[4]) + + MediaPickerItem( + title = "Camera Roll", + media = media, + selectedMedia = selected, + canLongPress = true, + showMultiSelectAction = false, + onBack = {}, + onStartMultiSelect = {}, + onToggleSelection = {}, + onSinglePick = {}, + ) +} + +private fun previewMediaList(): List { + return (1..12).map { i -> + Media( + "content://preview/media/$i".toUri(), + "preview_$i.jpg", + MediaTypes.IMAGE_JPEG, + /* date */ 0L, + /* width */ 1080, + /* height */ 1080, + /* size */ 1234L, + /* bucketId */ ALL_MEDIA_BUCKET_ID, + /* caption */ null + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollManager.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollManager.kt index 97fead0b02..52aa16d28c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollManager.kt @@ -30,8 +30,8 @@ class BackgroundPollManager @Inject constructor( appVisibilityManager.isAppVisible .debounce(1_000L) .distinctUntilChanged() - .collectLatest { shouldSchedule -> - if (shouldSchedule) { + .collectLatest { isAppVisible -> + if (!isAppVisible) { Log.i(TAG, "Scheduling background polling work.") BackgroundPollWorker.schedulePeriodic(application) } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt index eda1a598a8..03de22eef4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt @@ -13,6 +13,7 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.supervisorScope @@ -20,8 +21,11 @@ import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerManager import org.session.libsession.messaging.sending_receiving.pollers.PollerManager +import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.api.error.ErrorWithFailureDecision import org.thoughtcrime.securesms.groups.GroupPollerManager +import org.thoughtcrime.securesms.util.findCause import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.minutes @@ -139,9 +143,15 @@ class BackgroundPollWorker @AssistedInject constructor( } return Result.success() - } catch (exception: Exception) { - Log.e(TAG, "Background poll failed due to error: ${exception.message}.", exception) - return Result.retry() + } catch (exception: CancellationException) { + throw exception + } catch (e: Exception) { + Log.e(TAG, "Background poll failed", e) + return if (e.findCause() != null) { + Result.failure() + } else { + Result.retry() + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt index 24e7c1625c..345f370a8b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt @@ -376,7 +376,7 @@ private fun PlanBadge( maxLines = 1, overflow = TextOverflow.Ellipsis, style = LocalType.current.small.bold().copy( - color = LocalColors.current.accentButtonFillText + color = LocalColors.current.textOnAccent ) ) @@ -391,7 +391,7 @@ private fun PlanBadge( Image( painter = painterResource(id = R.drawable.ic_circle_help), contentDescription = null, - colorFilter = ColorFilter.tint(LocalColors.current.accentButtonFillText), + colorFilter = ColorFilter.tint(LocalColors.current.textOnAccent), modifier = Modifier .size(LocalDimensions.current.iconXXSmall) .clickable { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt index 9c359cf32e..134eaf19e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt @@ -52,7 +52,7 @@ interface ButtonType { override fun buttonColors() = ButtonDefaults.buttonColors( contentColor = LocalColors.current.background, containerColor = containerColor, - disabledContentColor = LocalColors.current.accentButtonFillText, + disabledContentColor = LocalColors.current.textOnAccent, disabledContainerColor = LocalColors.current.disabled ) } @@ -62,9 +62,9 @@ interface ButtonType { override fun border(enabled: Boolean) = null @Composable override fun buttonColors() = ButtonDefaults.buttonColors( - contentColor = LocalColors.current.accentButtonFillText, + contentColor = LocalColors.current.textOnAccent, containerColor = LocalColors.current.accent, - disabledContentColor = LocalColors.current.accentButtonFillText, + disabledContentColor = LocalColors.current.textOnAccent, disabledContainerColor = LocalColors.current.disabled ) } @@ -76,7 +76,7 @@ interface ButtonType { override fun buttonColors() = ButtonDefaults.buttonColors( contentColor = LocalColors.current.text, containerColor = LocalColors.current.backgroundTertiary, - disabledContentColor = LocalColors.current.accentButtonFillText, + disabledContentColor = LocalColors.current.textOnAccent, disabledContainerColor = LocalColors.current.disabled ) } @@ -88,7 +88,7 @@ interface ButtonType { override fun buttonColors() = ButtonDefaults.buttonColors( contentColor = Color.Black, containerColor = dangerDark, - disabledContentColor = LocalColors.current.accentButtonFillText, + disabledContentColor = LocalColors.current.textOnAccent, disabledContainerColor = LocalColors.current.disabled ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt index 18746d21f5..702391c596 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.unit.dp val LocalDimensions = staticCompositionLocalOf { Dimensions() } data class Dimensions( + val tinySpacing : Dp = 2.dp, val xxxsSpacing: Dp = 4.dp, val xxsSpacing: Dp = 8.dp, val xsSpacing: Dp = 12.dp, @@ -52,5 +53,5 @@ data class Dimensions( val minContentSize: Dp = 80.dp, val maxContentSize: Dp = 520.dp, val minContentSizeMedium: Dp = 160.dp, - val maxContentSizeMedium: Dp = 620.dp + val maxContentSizeMedium: Dp = 620.dp, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt index af22fb21fa..fa3733bc9b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt @@ -34,7 +34,7 @@ interface ThemeColors { val textBubbleReceived: Color val qrCodeContent: Color val qrCodeBackground: Color - val accentButtonFillText: Color + val textOnAccent: Color val accentText: Color } @@ -127,7 +127,7 @@ data class ClassicDark(override val accent: Color = primaryGreen) : ThemeColors override val textBubbleReceived = Color.White override val qrCodeContent = background override val qrCodeBackground = text - override val accentButtonFillText = Color.Black + override val textOnAccent = Color.Black override val accentText = accent override val textAlert: Color = classicDark0 } @@ -149,7 +149,7 @@ data class ClassicLight(override val accent: Color = primaryGreen) : ThemeColors override val textBubbleReceived = classicLight4 override val qrCodeContent = text override val qrCodeBackground = backgroundSecondary - override val accentButtonFillText = Color.Black + override val textOnAccent = Color.Black override val accentText = text override val textAlert: Color = classicLight0 } @@ -171,7 +171,7 @@ data class OceanDark(override val accent: Color = primaryBlue) : ThemeColors { override val textBubbleReceived = oceanDark4 override val qrCodeContent = background override val qrCodeBackground = text - override val accentButtonFillText = Color.Black + override val textOnAccent = Color.Black override val accentText = accent override val textAlert: Color = oceanDark0 } @@ -193,7 +193,7 @@ data class OceanLight(override val accent: Color = primaryBlue) : ThemeColors { override val textBubbleReceived = oceanLight1 override val qrCodeContent = text override val qrCodeBackground = backgroundSecondary - override val accentButtonFillText = Color.Black + override val textOnAccent = Color.Black override val accentText = text override val textAlert: Color = oceanLight0 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AppVisibilityManager.kt b/app/src/main/java/org/thoughtcrime/securesms/util/AppVisibilityManager.kt index c31aa0bb2e..3ec66512af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AppVisibilityManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AppVisibilityManager.kt @@ -1,38 +1,19 @@ package org.thoughtcrime.securesms.util -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ProcessLifecycleOwner import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import javax.inject.Inject import javax.inject.Singleton @Singleton class AppVisibilityManager @Inject constructor( - @ManagerScope scope: CoroutineScope -) : OnAppStartupComponent { - private val mutableIsAppVisible = MutableStateFlow(false) - - init { - // `addObserver` must be called on the main thread. - scope.launch(Dispatchers.Main) { - ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { - override fun onStart(owner: LifecycleOwner) { - mutableIsAppVisible.value = true - } - - override fun onStop(owner: LifecycleOwner) { - mutableIsAppVisible.value = false - } - }) - } - } - - val isAppVisible: StateFlow get() = mutableIsAppVisible + scope: CoroutineScope +) { + val isAppVisible: StateFlow = ProcessLifecycleOwner + .get() + .lifecycle + .currentStateFlow + .mapStateFlow(scope) { it.isAtLeast(Lifecycle.State.STARTED) } } diff --git a/app/src/main/res/drawable/image_shade.xml b/app/src/main/res/drawable/image_shade.xml deleted file mode 100644 index e7616a18c6..0000000000 --- a/app/src/main/res/drawable/image_shade.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/media_selected_indicator_off.xml b/app/src/main/res/drawable/media_selected_indicator_off.xml deleted file mode 100644 index 3bb5a47aa1..0000000000 --- a/app/src/main/res/drawable/media_selected_indicator_off.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/media_selected_indicator_on.xml b/app/src/main/res/drawable/media_selected_indicator_on.xml deleted file mode 100644 index 002385210b..0000000000 --- a/app/src/main/res/drawable/media_selected_indicator_on.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/mediapicker_folder_fragment.xml b/app/src/main/res/layout/mediapicker_folder_fragment.xml deleted file mode 100644 index ac676085aa..0000000000 --- a/app/src/main/res/layout/mediapicker_folder_fragment.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/mediapicker_folder_item.xml b/app/src/main/res/layout/mediapicker_folder_item.xml deleted file mode 100644 index a7b1547a7d..0000000000 --- a/app/src/main/res/layout/mediapicker_folder_item.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/mediapicker_item_fragment.xml b/app/src/main/res/layout/mediapicker_item_fragment.xml deleted file mode 100644 index 39543eb987..0000000000 --- a/app/src/main/res/layout/mediapicker_item_fragment.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/mediapicker_media_item.xml b/app/src/main/res/layout/mediapicker_media_item.xml deleted file mode 100644 index 13d69b2870..0000000000 --- a/app/src/main/res/layout/mediapicker_media_item.xml +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index e29c6d5ecb..1e1bba5ccb 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -63,9 +63,6 @@ 210dp 150dp - 175dp - 85dp - 4 10dp diff --git a/build-logic/src/main/kotlin/LocalSnodePoolPlugin.kt b/build-logic/src/main/kotlin/LocalSnodePoolPlugin.kt index 68a366e2da..d79b96dbb2 100644 --- a/build-logic/src/main/kotlin/LocalSnodePoolPlugin.kt +++ b/build-logic/src/main/kotlin/LocalSnodePoolPlugin.kt @@ -33,9 +33,9 @@ class LocalSnodePoolPlugin : Plugin { outputDir.set(project.layout.buildDirectory.dir("generated/${variant.name}")) seedUrls.set( listOf( - "https://seed1.getsession.org:4443/json_rpc", - "https://seed2.getsession.org:4443/json_rpc", - "https://seed3.getsession.org:4443/json_rpc", + "https://seed1.getsession.org/json_rpc", + "https://seed2.getsession.org/json_rpc", + "https://seed3.getsession.org/json_rpc", ) ) } @@ -67,6 +67,10 @@ abstract class GenerateLocalSnodePoolTask : DefaultTask() { @get:Inject abstract val execOps: ExecOperations + init { + outputs.upToDateWhen { false } // Always run to get fresh snode pool + } + @TaskAction fun generate() { val outDirFile = outputDir.get().asFile @@ -143,7 +147,6 @@ abstract class GenerateLocalSnodePoolTask : DefaultTask() { "--fail", "--silent", "--show-error", - "-k", "-X", "POST", "-H", "Content-Type: application/json", "--data", requestBody, diff --git a/build.gradle.kts b/build.gradle.kts index e39b6dc552..548541c970 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,7 +16,7 @@ buildscript { // classpath(files("libs/gradle-witness.jar")) // classpath("com.squareup:javapoet:1.13.0") if (project.hasProperty("huawei")) { - classpath("com.huawei.agconnect:agcp:1.9.4.300") + classpath("com.huawei.agconnect:agcp:1.9.5.300") } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ea31d73e0e..0fd17f7dbd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,14 +34,14 @@ media3ExoplayerVersion = "1.8.0" mockitoCoreVersion = "5.21.0" navVersion = "2.9.6" appcompatVersion = "1.7.1" -coreVersion = "1.16.0" +coreVersion = "1.17.0" coroutinesVersion = "1.10.2" daggerHiltVersion = "2.59" androidxHiltVersion = "1.3.0" glideVersion = "5.0.5" jacksonDatabindVersion = "2.9.8" junitVersion = "4.13.2" -kotlinxJsonVersion = "1.9.0" +kotlinxJsonVersion = "1.10.0" opencsvVersion = "5.12.0" orchestratorVersion = "1.6.1" photoviewVersion = "2.3.0" @@ -56,7 +56,7 @@ robolectricVersion = "4.16" roundedimageviewVersion = "2.1.0" runnerVersion = "1.7.0" rxbindingVersion = "3.1.0" -sqlcipherAndroidVersion = "4.9.0" +sqlcipherAndroidVersion = "4.13.0" streamVersion = "1.1.8" sqliteKtxVersion = "2.6.2" subsamplingScaleImageViewVersion = "3.10.0" @@ -67,6 +67,7 @@ uiTestJunit4Version = "1.10.1" workRuntimeKtxVersion = "2.11.0" zxingVersion = "3.5.4" huaweiPushVersion = "6.13.0.300" +huaweiAnalyticsVersion = "6.12.0.301" googlePlayReviewVersion = "2.0.2" coilVersion = "3.3.0" billingVersion = "8.3.0" @@ -161,9 +162,11 @@ subsampling-scale-image-view = { module = "com.davemorrissey.labs:subsampling-sc truth = { module = "com.google.truth:truth", version.ref = "truthVersion" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbineVersion" } huawei-push = { module = 'com.huawei.hms:push', version.ref = 'huaweiPushVersion' } +huawei-hianalytics = { module = "com.huawei.hms:hianalytics", version.ref = "huaweiAnalyticsVersion" } +huawei-availableupdate = { module = "com.huawei.hms:availableupdate", version.ref = "huaweiPushVersion" } google-play-review = { module = "com.google.android.play:review", version.ref = "googlePlayReviewVersion" } google-play-review-ktx = { module = "com.google.android.play:review-ktx", version.ref = "googlePlayReviewVersion" } -sqlite-web-viewer = { module = "io.github.simophin:sqlite-web-viewer", version = "0.0.3" } +sqlite-web-viewer = { module = "io.github.simophin:sqlite-web-viewer", version = "0.2.0" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilVersion" } coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coilVersion" } android-billing = { module = "com.android.billingclient:billing", version.ref = "billingVersion" }