diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index e9d3c6ab2f..b8bd208960 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -30,7 +30,7 @@ jobs: submodules: 'recursive' - name: Cache Gradle - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.gradle/caches @@ -54,14 +54,14 @@ jobs: - name: Upload build reports regardless if: always() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: build-reports-${{ matrix.variant }}-${{ matrix.build_type }} path: app/build/reports if-no-files-found: ignore - name: Upload artifacts - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: session-${{ matrix.variant }}-${{ matrix.build_type }} path: app/build/outputs/apk/${{ matrix.variant }}/${{ matrix.build_type }}/*-universal*apk diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a7b9f32f6b..b7a9a074b5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,9 +12,9 @@ plugins { alias(libs.plugins.hilt.android) alias(libs.plugins.dependency.analysis) alias(libs.plugins.google.services) - alias(libs.plugins.protobuf.compiler) id("generate-ip-country-data") + id("local-snode-pool") id("rename-apk") id("witness") } @@ -26,8 +26,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( @@ -86,22 +86,7 @@ kotlin { } } -protobuf { - protoc { - artifact = libs.protoc.get().toString() - } - - plugins { - generateProtoTasks { - all().forEach { - it.builtins { - create("java") { - } - } - } - } - } -} +val testJvmAgent = configurations.create("mockitoAgent") android { namespace = "network.loki.messenger" @@ -151,6 +136,13 @@ android { buildConfigField("String", "USER_AGENT", "\"OWA\"") buildConfigField("int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode") + buildConfigField("org.thoughtcrime.securesms.pro.ProBackendConfig", "PRO_BACKEND_DEV", """ + new org.thoughtcrime.securesms.pro.ProBackendConfig( + "https://pro-backend-dev.getsession.org", + "fc947730f49eb01427a66e050733294d9e520e545c7a27125a780634e0860a27" + ) + """.trimIndent()) + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments["clearPackageData"] = "true" testOptions { @@ -303,6 +295,9 @@ android { testOptions { unitTests.isIncludeAndroidResources = true + unitTests.all { + it.jvmArgs("-javaagent:${testJvmAgent.asPath}") + } } lint { @@ -332,6 +327,8 @@ android { testNamespace = "network.loki.messenger.test" } + + dependencies { implementation(project(":content-descriptions")) @@ -394,28 +391,19 @@ dependencies { implementation(libs.androidx.sqlite.ktx) implementation(libs.sqlcipher.android) implementation(libs.kotlinx.serialization.json) - implementation(libs.protobuf.java) implementation(libs.jackson.databind) implementation(libs.okhttp) implementation(libs.phrase) implementation(libs.copper.flow) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.guava) - implementation(libs.kovenant) - implementation(libs.kovenant.android) implementation(libs.opencsv) implementation(libs.androidx.work.runtime.ktx) implementation(libs.rxbinding) - if (hasIncludedLibSessionUtilProject) { - implementation( - group = libs.libsession.util.android.get().group, - name = libs.libsession.util.android.get().name, - version = "dev-snapshot" - ) - } else { - implementation(libs.libsession.util.android) - } + // If libsession_util project is included into the build, use that, otherwise use the published version + findProject(":libsession-util-android")?.let(::implementation) + ?: implementation(libs.libsession.util.android) implementation(libs.kryo) testImplementation(libs.junit) @@ -434,8 +422,14 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.truth) testImplementation(libs.truth) + testImplementation(libs.androidx.sqlite.framework) androidTestImplementation(libs.truth) testRuntimeOnly(libs.mockito.core) + testImplementation(libs.mockk) + testImplementation(libs.kotlin.test) + + // Pull in appropriate JVM agents for unit test + testJvmAgent(libs.mockito.core) { isTransitive = false } androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.contrib) @@ -449,7 +443,6 @@ dependencies { androidTestUtil(libs.androidx.orchestrator) testImplementation(libs.robolectric) - testImplementation(libs.robolectric.shadows.multidex) testImplementation(libs.conscrypt.openjdk.uber) testImplementation(libs.turbine) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cce3a226fb..3babe8fe18 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -218,6 +218,17 @@ + + + + + + + + + + + diff --git a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt index 1d99e66c52..030b5071c9 100644 --- a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -57,6 +57,7 @@ interface StorageProtocol { // Servers fun setServerCapabilities(server: String, capabilities: List) fun getServerCapabilities(server: String): List? + fun clearServerCapabilities(server: String) // Open Groups suspend fun addOpenGroup(urlAsString: String) diff --git a/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt b/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt index 7c3565f501..46fa5e6f12 100644 --- a/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt +++ b/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt @@ -7,9 +7,9 @@ import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager -import org.session.libsession.messaging.jobs.MessageSendJob import org.session.libsession.messaging.notifications.TokenFetcher -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock +import org.session.libsession.network.onion.PathManager import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.Device import org.session.libsession.utilities.TextSecurePreferences @@ -28,14 +28,14 @@ class MessagingModuleConfiguration @Inject constructor( val configFactory: ConfigFactoryProtocol, val tokenFetcher: TokenFetcher, val groupManagerV2: GroupManagerV2, - val clock: SnodeClock, val preferences: TextSecurePreferences, val deprecationManager: LegacyGroupDeprecationManager, val recipientRepository: RecipientRepository, val avatarUtils: AvatarUtils, val proStatusManager: ProStatusManager, - val messageSendJobFactory: MessageSendJob.Factory, val json: Json, + val snodeClock: SnodeClock, + val pathManager: PathManager ) { companion object { diff --git a/app/src/main/java/org/session/libsession/messaging/file_server/FileDownloadApi.kt b/app/src/main/java/org/session/libsession/messaging/file_server/FileDownloadApi.kt new file mode 100644 index 0000000000..b7f1b26ab3 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/file_server/FileDownloadApi.kt @@ -0,0 +1,60 @@ +package org.session.libsession.messaging.file_server + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.thoughtcrime.securesms.api.server.ServerApiErrorManager +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpBody +import org.thoughtcrime.securesms.api.http.HttpRequest +import org.thoughtcrime.securesms.api.http.HttpResponse +import org.thoughtcrime.securesms.api.server.ServerApi +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +class FileDownloadApi @AssistedInject constructor( + @Assisted private val fileId: String, + errorManager: ServerApiErrorManager, +) : ServerApi(errorManager) { + override fun buildRequest( + baseUrl: String, + x25519PubKeyHex: String + ): HttpRequest { + return HttpRequest( + url = "$baseUrl/file/$fileId".toHttpUrl(), + method = "GET", + headers = emptyMap(), + body = null + ) + } + + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): Response { + return Response( + data = response.body, + expires = response.parseFileServerExpiresHeader() + ) + } + + class Response( + val data: HttpBody, + val expires: ZonedDateTime? + ) + + @AssistedFactory + interface Factory { + fun create(fileId: String): FileDownloadApi + } + + companion object { + fun HttpResponse.parseFileServerExpiresHeader(): ZonedDateTime? { + return headers["expires"]?.let { + ZonedDateTime.parse(it, DateTimeFormatter.RFC_1123_DATE_TIME) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/file_server/FileRenewApi.kt b/app/src/main/java/org/session/libsession/messaging/file_server/FileRenewApi.kt new file mode 100644 index 0000000000..143b6e5686 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/file_server/FileRenewApi.kt @@ -0,0 +1,40 @@ +package org.session.libsession.messaging.file_server + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.thoughtcrime.securesms.api.server.ServerApiErrorManager +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpRequest +import org.thoughtcrime.securesms.api.http.HttpResponse +import org.thoughtcrime.securesms.api.server.ServerApi + +class FileRenewApi @AssistedInject constructor( + @Assisted private val fileId: String, + errorManager: ServerApiErrorManager, +) : ServerApi(errorManager) { + + override fun buildRequest( + baseUrl: String, + x25519PubKeyHex: String + ): HttpRequest { + return HttpRequest( + url = "$baseUrl/file/$fileId/extend".toHttpUrl(), + method = "POST", + headers = emptyMap(), + body = null, + ) + } + + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ) = Unit + + @AssistedFactory + interface Factory { + fun create(fileId: String): FileRenewApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/file_server/FileServer.kt b/app/src/main/java/org/session/libsession/messaging/file_server/FileServer.kt index 1d376ea8c9..cbdf01b8ac 100644 --- a/app/src/main/java/org/session/libsession/messaging/file_server/FileServer.kt +++ b/app/src/main/java/org/session/libsession/messaging/file_server/FileServer.kt @@ -1,9 +1,11 @@ package org.session.libsession.messaging.file_server import kotlinx.serialization.Serializable +import network.loki.messenger.libsession_util.Curve25519 import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import org.session.libsession.utilities.serializable.HttpUrlSerializer +import org.session.libsignal.utilities.Hex @Serializable data class FileServer( @@ -12,6 +14,12 @@ data class FileServer( val ed25519PublicKeyHex: String ) { constructor(url: String, ed25519PublicKeyHex: String) : this(url.toHttpUrl(), ed25519PublicKeyHex) + + val x25519PubKeyHex: String by lazy { + Hex.toStringCondensed( + Curve25519.pubKeyFromED25519(Hex.fromStringCondensed(ed25519PublicKeyHex)) + ) + } } val HttpUrl.isOfficial: Boolean diff --git a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt deleted file mode 100644 index e5128232f7..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt +++ /dev/null @@ -1,361 +0,0 @@ -package org.session.libsession.messaging.file_server - -import android.util.Base64 -import kotlinx.coroutines.CancellationException -import network.loki.messenger.libsession_util.Curve25519 -import network.loki.messenger.libsession_util.ED25519 -import network.loki.messenger.libsession_util.util.BlindKeyAPI -import okhttp3.Headers.Companion.toHeaders -import okhttp3.HttpUrl -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.RequestBody -import okhttp3.RequestBody.Companion.toRequestBody -import org.session.libsession.database.StorageProtocol -import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.utilities.await -import org.session.libsignal.utilities.ByteArraySlice -import org.session.libsignal.utilities.HTTP -import org.session.libsignal.utilities.Hex -import org.session.libsignal.utilities.JsonUtil -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.toHexString -import org.thoughtcrime.securesms.util.DateUtils.Companion.asEpochSeconds -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds - -@Singleton -class FileServerApi @Inject constructor( - private val storage: StorageProtocol, -) { - - companion object { - const val MAX_FILE_SIZE = 10_000_000 // 10 MB - - val DEFAULT_FILE_SERVER: FileServer = FileServer( - url = "http://filev2.getsession.org", - ed25519PublicKeyHex = "b8eef9821445ae16e2e97ef8aa6fe782fd11ad5253cd6723b281341dba22e371" - ) - } - - sealed class Error(message: String) : Exception(message) { - object ParsingFailed : Error("Invalid response.") - object InvalidURL : Error("Invalid URL.") - object NoEd25519KeyPair : Error("Couldn't find ed25519 key pair.") - } - - private data class Request( - val fileServer: FileServer, - val verb: HTTP.Verb, - val endpoint: String, - val queryParameters: Map = mapOf(), - val parameters: Any? = null, - val headers: Map = mapOf(), - val body: ByteArray? = null, - /** - * Always `true` under normal circumstances. You might want to disable - * this when running over Lokinet. - */ - val useOnionRouting: Boolean = true - ) - - private fun createBody(body: ByteArray?, parameters: Any?): RequestBody? { - if (body != null) return body.toRequestBody( - "application/octet-stream".toMediaType(), - 0, - body.size - ) - - if (parameters == null) return null - val parametersAsJSON = JsonUtil.toJson(parameters) - return parametersAsJSON.toRequestBody("application/json".toMediaType()) - } - - - private suspend fun send(request: Request): SendResponse { - val urlBuilder = request.fileServer.url - .newBuilder() - .addPathSegments(request.endpoint) - if (request.verb == HTTP.Verb.GET) { - for ((key, value) in request.queryParameters) { - urlBuilder.addQueryParameter(key, value) - } - } - val requestBuilder = okhttp3.Request.Builder() - .url(urlBuilder.build()) - .headers(request.headers.toHeaders()) - when (request.verb) { - HTTP.Verb.GET -> requestBuilder.get() - HTTP.Verb.PUT -> requestBuilder.put(createBody(request.body, request.parameters) ?: RequestBody.EMPTY) - HTTP.Verb.POST -> requestBuilder.post(createBody(request.body, request.parameters) ?: RequestBody.EMPTY) - HTTP.Verb.DELETE -> requestBuilder.delete(createBody(request.body, request.parameters)) - } - return if (request.useOnionRouting) { - try { - val response = OnionRequestAPI.sendOnionRequest( - request = requestBuilder.build(), - server = request.fileServer.url.host, - x25519PublicKey = - Hex.toStringCondensed( - Curve25519.pubKeyFromED25519(Hex.fromStringCondensed(request.fileServer.ed25519PublicKeyHex)) - ) - ).await() - - check(response.code in 200..299) { - "Error response from file server: ${response.code}" - } - - val body = response.body ?: throw Error.ParsingFailed - - SendResponse( - body = body, - headers = response.info["headers"] as? Map - ) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.e("Loki", "File server request failed", e) - throw e - } - } else { - error("It's currently not allowed to send non onion routed requests.") - } - } - - suspend fun upload( - file: ByteArray, - usedDeterministicEncryption: Boolean, - fileServer: FileServer, - customExpiresDuration: Duration? = null - ): UploadResult { - val request = Request( - fileServer = fileServer, - verb = HTTP.Verb.POST, - endpoint = "file", - body = file, - headers = buildMap { - put("Content-Disposition", "attachment") - put("Content-Type", "application/octet-stream") - if (customExpiresDuration != null) { - put("X-FS-TTL", customExpiresDuration.inWholeSeconds.toString()) - } - } - ) - val response = send(request) - val json = JsonUtil.fromJson(response.body, Map::class.java) - val id = json["id"]!!.toString() - val expiresEpochSeconds = (json.getOrDefault("expires", null) as? Number)?.toLong() - - return UploadResult( - fileId = id, - fileUrl = buildAttachmentUrl( - fileId = id, - fileServer = fileServer, - usesDeterministicEncryption = usedDeterministicEncryption - ).toString(), - expires = expiresEpochSeconds?.asEpochSeconds() - ) - } - - suspend fun download( - fileId: String, - fileServer: FileServer = DEFAULT_FILE_SERVER - ): SendResponse { - val request = Request( - fileServer = fileServer, - verb = HTTP.Verb.GET, - endpoint = "file/$fileId" - ) - return send(request) - } - - suspend fun renew(fileId: String, - customTtl: Duration? = null, - fileServer: FileServer = DEFAULT_FILE_SERVER) { - val resp = send(Request( - fileServer = fileServer, - verb = HTTP.Verb.POST, - endpoint = "file/$fileId/extend", - headers = customTtl?.let { - buildMap { - "X-FS-TTL" to it.inWholeSeconds.toString() - } - } ?: mapOf() - )) - - resp.expires - } - - fun buildAttachmentUrl( - fileId: String, - fileServer: FileServer, - usesDeterministicEncryption: Boolean - ): HttpUrl { - val urlFragment = sequenceOf( - "d".takeIf { usesDeterministicEncryption }, - if (!fileServer.url.isOfficial || fileServer.ed25519PublicKeyHex != DEFAULT_FILE_SERVER.ed25519PublicKeyHex) { - "p=${fileServer.ed25519PublicKeyHex}" - } else { - null - } - ).filterNotNull() - .joinToString(separator = "&") - - return fileServer.url - .newBuilder() - .addPathSegment("file") - .addPathSegment(fileId) - .fragment(urlFragment.takeIf { it.isNotBlank() }) - .build() - } - - data class URLParseResult( - val fileId: String, - val fileServer: FileServer, - val usesDeterministicEncryption: Boolean - ) - - fun parseAttachmentUrl(url: HttpUrl): URLParseResult { - check(url.pathSegments.size == 2) { - "Invalid URL: requiring exactly 2 path segments" - } - - check(url.pathSegments[0] == "file") { - "Invalid URL: first path segment must be 'file'" - } - - val id = url.pathSegments[1] - check(id.isNotBlank()) { - "Invalid URL: id must not be blank" - } - - var deterministicEncryption = false - var fileServerPubKeyHex: String? = null - - url.fragment - .orEmpty() - .splitToSequence('&') - .forEach { fragment -> - when { - fragment == "d" || fragment == "d=" -> deterministicEncryption = true - fragment.startsWith("p=", ignoreCase = true) -> { - fileServerPubKeyHex = fragment.substringAfter("p=").takeIf { it.isNotBlank() } - } - } - } - - val fileServerUrl = url.newBuilder() - .removePathSegment(0) // remove "file" - .removePathSegment(0) // remove id - .fragment(null) // remove fragment - .build() - - when { - !fileServerPubKeyHex.isNullOrEmpty() -> { - // We'll use the public key we get from the URL - return URLParseResult( - fileId = id, - fileServer = FileServer(url = fileServerUrl, ed25519PublicKeyHex = fileServerPubKeyHex), - usesDeterministicEncryption = deterministicEncryption - ) - } - - fileServerUrl == DEFAULT_FILE_SERVER.url -> { - // We'll use the default file server - return URLParseResult( - fileId = id, - fileServer = DEFAULT_FILE_SERVER, - usesDeterministicEncryption = deterministicEncryption - ) - } - - fileServerUrl.isOfficial -> { - // We don't have a public key, but given it's an official file server, - // we can use the default public key - return URLParseResult( - fileId = id, - fileServer = FileServer( - url = fileServerUrl, - ed25519PublicKeyHex = DEFAULT_FILE_SERVER.ed25519PublicKeyHex - ), - usesDeterministicEncryption = deterministicEncryption - ) - } - - else -> { - // We don't have a public key, and it's not the default file server - throw Error.InvalidURL - } - } - } - - /** - * Returns the current version of session - * This is effectively proxying (and caching) the response from the github release - * page. - * - * Note that the value is cached and can be up to 30 minutes out of date normally, and up to 24 - * hours out of date if we cannot reach the Github API for some reason. - * - * https://github.com/session-foundation/session-file-server/blob/dev/doc/api.yaml#L119 - */ - suspend fun getClientVersion(fileServer: FileServer = DEFAULT_FILE_SERVER): VersionData { - // Generate the auth signature - val secretKey = storage.getUserED25519KeyPair()?.secretKey?.data - ?: throw (Error.NoEd25519KeyPair) - - val blindedKeys = BlindKeyAPI.blindVersionKeyPair(secretKey) - val timestamp = System.currentTimeMillis().milliseconds.inWholeSeconds // The current timestamp in seconds - val signature = BlindKeyAPI.blindVersionSign(secretKey, timestamp) - - // The hex encoded version-blinded public key with a 07 prefix - val blindedPkHex = "07" + blindedKeys.pubKey.data.toHexString() - - val request = Request( - fileServer = fileServer, - verb = HTTP.Verb.GET, - endpoint = "session_version", - queryParameters = mapOf("platform" to "android"), - headers = mapOf( - "X-FS-Pubkey" to blindedPkHex, - "X-FS-Timestamp" to timestamp.toString(), - "X-FS-Signature" to Base64.encodeToString(signature, Base64.NO_WRAP) - ) - ) - - // transform the promise into a coroutine - val result = send(request) - - // map out the result - return JsonUtil.fromJson(result.body, Map::class.java).let { - VersionData( - statusCode = it["status_code"] as? Int ?: 0, - version = it["result"] as? String ?: "", - updated = it["updated"] as? Double ?: 0.0 - ) - } - } - - data class UploadResult( - val fileId: String, - val fileUrl: String, - val expires: ZonedDateTime? - ) - - data class SendResponse( - val body: ByteArraySlice, - val headers: Map? - ) { - /** - * The "expires" header's value if any - */ - val expires: ZonedDateTime? by lazy { - headers?.get("expires")?.let { - ZonedDateTime.parse(it, DateTimeFormatter.RFC_1123_DATE_TIME) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApis.kt b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApis.kt new file mode 100644 index 0000000000..8989e70cd2 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApis.kt @@ -0,0 +1,132 @@ +package org.session.libsession.messaging.file_server + +import okhttp3.HttpUrl +import java.time.ZonedDateTime + +object FileServerApis { + const val MAX_FILE_SIZE = 10_000_000 // 10 MB + + val DEFAULT_FILE_SERVER: FileServer = FileServer( + url = "http://filev2.getsession.org", + ed25519PublicKeyHex = "b8eef9821445ae16e2e97ef8aa6fe782fd11ad5253cd6723b281341dba22e371" + ) + + + fun buildAttachmentUrl( + fileId: String, + fileServer: FileServer, + usesDeterministicEncryption: Boolean + ): HttpUrl { + val urlFragment = sequenceOf( + "d".takeIf { usesDeterministicEncryption }, + if (!fileServer.url.isOfficial || fileServer.ed25519PublicKeyHex != DEFAULT_FILE_SERVER.ed25519PublicKeyHex) { + "p=${fileServer.ed25519PublicKeyHex}" + } else { + null + } + ).filterNotNull() + .joinToString(separator = "&") + + return fileServer.url + .newBuilder() + .addPathSegment("file") + .addPathSegment(fileId) + .fragment(urlFragment.takeIf { it.isNotBlank() }) + .build() + } + + fun parseAttachmentUrl(url: HttpUrl): URLParseResult { + check(url.pathSegments.size == 2) { + "Invalid URL: requiring exactly 2 path segments" + } + + check(url.pathSegments[0] == "file") { + "Invalid URL: first path segment must be 'file'" + } + + val id = url.pathSegments[1] + check(id.isNotBlank()) { + "Invalid URL: id must not be blank" + } + + var deterministicEncryption = false + var fileServerPubKeyHex: String? = null + + url.fragment + .orEmpty() + .splitToSequence('&') + .forEach { fragment -> + when { + fragment == "d" || fragment == "d=" -> deterministicEncryption = true + fragment.startsWith("p=", ignoreCase = true) -> { + fileServerPubKeyHex = fragment.substringAfter("p=").takeIf { it.isNotBlank() } + } + } + } + + val fileServerUrl = url.newBuilder() + .removePathSegment(0) // remove "file" + .removePathSegment(0) // remove id + .fragment(null) // remove fragment + .build() + + when { + !fileServerPubKeyHex.isNullOrEmpty() -> { + // We'll use the public key we get from the URL + return URLParseResult( + fileId = id, + fileServer = FileServer(url = fileServerUrl, ed25519PublicKeyHex = fileServerPubKeyHex), + usesDeterministicEncryption = deterministicEncryption + ) + } + + fileServerUrl == DEFAULT_FILE_SERVER.url -> { + // We'll use the default file server + return URLParseResult( + fileId = id, + fileServer = DEFAULT_FILE_SERVER, + usesDeterministicEncryption = deterministicEncryption + ) + } + + fileServerUrl.isOfficial -> { + // We don't have a public key, but given it's an official file server, + // we can use the default public key + return URLParseResult( + fileId = id, + fileServer = FileServer( + url = fileServerUrl, + ed25519PublicKeyHex = DEFAULT_FILE_SERVER.ed25519PublicKeyHex + ), + usesDeterministicEncryption = deterministicEncryption + ) + } + + else -> { + // We don't have a public key, and it's not the default file server + throw Error.InvalidURL + } + } + } + + sealed class Error(message: String) : Exception(message) { + object ParsingFailed : Error("Invalid response.") + object InvalidURL : Error("Invalid URL.") + object NoEd25519KeyPair : Error("Couldn't find ed25519 key pair.") + } + + data class URLParseResult( + val fileId: String, + val fileServer: FileServer, + val usesDeterministicEncryption: Boolean + ) + + + + data class UploadResult( + val fileId: String, + val fileUrl: String, + val expires: ZonedDateTime? + ) + +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/file_server/FileUploadApi.kt b/app/src/main/java/org/session/libsession/messaging/file_server/FileUploadApi.kt new file mode 100644 index 0000000000..9a96245641 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/file_server/FileUploadApi.kt @@ -0,0 +1,86 @@ +package org.session.libsession.messaging.file_server + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.thoughtcrime.securesms.api.server.ServerApiErrorManager +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpBody +import org.thoughtcrime.securesms.api.http.HttpRequest +import org.thoughtcrime.securesms.api.http.HttpResponse +import org.thoughtcrime.securesms.api.server.ServerApi +import org.thoughtcrime.securesms.util.DateUtils.Companion.asEpochSeconds + +class FileUploadApi @AssistedInject constructor( + @Assisted private val fileServer: FileServer, + @Assisted private val data: ByteArray, + @Assisted private val usedDeterministicEncryption: Boolean, + @Assisted private val customExpiresSeconds: Long?, + errorManager: ServerApiErrorManager, + private val json: Json, +) : ServerApi( + errorManager +) { + override fun buildRequest( + baseUrl: String, + x25519PubKeyHex: String + ): HttpRequest { + check(fileServer.url.toString().startsWith(baseUrl)) { + "FileServer URL ${fileServer.url} does not match base URL $baseUrl" + } + + return HttpRequest( + url = "$baseUrl/file".toHttpUrl(), + method = "POST", + headers = buildMap { + put("Content-Disposition", "attachment") + put("Content-Type", "application/octet-stream") + if (customExpiresSeconds != null) { + put("X-FS-TTL", customExpiresSeconds.toString()) + } + }, + body = HttpBody.Bytes(data), + ) + } + + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): FileServerApis.UploadResult { + @Suppress("OPT_IN_USAGE") + val response = json.decodeFromStream(response.body.asInputStream()) + return FileServerApis.UploadResult( + fileId = response.id, + fileUrl = FileServerApis.buildAttachmentUrl( + fileId = response.id, + fileServer = fileServer, + usesDeterministicEncryption = usedDeterministicEncryption + ).toString(), + expires = response.expiresEpochSeconds?.toLong()?.asEpochSeconds() + ) + } + + @Serializable + private class Response( + val id: String, + + @SerialName("expires") + val expiresEpochSeconds: Double?, + ) + + @AssistedFactory + interface Factory { + fun create( + fileServer: FileServer, + data: ByteArray, + usedDeterministicEncryption: Boolean, + customExpiresSeconds: Long? = null, + ): FileUploadApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/file_server/GetClientVersionApi.kt b/app/src/main/java/org/session/libsession/messaging/file_server/GetClientVersionApi.kt new file mode 100644 index 0000000000..d1e34cae05 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/file_server/GetClientVersionApi.kt @@ -0,0 +1,74 @@ +package org.session.libsession.messaging.file_server + +import android.util.Base64 +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import network.loki.messenger.libsession_util.util.BlindKeyAPI +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.thoughtcrime.securesms.api.server.ServerApiErrorManager +import org.session.libsession.network.SnodeClock +import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpRequest +import org.thoughtcrime.securesms.api.http.HttpResponse +import org.thoughtcrime.securesms.api.server.ServerApi +import org.thoughtcrime.securesms.auth.LoginStateRepository +import javax.inject.Inject + +/** + * Returns the current version of session + * This is effectively proxying (and caching) the response from the github release + * page. + * + * Note that the value is cached and can be up to 30 minutes out of date normally, and up to 24 + * hours out of date if we cannot reach the Github API for some reason. + * + * https://github.com/session-foundation/session-file-server/blob/dev/doc/api.yaml#L119 + */ +class GetClientVersionApi @Inject constructor( + errorManager: ServerApiErrorManager, + private val loginStateRepository: LoginStateRepository, + private val snodeClock: SnodeClock, + private val json: Json, +) : ServerApi(errorManager) { + override fun buildRequest( + baseUrl: String, + x25519PubKeyHex: String + ): HttpRequest { + val secretKey = loginStateRepository + .requireLoggedInState() + .accountEd25519KeyPair + .secretKey + .data + + val blindedKeys = BlindKeyAPI.blindVersionKeyPair(secretKey) + + // The hex encoded version-blinded public key with a 07 prefix + val blindedPkHex = "07" + blindedKeys.pubKey.data.toHexString() + + val timestamp = snodeClock.currentTimeSeconds() + val signature = BlindKeyAPI.blindVersionSign(secretKey, timestamp) + + return HttpRequest( + url = "$baseUrl/session_version?platform=android".toHttpUrl(), + method = "GET", + headers = mapOf( + "X-FS-Pubkey" to blindedPkHex, + "X-FS-Timestamp" to timestamp.toString(), + "X-FS-Signature" to Base64.encodeToString(signature, Base64.NO_WRAP), + ), + body = null + ) + } + + @OptIn(ExperimentalSerializationApi::class) + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): VersionData { + return response.body.asInputStream() + .use(json::decodeFromStream) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/file_server/VersionData.kt b/app/src/main/java/org/session/libsession/messaging/file_server/VersionData.kt index b7c28020e7..e79e5b2f52 100644 --- a/app/src/main/java/org/session/libsession/messaging/file_server/VersionData.kt +++ b/app/src/main/java/org/session/libsession/messaging/file_server/VersionData.kt @@ -1,7 +1,16 @@ package org.session.libsession.messaging.file_server +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable data class VersionData( + @SerialName("status_code") val statusCode: Int, // The value 200. Included for backwards compatibility, and may be removed someday. + + @SerialName("result") val version: String, // The Session version. + + @SerialName("updated") val updated: Double // The unix timestamp when this version was retrieved from Github; this can be up to 24 hours ago in case of consistent fetch errors, though normally will be within the last 30 minutes. ) \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt b/app/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt index a4c2bc8f49..7e5cd7a7d1 100644 --- a/app/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt +++ b/app/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt @@ -23,6 +23,7 @@ class GroupInviteException( val isPromotion: Boolean, val inviteeAccountIds: List, val groupName: String, + val isReinvite: Boolean, underlying: Throwable ) : RuntimeException(underlying) { init { @@ -41,19 +42,26 @@ class GroupInviteException( val third = inviteeAccountIds.getOrNull(2)?.let(getInviteeName) if (second != null && third != null) { - return Phrase.from(context, if (isPromotion) R.string.adminPromotionFailedDescriptionMultiple else R.string.groupInviteFailedMultiple) + val errorString = + if (isPromotion) if (isReinvite) R.string.failedResendPromotionMultiple else R.string.adminPromotionFailedDescriptionMultiple else + if (isReinvite) R.string.failedResendInviteMultiple else R.string.groupInviteFailedMultiple + return Phrase.from(context, errorString) .put(NAME_KEY, first) .put(COUNT_KEY, inviteeAccountIds.size - 1) .put(GROUP_NAME_KEY, groupName) .format() } else if (second != null) { - return Phrase.from(context, if (isPromotion) R.string.adminPromotionFailedDescriptionTwo else R.string.groupInviteFailedTwo) + val errorString = if (isPromotion) if (isReinvite) R.string.failedResendPromotionTwo else R.string.adminPromotionFailedDescriptionTwo else + if (isReinvite) R.string.failedResendInviteTwo else R.string.groupInviteFailedTwo + return Phrase.from(context, errorString) .put(NAME_KEY, first) .put(OTHER_NAME_KEY, second) .put(GROUP_NAME_KEY, groupName) .format() } else { - return Phrase.from(context, if (isPromotion) R.string.adminPromotionFailedDescription else R.string.groupInviteFailedUser) + val errorString = if (isPromotion) if (isReinvite) R.string.failedResendPromotion else R.string.adminPromotionFailedDescription else + if (isReinvite) R.string.failedResendInvite else R.string.groupInviteFailedUser + return Phrase.from(context, errorString) .put(NAME_KEY, first) .put(GROUP_NAME_KEY, groupName) .format() diff --git a/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt b/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt index b8ea9990ec..c9abda96d0 100644 --- a/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt +++ b/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt @@ -4,8 +4,9 @@ import androidx.annotation.StringRes import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.utilities.recipients.Recipient -import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage +import org.session.protos.SessionProtos.GroupUpdateDeleteMemberContentMessage import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.groups.MemberInvite /** * Business logic handling group v2 operations like inviting members, @@ -25,6 +26,11 @@ interface GroupManagerV2 { isReinvite: Boolean, // Whether this comes from a re-invite ) + suspend fun reinviteMembers( + group: AccountId, + invites: List + ) + suspend fun removeMembers( groupAccountId: AccountId, removedMembers: List, @@ -52,7 +58,7 @@ interface GroupManagerV2 { ) suspend fun handleMemberLeftMessage(memberId: AccountId, group: AccountId) - suspend fun leaveGroup(groupId: AccountId) + suspend fun leaveGroup(groupId: AccountId, deleteGroup : Boolean = false) suspend fun promoteMember(group: AccountId, members: List, isRepromote: Boolean) suspend fun handleInvitation( @@ -119,6 +125,10 @@ interface GroupManagerV2 { fun getLeaveGroupConfirmationDialogData(groupId: AccountId, name: String): ConfirmDialogData? + fun getDeleteGroupConfirmationDialogData(groupId : AccountId, name : String) : ConfirmDialogData? + + fun isCurrentUserLastAdmin(groupId : AccountId) : Boolean + data class ConfirmDialogData( val title: String, val message: CharSequence, @@ -126,5 +136,6 @@ interface GroupManagerV2 { @StringRes val negativeText: Int, @StringRes val positiveQaTag: Int?, @StringRes val negativeQaTag: Int?, + val showCloseButton: Boolean = false ) } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt index 46f473e210..7eb6300236 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -7,22 +7,29 @@ import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.StorageProtocol -import org.session.libsession.messaging.file_server.FileServerApi -import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.messaging.file_server.FileDownloadApi +import org.session.libsession.messaging.file_server.FileServerApis +import org.session.libsession.messaging.open_groups.api.CommunityApiExecutor +import org.session.libsession.messaging.open_groups.api.CommunityApiRequest +import org.session.libsession.messaging.open_groups.api.CommunityFileDownloadApi +import org.session.libsession.messaging.open_groups.api.execute import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.utilities.Data -import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.DecodedAudio import org.session.libsession.utilities.InputStreamMediaDataSource import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.Base64 -import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.api.error.UnhandledStatusCodeException +import org.thoughtcrime.securesms.api.server.ServerApiExecutor +import org.thoughtcrime.securesms.api.server.ServerApiRequest +import org.thoughtcrime.securesms.api.server.execute import org.thoughtcrime.securesms.attachments.AttachmentProcessor import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.util.findCause class AttachmentDownloadJob @AssistedInject constructor( @Assisted("attachmentID") val attachmentID: Long, @@ -30,7 +37,10 @@ class AttachmentDownloadJob @AssistedInject constructor( private val storage: StorageProtocol, private val messageDataProvider: MessageDataProvider, private val attachmentProcessor: AttachmentProcessor, - private val fileServerApi: FileServerApi, + private val serverApiExecutor: ServerApiExecutor, + private val fileDownloadApiFactory: FileDownloadApi.Factory, + private val communityApiExecutor: CommunityApiExecutor, + private val communityFileDownloadApiFactory: CommunityFileDownloadApi.Factory, ) : Job { override var delegate: JobDelegate? = null override var id: String? = null @@ -83,7 +93,7 @@ class AttachmentDownloadJob @AssistedInject constructor( val threadID = storage.getThreadIdForMms(mmsMessageId) val handleFailure: (java.lang.Exception, attachmentId: AttachmentId?) -> Unit = { exception, attachment -> - if(exception is HTTP.HTTPRequestFailedException && exception.statusCode == 404){ + if (exception.findCause()?.code == 404){ attachment?.let { id -> Log.d("AttachmentDownloadJob", "Setting attachment state = failed, have attachment") messageDataProvider.setAttachmentState(AttachmentState.EXPIRED, id, mmsMessageId) @@ -94,7 +104,7 @@ class AttachmentDownloadJob @AssistedInject constructor( } else if (exception == Error.NoAttachment || exception == Error.NoThread || exception == Error.NoSender - || (exception is OnionRequestAPI.HTTPRequestFailedAtDestinationException && exception.statusCode == 400) + || (exception.findCause()?.code == 400) || exception is NonRetryableException) { attachment?.let { id -> Log.d("AttachmentDownloadJob", "Setting attachment state = failed, have attachment") @@ -157,7 +167,7 @@ class AttachmentDownloadJob @AssistedInject constructor( val decrypted = if (threadRecipient?.address !is Address.Community) { Log.d("AttachmentDownloadJob", "downloading normal attachment") - val r = runCatching { fileServerApi.parseAttachmentUrl(attachment.url.toHttpUrl()) } + val r = runCatching { FileServerApis.parseAttachmentUrl(attachment.url.toHttpUrl()) } .recover { throw NonRetryableException("Invalid file server URL", it) } .getOrThrow() @@ -165,10 +175,12 @@ class AttachmentDownloadJob @AssistedInject constructor( throw NonRetryableException("Missing attachment key") }.let(Base64::decode) - val cipherText = fileServerApi.download( - fileId = r.fileId, - fileServer = r.fileServer - ).body + val cipherText = serverApiExecutor.execute( + ServerApiRequest( + fileServer = r.fileServer, + api = fileDownloadApiFactory.create(r.fileId) + ) + ).data.toByteArraySlice() runCatching { if (r.usesDeterministicEncryption) { @@ -189,7 +201,17 @@ class AttachmentDownloadJob @AssistedInject constructor( Log.d("AttachmentDownloadJob", "downloading open group attachment") val url = attachment.url.toHttpUrlOrNull()!! val fileID = url.pathSegments.last() - OpenGroupApi.download(fileID, room = threadRecipient.address.room, server = threadRecipient.address.serverUrl) + + communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = threadRecipient.address.serverUrl, + api = communityFileDownloadApiFactory.create( + room = threadRecipient.address.room, + fileId = fileID, + requiresSigning = true, + ) + ) + ).toByteArraySlice() } Log.d("AttachmentDownloadJob", "getting input stream") diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt index 86ce01eec7..8c8413eb40 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt @@ -9,10 +9,14 @@ import dagger.assisted.AssistedInject import network.loki.messenger.libsession_util.encrypt.Attachments import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.StorageProtocol -import org.session.libsession.messaging.file_server.FileServerApi +import org.session.libsession.messaging.file_server.FileServerApis +import org.session.libsession.messaging.file_server.FileUploadApi import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Message -import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.messaging.open_groups.api.CommunityApiExecutor +import org.session.libsession.messaging.open_groups.api.CommunityApiRequest +import org.session.libsession.messaging.open_groups.api.CommunityFileUploadApi +import org.session.libsession.messaging.open_groups.api.execute import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.Data import org.session.libsession.utilities.Address @@ -22,6 +26,10 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UploadResult import org.session.libsignal.messages.SignalServiceAttachmentStream import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.api.http.HttpBody +import org.thoughtcrime.securesms.api.server.ServerApiExecutor +import org.thoughtcrime.securesms.api.server.ServerApiRequest +import org.thoughtcrime.securesms.api.server.execute import org.thoughtcrime.securesms.attachments.AttachmentProcessor import org.thoughtcrime.securesms.database.ThreadDatabase @@ -36,8 +44,11 @@ class AttachmentUploadJob @AssistedInject constructor( private val threadDatabase: ThreadDatabase, private val attachmentProcessor: AttachmentProcessor, private val preferences: TextSecurePreferences, - private val fileServerApi: FileServerApi, private val messageSender: MessageSender, + private val serverApiExecutor: ServerApiExecutor, + private val fileUploadApiFactory: FileUploadApi.Factory, + private val communityApiExecutor: CommunityApiExecutor, + private val communityFileUploadApiFactory: CommunityFileUploadApi.Factory, ) : Job { override var delegate: JobDelegate? = null override var id: String? = null @@ -75,20 +86,33 @@ class AttachmentUploadJob @AssistedInject constructor( attachment = attachment, encrypt = false ) { data, _ -> - val id = OpenGroupApi.upload(data, threadAddress.room, threadAddress.serverUrl) + val id = communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = threadAddress.serverUrl, + api = communityFileUploadApiFactory.create( + file = HttpBody.Bytes(data), + room = threadAddress.room, + ) + ) + ) id to "${threadAddress.serverUrl}/file/$id" } handleSuccess(dispatcherName, attachment, keyAndResult.first, keyAndResult.second) } else { - val fileServer = preferences.alternativeFileServer ?: FileServerApi.DEFAULT_FILE_SERVER + val fileServer = preferences.alternativeFileServer ?: FileServerApis.DEFAULT_FILE_SERVER val keyAndResult = upload( attachment = attachment, encrypt = true ) { data, isDeterministicallyEncrypted -> - val result = fileServerApi.upload( - file = data, - usedDeterministicEncryption = isDeterministicallyEncrypted, - fileServer = fileServer + val result = serverApiExecutor.execute( + ServerApiRequest( + fileServer = fileServer, + api = fileUploadApiFactory.create( + data = data, + usedDeterministicEncryption = isDeterministicallyEncrypted, + fileServer = fileServer, + ) + ) ) result.fileId to result.fileUrl diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt deleted file mode 100644 index 410acddafc..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt +++ /dev/null @@ -1,415 +0,0 @@ -package org.session.libsession.messaging.jobs - -import android.content.Context -import com.google.protobuf.ByteString -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import network.loki.messenger.libsession_util.PRIORITY_HIDDEN -import org.session.libsession.database.StorageProtocol -import org.session.libsession.messaging.messages.Destination -import org.session.libsession.messaging.messages.Message -import org.session.libsession.messaging.messages.Message.Companion.senderOrSync -import org.session.libsession.messaging.messages.control.CallMessage -import org.session.libsession.messaging.messages.control.DataExtractionNotification -import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate -import org.session.libsession.messaging.messages.control.MessageRequestResponse -import org.session.libsession.messaging.messages.control.ReadReceipt -import org.session.libsession.messaging.messages.control.TypingIndicator -import org.session.libsession.messaging.messages.control.UnsendRequest -import org.session.libsession.messaging.messages.visible.ParsedMessage -import org.session.libsession.messaging.messages.visible.VisibleMessage -import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.messaging.sending_receiving.MessageReceiver -import org.session.libsession.messaging.sending_receiving.ReceivedMessageHandler -import org.session.libsession.messaging.sending_receiving.VisibleMessageHandlerContext -import org.session.libsession.messaging.sending_receiving.constructReactionRecords -import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier -import org.session.libsession.messaging.utilities.Data -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.Address.Companion.toAddress -import org.session.libsession.utilities.ConfigFactoryProtocol -import org.session.libsession.utilities.UserConfigType -import org.session.libsignal.protos.UtilProtos -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.database.RecipientRepository -import org.thoughtcrime.securesms.database.ThreadDatabase -import org.thoughtcrime.securesms.database.model.MessageId -import org.thoughtcrime.securesms.database.model.ReactionRecord -import kotlin.math.max - -data class MessageReceiveParameters( - val data: ByteArray, - val serverHash: String? = null, - val openGroupMessageServerID: Long? = null, - val reactions: Map? = null, - val closedGroup: Destination.ClosedGroup? = null -) - -@Deprecated("BatchMessageReceiveJob is now only here so that existing persisted jobs can be processed.") -class BatchMessageReceiveJob @AssistedInject constructor( - @Assisted private val messages: List, - @Assisted val fromCommunity: Address.Community?, // The community the messages are received in, if any - private val configFactory: ConfigFactoryProtocol, - private val storage: StorageProtocol, - @param:ApplicationContext private val context: Context, - private val receivedMessageHandler: ReceivedMessageHandler, - private val visibleMessageHandlerContextFactory: VisibleMessageHandlerContext.Factory, - private val messageNotifier: MessageNotifier, - private val threadDatabase: ThreadDatabase, - private val recipientRepository: RecipientRepository, - private val messageReceiver: MessageReceiver, -) : Job { - - override var delegate: JobDelegate? = null - override var id: String? = null - override var failureCount: Int = 0 - override val maxFailureCount: Int = 1 // handled in JobQueue onJobFailed - // Failure Exceptions must be retryable if they're a MessageReceiver.Error - val failures = mutableListOf() - - companion object { - const val TAG = "BatchMessageReceiveJob" - const val KEY = "BatchMessageReceiveJob" - - const val BATCH_DEFAULT_NUMBER = 512 - - // used for processing messages that don't have a thread and shouldn't create one - const val NO_THREAD_MAPPING = -1L - - // Keys used for database storage - private val NUM_MESSAGES_KEY = "numMessages" - private val DATA_KEY = "data" - private val SERVER_HASH_KEY = "serverHash" - private val OPEN_GROUP_MESSAGE_SERVER_ID_KEY = "openGroupMessageServerID" - @Deprecated("No longer used, keep for backwards compatibility") - private val OPEN_GROUP_ID_KEY = "open_group_id" - private val CLOSED_GROUP_DESTINATION_KEY = "closed_group_destination" - private val FROM_COMMUNITY_KEY = "from_community" - } - - fun recreateWithNewMessages( - newMessages: List, - ): BatchMessageReceiveJob { - return BatchMessageReceiveJob( - messages = newMessages, - configFactory = configFactory, - storage = storage, - context = context, - receivedMessageHandler = receivedMessageHandler, - visibleMessageHandlerContextFactory = visibleMessageHandlerContextFactory, - messageNotifier = messageNotifier, - fromCommunity = fromCommunity, - threadDatabase = threadDatabase, - recipientRepository = recipientRepository, - messageReceiver = messageReceiver, - ) - } - - private fun shouldCreateThread(parsedMessage: ParsedMessage): Boolean { - val message = parsedMessage.message - if (message is VisibleMessage) return true - else { // message is control message otherwise - return when(message) { - is DataExtractionNotification -> false - is MessageRequestResponse -> false - is ExpirationTimerUpdate -> false - is TypingIndicator -> false - is UnsendRequest -> false - is ReadReceipt -> false - is CallMessage -> false // TODO: maybe - else -> false // shouldn't happen, or I guess would be Visible - } - } - } - - override suspend fun execute(dispatcherName: String) { - executeAsync(dispatcherName) - } - - private fun isHidden(message: Message): Boolean { - // if the contact is marked as hidden for 1on1 messages - // and the message's sentTimestamp is earlier than the sentTimestamp of the last config - val publicKey = storage.getUserPublicKey() - if (message.sentTimestamp == null || publicKey == null) return false - - val contactConfigTimestamp = configFactory.getConfigTimestamp(UserConfigType.CONTACTS, publicKey) - - return configFactory.withUserConfigs { configs -> - message.groupPublicKey == null && // not a group - message.openGroupServerMessageID == null && // not a community - // not marked as hidden - configs.contacts.get(message.senderOrSync)?.priority == PRIORITY_HIDDEN && - // the message's sentTimestamp is earlier than the sentTimestamp of the last config - message.sentTimestamp!! < contactConfigTimestamp - } - } - - suspend fun executeAsync(dispatcherName: String) { - val threadMap = mutableMapOf>>() - val localUserPublicKey = storage.getUserPublicKey() - val serverPublicKey = fromCommunity?.let { storage.getOpenGroupPublicKey(it.serverUrl) } - val currentClosedGroups = storage.getAllActiveClosedGroupPublicKeys() - - // parse and collect IDs - messages.forEach { messageParameters -> - val (data, serverHash, openGroupMessageServerID) = messageParameters - try { - val (message, proto) = messageReceiver.parse( - data, - openGroupMessageServerID, - openGroupPublicKey = serverPublicKey, - currentClosedGroups = currentClosedGroups, - closedGroupSessionId = messageParameters.closedGroup?.publicKey - ) - message.serverHash = serverHash - val parsedParams = ParsedMessage(messageParameters, message, proto) - - if(isHidden(message)) return@forEach - - val threadAddress = when { - fromCommunity != null -> fromCommunity - message.groupPublicKey != null -> message.groupPublicKey!!.toAddress() - else -> message.senderOrSync.toAddress() - } as Address.Conversable - - val threadID = if (shouldCreateThread(parsedParams)) { - threadDatabase.getOrCreateThreadIdFor(threadAddress) - } else { - threadDatabase.getThreadIdIfExistsFor(threadAddress) - } - threadMap.getOrPut(threadID) { threadAddress to mutableListOf() }.second += parsedParams - } catch (e: Exception) { - when (e) { - is MessageReceiver.Error.DuplicateMessage, MessageReceiver.Error.SelfSend -> { - Log.i(TAG, "Couldn't receive message, failed with error: ${e.message} (id: $id)") - } - is MessageReceiver.Error -> { - if (!e.isRetryable) { - Log.e(TAG, "Couldn't receive message, failed permanently (id: $id)", e) - } - else { - Log.e(TAG, "Couldn't receive message, failed (id: $id)", e) - failures += messageParameters - } - } - else -> { - Log.e(TAG, "Couldn't receive message, failed (id: $id)", e) - failures += messageParameters - } - } - } - } - - // iterate over threads and persist them (persistence is the longest constant in the batch process operation) - suspend fun processMessages(threadId: Long, threadAddress: Address.Conversable, messages: List) { - // The LinkedHashMap should preserve insertion order - val messageIds = linkedMapOf>() - val myLastSeen = storage.getLastSeen(threadId) - var updatedLastSeen = myLastSeen.takeUnless { it == -1L } ?: 0 - val handlerContext = visibleMessageHandlerContextFactory.create( - threadId = threadId, - threadAddress, - ) - - val communityReactions = mutableMapOf>() - - messages.forEach { (parameters, message, proto) -> - try { - when (message) { - is VisibleMessage -> { - val isUserBlindedSender = - message.sender == handlerContext.userBlindedKey - - if (message.sender == localUserPublicKey || isUserBlindedSender) { - // use sent timestamp here since that is technically the last one we have - updatedLastSeen = max(updatedLastSeen, message.sentTimestamp!!) - } - val messageId = receivedMessageHandler.handleVisibleMessage( - message = message, - proto = proto, - context = handlerContext, - runThreadUpdate = false, - runProfileUpdate = true - ) - - if (messageId != null && message.reaction == null) { - messageIds[messageId] = Pair( - (message.sender == localUserPublicKey || isUserBlindedSender), - message.hasMention - ) - } - - parameters.openGroupMessageServerID?.let { - constructReactionRecords( - openGroupMessageServerID = it, - context = handlerContext, - reactions = parameters.reactions, - out = communityReactions - ) - } - } - - is UnsendRequest -> { - val deletedMessage = receivedMessageHandler.handleUnsendRequest(message) - - // If we removed a message then ensure it isn't in the 'messageIds' - if (deletedMessage != null) { - messageIds.remove(deletedMessage) - } - } - - else -> receivedMessageHandler.handle( - message = message, - proto = proto, - threadId = threadId, - threadAddress = threadAddress - ) - } - } catch (e: Exception) { - Log.e(TAG, "Couldn't process message (id: $id)", e) - if (e is MessageReceiver.Error && !e.isRetryable) { - Log.e(TAG, "Message failed permanently (id: $id)", e) - } else { - Log.e(TAG, "Message failed (id: $id)", e) - failures += parameters - } - } - } - // increment unreads, notify, and update thread - // last seen will be the current last seen if not changed (re-computes the read counts for thread record) - // might have been updated from a different thread at this point - val storedLastSeen = storage.getLastSeen(threadId).let { if (it == -1L) 0 else it } - updatedLastSeen = max(updatedLastSeen, storedLastSeen) - // Only call markConversationAsRead() when lastSeen actually advanced (we sent a message). - // For incoming-only batches (like reactions), skip this to preserve REACTIONS_UNREAD flags - // so the notification system can detect them. Thread updates happen separately below. - if (updatedLastSeen > 0 || storedLastSeen == 0L) { - storage.markConversationAsRead(threadId, updatedLastSeen, force = true) - } - storage.updateThread(threadId, true) - messageNotifier.updateNotification(context, threadId) - - if (communityReactions.isNotEmpty()) { - storage.addReactions(communityReactions, replaceAll = true, notifyUnread = false) - } - } - - coroutineScope { - val withoutDefault = threadMap.entries.filter { it.key != NO_THREAD_MAPPING } - val deferredThreadMap = withoutDefault.map { (threadId, data) -> - val (threadAddress, messages) = data - async(Dispatchers.Default) { - processMessages( - threadId = threadId, - threadAddress = threadAddress, - messages = messages - ) - } - } - // await all thread processing - deferredThreadMap.awaitAll() - } - - val noThreadMessages = threadMap[NO_THREAD_MAPPING] - if (noThreadMessages != null && noThreadMessages.second.isNotEmpty()) { - processMessages(NO_THREAD_MAPPING, noThreadMessages.first, noThreadMessages.second) - } - - if (failures.isEmpty()) { - handleSuccess(dispatcherName) - } else { - handleFailure(dispatcherName) - } - } - - private fun handleSuccess(dispatcherName: String) { - Log.i(TAG, "Completed processing of ${messages.size} messages (id: $id)") - delegate?.handleJobSucceeded(this, dispatcherName) - } - - private fun handleFailure(dispatcherName: String) { - Log.i(TAG, "Handling failure of ${failures.size} messages (${messages.size - failures.size} processed successfully) (id: $id)") - delegate?.handleJobFailed(this, dispatcherName, Exception("One or more jobs resulted in failure")) - } - - override fun serialize(): Data { - val arraySize = messages.size - val dataArrays = UtilProtos.ByteArrayList.newBuilder() - .addAllContent(messages.map(MessageReceiveParameters::data).map(ByteString::copyFrom)) - .build() - val serverHashes = messages.map { it.serverHash.orEmpty() } - val openGroupServerIds = messages.map { it.openGroupMessageServerID ?: -1L } - val closedGroups = messages.map { it.closedGroup?.publicKey.orEmpty() } - return Data.Builder() - .putInt(NUM_MESSAGES_KEY, arraySize) - .putByteArray(DATA_KEY, dataArrays.toByteArray()) - .putLongArray(OPEN_GROUP_MESSAGE_SERVER_ID_KEY, openGroupServerIds.toLongArray()) - .putStringArray(SERVER_HASH_KEY, serverHashes.toTypedArray()) - .putStringArray(CLOSED_GROUP_DESTINATION_KEY, closedGroups.toTypedArray()) - .putString(FROM_COMMUNITY_KEY, fromCommunity?.address) - .build() - } - - override fun getFactoryKey(): String = KEY - - - @AssistedFactory - abstract class Factory : Job.DeserializeFactory { - @Deprecated("New code should try to handle message directly instead of creating this job") - protected abstract fun create( - messages: List, - fromCommunity: Address.Community?, - ): BatchMessageReceiveJob - - override fun create(data: Data): BatchMessageReceiveJob { - val numMessages = data.getInt(NUM_MESSAGES_KEY) - val dataArrays = data.getByteArray(DATA_KEY) - val contents = - UtilProtos.ByteArrayList.parseFrom(dataArrays).contentList.map(ByteString::toByteArray) - val serverHashes = - if (data.hasStringArray(SERVER_HASH_KEY)) data.getStringArray(SERVER_HASH_KEY) else arrayOf() - val openGroupMessageServerIDs = data.getLongArray(OPEN_GROUP_MESSAGE_SERVER_ID_KEY) - val openGroupID = data.getStringOrDefault(OPEN_GROUP_ID_KEY, null) - val closedGroups = - if (data.hasStringArray(CLOSED_GROUP_DESTINATION_KEY)) data.getStringArray(CLOSED_GROUP_DESTINATION_KEY) - else arrayOf() - - val parameters = (0 until numMessages).map { index -> - val serverHash = serverHashes[index].let { if (it.isEmpty()) null else it } - val serverId = openGroupMessageServerIDs[index].let { if (it == -1L) null else it } - val closedGroup = closedGroups.getOrNull(index)?.let { - if (it.isEmpty()) null else Destination.ClosedGroup(it) - } - MessageReceiveParameters( - data = contents[index], - serverHash = serverHash, - openGroupMessageServerID = serverId, - closedGroup = closedGroup - ) - } - var fromCommunity = data.getStringOrDefault(FROM_COMMUNITY_KEY, null) - ?.toAddress() as? Address.Community - - if (fromCommunity == null && openGroupID != null) { - // This is the old "server.room" format, which we no longer use but will have - // to support for a bit for the persisted data to run through the system. - val split = openGroupID.lastIndexOf(".") - .takeIf { it >= 0 && it < openGroupID.length - 1 } - ?.let { openGroupID.substring(0, it) to openGroupID.substring(it + 1) } - - if (split != null) { - fromCommunity = Address.Community(serverUrl = split.first, room = split.second) - } - } - - return create(messages = parameters, fromCommunity = fromCommunity) - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt index 70157d8ef1..03d546c69a 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt @@ -18,25 +18,31 @@ import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.Data import org.session.libsession.messaging.utilities.MessageAuthentication.buildGroupInviteSignature -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.getGroup -import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInviteMessage -import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage +import org.session.libsession.utilities.withGroupConfigs +import org.session.libsession.utilities.withMutableGroupConfigs import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log +import org.session.protos.SessionProtos.GroupUpdateInviteMessage +import org.session.protos.SessionProtos.GroupUpdateMessage class InviteContactsJob @AssistedInject constructor( @Assisted val groupSessionId: String, @Assisted val memberSessionIds: Array, + @Assisted val isReinvite: Boolean, private val configFactory: ConfigFactoryProtocol, private val messageSender: MessageSender, + private val snodeClock: SnodeClock + ) : Job { companion object { const val KEY = "InviteContactJob" private const val GROUP = "group" private const val MEMBER = "member" + private const val REINVITE = "reinvite" } @@ -66,7 +72,7 @@ class InviteContactsJob @AssistedInject constructor( configs.groupInfo.getName() to configs.groupKeys.makeSubAccount(memberSessionId) } - val timestamp = SnodeAPI.nowWithOffset + val timestamp = snodeClock.currentTimeMillis() val signature = ED25519.sign( ed25519PrivateKey = adminKey.data, message = buildGroupInviteSignature(memberId, timestamp), @@ -130,10 +136,17 @@ class InviteContactsJob @AssistedInject constructor( inviteeAccountIds = failures.map { it.first }, groupName = groupName.orEmpty(), underlying = firstError, - ).format(MessagingModuleConfiguration.shared.context, - MessagingModuleConfiguration.shared.recipientRepository).let { + isReinvite = isReinvite + ).format( + MessagingModuleConfiguration.shared.context, + MessagingModuleConfiguration.shared.recipientRepository + ).let { withContext(Dispatchers.Main) { - Toast.makeText(MessagingModuleConfiguration.shared.context, it, Toast.LENGTH_LONG).show() + Toast.makeText( + MessagingModuleConfiguration.shared.context, + it, + Toast.LENGTH_LONG + ).show() } } } @@ -144,6 +157,7 @@ class InviteContactsJob @AssistedInject constructor( Data.Builder() .putString(GROUP, groupSessionId) .putStringArray(MEMBER, memberSessionIds) + .putBoolean(REINVITE, isReinvite) .build() override fun getFactoryKey(): String = KEY @@ -153,14 +167,17 @@ class InviteContactsJob @AssistedInject constructor( abstract fun create( groupSessionId: String, memberSessionIds: Array, + isReinvite: Boolean ): InviteContactsJob override fun create(data: Data): InviteContactsJob? { val groupSessionId = data.getString(GROUP) ?: return null val memberSessionIds = data.getStringArray(MEMBER) ?: return null + val reinvite = data.getBooleanOrDefault(REINVITE, false) return create( groupSessionId = groupSessionId, memberSessionIds = memberSessionIds, + isReinvite = reinvite ) } } diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt b/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt index 38cda9a392..ea60427a04 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt @@ -38,7 +38,6 @@ class JobQueue : JobDelegate { for (job in channel) { if (!isActive) break val communityAddress = when (job) { - is BatchMessageReceiveJob -> job.fromCommunity?.address is OpenGroupDeleteJob -> job.address?.address is TrimThreadJob -> job.communityAddress?.address else -> null @@ -121,7 +120,6 @@ class JobQueue : JobDelegate { while (isActive) { when (val job = queue.receive()) { is InviteContactsJob, - is NotifyPNServerJob, is AttachmentUploadJob, is MessageSendJob -> { txQueue.send(job) @@ -132,10 +130,8 @@ class JobQueue : JobDelegate { is OpenGroupDeleteJob -> { openGroupQueue.send(job) } - is TrimThreadJob, - is BatchMessageReceiveJob -> { - if ((job is BatchMessageReceiveJob && job.fromCommunity != null) - || (job is TrimThreadJob && job.communityAddress != null)) { + is TrimThreadJob -> { + if (job.communityAddress != null) { openGroupQueue.send(job) } else { rxQueue.send(job) @@ -222,8 +218,6 @@ class JobQueue : JobDelegate { AttachmentUploadJob.KEY, AttachmentDownloadJob.KEY, MessageSendJob.KEY, - NotifyPNServerJob.KEY, - BatchMessageReceiveJob.KEY, OpenGroupDeleteJob.KEY, InviteContactsJob.KEY, ) @@ -249,16 +243,6 @@ class JobQueue : JobDelegate { return } - // Batch message receive job, re-queue non-permanently failed jobs - if (job is BatchMessageReceiveJob && job.failureCount <= 0) { - val replacementParameters = job.failures.toList() - if (replacementParameters.isNotEmpty()) { - val newJob = job.recreateWithNewMessages(replacementParameters) - newJob.failureCount = job.failureCount + 1 - add(newJob) - } - } - // Regular job failure job.failureCount += 1 diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt index 1cb9e88bfa..eeec1748ac 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt @@ -22,9 +22,10 @@ import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.Data import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigUpdateNotification +import org.session.libsession.utilities.withGroupConfigs import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.api.error.UnhandledStatusCodeException class MessageSendJob @AssistedInject constructor( @Assisted val message: Message, @@ -102,8 +103,8 @@ class MessageSendJob @AssistedInject constructor( this.handleSuccess(dispatcherName) statusCallback?.trySend(Result.success(Unit)) - } catch (e: HTTP.HTTPRequestFailedException) { - if (e.statusCode == 429) { this.handlePermanentFailure(dispatcherName, e) } + } catch (e: UnhandledStatusCodeException) { + if (e.code == 429) { this.handlePermanentFailure(dispatcherName, e) } else { this.handleFailure(dispatcherName, e) } statusCallback?.trySend(Result.failure(e)) diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt deleted file mode 100644 index 62e2fb94bb..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt +++ /dev/null @@ -1,94 +0,0 @@ -package org.session.libsession.messaging.jobs - -import com.esotericsoftware.kryo.Kryo -import com.esotericsoftware.kryo.io.Input -import com.esotericsoftware.kryo.io.Output -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.Request -import okhttp3.RequestBody -import org.session.libsession.messaging.jobs.Job.Companion.MAX_BUFFER_SIZE_BYTES -import org.session.libsession.messaging.sending_receiving.notifications.Server -import org.session.libsession.messaging.utilities.Data -import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.SnodeMessage -import org.session.libsession.snode.Version -import org.session.libsignal.utilities.JsonUtil -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.retryIfNeeded - -class NotifyPNServerJob(val message: SnodeMessage) : Job { - override var delegate: JobDelegate? = null - override var id: String? = null - override var failureCount: Int = 0 - - override val maxFailureCount: Int = 20 - companion object { - val KEY: String = "NotifyPNServerJob" - - // Keys used for database storage - private val MESSAGE_KEY = "message" - } - - override suspend fun execute(dispatcherName: String) { - val server = Server.LEGACY - val parameters = mapOf( "data" to message.data, "send_to" to message.recipient ) - val url = "${server.url}/notify" - val body = RequestBody.create("application/json".toMediaType(), JsonUtil.toJson(parameters)) - val request = Request.Builder().url(url).post(body).build() - retryIfNeeded(4) { - OnionRequestAPI.sendOnionRequest( - request, - server.url, - server.publicKey, - Version.V2 - ) success { response -> - when (response.code) { - null, 0 -> Log.d("NotifyPNServerJob", "Couldn't notify PN server due to error: ${response.message}.") - } - } fail { exception -> - Log.d("NotifyPNServerJob", "Couldn't notify PN server due to error: $exception.") - } - } success { - handleSuccess(dispatcherName) - } fail { - handleFailure(dispatcherName, it) - } - } - - private fun handleSuccess(dispatcherName: String) { - delegate?.handleJobSucceeded(this, dispatcherName) - } - - private fun handleFailure(dispatcherName: String, error: Exception) { - delegate?.handleJobFailed(this, dispatcherName, error) - } - - override fun serialize(): Data { - val kryo = Kryo() - kryo.isRegistrationRequired = false - val serializedMessage = ByteArray(4096) - val output = Output(serializedMessage, MAX_BUFFER_SIZE_BYTES) - kryo.writeObject(output, message) - output.close() - return Data.Builder() - .putByteArray(MESSAGE_KEY, serializedMessage) - .build(); - } - - override fun getFactoryKey(): String { - return KEY - } - - class DeserializeFactory : Job.DeserializeFactory { - - override fun create(data: Data): NotifyPNServerJob { - val serializedMessage = data.getByteArray(MESSAGE_KEY) - val kryo = Kryo() - kryo.isRegistrationRequired = false - val input = Input(serializedMessage) - val message = kryo.readObject(input, SnodeMessage::class.java) - input.close() - return NotifyPNServerJob(message) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt b/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt index e1780ce19f..663ee8079e 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt @@ -5,7 +5,6 @@ import javax.inject.Inject class SessionJobManagerFactories @Inject constructor( private val attachmentDownloadJobFactory: AttachmentDownloadJob.Factory, private val attachmentUploadJobFactory: AttachmentUploadJob.Factory, - private val batchFactory: BatchMessageReceiveJob.Factory, private val trimThreadFactory: TrimThreadJob.Factory, private val messageSendJobFactory: MessageSendJob.Factory, private val deleteJobFactory: OpenGroupDeleteJob.Factory, @@ -17,9 +16,7 @@ class SessionJobManagerFactories @Inject constructor( AttachmentDownloadJob.KEY to attachmentDownloadJobFactory, AttachmentUploadJob.KEY to attachmentUploadJobFactory, MessageSendJob.KEY to messageSendJobFactory, - NotifyPNServerJob.KEY to NotifyPNServerJob.DeserializeFactory(), TrimThreadJob.KEY to trimThreadFactory, - BatchMessageReceiveJob.KEY to batchFactory, OpenGroupDeleteJob.KEY to deleteJobFactory, InviteContactsJob.KEY to inviteContactsJobFactory, ) diff --git a/app/src/main/java/org/session/libsession/messaging/messages/Destination.kt b/app/src/main/java/org/session/libsession/messaging/messages/Destination.kt index 5ddbad9fa1..3b6c5f3f86 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/Destination.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/Destination.kt @@ -2,6 +2,7 @@ package org.session.libsession.messaging.messages import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.utilities.Address +import org.session.libsession.utilities.withUserConfigs sealed class Destination { diff --git a/app/src/main/java/org/session/libsession/messaging/messages/Message.kt b/app/src/main/java/org/session/libsession/messaging/messages/Message.kt index 38d0c2eedc..311b638b13 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/Message.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/Message.kt @@ -1,5 +1,8 @@ package org.session.libsession.messaging.messages +import network.loki.messenger.libsession_util.protocol.ProFeature +import network.loki.messenger.libsession_util.protocol.ProMessageFeature +import network.loki.messenger.libsession_util.protocol.ProProfileFeature import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.MessagingModuleConfiguration @@ -7,9 +10,11 @@ import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.snode.SnodeMessage import org.session.libsession.utilities.Address -import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType +import org.session.protos.SessionProtos +import org.session.protos.SessionProtos.Content.ExpirationType import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.pro.toProMessageBitSetValue +import org.thoughtcrime.securesms.pro.toProProfileBitSetValue abstract class Message { var id: MessageId? = null // Message ID in the database. Not all messages will be saved to db. @@ -27,6 +32,20 @@ abstract class Message { var expiryMode: ExpiryMode = ExpiryMode.NONE + /** + * The pro features enabled for this message. + * + * Note: + * * When this message is an incoming message, the pro features will only be populated + * if we can prove that the sender has an active pro subscription. + * + * * When this message represents an outgoing message, this property can be populated by + * application code at their wishes but the actual translating to protobuf onto the wired will + * be checked against the current user's pro proof, if no active pro subscription is found, + * the pro features will not be sent in the protobuf messages. + */ + var proFeatures: Set = emptySet() + open val coerceDisappearAfterSendToRead = false open val defaultTtl: Long = SnodeMessage.DEFAULT_TTL @@ -49,17 +68,17 @@ abstract class Message { && recipient != null protected abstract fun buildProto( - builder: SignalServiceProtos.Content.Builder, + builder: SessionProtos.Content.Builder, messageDataProvider: MessageDataProvider ) fun toProto( - builder: SignalServiceProtos.Content.Builder, + builder: SessionProtos.Content.Builder, messageDataProvider: MessageDataProvider ) { // First apply common message data // * Expiry mode - builder.expirationTimerSeconds = expiryMode.expirySeconds.toInt() + builder.expirationTimer = expiryMode.expirySeconds.toInt() builder.expirationType = when (expiryMode) { is ExpiryMode.AfterSend -> ExpirationType.DELETE_AFTER_SEND is ExpiryMode.AfterRead -> ExpirationType.DELETE_AFTER_READ @@ -67,7 +86,20 @@ abstract class Message { } // * Timestamps - builder.setSigTimestampMs(sentTimestamp!!) + builder.setSigTimestamp(sentTimestamp!!) + + // Pro features + if (proFeatures.any { it is ProMessageFeature }) { + builder.proMessageBuilder.setMsgBitset( + proFeatures.toProMessageBitSetValue() + ) + } + + if (proFeatures.any { it is ProProfileFeature }) { + builder.proMessageBuilder.setProfileBitset( + proFeatures.toProProfileBitSetValue() + ) + } // Then ask the subclasses to build their specific proto buildProto(builder, messageDataProvider) @@ -76,8 +108,8 @@ abstract class Message { abstract fun shouldDiscardIfBlocked(): Boolean } -inline fun M.copyExpiration(proto: SignalServiceProtos.Content): M = apply { - (proto.takeIf { it.hasExpirationTimerSeconds() }?.expirationTimerSeconds ?: proto.dataMessage?.expireTimerSeconds)?.let { duration -> +inline fun M.copyExpiration(proto: SessionProtos.Content): M = apply { + proto.takeIf { it.hasExpirationTimer() }?.expirationTimer?.let { duration -> expiryMode = when (proto.expirationType.takeIf { duration > 0 }) { ExpirationType.DELETE_AFTER_SEND -> ExpiryMode.AfterSend(duration.toLong()) ExpirationType.DELETE_AFTER_READ -> ExpiryMode.AfterRead(duration.toLong()) diff --git a/app/src/main/java/org/session/libsession/messaging/messages/ProfileUpdateHandler.kt b/app/src/main/java/org/session/libsession/messaging/messages/ProfileUpdateHandler.kt index 86a6b8ec96..75dc0fab54 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/ProfileUpdateHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/ProfileUpdateHandler.kt @@ -1,6 +1,8 @@ package org.session.libsession.messaging.messages import com.google.protobuf.ByteString +import network.loki.messenger.libsession_util.pro.ProProof +import network.loki.messenger.libsession_util.protocol.DecodedPro import network.loki.messenger.libsession_util.protocol.ProProfileFeature import network.loki.messenger.libsession_util.util.BaseCommunityInfo import network.loki.messenger.libsession_util.util.BitSet @@ -10,9 +12,10 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.updateContact -import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsession.utilities.withMutableUserConfigs import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log +import org.session.protos.SessionProtos import org.thoughtcrime.securesms.database.BlindMappingRepository import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.RecipientSettingsDatabase @@ -77,9 +80,7 @@ class ProfileUpdateHandler @Inject constructor( profilePicture = updates.pic } - if (updates.proFeatures != null) { - proFeatures = updates.proFeatures - } + proFeatures = updates.proFeatures if (updates.profileUpdateTime != null) { profileUpdatedEpochSeconds = updates.profileUpdateTime.toEpochSeconds() @@ -115,9 +116,7 @@ class ProfileUpdateHandler @Inject constructor( c.name = updates.name } - if (updates.proFeatures != null) { - c.proFeatures = updates.proFeatures - } + c.proFeatures = updates.proFeatures if (updates.profileUpdateTime != null) { c.profileUpdatedEpochSeconds = updates.profileUpdateTime.toEpochSeconds() @@ -154,16 +153,15 @@ class ProfileUpdateHandler @Inject constructor( r.copy( name = updates.name ?: r.name, profilePic = updates.pic ?: r.profilePic, - blocksCommunityMessagesRequests = updates.blocksCommunityMessageRequests ?: r.blocksCommunityMessagesRequests, + blocksCommunityMessagesRequests = updates.blocksCommunityMessageRequests, proData = updates.proProof?.let { RecipientSettings.ProData( info = it, - features = updates.proFeatures ?: BitSet() + features = updates.proFeatures, ) }, ) - } else if (updates.blocksCommunityMessageRequests != null && - r.blocksCommunityMessagesRequests != updates.blocksCommunityMessageRequests) { + } else if (r.blocksCommunityMessagesRequests != updates.blocksCommunityMessageRequests) { r.copy(blocksCommunityMessagesRequests = updates.blocksCommunityMessageRequests) } else { r @@ -190,23 +188,18 @@ class ProfileUpdateHandler @Inject constructor( } class Updates private constructor( - // Name to update, must be non-blank if provided. - val name: String? = null, - val pic: UserPic? = null, - val proProof: Conversation.ProProofInfo? = null, - val proFeatures: BitSet? = null, - val blocksCommunityMessageRequests: Boolean? = null, + val name: String?, + val pic: UserPic?, + val proProof: Conversation.ProProofInfo?, + val proFeatures: BitSet, + val blocksCommunityMessageRequests: Boolean, val profileUpdateTime: Instant?, ) { - init { - check(name == null || name.isNotBlank()) { - "Name must be non-blank if provided" - } - } - companion object { - fun create(content: SignalServiceProtos.Content): Updates? { - val profile: SignalServiceProtos.DataMessage.LokiProfile + fun create(content: SessionProtos.Content, + nowMills: Long, + pro: DecodedPro?): Updates? { + val profile: SessionProtos.LokiProfile val profilePicKey: ByteString? when { @@ -248,20 +241,33 @@ class ProfileUpdateHandler @Inject constructor( content.dataMessage.hasBlocksCommunityMessageRequests()) { content.dataMessage.blocksCommunityMessageRequests } else { - null + true } - if (name == null && pic == null && blocksCommunityMessageRequests == null) { - // Nothing is updated.. - return null + val proProofInfo: Conversation.ProProofInfo? + val proFeatures: BitSet + + if (pro?.status == ProProof.STATUS_VALID && + pro.proof != null && + pro.proof!!.expiryMs > nowMills) { + proProofInfo = Conversation.ProProofInfo( + genIndexHash = pro.proof!!.genIndexHashHex.hexToByteArray(), + expiryMs = pro.proof!!.expiryMs, + ) + proFeatures = pro.proProfileFeatures + } else { + proProofInfo = null + proFeatures = BitSet() } return Updates( name = name, pic = pic, blocksCommunityMessageRequests = blocksCommunityMessageRequests, - profileUpdateTime = if (profile.hasLastProfileUpdateSeconds()) { - Instant.ofEpochSecond(profile.lastProfileUpdateSeconds) + proProof = proProofInfo, + proFeatures = proFeatures, + profileUpdateTime = if (profile.hasLastUpdateSeconds()) { + Instant.ofEpochSecond(profile.lastUpdateSeconds) } else { null } diff --git a/app/src/main/java/org/session/libsession/messaging/messages/control/CallMessage.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/CallMessage.kt index f4c9781730..a6c84e2425 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/control/CallMessage.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/control/CallMessage.kt @@ -2,15 +2,15 @@ package org.session.libsession.messaging.messages.control import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.copyExpiration -import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.ANSWER -import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.END_CALL -import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.OFFER -import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.PRE_OFFER +import org.session.protos.SessionProtos +import org.session.protos.SessionProtos.CallMessage.Type.ANSWER +import org.session.protos.SessionProtos.CallMessage.Type.END_CALL +import org.session.protos.SessionProtos.CallMessage.Type.OFFER +import org.session.protos.SessionProtos.CallMessage.Type.PRE_OFFER import java.util.UUID class CallMessage(): ControlMessage() { - var type: SignalServiceProtos.CallMessage.Type? = null + var type: SessionProtos.CallMessage.Type? = null var sdps: List = listOf() var sdpMLineIndexes: List = listOf() var sdpMids: List = listOf() @@ -26,7 +26,7 @@ class CallMessage(): ControlMessage() { override fun isValid(): Boolean = super.isValid() && type != null && callId != null && (sdps.isNotEmpty() || type in listOf(END_CALL, PRE_OFFER)) - constructor(type: SignalServiceProtos.CallMessage.Type, + constructor(type: SessionProtos.CallMessage.Type, sdps: List, sdpMLineIndexes: List, sdpMids: List, @@ -64,7 +64,7 @@ class CallMessage(): ControlMessage() { fun endCall(callId: UUID) = CallMessage(END_CALL, emptyList(), emptyList(), emptyList(), callId) - fun fromProto(proto: SignalServiceProtos.Content): CallMessage? { + fun fromProto(proto: SessionProtos.Content): CallMessage? { val callMessageProto = if (proto.hasCallMessage()) proto.callMessage else return null val type = callMessageProto.type val sdps = callMessageProto.sdpsList @@ -77,7 +77,7 @@ class CallMessage(): ControlMessage() { } protected override fun buildProto( - builder: SignalServiceProtos.Content.Builder, + builder: SessionProtos.Content.Builder, messageDataProvider: MessageDataProvider ) { builder.callMessageBuilder diff --git a/app/src/main/java/org/session/libsession/messaging/messages/control/DataExtractionNotification.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/DataExtractionNotification.kt index 79872cc635..2ee5e1a2f6 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/control/DataExtractionNotification.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/control/DataExtractionNotification.kt @@ -2,7 +2,7 @@ package org.session.libsession.messaging.messages.control import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.copyExpiration -import org.session.libsignal.protos.SignalServiceProtos +import org.session.protos.SessionProtos class DataExtractionNotification() : ControlMessage() { var kind: Kind? = null @@ -25,13 +25,13 @@ class DataExtractionNotification() : ControlMessage() { companion object { const val TAG = "DataExtractionNotification" - fun fromProto(proto: SignalServiceProtos.Content): DataExtractionNotification? { + fun fromProto(proto: SessionProtos.Content): DataExtractionNotification? { if (!proto.hasDataExtractionNotification()) return null val dataExtractionNotification = proto.dataExtractionNotification!! val kind: Kind = when(dataExtractionNotification.type) { - SignalServiceProtos.DataExtractionNotification.Type.SCREENSHOT -> Kind.Screenshot() - SignalServiceProtos.DataExtractionNotification.Type.MEDIA_SAVED -> { - val timestamp = if (dataExtractionNotification.hasTimestampMs()) dataExtractionNotification.timestampMs else return null + SessionProtos.DataExtractionNotification.Type.SCREENSHOT -> Kind.Screenshot() + SessionProtos.DataExtractionNotification.Type.MEDIA_SAVED -> { + val timestamp = if (dataExtractionNotification.hasTimestamp()) dataExtractionNotification.timestamp else return null Kind.MediaSaved(timestamp) } } @@ -53,16 +53,16 @@ class DataExtractionNotification() : ControlMessage() { } } - protected override fun buildProto( - builder: SignalServiceProtos.Content.Builder, + override fun buildProto( + builder: SessionProtos.Content.Builder, messageDataProvider: MessageDataProvider ) { val dataExtractionNotification = builder.dataExtractionNotificationBuilder when (val kind = kind!!) { - is Kind.Screenshot -> dataExtractionNotification.type = SignalServiceProtos.DataExtractionNotification.Type.SCREENSHOT + is Kind.Screenshot -> dataExtractionNotification.type = SessionProtos.DataExtractionNotification.Type.SCREENSHOT is Kind.MediaSaved -> { - dataExtractionNotification.type = SignalServiceProtos.DataExtractionNotification.Type.MEDIA_SAVED - dataExtractionNotification.timestampMs = kind.timestamp + dataExtractionNotification.type = SessionProtos.DataExtractionNotification.Type.MEDIA_SAVED + dataExtractionNotification.timestamp = kind.timestamp } } } diff --git a/app/src/main/java/org/session/libsession/messaging/messages/control/ExpirationTimerUpdate.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/ExpirationTimerUpdate.kt index c7d1f21903..10a7bc5487 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/control/ExpirationTimerUpdate.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/control/ExpirationTimerUpdate.kt @@ -3,8 +3,8 @@ package org.session.libsession.messaging.messages.control import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.copyExpiration -import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.protos.SignalServiceProtos.DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE +import org.session.protos.SessionProtos +import org.session.protos.SessionProtos.DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE /** In the case of a sync message, the public key of the person the message was targeted at. * @@ -19,19 +19,18 @@ data class ExpirationTimerUpdate(var syncTarget: String? = null, val isGroup: Bo const val TAG = "ExpirationTimerUpdate" private val storage = MessagingModuleConfiguration.shared.storage - fun fromProto(proto: SignalServiceProtos.Content, isGroup: Boolean): ExpirationTimerUpdate? = + fun fromProto(proto: SessionProtos.Content, isGroup: Boolean): ExpirationTimerUpdate? = proto.dataMessage?.takeIf { it.flags and EXPIRATION_TIMER_UPDATE_VALUE != 0 }?.run { ExpirationTimerUpdate(takeIf { hasSyncTarget() }?.syncTarget, isGroup).copyExpiration(proto) } } - protected override fun buildProto( - builder: SignalServiceProtos.Content.Builder, + override fun buildProto( + builder: SessionProtos.Content.Builder, messageDataProvider: MessageDataProvider ) { builder.dataMessageBuilder .setFlags(EXPIRATION_TIMER_UPDATE_VALUE) - .setExpireTimerSeconds(expiryMode.expirySeconds.toInt()) .also { builder -> // Sync target syncTarget?.let { builder.syncTarget = it } diff --git a/app/src/main/java/org/session/libsession/messaging/messages/control/GroupUpdated.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/GroupUpdated.kt index 16fa0cba23..ef4f4e848c 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/control/GroupUpdated.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/control/GroupUpdated.kt @@ -1,11 +1,10 @@ package org.session.libsession.messaging.messages.control import org.session.libsession.database.MessageDataProvider -import org.session.libsignal.protos.SignalServiceProtos.Content -import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage +import org.session.protos.SessionProtos class GroupUpdated @JvmOverloads constructor( - val inner: GroupUpdateMessage = GroupUpdateMessage.getDefaultInstance(), + val inner: SessionProtos.GroupUpdateMessage = SessionProtos.GroupUpdateMessage.getDefaultInstance(), ): ControlMessage() { override fun isValid(): Boolean { @@ -20,7 +19,7 @@ class GroupUpdated @JvmOverloads constructor( && !inner.hasInviteResponse() && !inner.hasDeleteMemberContent() companion object { - fun fromProto(message: Content): GroupUpdated? = + fun fromProto(message: SessionProtos.Content): GroupUpdated? = if (message.hasDataMessage() && message.dataMessage.hasGroupUpdateMessage()) GroupUpdated( inner = message.dataMessage.groupUpdateMessage, @@ -28,7 +27,7 @@ class GroupUpdated @JvmOverloads constructor( else null } - override fun buildProto(builder: Content.Builder, messageDataProvider: MessageDataProvider) { + override fun buildProto(builder: SessionProtos.Content.Builder, messageDataProvider: MessageDataProvider) { builder.dataMessageBuilder .setGroupUpdateMessage(inner) .apply { profile?.let(this::setProfile) } diff --git a/app/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt index bb7057b17e..cd122710d4 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt @@ -2,7 +2,7 @@ package org.session.libsession.messaging.messages.control import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.copyExpiration -import org.session.libsignal.protos.SignalServiceProtos +import org.session.protos.SessionProtos class MessageRequestResponse(val isApproved: Boolean) : ControlMessage() { @@ -11,7 +11,7 @@ class MessageRequestResponse(val isApproved: Boolean) : ControlMessage() { override fun shouldDiscardIfBlocked(): Boolean = true override fun buildProto( - builder: SignalServiceProtos.Content.Builder, + builder: SessionProtos.Content.Builder, messageDataProvider: MessageDataProvider ) { builder.messageRequestResponseBuilder @@ -21,7 +21,7 @@ class MessageRequestResponse(val isApproved: Boolean) : ControlMessage() { companion object { const val TAG = "MessageRequestResponse" - fun fromProto(proto: SignalServiceProtos.Content): MessageRequestResponse? { + fun fromProto(proto: SessionProtos.Content): MessageRequestResponse? { val messageRequestResponseProto = if (proto.hasMessageRequestResponse()) proto.messageRequestResponse else return null val isApproved = messageRequestResponseProto.isApproved return MessageRequestResponse(isApproved) diff --git a/app/src/main/java/org/session/libsession/messaging/messages/control/ReadReceipt.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/ReadReceipt.kt index 4c8021e416..3fb6350d22 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/control/ReadReceipt.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/control/ReadReceipt.kt @@ -2,7 +2,7 @@ package org.session.libsession.messaging.messages.control import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.copyExpiration -import org.session.libsignal.protos.SignalServiceProtos +import org.session.protos.SessionProtos class ReadReceipt() : ControlMessage() { var timestamps: List? = null @@ -19,10 +19,10 @@ class ReadReceipt() : ControlMessage() { companion object { const val TAG = "ReadReceipt" - fun fromProto(proto: SignalServiceProtos.Content): ReadReceipt? { + fun fromProto(proto: SessionProtos.Content): ReadReceipt? { val receiptProto = if (proto.hasReceiptMessage()) proto.receiptMessage else return null - if (receiptProto.type != SignalServiceProtos.ReceiptMessage.Type.READ) return null - val timestamps = receiptProto.timestampMsList + if (receiptProto.type != SessionProtos.ReceiptMessage.Type.READ) return null + val timestamps = receiptProto.timestampList if (timestamps.isEmpty()) return null return ReadReceipt(timestamps = timestamps) .copyExpiration(proto) @@ -33,14 +33,14 @@ class ReadReceipt() : ControlMessage() { this.timestamps = timestamps } - protected override fun buildProto( - builder: SignalServiceProtos.Content.Builder, + override fun buildProto( + builder: SessionProtos.Content.Builder, messageDataProvider: MessageDataProvider ) { builder .receiptMessageBuilder - .setType(SignalServiceProtos.ReceiptMessage.Type.READ) - .addAllTimestampMs(requireNotNull(timestamps) { + .setType(SessionProtos.ReceiptMessage.Type.READ) + .addAllTimestamp(requireNotNull(timestamps) { "Timestamps is null" }) } diff --git a/app/src/main/java/org/session/libsession/messaging/messages/control/TypingIndicator.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/TypingIndicator.kt index ab2c17a1f2..ed9c2baf13 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/control/TypingIndicator.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/control/TypingIndicator.kt @@ -2,7 +2,7 @@ package org.session.libsession.messaging.messages.control import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.copyExpiration -import org.session.libsignal.protos.SignalServiceProtos +import org.session.protos.SessionProtos class TypingIndicator() : ControlMessage() { var kind: Kind? = null @@ -19,7 +19,7 @@ class TypingIndicator() : ControlMessage() { companion object { const val TAG = "TypingIndicator" - fun fromProto(proto: SignalServiceProtos.Content): TypingIndicator? { + fun fromProto(proto: SessionProtos.Content): TypingIndicator? { val typingIndicatorProto = if (proto.hasTypingMessage()) proto.typingMessage else return null val kind = Kind.fromProto(typingIndicatorProto.action) return TypingIndicator(kind = kind) @@ -32,17 +32,17 @@ class TypingIndicator() : ControlMessage() { companion object { @JvmStatic - fun fromProto(proto: SignalServiceProtos.TypingMessage.Action): Kind = + fun fromProto(proto: SessionProtos.TypingMessage.Action): Kind = when (proto) { - SignalServiceProtos.TypingMessage.Action.STARTED -> STARTED - SignalServiceProtos.TypingMessage.Action.STOPPED -> STOPPED + SessionProtos.TypingMessage.Action.STARTED -> STARTED + SessionProtos.TypingMessage.Action.STOPPED -> STOPPED } } - fun toProto(): SignalServiceProtos.TypingMessage.Action { + fun toProto(): SessionProtos.TypingMessage.Action { when (this) { - STARTED -> return SignalServiceProtos.TypingMessage.Action.STARTED - STOPPED -> return SignalServiceProtos.TypingMessage.Action.STOPPED + STARTED -> return SessionProtos.TypingMessage.Action.STARTED + STOPPED -> return SessionProtos.TypingMessage.Action.STOPPED } } } @@ -51,12 +51,12 @@ class TypingIndicator() : ControlMessage() { this.kind = kind } - protected override fun buildProto( - builder: SignalServiceProtos.Content.Builder, + override fun buildProto( + builder: SessionProtos.Content.Builder, messageDataProvider: MessageDataProvider ) { builder.typingMessageBuilder - .setTimestampMs(sentTimestamp!!) + .setTimestamp(sentTimestamp!!) .setAction(kind!!.toProto()) } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/messages/control/UnsendRequest.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/UnsendRequest.kt index 38f5d7d34b..81dddb8467 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/control/UnsendRequest.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/control/UnsendRequest.kt @@ -2,7 +2,7 @@ package org.session.libsession.messaging.messages.control import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.copyExpiration -import org.session.libsignal.protos.SignalServiceProtos +import org.session.protos.SessionProtos class UnsendRequest(var timestamp: Long? = null, var author: String? = null): ControlMessage() { @@ -20,16 +20,16 @@ class UnsendRequest(var timestamp: Long? = null, var author: String? = null): Co companion object { const val TAG = "UnsendRequest" - fun fromProto(proto: SignalServiceProtos.Content): UnsendRequest? = - proto.takeIf { it.hasUnsendRequest() }?.unsendRequest?.run { UnsendRequest(timestampMs, author) }?.copyExpiration(proto) + fun fromProto(proto: SessionProtos.Content): UnsendRequest? = + proto.takeIf { it.hasUnsendRequest() }?.unsendRequest?.run { UnsendRequest(timestamp, author) }?.copyExpiration(proto) } - protected override fun buildProto( - builder: SignalServiceProtos.Content.Builder, + override fun buildProto( + builder: SessionProtos.Content.Builder, messageDataProvider: MessageDataProvider ) { builder.unsendRequestBuilder - .setTimestampMs(timestamp!!) + .setTimestamp(timestamp!!) .setAuthor(author!!) } diff --git a/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.kt b/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.kt index b7183cd8c8..830a656058 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.kt @@ -48,6 +48,7 @@ class OutgoingMediaMessage( linkPreviews = linkPreview?.let { listOf(it) } ?: emptyList(), group = null, isGroupUpdateMessage = false, + proFeatures = message.proFeatures ) constructor( diff --git a/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.kt b/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.kt index 912da3d5c7..eab343b176 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.kt @@ -6,14 +6,14 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.utilities.Address -data class OutgoingTextMessage( +data class OutgoingTextMessage private constructor( val recipient: Address, val message: String?, val expiresInMillis: Long, val expireStartedAtMillis: Long, val sentTimestampMillis: Long, val isOpenGroupInvitation: Boolean, - val proFeatures: Set = emptySet() + val proFeatures: Set ) { constructor( message: VisibleMessage, @@ -27,6 +27,7 @@ data class OutgoingTextMessage( expireStartedAtMillis = expireStartedAtMillis, sentTimestampMillis = message.sentTimestamp!!, isOpenGroupInvitation = false, + proFeatures = message.proFeatures ) companion object { @@ -36,7 +37,8 @@ data class OutgoingTextMessage( sentTimestampMillis: Long, expiresInMillis: Long, expireStartedAtMillis: Long, - ): OutgoingTextMessage? { + proFeatures: Set, + ): OutgoingTextMessage? { return OutgoingTextMessage( recipient = recipient, message = UpdateMessageData.buildOpenGroupInvitation( @@ -47,6 +49,7 @@ data class OutgoingTextMessage( expireStartedAtMillis = expireStartedAtMillis, sentTimestampMillis = sentTimestampMillis, isOpenGroupInvitation = true, + proFeatures = proFeatures ) } } diff --git a/app/src/main/java/org/session/libsession/messaging/messages/visible/Attachment.kt b/app/src/main/java/org/session/libsession/messaging/messages/visible/Attachment.kt index d7a2cb82dc..cfb1c2f1b2 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/visible/Attachment.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/visible/Attachment.kt @@ -6,7 +6,7 @@ import android.webkit.MimeTypeMap import com.google.protobuf.ByteString import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment import org.session.libsignal.messages.SignalServiceAttachmentPointer -import org.session.libsignal.protos.SignalServiceProtos +import org.session.protos.SessionProtos import org.session.libsignal.utilities.guava.Optional import java.io.File import org.session.libsession.messaging.sending_receiving.attachments.Attachment as SignalAttachment @@ -24,7 +24,7 @@ class Attachment { companion object { - fun fromProto(proto: SignalServiceProtos.AttachmentPointer): Attachment { + fun fromProto(proto: SessionProtos.AttachmentPointer): Attachment { val result = Attachment() // Note: For legacy Session Android clients this filename will be null and we'll synthesise an appropriate filename @@ -49,7 +49,7 @@ class Attachment { result.key = proto.key.toByteArray() result.digest = proto.digest.toByteArray() - val kind: Kind = if (proto.hasFlags() && proto.flags.and(SignalServiceProtos.AttachmentPointer.Flags.VOICE_MESSAGE_VALUE) > 0) { + val kind: Kind = if (proto.hasFlags() && proto.flags.and(SessionProtos.AttachmentPointer.Flags.VOICE_MESSAGE_VALUE) > 0) { Kind.VOICE_MESSAGE } else { Kind.GENERIC @@ -68,8 +68,8 @@ class Attachment { return result } - fun createAttachmentPointer(attachment: SignalServiceAttachmentPointer): SignalServiceProtos.AttachmentPointer? { - val builder = SignalServiceProtos.AttachmentPointer.newBuilder() + fun createAttachmentPointer(attachment: SignalServiceAttachmentPointer): SessionProtos.AttachmentPointer? { + val builder = SessionProtos.AttachmentPointer.newBuilder() .setContentType(attachment.contentType) .setId(attachment.id.toString().toLongOrNull() ?: 0L) .setKey(ByteString.copyFrom(attachment.key)) @@ -89,7 +89,7 @@ class Attachment { if (attachment.preview.isPresent) { builder.thumbnail = ByteString.copyFrom(attachment.preview.get()) } if (attachment.width > 0) { builder.width = attachment.width } if (attachment.height > 0) { builder.height = attachment.height } - if (attachment.voiceNote) { builder.flags = SignalServiceProtos.AttachmentPointer.Flags.VOICE_MESSAGE_VALUE } + if (attachment.voiceNote) { builder.flags = SessionProtos.AttachmentPointer.Flags.VOICE_MESSAGE_VALUE } if (attachment.caption.isPresent) { builder.caption = attachment.caption.get() } return builder.build() @@ -106,7 +106,7 @@ class Attachment { return (contentType != null && kind != null && size != null && sizeInBytes != null && url != null) } - fun toProto(): SignalServiceProtos.AttachmentPointer? { + fun toProto(): SessionProtos.AttachmentPointer? { TODO("Not implemented") } diff --git a/app/src/main/java/org/session/libsession/messaging/messages/visible/LinkPreview.kt b/app/src/main/java/org/session/libsession/messaging/messages/visible/LinkPreview.kt index 6187c9a9e3..263266e267 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/visible/LinkPreview.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/visible/LinkPreview.kt @@ -3,7 +3,7 @@ package org.session.libsession.messaging.messages.visible import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview as SignalLinkPreiview import org.session.libsignal.utilities.Log -import org.session.libsignal.protos.SignalServiceProtos +import org.session.protos.SessionProtos class LinkPreview() { var title: String? = null @@ -17,7 +17,7 @@ class LinkPreview() { companion object { const val TAG = "LinkPreview" - fun fromProto(proto: SignalServiceProtos.DataMessage.Preview): LinkPreview? { + fun fromProto(proto: SessionProtos.DataMessage.Preview): LinkPreview? { val title = proto.title val url = proto.url return LinkPreview(title, url, null) @@ -35,13 +35,13 @@ class LinkPreview() { this.attachmentID = attachmentID } - fun toProto(): SignalServiceProtos.DataMessage.Preview? { + fun toProto(): SessionProtos.DataMessage.Preview? { val url = url if (url == null) { Log.w(TAG, "Couldn't construct link preview proto from: $this") return null } - val linkPreviewProto = SignalServiceProtos.DataMessage.Preview.newBuilder() + val linkPreviewProto = SessionProtos.DataMessage.Preview.newBuilder() linkPreviewProto.url = url title?.let { linkPreviewProto.title = it } val database = MessagingModuleConfiguration.shared.messageDataProvider diff --git a/app/src/main/java/org/session/libsession/messaging/messages/visible/OpenGroupInvitation.kt b/app/src/main/java/org/session/libsession/messaging/messages/visible/OpenGroupInvitation.kt index 71faa53769..2f86f2d51d 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/visible/OpenGroupInvitation.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/visible/OpenGroupInvitation.kt @@ -1,6 +1,6 @@ package org.session.libsession.messaging.messages.visible -import org.session.libsignal.protos.SignalServiceProtos +import org.session.protos.SessionProtos import org.session.libsignal.utilities.Log class OpenGroupInvitation() { @@ -14,7 +14,7 @@ class OpenGroupInvitation() { companion object { const val TAG = "OpenGroupInvitation" - fun fromProto(proto: SignalServiceProtos.DataMessage.OpenGroupInvitation): OpenGroupInvitation { + fun fromProto(proto: SessionProtos.DataMessage.OpenGroupInvitation): OpenGroupInvitation { return OpenGroupInvitation(proto.url, proto.name) } } @@ -24,8 +24,8 @@ class OpenGroupInvitation() { this.name = serverName } - fun toProto(): SignalServiceProtos.DataMessage.OpenGroupInvitation? { - val openGroupInvitationProto = SignalServiceProtos.DataMessage.OpenGroupInvitation.newBuilder() + fun toProto(): SessionProtos.DataMessage.OpenGroupInvitation? { + val openGroupInvitationProto = SessionProtos.DataMessage.OpenGroupInvitation.newBuilder() openGroupInvitationProto.url = url openGroupInvitationProto.name = name return try { diff --git a/app/src/main/java/org/session/libsession/messaging/messages/visible/ParsedMessage.kt b/app/src/main/java/org/session/libsession/messaging/messages/visible/ParsedMessage.kt deleted file mode 100644 index b0c5ced904..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/messages/visible/ParsedMessage.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.session.libsession.messaging.messages.visible - -import org.session.libsession.messaging.jobs.MessageReceiveParameters -import org.session.libsession.messaging.messages.Message -import org.session.libsignal.protos.SignalServiceProtos - -data class ParsedMessage( - val parameters: MessageReceiveParameters, - val message: Message, - val proto: SignalServiceProtos.Content -) \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/messages/visible/Quote.kt b/app/src/main/java/org/session/libsession/messaging/messages/visible/Quote.kt index 0b247c59cf..8b26ebc25e 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/visible/Quote.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/visible/Quote.kt @@ -3,7 +3,7 @@ package org.session.libsession.messaging.messages.visible import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel as SignalQuote -import org.session.libsignal.protos.SignalServiceProtos +import org.session.protos.SessionProtos import org.session.libsignal.utilities.Log class Quote() { @@ -17,7 +17,7 @@ class Quote() { companion object { const val TAG = "Quote" - fun fromProto(proto: SignalServiceProtos.DataMessage.Quote): Quote? { + fun fromProto(proto: SessionProtos.DataMessage.Quote): Quote? { val timestamp = proto.id val publicKey = proto.author val text = proto.text @@ -38,14 +38,14 @@ class Quote() { this.attachmentID = attachmentID } - fun toProto(): SignalServiceProtos.DataMessage.Quote? { + fun toProto(): SessionProtos.DataMessage.Quote? { val timestamp = timestamp val publicKey = publicKey if (timestamp == null || publicKey == null) { Log.w(TAG, "Couldn't construct quote proto from: $this") return null } - val quoteProto = SignalServiceProtos.DataMessage.Quote.newBuilder() + val quoteProto = SessionProtos.DataMessage.Quote.newBuilder() quoteProto.id = timestamp quoteProto.author = publicKey text?.let { quoteProto.text = it } @@ -60,7 +60,7 @@ class Quote() { } } - private fun addAttachmentsIfNeeded(quoteProto: SignalServiceProtos.DataMessage.Quote.Builder) { + private fun addAttachmentsIfNeeded(quoteProto: SessionProtos.DataMessage.Quote.Builder) { val attachmentID = attachmentID ?: return Log.w(TAG, "Cannot add attachment with null attachmentID - bailing.") val database = MessagingModuleConfiguration.shared.messageDataProvider @@ -72,7 +72,7 @@ class Quote() { return Log.w(TAG,"Cannot send a message before all associated attachments have been uploaded - bailing.") } - val quotedAttachmentProto = SignalServiceProtos.DataMessage.Quote.QuotedAttachment.newBuilder() + val quotedAttachmentProto = SessionProtos.DataMessage.Quote.QuotedAttachment.newBuilder() quotedAttachmentProto.contentType = pointer.contentType quotedAttachmentProto.fileName = pointer.filename quotedAttachmentProto.thumbnail = Attachment.createAttachmentPointer(pointer) diff --git a/app/src/main/java/org/session/libsession/messaging/messages/visible/Reaction.kt b/app/src/main/java/org/session/libsession/messaging/messages/visible/Reaction.kt index 6e6327b248..2277fe8b1b 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/visible/Reaction.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/visible/Reaction.kt @@ -1,7 +1,7 @@ package org.session.libsession.messaging.messages.visible -import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.protos.SignalServiceProtos.DataMessage.Reaction.Action +import org.session.protos.SessionProtos +import org.session.protos.SessionProtos.DataMessage.Reaction.Action import org.session.libsignal.utilities.Log class Reaction() { @@ -22,7 +22,7 @@ class Reaction() { companion object { const val TAG = "Quote" - fun fromProto(proto: SignalServiceProtos.DataMessage.Reaction): Reaction { + fun fromProto(proto: SessionProtos.DataMessage.Reaction): Reaction { val react = proto.action == Action.REACT return Reaction(publicKey = proto.author, emoji = proto.emoji, react = react, timestamp = proto.id, count = 1) } @@ -42,7 +42,7 @@ class Reaction() { this.index = index } - fun toProto(): SignalServiceProtos.DataMessage.Reaction? { + fun toProto(): SessionProtos.DataMessage.Reaction? { val timestamp = timestamp val publicKey = publicKey val emoji = emoji @@ -51,7 +51,7 @@ class Reaction() { Log.w(TAG, "Couldn't construct reaction proto from: $this") return null } - val reactionProto = SignalServiceProtos.DataMessage.Reaction.newBuilder() + val reactionProto = SessionProtos.DataMessage.Reaction.newBuilder() reactionProto.id = timestamp reactionProto.author = publicKey reactionProto.emoji = emoji diff --git a/app/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt b/app/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt index 410d244885..26fe22bc9c 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt @@ -9,7 +9,7 @@ import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.copyExpiration import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment -import org.session.libsignal.protos.SignalServiceProtos +import org.session.protos.SessionProtos import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.pro.toProMessageBitSetValue import org.thoughtcrime.securesms.pro.toProProfileBitSetValue @@ -30,12 +30,11 @@ data class VisibleMessage( var reaction: Reaction? = null, var hasMention: Boolean = false, var blocksMessageRequests: Boolean = false, - var proFeatures: Set = emptySet() ) : Message() { // This empty constructor is needed for kryo serialization @Keep - constructor(): this(proFeatures = emptySet()) + constructor(): this(text = null) override val isSelfSendValid: Boolean = true @@ -56,7 +55,7 @@ data class VisibleMessage( companion object { const val TAG = "VisibleMessage" - fun fromProto(proto: SignalServiceProtos.Content): VisibleMessage? = + fun fromProto(proto: SessionProtos.Content): VisibleMessage? = proto.dataMessage?.let { VisibleMessage().apply { if (it.hasSyncTarget()) syncTarget = it.syncTarget text = it.body @@ -71,7 +70,7 @@ data class VisibleMessage( } protected override fun buildProto( - builder: SignalServiceProtos.Content.Builder, + builder: SessionProtos.Content.Builder, messageDataProvider: MessageDataProvider ) { val dataMessage = builder.dataMessageBuilder @@ -115,19 +114,6 @@ data class VisibleMessage( if (syncTarget != null) { dataMessage.syncTarget = syncTarget } - - // Pro features - if (proFeatures.any { it is ProMessageFeature }) { - builder.proMessageBuilder.setMsgBitset( - proFeatures.toProMessageBitSetValue() - ) - } - - if (proFeatures.any { it is ProProfileFeature }) { - builder.proMessageBuilder.setProfileBitset( - proFeatures.toProProfileBitSetValue() - ) - } } // endregion diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/CommunityModule.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/CommunityModule.kt new file mode 100644 index 0000000000..0dedf4115f --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/CommunityModule.kt @@ -0,0 +1,36 @@ +package org.session.libsession.messaging.open_groups + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import org.session.libsession.messaging.open_groups.api.CommunityApiBatcher +import org.session.libsession.messaging.open_groups.api.CommunityApiExecutor +import org.session.libsession.messaging.open_groups.api.CommunityApiExecutorImpl +import org.thoughtcrime.securesms.api.AutoRetryApiExecutor +import org.thoughtcrime.securesms.api.batch.BatchApiExecutor +import org.thoughtcrime.securesms.api.server.ServerApiExecutor +import org.thoughtcrime.securesms.dependencies.ManagerScope +import javax.inject.Named +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class CommunityModule { + @Provides + @Singleton + fun provideCommunityApiExecutor( + executor: CommunityApiExecutorImpl, + batcher: CommunityApiBatcher, + @ManagerScope scope: CoroutineScope, + ): CommunityApiExecutor { + return AutoRetryApiExecutor( + actualExecutor = BatchApiExecutor( + actualExecutor = executor, + batcher = batcher, + scope = scope + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/Endpoint.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/Endpoint.kt deleted file mode 100644 index e50fe293e1..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/Endpoint.kt +++ /dev/null @@ -1,81 +0,0 @@ -package org.session.libsession.messaging.open_groups - -sealed class Endpoint(val value: String) { - - object Onion : Endpoint("oxen/v4/lsrpc") - object Batch : Endpoint("batch") - object Sequence : Endpoint("sequence") - object Capabilities : Endpoint("capabilities") - - // Rooms - - object Rooms : Endpoint("rooms") - data class Room(val roomToken: String) : Endpoint("room/$roomToken") - data class RoomPollInfo(val roomToken: String, val infoUpdated: Int) : - Endpoint("room/$roomToken/pollInfo/$infoUpdated") - - // Messages - - data class RoomMessage(val roomToken: String) : Endpoint("room/$roomToken/message") - - data class RoomMessageIndividual(val roomToken: String, val messageId: Long) : - Endpoint("room/$roomToken/message/$messageId") - - data class RoomMessagesRecent(val roomToken: String) : - Endpoint("room/$roomToken/messages/recent") - - data class RoomMessagesBefore(val roomToken: String, val messageId: Long) : - Endpoint("room/$roomToken/messages/before/$messageId") - - data class RoomMessagesSince(val roomToken: String, val seqNo: Long) : - Endpoint("room/$roomToken/messages/since/$seqNo") - - data class RoomDeleteMessages(val roomToken: String, val accountId: String) : - Endpoint("room/$roomToken/all/$accountId") - - data class Reactors(val roomToken: String, val messageId: Long, val emoji: String): - Endpoint("room/$roomToken/reactors/$messageId/$emoji") - - data class Reaction(val roomToken: String, val messageId: Long, val emoji: String): - Endpoint("room/$roomToken/reaction/$messageId/$emoji") - - data class ReactionDelete(val roomToken: String, val messageId: Long, val emoji: String): - Endpoint("room/$roomToken/reactions/$messageId/$emoji") - - // Pinning - - data class RoomPinMessage(val roomToken: String, val messageId: Long) : - Endpoint("room/$roomToken/pin/$messageId") - - data class RoomUnpinMessage(val roomToken: String, val messageId: Long) : - Endpoint("room/$roomToken/unpin/$messageId") - - data class RoomUnpinAll(val roomToken: String) : Endpoint("room/$roomToken/unpin/all") - - // Files - - object File: Endpoint("file") - data class FileIndividual(val fileId: Long): Endpoint("file/$fileId") - - data class RoomFile(val roomToken: String) : Endpoint("room/$roomToken/file") - data class RoomFileIndividual( - val roomToken: String, - val fileId: String - ) : Endpoint("room/$roomToken/file/$fileId") - - // Inbox/Outbox (Message Requests) - - object Inbox : Endpoint("inbox") - data class InboxSince(val id: Long) : Endpoint("inbox/since/$id") - data class InboxFor(val accountId: String) : Endpoint("inbox/$accountId") - - object Outbox : Endpoint("outbox") - data class OutboxSince(val id: Long) : Endpoint("outbox/since/$id") - - // Users - - data class UserBan(val accountId: String) : Endpoint("user/$accountId/ban") - data class UserUnban(val accountId: String) : Endpoint("user/$accountId/unban") - data class UserModerator(val accountId: String) : Endpoint("user/$accountId/moderator") - -} diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OfficialCommunityRepository.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OfficialCommunityRepository.kt new file mode 100644 index 0000000000..8f17a7a2e1 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OfficialCommunityRepository.kt @@ -0,0 +1,123 @@ +package org.session.libsession.messaging.open_groups + +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.shareIn +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.open_groups.api.CommunityApiExecutor +import org.session.libsession.messaging.open_groups.api.CommunityApiRequest +import org.session.libsession.messaging.open_groups.api.CommunityFileDownloadApi +import org.session.libsession.messaging.open_groups.api.GetCapsApi +import org.session.libsession.messaging.open_groups.api.GetRoomsApi +import org.session.libsession.messaging.open_groups.api.execute +import org.thoughtcrime.securesms.dependencies.ManagerScope +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +class OfficialCommunityRepository @Inject constructor( + storage: StorageProtocol, + communityApiExecutor: CommunityApiExecutor, + getRoomsApiFactory: GetRoomsApi.Factory, + getCapsApi: Provider, + communityFileDownloadApiFactory: CommunityFileDownloadApi.Factory, + @ManagerScope scope: CoroutineScope, +) { + private val refreshTrigger = MutableSharedFlow() + + @Suppress("OPT_IN_USAGE") + private val officialCommunitiesCache = + refreshTrigger + .onStart { emit(Unit) } + .flatMapLatest { + flow { + emit(runCatching { + coroutineScope { + val roomsDeferred = async { + communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = OFFICIAL_COMMUNITY_URL, + serverPubKey = OFFICIAL_COMMUNITY_X25519_PUB_KEY_HEX, + api = getRoomsApiFactory.create(requiresSigning = false) + ) + ) + } + + val capsDeferred = async { + communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = OFFICIAL_COMMUNITY_URL, + serverPubKey = OFFICIAL_COMMUNITY_X25519_PUB_KEY_HEX, + api = getCapsApi.get(), + ) + ) + } + + + val rooms = roomsDeferred.await() + val roomAvatars = rooms.associate { room -> + room.token to room.imageId?.let { fileId -> + try { + communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = OFFICIAL_COMMUNITY_URL, + serverPubKey = OFFICIAL_COMMUNITY_X25519_PUB_KEY_HEX, + api = communityFileDownloadApiFactory.create( + room = room.token, + fileId = fileId, + requiresSigning = false, + ) + ) + ).toByteArraySlice() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Failed to download official community room avatar for room ${room.token}", e) + null + } + } + } + + storage.setServerCapabilities(OFFICIAL_COMMUNITY_URL, capsDeferred.await().capabilities) + + rooms.map { room -> + OpenGroupApi.DefaultGroup( + serverUrl = OFFICIAL_COMMUNITY_URL, + id = room.token, + name = room.name, + image = roomAvatars[room.token], + publicKey = OFFICIAL_COMMUNITY_X25519_PUB_KEY_HEX + ) + } + } + }) + } + } + .shareIn(scope, SharingStarted.Lazily) + + suspend fun fetchOfficialCommunities(): List { + if (officialCommunitiesCache.replayCache.firstOrNull()?.isFailure == true) { + refreshTrigger.emit(Unit) + } + + return officialCommunitiesCache.first().getOrThrow() + } + + companion object { + const val OFFICIAL_COMMUNITY_URL = "https://open.getsession.org" + const val OFFICIAL_COMMUNITY_URL_INSECURE = "http://open.getsession.org" + private const val OFFICIAL_COMMUNITY_X25519_PUB_KEY_HEX = "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238" + + private const val TAG = "OfficialCommunityRepo" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index 3d650db223..2627546900 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -1,68 +1,19 @@ package org.session.libsession.messaging.open_groups -import com.fasterxml.jackson.annotation.JsonInclude -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.PropertyNamingStrategy import com.fasterxml.jackson.databind.annotation.JsonNaming -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.json.decodeFromStream -import network.loki.messenger.libsession_util.ED25519 -import network.loki.messenger.libsession_util.Hash -import network.loki.messenger.libsession_util.util.BlindKeyAPI -import okhttp3.Headers.Companion.toHeaders -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.RequestBody -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.OnionResponse -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.utilities.await -import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.Base64.encodeBytes +import org.session.libsession.utilities.serializable.InstantAsSecondDoubleSerializer import org.session.libsignal.utilities.ByteArraySlice -import org.session.libsignal.utilities.HTTP -import org.session.libsignal.utilities.HTTP.Verb.DELETE -import org.session.libsignal.utilities.HTTP.Verb.GET -import org.session.libsignal.utilities.HTTP.Verb.POST -import org.session.libsignal.utilities.HTTP.Verb.PUT -import org.session.libsignal.utilities.Hex -import org.session.libsignal.utilities.IdPrefix -import org.session.libsignal.utilities.JsonUtil -import org.session.libsignal.utilities.Log -import java.security.SecureRandom -import java.util.concurrent.TimeUnit +import java.time.Instant object OpenGroupApi { - val defaultRooms = MutableSharedFlow>(replay = 1) - const val defaultServerPublicKey = "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238" const val legacyServerIP = "116.203.70.33" - const val legacyDefaultServer = "http://116.203.70.33" // TODO: migrate all references to use new value - /** For migration purposes only, don't use this value in joining groups */ - const val httpDefaultServer = "http://open.getsession.org" + data class DefaultGroup(val serverUrl: String, val publicKey: String, val id: String, val name: String, val image: ByteArraySlice?) { - const val defaultServer = "https://open.getsession.org" - - val pendingReactions = mutableListOf() - - sealed class Error(message: String) : Exception(message) { - object Generic : Error("An error occurred.") - object ParsingFailed : Error("Invalid response.") - object DecryptionFailed : Error("Couldn't decrypt response.") - object SigningFailed : Error("Couldn't sign message.") - object InvalidURL : Error("Invalid URL.") - object NoPublicKey : Error("Couldn't find server public key.") - object NoEd25519KeyPair : Error("Couldn't find ed25519 key pair.") - } - - data class DefaultGroup(val id: String, val name: String, val image: ByteArraySlice?) { - - val joinURL: String get() = "$defaultServer/$id?public_key=$defaultServerPublicKey" + val joinURL: String get() = "$serverUrl/$id?public_key=$publicKey" } @Serializable @@ -74,7 +25,8 @@ object OpenGroupApi { val infoUpdates: Int = 0, @SerialName("message_sequence") val messageSequence: Long = 0, - val created: Double = 0.0, + @Serializable(with = InstantAsSecondDoubleSerializer::class) + val created: Instant? = null, @SerialName("active_users") val activeUsers: Int = 0, @SerialName("active_users_cutoff") @@ -117,30 +69,7 @@ object OpenGroupApi { val pinnedBy: String = "" ) - data class BatchRequestInfo( - val request: BatchRequest, - val endpoint: Endpoint, - val queryParameters: Map = mapOf(), - val responseType: TypeReference? - ) - - @JsonInclude(JsonInclude.Include.NON_NULL) - data class BatchRequest( - val method: HTTP.Verb, - val path: String, - val headers: Map = emptyMap(), - val json: Map? = null, - val b64: String? = null, - val bytes: ByteArray? = null, - ) - - data class BatchResponse( - val endpoint: Endpoint, - val code: Int, - val headers: Map, - val body: T? - ) - + @Serializable data class Capabilities( val capabilities: List = emptyList(), val missing: List = emptyList() @@ -176,31 +105,44 @@ object OpenGroupApi { ) @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) + @Serializable data class DirectMessage( val id: Long = 0, val sender: String = "", val recipient: String = "", - val postedAt: Long = 0, - val expiresAt: Long = 0, + @SerialName("posted_at") + @Serializable(with = InstantAsSecondDoubleSerializer::class) + val postedAt: Instant? = null, + @SerialName("expires_at") + @Serializable(with = InstantAsSecondDoubleSerializer::class) + val expiresAt: Instant? = null, val message: String = "", ) @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) + @Serializable data class Message( val id : Long = 0, + @SerialName("session_id") val sessionId: String = "", - val posted: Double = 0.0, - val edited: Long = 0, + @Serializable(with = InstantAsSecondDoubleSerializer::class) + val posted: Instant? = null, + @Serializable(with = InstantAsSecondDoubleSerializer::class) + val edited: Instant? = null, val seqno: Long = 0, val deleted: Boolean = false, val whisper: Boolean = false, + @SerialName("whisper_mods") val whisperMods: String = "", + + @SerialName("whisper_to") val whisperTo: String = "", val data: String? = null, val signature: String? = null, val reactions: Map? = null, ) + @Serializable data class Reaction( val count: Long = 0, val reactors: List = emptyList(), @@ -215,527 +157,4 @@ object OpenGroupApi { val added: Boolean ) - @Serializable - data class DeleteReactionResponse( - @SerialName("seqno") - val seqNo: Long, - val removed: Boolean - ) - - data class DeleteAllReactionsResponse( - val seqNo: Long, - val removed: Boolean - ) - - data class PendingReaction( - val server: String, - val room: String, - val messageId: Long, - val emoji: String, - val add: Boolean, - var seqNo: Long? = null - ) - - @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) - data class SendMessageRequest( - val data: String? = null, - val signature: String? = null, - val whisperTo: List? = null, - val whisperMods: Boolean? = null, - val files: List? = null - ) - - data class MessageDeletion( - @JsonProperty("id") - val id: Long = 0, - @JsonProperty("deleted_message_id") - val deletedMessageServerID: Long = 0 - ) { - - companion object { - val empty = MessageDeletion() - } - } - - data class Request( - val verb: HTTP.Verb, - val room: String?, - val server: String, - val endpoint: Endpoint, - val queryParameters: Map = mapOf(), - val parameters: Any? = null, - val headers: Map = mapOf(), - val body: ByteArray? = null, - /** - * Always `true` under normal circumstances. You might want to disable - * this when running over Lokinet. - */ - val useOnionRouting: Boolean = true - ) - - private fun createBody(body: ByteArray?, parameters: Any?): RequestBody? { - if (body != null) return RequestBody.create("application/octet-stream".toMediaType(), body) - if (parameters == null) return null - val parametersAsJSON = JsonUtil.toJson(parameters) - return RequestBody.create("application/json".toMediaType(), parametersAsJSON) - } - - private suspend fun getResponseBody( - request: Request, - signRequest: Boolean = true, - serverPubKeyHex: String? = null - ): ByteArraySlice { - val response = send(request, signRequest = signRequest, serverPubKeyHex = serverPubKeyHex) - - return response.body ?: throw Error.ParsingFailed - } - - private suspend fun getResponseBodyJson( - request: Request, - signRequest: Boolean = true, - serverPubKeyHex: String? = null - ): Map<*, *> { - val response = send(request, signRequest = signRequest, serverPubKeyHex = serverPubKeyHex) - return JsonUtil.fromJson(response.body, Map::class.java) - } - - suspend fun getOrFetchServerCapabilities(server: String): List { - val storage = MessagingModuleConfiguration.shared.storage - val caps = storage.getServerCapabilities(server) - - if (caps != null) { - return caps - } - - val fetched = getCapabilities(server, - serverPubKeyHex = defaultServerPublicKey.takeIf { server == defaultServer } - ) - - storage.setServerCapabilities(server, fetched.capabilities) - return fetched.capabilities - } - - private suspend fun send(request: Request, signRequest: Boolean, serverPubKeyHex: String? = null): OnionResponse { - request.server.toHttpUrlOrNull() ?: throw(Error.InvalidURL) - - val urlBuilder = StringBuilder("${request.server}/${request.endpoint.value}") - if (request.verb == GET && request.queryParameters.isNotEmpty()) { - urlBuilder.append("?") - for ((key, value) in request.queryParameters) { - urlBuilder.append("$key=$value") - } - } - - val serverPublicKey = serverPubKeyHex - ?: MessagingModuleConfiguration.shared.storage.getOpenGroupPublicKey(request.server) - ?: throw Error.NoPublicKey - val urlRequest = urlBuilder.toString() - - val headers = if (signRequest) { - val serverCapabilities = getOrFetchServerCapabilities(request.server) - - val ed25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() - ?: throw Error.NoEd25519KeyPair - - val headers = request.headers.toMutableMap() - val nonce = ByteArray(16).also { SecureRandom().nextBytes(it) } - val timestamp = TimeUnit.MILLISECONDS.toSeconds(SnodeAPI.nowWithOffset) - val bodyHash = if (request.parameters != null) { - val parameterBytes = JsonUtil.toJson(request.parameters).toByteArray() - Hash.hash64(parameterBytes) - } else if (request.body != null) { - Hash.hash64(request.body) - } else { - byteArrayOf() - } - - val messageBytes = Hex.fromStringCondensed(serverPublicKey) - .plus(nonce) - .plus("$timestamp".toByteArray(Charsets.US_ASCII)) - .plus(request.verb.rawValue.toByteArray()) - .plus("/${request.endpoint.value}".toByteArray()) - .plus(bodyHash) - - val signature: ByteArray - val pubKey: String - - if (serverCapabilities.isEmpty() || serverCapabilities.contains(Capability.BLIND.name.lowercase())) { - pubKey = AccountId( - IdPrefix.BLINDED, - BlindKeyAPI.blind15KeyPair( - ed25519SecretKey = ed25519KeyPair.secretKey.data, - serverPubKey = Hex.fromStringCondensed(serverPublicKey) - ).pubKey.data - ).hexString - - try { - signature = BlindKeyAPI.blind15Sign( - ed25519SecretKey = ed25519KeyPair.secretKey.data, - serverPubKey = serverPublicKey, - message = messageBytes - ) - } catch (e: Exception) { - throw Error.SigningFailed - } - } else { - pubKey = AccountId( - IdPrefix.UN_BLINDED, - ed25519KeyPair.pubKey.data - ).hexString - - signature = ED25519.sign( - ed25519PrivateKey = ed25519KeyPair.secretKey.data, - message = messageBytes - ) - } - headers["X-SOGS-Nonce"] = encodeBytes(nonce) - headers["X-SOGS-Timestamp"] = "$timestamp" - headers["X-SOGS-Pubkey"] = pubKey - headers["X-SOGS-Signature"] = encodeBytes(signature) - headers - } else { - request.headers - } - - val requestBuilder = okhttp3.Request.Builder() - .url(urlRequest) - .headers(headers.toHeaders()) - when (request.verb) { - GET -> requestBuilder.get() - PUT -> requestBuilder.put(createBody(request.body, request.parameters)!!) - POST -> requestBuilder.post(createBody(request.body, request.parameters)!!) - DELETE -> requestBuilder.delete(createBody(request.body, request.parameters)) - } - if (!request.room.isNullOrEmpty()) { - requestBuilder.header("Room", request.room) - } - return if (request.useOnionRouting) { - OnionRequestAPI.sendOnionRequest(requestBuilder.build(), request.server, serverPublicKey).fail { e -> - when (e) { - // No need for the stack trace for HTTP errors - is HTTP.HTTPRequestFailedException -> Log.e("SOGS", "Failed onion request: ${e.message}") - else -> Log.e("SOGS", "Failed onion request", e) - } - }.await() - } else { - throw IllegalStateException("It's currently not allowed to send non onion routed requests.") - } - } - - suspend fun downloadOpenGroupProfilePicture( - server: String, - roomID: String, - imageId: String, - signRequest: Boolean = true, - serverPubKeyHex: String? = null, - ): ByteArraySlice { - val request = Request( - verb = GET, - room = roomID, - server = server, - endpoint = Endpoint.RoomFileIndividual(roomID, imageId) - ) - return getResponseBody(request, signRequest = signRequest, serverPubKeyHex = serverPubKeyHex) - } - - // region Upload/Download - suspend fun upload(file: ByteArray, room: String, server: String): String { - val request = Request( - verb = POST, - room = room, - server = server, - endpoint = Endpoint.RoomFile(room), - body = file, - headers = mapOf( - "Content-Disposition" to "attachment", - "Content-Type" to "application/octet-stream" - ) - ) - val json = getResponseBodyJson(request, signRequest = true) - return json["id"]?.toString() ?: throw Error.ParsingFailed - } - - suspend fun download(fileId: String, room: String, server: String): ByteArraySlice { - val request = Request( - verb = GET, - room = room, - server = server, - endpoint = Endpoint.RoomFileIndividual(room, fileId) - ) - return getResponseBody(request, signRequest = true) - } - // endregion - - // region Sending - suspend fun sendMessage( - message: OpenGroupMessage, - room: String, - server: String, - whisperTo: List? = null, - whisperMods: Boolean? = null, - fileIds: List? = null - ): OpenGroupMessage { - val signedMessage = message.sign(server) ?:throw Error.SigningFailed - val parameters = signedMessage.toJSON().toMutableMap() - - // add file IDs if there are any (from attachments) - if (!fileIds.isNullOrEmpty()) { - parameters += "files" to fileIds - } - - val request = Request( - verb = POST, - room = room, - server = server, - endpoint = Endpoint.RoomMessage(room), - parameters = parameters - ) - val json = getResponseBodyJson(request, signRequest = true) - @Suppress("UNCHECKED_CAST") val rawMessage = json as? Map - ?: throw Error.ParsingFailed - val result = OpenGroupMessage.fromJSON(rawMessage) ?: throw Error.ParsingFailed - val storage = MessagingModuleConfiguration.shared.storage - storage.addReceivedMessageTimestamp(result.sentTimestamp) - return result - } - // endregion - - @OptIn(ExperimentalSerializationApi::class) - suspend fun addReaction(room: String, server: String, messageId: Long, emoji: String): AddReactionResponse { - val request = Request( - verb = PUT, - room = room, - server = server, - endpoint = Endpoint.Reaction(room, messageId, emoji), - parameters = emptyMap() - ) - - val response = getResponseBody(request, signRequest = true) - val reaction: AddReactionResponse = response.inputStream().use( MessagingModuleConfiguration.shared.json::decodeFromStream) - - return reaction - } - - @OptIn(ExperimentalSerializationApi::class) - suspend fun deleteReaction(room: String, server: String, messageId: Long, emoji: String): DeleteReactionResponse { - val request = Request( - verb = DELETE, - room = room, - server = server, - endpoint = Endpoint.Reaction(room, messageId, emoji) - ) - - val response = getResponseBody(request, signRequest = true) - val reaction: DeleteReactionResponse = MessagingModuleConfiguration.shared.json.decodeFromStream(response.inputStream()) - - return reaction - } - - suspend fun deleteAllReactions(room: String, server: String, messageId: Long, emoji: String): DeleteAllReactionsResponse { - val request = Request( - verb = DELETE, - room = room, - server = server, - endpoint = Endpoint.ReactionDelete(room, messageId, emoji) - ) - val response = getResponseBody(request, signRequest = true) - return JsonUtil.fromJson(response, DeleteAllReactionsResponse::class.java) - } - // endregion - - // region Message Deletion - suspend fun deleteMessage(serverID: Long, room: String, server: String) { - val request = Request(verb = DELETE, room = room, server = server, endpoint = Endpoint.RoomMessageIndividual(room, serverID)) - send(request, signRequest = true) - Log.d("Loki", "Message deletion successful.") - } - - // endregion - - // region Moderation - suspend fun ban(publicKey: String, room: String, server: String) { - val parameters = mapOf("rooms" to listOf(room)) - val request = Request( - verb = POST, - room = room, - server = server, - endpoint = Endpoint.UserBan(publicKey), - parameters = parameters - ) - - send(request, signRequest = true) - Log.d("Loki", "Banned user: $publicKey from: $server.$room.") - } - - suspend fun banAndDeleteAll(publicKey: String, room: String, server: String) { - - val requests = mutableListOf>( - // Ban request - BatchRequestInfo( - request = BatchRequest( - method = POST, - path = "/user/$publicKey/ban", - json = mapOf("rooms" to listOf(room)) - ), - endpoint = Endpoint.UserBan(publicKey), - responseType = object: TypeReference(){} - ), - // Delete request - BatchRequestInfo( - request = BatchRequest(DELETE, "/room/$room/all/$publicKey"), - endpoint = Endpoint.RoomDeleteMessages(room, publicKey), - responseType = object: TypeReference(){} - ) - ) - sequentialBatch(server, requests) - Log.d("Loki", "Banned user: $publicKey from: $server.$room.") - } - - suspend fun unban(publicKey: String, room: String, server: String) { - val request = Request(verb = DELETE, room = room, server = server, endpoint = Endpoint.UserUnban(publicKey)) - send(request, signRequest = true) - Log.d("Loki", "Unbanned user: $publicKey from: $server.$room") - } - // endregion - - // region General - - suspend fun parallelBatch( - server: String, - requests: MutableList> - ): List> { - val request = Request( - verb = POST, - room = null, - server = server, - endpoint = Endpoint.Batch, - parameters = requests.map { it.request } - ) - return getBatchResponseJson(request, requests) - } - - private suspend fun sequentialBatch( - server: String, - requests: MutableList> - ): List> { - val request = Request( - verb = POST, - room = null, - server = server, - endpoint = Endpoint.Sequence, - parameters = requests.map { it.request } - ) - return getBatchResponseJson(request, requests) - } - - private suspend fun getBatchResponseJson( - request: Request, - requests: MutableList>, - signRequest: Boolean = true - ): List> { - val batch = getResponseBody(request, signRequest = signRequest) - val results = JsonUtil.fromJson(batch, List::class.java) ?: throw Error.ParsingFailed - return results.mapIndexed { idx, result -> - val response = result as? Map<*, *> ?: throw Error.ParsingFailed - val code = response["code"] as Int - BatchResponse( - endpoint = requests[idx].endpoint, - code = code, - headers = response["headers"] as Map, - body = if (code in 200..299) { - requests[idx].responseType?.let { respType -> - JsonUtil.toJson(response["body"]).takeIf { it != "[]" }?.let { - JsonUtil.fromJson(it, respType) - } ?: response["body"] - } - - } else null - ) - } - } - - suspend fun getDefaultServerCapabilities(): List { - return getOrFetchServerCapabilities(defaultServer) - } - - suspend fun getDefaultRoomsIfNeeded(): List { - val groups = getAllRooms() - - val earlyGroups = groups.map { group -> - DefaultGroup(group.token, group.name, null) - } - // See if we have any cached rooms, and if they already have images don't overwrite them with early non-image results - defaultRooms.replayCache.firstOrNull()?.let { replayed -> - if (replayed.none { it.image?.isNotEmpty() == true }) { - defaultRooms.tryEmit(earlyGroups) - } - } - val images = groups.associate { group -> - group.token to group.imageId?.let { downloadOpenGroupProfilePicture( - server = defaultServer, - roomID = group.token, - imageId = it, - signRequest = false, - serverPubKeyHex = defaultServerPublicKey, - ) } - } - - return groups.map { group -> - val image = try { - images[group.token]!! - } catch (e: Exception) { - // No image or image failed to download - null - } - DefaultGroup(group.token, group.name, image) - }.also(defaultRooms::tryEmit) - } - - private suspend fun getAllRooms(): List { - val request = Request( - verb = GET, - room = null, - server = defaultServer, - endpoint = Endpoint.Rooms - ) - val response = getResponseBody( - request = request, - signRequest = false, - serverPubKeyHex = defaultServerPublicKey - ) - - return MessagingModuleConfiguration.shared.json - .decodeFromStream>(response.inputStream()).toList() - } - - suspend fun getCapabilities(server: String, serverPubKeyHex: String? = null): Capabilities { - val request = Request(verb = GET, room = null, server = server, endpoint = Endpoint.Capabilities) - val response = getResponseBody(request, signRequest = false, serverPubKeyHex) - return JsonUtil.fromJson(response, Capabilities::class.java) - } - - suspend fun sendDirectMessage(message: String, blindedAccountId: String, server: String): DirectMessage { - val request = Request( - verb = POST, - room = null, - server = server, - endpoint = Endpoint.InboxFor(blindedAccountId), - parameters = mapOf("message" to message) - ) - val response = getResponseBody(request) - return JsonUtil.fromJson(response, DirectMessage::class.java) - } - - suspend fun deleteAllInboxMessages(server: String): Map<*, *> { - val request = Request( - verb = DELETE, - room = null, - server = server, - endpoint = Endpoint.Inbox - ) - val response = getResponseBody(request) - return JsonUtil.fromJson(response, Map::class.java) - } - - // endregion } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt index 93fac63e91..880fd34591 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt @@ -1,16 +1,8 @@ package org.session.libsession.messaging.open_groups -import network.loki.messenger.libsession_util.ED25519 -import network.loki.messenger.libsession_util.util.BlindKeyAPI -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability import org.session.libsignal.crypto.PushTransportDetails -import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64.decode -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.removingIdPrefixIfNeeded -import org.session.libsignal.utilities.toHexString +import org.session.protos.SessionProtos data class OpenGroupMessage( val serverID: Long? = null, @@ -27,66 +19,8 @@ data class OpenGroupMessage( val base64EncodedSignature: String? = null, val reactions: Map? = null ) { - - companion object { - fun fromJSON(json: Map): OpenGroupMessage? { - val base64EncodedData = json["data"] as? String ?: return null - val sentTimestamp = json["posted"] as? Double ?: return null - val serverID = json["id"] as? Int - val sender = json["session_id"] as? String - val base64EncodedSignature = json["signature"] as? String - return OpenGroupMessage( - serverID = serverID?.toLong(), - sender = sender, - sentTimestamp = (sentTimestamp * 1000).toLong(), - base64EncodedData = base64EncodedData, - base64EncodedSignature = base64EncodedSignature - ) - } - } - - fun sign(server: String): OpenGroupMessage? { - if (base64EncodedData.isNullOrEmpty()) return null - val userEdKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() ?: return null - val communityServerPubKey = MessagingModuleConfiguration.shared.storage.getOpenGroupPublicKey(server) ?: return null - val serverCapabilities = MessagingModuleConfiguration.shared.storage.getServerCapabilities(server) - val signature = if (serverCapabilities?.contains(Capability.BLIND.name.lowercase()) == true) { - runCatching { - BlindKeyAPI.blind15Sign( - ed25519SecretKey = userEdKeyPair.secretKey.data, - serverPubKey = communityServerPubKey, - message = decode(base64EncodedData) - ) - }.onFailure { - Log.e("OpenGroupMessage", "Failed to sign message with blind key", it) - }.getOrNull() ?: return null - } - else { - val x25519PublicKey = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair().pubKey.data - if (sender != x25519PublicKey.toHexString() && !userEdKeyPair.pubKey.data.toHexString().equals(sender?.removingIdPrefixIfNeeded(), true)) return null - try { - ED25519.sign( - ed25519PrivateKey = userEdKeyPair.secretKey.data, - message = decode(base64EncodedData) - ) - } catch (e: Exception) { - Log.w("Loki", "Couldn't sign open group message.", e) - return null - } - } - return copy(base64EncodedSignature = Base64.encodeBytes(signature)) - } - - fun toJSON(): Map { - val json = mutableMapOf( "data" to base64EncodedData, "timestamp" to sentTimestamp ) - serverID?.let { json["server_id"] = it } - sender?.let { json["public_key"] = it } - base64EncodedSignature?.let { json["signature"] = it } - return json - } - - fun toProto(): SignalServiceProtos.Content { + fun toProto(): SessionProtos.Content { val data = decode(base64EncodedData).let(PushTransportDetails::getStrippedPaddingMessageBody) - return SignalServiceProtos.Content.parseFrom(data) + return SessionProtos.Content.parseFrom(data) } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupUtils.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupUtils.kt index ebf2965b1a..53f2a90ef8 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupUtils.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupUtils.kt @@ -1,9 +1,9 @@ package org.session.libsession.messaging.open_groups fun String.migrateLegacyServerUrl() = if (contains(OpenGroupApi.legacyServerIP)) { - OpenGroupApi.defaultServer -} else if (contains(OpenGroupApi.httpDefaultServer)) { - OpenGroupApi.defaultServer + OfficialCommunityRepository.OFFICIAL_COMMUNITY_URL +} else if (contains(OfficialCommunityRepository.OFFICIAL_COMMUNITY_URL_INSECURE)) { + OfficialCommunityRepository.OFFICIAL_COMMUNITY_URL } else { this } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/AddReactionApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/AddReactionApi.kt new file mode 100644 index 0000000000..355b3627b2 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/AddReactionApi.kt @@ -0,0 +1,57 @@ +package org.session.libsession.messaging.open_groups.api + +import android.net.Uri +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromStream +import okhttp3.MediaType +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpBody +import org.thoughtcrime.securesms.api.http.HttpResponse + +class AddReactionApi @AssistedInject constructor( + @Assisted("room") override val room: String, + @Assisted val messageId: Long, + @Assisted val emoji: String, + deps: CommunityApiDependencies, +) : CommunityApi(deps) { + override val requiresSigning: Boolean get() = true + override val httpMethod: String get() = "PUT" + override val httpEndpoint: String = buildString { + append("/room/") + append(Uri.encode(room)) + append("/reaction/") + append(messageId) + append("/") + append(Uri.encode(emoji)) + } + + override fun buildRequestBody( + serverBaseUrl: String, + x25519PubKeyHex: String + ): Pair { + return buildJsonRequestBody(JsonObject(emptyMap())) + } + + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): OpenGroupApi.AddReactionResponse { + @Suppress("OPT_IN_USAGE") + return response.body.asInputStream() + .use(json::decodeFromStream) + } + + @AssistedFactory + interface Factory { + fun create( + @Assisted("room") room: String, + messageId: Long, + emoji: String + ): AddReactionApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/BanUserApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/BanUserApi.kt new file mode 100644 index 0000000000..ab5e7b9394 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/BanUserApi.kt @@ -0,0 +1,47 @@ +package org.session.libsession.messaging.open_groups.api + +import android.net.Uri +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.Serializable +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpBody +import org.thoughtcrime.securesms.api.http.HttpResponse +import okhttp3.MediaType + +class BanUserApi @AssistedInject constructor( + @Assisted("user") private val userToBan: String, + @Assisted override val room: String, + deps: CommunityApiDependencies, +) : CommunityApi(deps) { + override val requiresSigning: Boolean get() = true + override val httpMethod: String get() = "POST" + override val httpEndpoint: String = + "/user/${Uri.encode(userToBan)}/ban" + + @Serializable + private data class BanBody(val rooms: List) + + override fun buildRequestBody( + serverBaseUrl: String, + x25519PubKeyHex: String + ): Pair { + // JSON body {"rooms":[room]} + return buildJsonRequestBody(BanBody(rooms = listOf(room))) + } + + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ) = Unit + + @AssistedFactory + interface Factory { + fun create( + @Assisted("user") userToBan: String, + room: String + ): BanUserApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/BatchApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/BatchApi.kt new file mode 100644 index 0000000000..73f480b96e --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/BatchApi.kt @@ -0,0 +1,111 @@ +package org.session.libsession.messaging.open_groups.api + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromStream +import okhttp3.MediaType +import org.session.libsignal.utilities.Base64 +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpBody +import org.thoughtcrime.securesms.api.http.HttpRequest +import org.thoughtcrime.securesms.api.http.HttpResponse + +class BatchApi @AssistedInject constructor( + @Assisted private val items: List, + deps: CommunityApiDependencies, +) : CommunityApi>(deps) { + override val room: String? get() = null + override val requiresSigning: Boolean get() = true + override val httpMethod: String get() = "POST" + + @OptIn(ExperimentalSerializationApi::class) + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): List { + return response.body.asInputStream().use(json::decodeFromStream) + } + + override val httpEndpoint: String + get() = "/batch" + + override fun buildRequestBody( + serverBaseUrl: String, + x25519PubKeyHex: String + ): Pair { + return buildJsonRequestBody(items) + } + + @Serializable + class BatchRequestItem( + val method: String, + val path: String, + val headers: Map, + val json: JsonElement? = null, + val b64: String? = null + ) { + init { + check(json == null || b64 == null) { "Only one of 'json' or 'b64' can be set" } + } + + constructor(httpRequest: HttpRequest, json: Json) : this( + method = httpRequest.method, + // Path includes query parameters + path = buildString { + append(httpRequest.url.encodedPath) + if (httpRequest.url.encodedQuery != null) { + append("?") + append(httpRequest.url.encodedQuery) + } + }, + headers = httpRequest.headers.toMap(), + json = if (httpRequest.isJsonBody) { + @Suppress("OPT_IN_USAGE") + httpRequest.body?.asInputStream()?.use(json::decodeFromStream) + } else { + null + }, + b64 = if (!httpRequest.isJsonBody) { + httpRequest.body?.toBytes()?.let(Base64::encodeBytes) + } else { + null + } + ) + } + + + @Serializable + class BatchResponseItem( + val code: Int, + val headers: Map, + val body: JsonElement? + ) { + fun toHttpResponse(json: Json): HttpResponse { + return HttpResponse( + statusCode = code, + headers = headers, + body = body?.let { + HttpBody.Text(json.encodeToString(it)) + } ?: HttpBody.empty() + ) + } + } + + @AssistedFactory + interface Factory { + fun create(items: List): BatchApi + } + + companion object { + private val HttpRequest.isJsonBody: Boolean + get() { + return getHeader("Content-Type")?.startsWith("application/json", ignoreCase = true) == true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/CommunityApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/CommunityApi.kt new file mode 100644 index 0000000000..ec515c763a --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/CommunityApi.kt @@ -0,0 +1,173 @@ +package org.session.libsession.messaging.open_groups.api + +import androidx.collection.arrayMapOf +import kotlinx.serialization.json.Json +import network.loki.messenger.libsession_util.ED25519 +import network.loki.messenger.libsession_util.Hash +import network.loki.messenger.libsession_util.util.BlindKeyAPI +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.thoughtcrime.securesms.api.server.ServerApiErrorManager +import org.session.libsession.network.SnodeClock +import org.session.libsession.network.model.FailureDecision +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.IdPrefix +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.error.ErrorWithFailureDecision +import org.thoughtcrime.securesms.api.http.HttpBody +import org.thoughtcrime.securesms.api.http.HttpRequest +import org.thoughtcrime.securesms.api.http.HttpResponse +import org.thoughtcrime.securesms.api.server.ServerApi +import org.thoughtcrime.securesms.auth.LoginStateRepository +import java.io.ByteArrayOutputStream +import java.security.SecureRandom +import javax.inject.Inject +import javax.inject.Provider + +abstract class CommunityApi( + errorManager: ServerApiErrorManager, + protected val json: Json, + protected val loginStateRepository: Provider, + protected val configFactory: ConfigFactoryProtocol, + protected val storage: StorageProtocol, + protected val clock: SnodeClock, +) : ServerApi( + errorManager = errorManager, +) { + constructor(deps: CommunityApiDependencies) : this( + errorManager = deps.errorManager, + json = deps.json, + loginStateRepository = deps.loginStateRepository, + configFactory = deps.configFactory, + storage = deps.storage, + clock = deps.clock, + ) + + + // The room ID associated with this API call + abstract val room: String? + + abstract val requiresSigning: Boolean + + abstract val httpMethod: String + abstract val httpEndpoint: String + + open fun buildRequestBody(serverBaseUrl: String, x25519PubKeyHex: String): Pair? = null + + protected inline fun buildJsonRequestBody(obj: T): Pair { + return "application/json".toMediaType() to HttpBody.Text(json.encodeToString(obj)) + } + + override fun buildRequest(baseUrl: String, x25519PubKeyHex: String): HttpRequest { + val builtBody = buildRequestBody(baseUrl, x25519PubKeyHex) + val headers = arrayMapOf() + + val httpBody = if (builtBody != null) { + headers["Content-Type"] = builtBody.first.toString() + headers["Content-Length"] = builtBody.second.byteLength.toString() + builtBody.second + } else { + null + } + + room?.let { + headers["Room"] = it + } + + if (requiresSigning) { + val loggedInState = loginStateRepository.get().requireLoggedInState() + val bodyHash = builtBody?.let { Hash.hash64(it.second.toBytes()) } ?: byteArrayOf() + val nonce = ByteArray(16).also(SecureRandom()::nextBytes) + val timestamp = clock.currentTimeSeconds().toString() + + val messageToSign = ByteArrayOutputStream().use { stream -> + stream.write(Hex.fromStringCondensed(x25519PubKeyHex)) + stream.write(nonce) + + stream.writer().use { w -> + w.write(timestamp) + w.write(httpMethod) + w.write(httpEndpoint) + } + + stream.write(bodyHash) + stream.toByteArray() + } + + val pubKeyHexUsedToSign: String + val signature: ByteArray + + if (storage.getServerCapabilities(baseUrl) + ?.contains(OpenGroupApi.Capability.BLIND.name.lowercase()) == true) { + pubKeyHexUsedToSign = AccountId( + IdPrefix.BLINDED, + loggedInState.getBlindedKeyPair(baseUrl, x25519PubKeyHex).pubKey.data + ).hexString + + signature = BlindKeyAPI.blind15Sign( + ed25519SecretKey = loggedInState.accountEd25519KeyPair.secretKey.data, + serverPubKey = x25519PubKeyHex, + message = messageToSign + ) + } else { + pubKeyHexUsedToSign = AccountId( + IdPrefix.UN_BLINDED, + loggedInState.accountEd25519KeyPair.pubKey.data + ).hexString + + signature = ED25519.sign( + ed25519PrivateKey = loggedInState.accountEd25519KeyPair.secretKey.data, + message = messageToSign + ) + } + + headers["X-SOGS-Nonce"] = Base64.encodeBytes(nonce) + headers["X-SOGS-Timestamp"] = timestamp + headers["X-SOGS-PubKey"] = pubKeyHexUsedToSign + headers["X-SOGS-Signature"] = Base64.encodeBytes(signature) + } + + return HttpRequest( + url = requireNotNull(baseUrl.toHttpUrl().resolve(httpEndpoint)) { + "Could not resolve URL for endpoint $httpEndpoint with base $baseUrl" + }, + method = httpMethod, + headers = headers, + body = httpBody, + ) + } + + + override suspend fun handleErrorResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): Nothing { + if (response.statusCode == 400 && + response.body.toText()?.contains("Invalid authentication: this server requires the use of blinded ids", ignoreCase = true) == true) { + storage.clearServerCapabilities(baseUrl) + throw ErrorWithFailureDecision( + cause = RuntimeException("Server requires blinded ids"), + failureDecision = FailureDecision.Retry, + ) + } + + super.handleErrorResponse(executorContext, baseUrl, response) + } + + class CommunityApiDependencies @Inject constructor( + val errorManager: ServerApiErrorManager, + val json: Json, + val loginStateRepository: Provider, + val configFactory: ConfigFactoryProtocol, + val storage: StorageProtocol, + val clock: SnodeClock, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/CommunityApiBatcher.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/CommunityApiBatcher.kt new file mode 100644 index 0000000000..523be59bc3 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/CommunityApiBatcher.kt @@ -0,0 +1,82 @@ +package org.session.libsession.messaging.open_groups.api + +import kotlinx.serialization.json.Json +import org.session.libsession.database.StorageProtocol +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.batch.Batcher +import javax.inject.Inject + +class CommunityApiBatcher @Inject constructor( + private val batchApiFactory: BatchApi.Factory, + private val json: Json, + private val storage: StorageProtocol, +) : Batcher, Any, BatchApi.BatchRequestItem> { + override fun transformRequestForBatching( + ctx: ApiExecutorContext, + req: CommunityApiRequest<*> + ): BatchApi.BatchRequestItem { + val pubKey = requireNotNull(req.serverPubKey ?: storage.getOpenGroupPublicKey(req.serverBaseUrl)) { + "No stored x25519 public key for server ${req.serverBaseUrl}" + } + + return BatchApi.BatchRequestItem( + httpRequest = req.api.buildRequest( + baseUrl = req.serverBaseUrl, + x25519PubKeyHex = pubKey + ), + json = json + ) + } + + override fun constructBatchRequest( + firstRequest: CommunityApiRequest<*>, + intermediateRequests: List + ): CommunityApiRequest<*> { + return CommunityApiRequest( + serverBaseUrl = firstRequest.serverBaseUrl, + serverPubKey = firstRequest.serverPubKey, + api = batchApiFactory.create(intermediateRequests) + ) + } + + override fun batchKey(req: CommunityApiRequest<*>): Any? { + // Shouldn't batch the batch requests themselves + if (req.api is BatchApi) { + return null + } + + // Shouldn't batch APIs that return large amount of data + if (req.api is CommunityFileDownloadApi) { + return null + } + + // Only batch requests that require signing + if (!req.api.requiresSigning) { + return null + } + + return req.serverBaseUrl + } + + @Suppress("UNCHECKED_CAST") + override suspend fun deconstructBatchResponse( + requests: List>>, + response: Any + ): List> { + response as List + + check(requests.size == response.size) { + "Mismatched batch response size: expected=${requests.size}, actual=${response.size}" + } + + return requests.mapIndexed { index, (ctx, req) -> + runCatching { + req.api.processResponse( + executorContext = ctx, + response = response[index].toHttpResponse(json), + baseUrl = req.serverBaseUrl, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/CommunityApiExecutor.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/CommunityApiExecutor.kt new file mode 100644 index 0000000000..2f890ad891 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/CommunityApiExecutor.kt @@ -0,0 +1,51 @@ +package org.session.libsession.messaging.open_groups.api + +import org.session.libsession.database.StorageProtocol +import org.thoughtcrime.securesms.api.ApiExecutor +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.server.ServerApi +import org.thoughtcrime.securesms.api.server.ServerApiExecutor +import org.thoughtcrime.securesms.api.server.ServerApiRequest +import org.thoughtcrime.securesms.api.server.ServerApiResponse +import javax.inject.Inject + +data class CommunityApiRequest>( + val serverBaseUrl: String, + val api: Api, + val serverPubKey: String? = null, // Null to let executor fill in from config data +) { + override fun toString(): String { + return "CommunityApiRequest(${api::class.java.simpleName}, serverBaseUrl=$serverBaseUrl)" + } +} + +typealias CommunityApiExecutor = ApiExecutor, ServerApiResponse> + +class CommunityApiExecutorImpl @Inject constructor( + private val serverApiExecutor: ServerApiExecutor, + private val storage: StorageProtocol +) : CommunityApiExecutor { + override suspend fun send( + ctx: ApiExecutorContext, + req: CommunityApiRequest<*> + ): ServerApiResponse { + val x25519PubKey = checkNotNull( + req.serverPubKey ?: storage.getOpenGroupPublicKey(req.serverBaseUrl)) { + "No stored x25519 public key for server ${req.serverBaseUrl}" + } + + @Suppress("UNCHECKED_CAST") + return serverApiExecutor.send(ctx, ServerApiRequest( + serverBaseUrl = req.serverBaseUrl, + serverX25519PubKeyHex = x25519PubKey, + api = req.api as ServerApi + )) + } +} + +suspend inline fun > CommunityApiExecutor.execute( + req: CommunityApiRequest, + ctx: ApiExecutorContext = ApiExecutorContext() +): Resp { + return send(ctx, req) as Resp +} diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/CommunityFileDownloadApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/CommunityFileDownloadApi.kt new file mode 100644 index 0000000000..f9266915f3 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/CommunityFileDownloadApi.kt @@ -0,0 +1,54 @@ +package org.session.libsession.messaging.open_groups.api + +import android.net.Uri +import android.util.Base64 +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpBody +import org.thoughtcrime.securesms.api.http.HttpResponse + +class CommunityFileDownloadApi @AssistedInject constructor( + @Assisted override val requiresSigning: Boolean, + @Assisted("room") override val room: String?, + @Assisted val fileId: String, + deps: CommunityApiDependencies, +) : CommunityApi(deps) { + override val httpMethod: String get() = "GET" + override val httpEndpoint: String = if (room != null) { + "/room/${Uri.encode(room)}/file/${Uri.encode(fileId)}" + } else { + "/file/${Uri.encode(fileId)}" + } + + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): HttpBody { + // If the response body is text, it's very likely they were base64 encoded + // before being sent over the network. Try to decode it. + if (response.body is HttpBody.Text) { + val bytes = runCatching { + Base64.decode(response.body.text, Base64.DEFAULT) + }.getOrNull() + + if (bytes != null) { + return HttpBody.Bytes(bytes) + } + } + + return response.body + } + + @AssistedFactory + interface Factory { + fun create( + @Assisted("room") + room: String?, + fileId: String, + requiresSigning: Boolean, + ): CommunityFileDownloadApi + } +} diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/CommunityFileUploadApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/CommunityFileUploadApi.kt new file mode 100644 index 0000000000..1eb9762d81 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/CommunityFileUploadApi.kt @@ -0,0 +1,57 @@ +package org.session.libsession.messaging.open_groups.api + +import android.net.Uri +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.decodeFromStream +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpBody +import org.thoughtcrime.securesms.api.http.HttpRequest +import org.thoughtcrime.securesms.api.http.HttpResponse + +class CommunityFileUploadApi @AssistedInject constructor( + @Assisted private val file: HttpBody, + @Assisted override val room: String, + deps: CommunityApiDependencies +) : CommunityApi(deps) { + override val requiresSigning: Boolean get() = true + override val httpMethod: String get() = "POST" + override val httpEndpoint: String = "/room/${Uri.encode(room)}/file" + + override fun buildRequest(baseUrl: String, x25519PubKeyHex: String): HttpRequest { + val request = super.buildRequest(baseUrl, x25519PubKeyHex) + return request.copy( + headers = request.headers.toMutableMap().apply { + this["Content-Type"] = "application/octet-stream" + this["Content-Disposition"] = "attachment" + }, + body = file, + ) + } + + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): String { + @Suppress("OPT_IN_USAGE") + return response.body.asInputStream() + .use { json.decodeFromStream(it) } + .id + } + + @Serializable + private class UploadResult( + val id: String + ) + + @AssistedFactory + interface Factory { + fun create( + file: HttpBody, + room: String + ): CommunityFileUploadApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/DeleteAllInboxMessagesApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/DeleteAllInboxMessagesApi.kt new file mode 100644 index 0000000000..0426b4f9d5 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/DeleteAllInboxMessagesApi.kt @@ -0,0 +1,20 @@ +package org.session.libsession.messaging.open_groups.api + +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpResponse +import javax.inject.Inject + +class DeleteAllInboxMessagesApi @Inject constructor( + deps: CommunityApiDependencies, +): CommunityApi(deps) { + override val room: String? get() = null + override val requiresSigning: Boolean get() = true + override val httpMethod: String get() = "DELETE" + override val httpEndpoint: String get() = "/inbox" + + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ) = Unit +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/DeleteAllReactionsApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/DeleteAllReactionsApi.kt new file mode 100644 index 0000000000..a4c437ae15 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/DeleteAllReactionsApi.kt @@ -0,0 +1,43 @@ +package org.session.libsession.messaging.open_groups.api + +import android.net.Uri +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.json.decodeFromStream +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpResponse + +class DeleteAllReactionsApi @AssistedInject constructor( + @Assisted("room") override val room: String, + @Assisted val messageId: Long, + @Assisted val emoji: String, + deps: CommunityApiDependencies, +) : CommunityApi(deps) { + override val requiresSigning: Boolean get() = true + override val httpMethod: String get() = "DELETE" + override val httpEndpoint: String = buildString { + append("/room/") + append(Uri.encode(room)) + append("/reactions/") + append(messageId) + append("/") + append(Uri.encode(emoji)) + } + + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ) = Unit + + @AssistedFactory + interface Factory { + fun create( + @Assisted("room") room: String, + messageId: Long, + emoji: String + ): DeleteAllReactionsApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/DeleteMessageApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/DeleteMessageApi.kt new file mode 100644 index 0000000000..85316f6934 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/DeleteMessageApi.kt @@ -0,0 +1,31 @@ +package org.session.libsession.messaging.open_groups.api + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpResponse + +class DeleteMessageApi @AssistedInject constructor( + @Assisted override val room: String, + @Assisted private val messageId: Long, + deps: CommunityApiDependencies +) : CommunityApi(deps) { + override val requiresSigning: Boolean get() = true + override val httpMethod: String get() = "DELETE" + override val httpEndpoint: String = "/room/$room/message/$messageId" + + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ) = Unit + + @AssistedFactory + interface Factory { + fun create( + room: String, + messageId: Long + ): DeleteMessageApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/DeleteReactionApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/DeleteReactionApi.kt new file mode 100644 index 0000000000..07793f6533 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/DeleteReactionApi.kt @@ -0,0 +1,41 @@ +package org.session.libsession.messaging.open_groups.api + +import android.net.Uri +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpResponse + +class DeleteReactionApi @AssistedInject constructor( + @Assisted("room") override val room: String, + @Assisted val messageId: Long, + @Assisted val emoji: String, + deps: CommunityApiDependencies, +) : CommunityApi(deps) { + override val requiresSigning: Boolean get() = true + override val httpMethod: String get() = "DELETE" + override val httpEndpoint: String = buildString { + append("/room/") + append(Uri.encode(room)) + append("/reaction/") + append(messageId) + append("/") + append(Uri.encode(emoji)) + } + + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ) = Unit + + @AssistedFactory + interface Factory { + fun create( + @Assisted("room") room: String, + messageId: Long, + emoji: String + ): DeleteReactionApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/DeleteUserMessagesApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/DeleteUserMessagesApi.kt new file mode 100644 index 0000000000..edd04ef1a8 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/DeleteUserMessagesApi.kt @@ -0,0 +1,33 @@ +package org.session.libsession.messaging.open_groups.api + +import android.net.Uri +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpResponse + +class DeleteUserMessagesApi @AssistedInject constructor( + @Assisted("user") private val userToDelete: String, + @Assisted override val room: String, + deps: CommunityApiDependencies, +) : CommunityApi(deps) { + override val requiresSigning: Boolean get() = true + override val httpMethod: String get() = "DELETE" + override val httpEndpoint: String = + "/room/${Uri.encode(room)}/all/${Uri.encode(userToDelete)}" + + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ) = Unit + + @AssistedFactory + interface Factory { + fun create( + @Assisted("user") userToDelete: String, + room: String + ): DeleteUserMessagesApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/GetCapsApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/GetCapsApi.kt new file mode 100644 index 0000000000..81546d48e2 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/GetCapsApi.kt @@ -0,0 +1,25 @@ +package org.session.libsession.messaging.open_groups.api + +import kotlinx.serialization.json.decodeFromStream +import org.session.libsession.messaging.open_groups.OpenGroupApi.Capabilities +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpResponse +import javax.inject.Inject + +class GetCapsApi @Inject constructor( + deps: CommunityApiDependencies, +) : CommunityApi(deps) { + override val room: String? get() = null + override val requiresSigning: Boolean get() = false + override val httpMethod: String get() = "GET" + override val httpEndpoint: String get() = "/capabilities" + + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): Capabilities { + @Suppress("OPT_IN_USAGE") + return response.body.asInputStream().use(json::decodeFromStream) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/GetDirectMessagesApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/GetDirectMessagesApi.kt new file mode 100644 index 0000000000..8e01fd7976 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/GetDirectMessagesApi.kt @@ -0,0 +1,57 @@ +package org.session.libsession.messaging.open_groups.api + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.json.decodeFromStream +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpResponse + +class GetDirectMessagesApi @AssistedInject constructor( + @Assisted inboxOrOutbox: Boolean, + @Assisted sinceLastId: Long?, + deps: CommunityApiDependencies, +) : CommunityApi>(deps) { + override val room: String? get() = null + override val requiresSigning: Boolean get() = true + override val httpMethod: String get() = "GET" + override val httpEndpoint: String = when { + inboxOrOutbox && sinceLastId == null -> "/inbox" + inboxOrOutbox && sinceLastId != null -> "/inbox/since/$sinceLastId" + !inboxOrOutbox && sinceLastId == null -> "/outbox" + else /* !isInboxOrOutbox && sinceSeqNo != null */ -> "/outbox/since/$sinceLastId" + } + + override suspend fun processResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): List { + if (response.statusCode == 304) { + // This "Not Modified" error is specific to the direct messages API to indicate there + // are no new messages, this is not an error for our purposes, so we won't go through + // the usual error handling path. + return emptyList() + } + + return super.processResponse(executorContext, baseUrl, response) + } + + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): List { + @Suppress("OPT_IN_USAGE") + return response.body.asInputStream().use(json::decodeFromStream) + } + + @AssistedFactory + interface Factory { + fun create( + inboxOrOutbox: Boolean, + sinceLastId: Long? + ): GetDirectMessagesApi + } +} diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/GetRoomMessagesApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/GetRoomMessagesApi.kt new file mode 100644 index 0000000000..add2d383c9 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/GetRoomMessagesApi.kt @@ -0,0 +1,62 @@ +package org.session.libsession.messaging.open_groups.api + +import android.net.Uri +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.json.decodeFromStream +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpRequest +import org.thoughtcrime.securesms.api.http.HttpResponse + +class GetRoomMessagesApi @AssistedInject constructor( + @Assisted override val room: String, + @Assisted sinceSeqNo: Long?, + @Assisted reactors: Int?, + deps: CommunityApiDependencies, +) : CommunityApi>(deps) { + override val requiresSigning: Boolean get() = true + override val httpMethod: String get() = "GET" + override val httpEndpoint: String = buildString { + append("/room/") + append(Uri.encode(room)) + append("/messages") + + if (sinceSeqNo != null) { + append("/since/") + append(sinceSeqNo) + } else { + append("/recent") + } + + append("?t=r") // Not sure what 't=r' means, but it's in the original code + if (reactors != null) { + append("&reactors=") + append(reactors) + } + } + + override fun buildRequest(baseUrl: String, x25519PubKeyHex: String): HttpRequest { + val request = super.buildRequest(baseUrl, x25519PubKeyHex) + return request + } + + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): List { + @Suppress("OPT_IN_USAGE") + return response.body.asInputStream().use(json::decodeFromStream) + } + + @AssistedFactory + interface Factory { + fun create( + room: String, + sinceSeqNo: Long?, + reactors: Int? = 5 + ): GetRoomMessagesApi + } +} diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/GetRoomsApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/GetRoomsApi.kt new file mode 100644 index 0000000000..bf52b3faef --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/GetRoomsApi.kt @@ -0,0 +1,32 @@ +package org.session.libsession.messaging.open_groups.api + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.json.decodeFromStream +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpResponse + +class GetRoomsApi @AssistedInject constructor( + @Assisted override val requiresSigning: Boolean, + deps: CommunityApiDependencies, +) : CommunityApi>(deps) { + override val room: String? get() = null + override val httpMethod: String get() = "GET" + override val httpEndpoint: String get() = "/rooms" + + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): List { + @Suppress("OPT_IN_USAGE") + return json.decodeFromStream(response.body.asInputStream()) + } + + @AssistedFactory + interface Factory { + fun create(requiresSigning: Boolean): GetRoomsApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/PollRoomApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/PollRoomApi.kt new file mode 100644 index 0000000000..7f02e6bd36 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/PollRoomApi.kt @@ -0,0 +1,31 @@ +package org.session.libsession.messaging.open_groups.api + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromStream +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpResponse + +class PollRoomApi @AssistedInject constructor( + @Assisted override val room: String, + @Assisted infoUpdates: Int, + deps: CommunityApiDependencies, +) : CommunityApi(deps) { + override val httpMethod: String get() = "GET" + override val httpEndpoint: String = "room/$room/pollInfo/$infoUpdates" + override val requiresSigning: Boolean get() = true + + @Suppress("OPT_IN_USAGE") + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): JsonObject = response.body.asInputStream().use(json::decodeFromStream) + + @AssistedFactory + interface Factory { + fun create(room: String, infoUpdates: Int): PollRoomApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/SendDirectMessageApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/SendDirectMessageApi.kt new file mode 100644 index 0000000000..67ac5f06d9 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/SendDirectMessageApi.kt @@ -0,0 +1,56 @@ +package org.session.libsession.messaging.open_groups.api + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.decodeFromStream +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.utilities.Address +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpBody +import org.thoughtcrime.securesms.api.http.HttpResponse + +class SendDirectMessageApi @AssistedInject constructor( + @Assisted private val recipient: Address.Blinded, + @Assisted private val messageContent: String, + deps: CommunityApiDependencies, +) : CommunityApi(deps) { + override val room: String? get() = null + override val requiresSigning: Boolean get() = true + override val httpMethod: String get() = "POST" + override val httpEndpoint: String = "/inbox/${recipient.blindedId.hexString}" + + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): OpenGroupApi.DirectMessage { + @Suppress("OPT_IN_USAGE") + return response.body.asInputStream().use(json::decodeFromStream) + } + + override fun buildRequestBody( + serverBaseUrl: String, + x25519PubKeyHex: String + ): Pair { + return buildJsonRequestBody(Request(messageContent)) + } + + @Serializable + private class Request( + val message: String + ) + + @AssistedFactory + interface Factory { + fun create( + recipient: Address.Blinded, + messageContent: String + ): SendDirectMessageApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/SendMessageApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/SendMessageApi.kt new file mode 100644 index 0000000000..73860bcd94 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/SendMessageApi.kt @@ -0,0 +1,100 @@ +package org.session.libsession.messaging.open_groups.api + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.decodeFromStream +import network.loki.messenger.libsession_util.ED25519 +import network.loki.messenger.libsession_util.util.BlindKeyAPI +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.messaging.open_groups.OpenGroupMessage +import org.session.libsignal.utilities.Base64 +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpBody +import org.thoughtcrime.securesms.api.http.HttpResponse + +class SendMessageApi @AssistedInject constructor( + @Assisted override val room: String, + @Assisted val message: OpenGroupMessage, + @Assisted val fileIds: List, + deps: CommunityApiDependencies, +) : CommunityApi(deps) { + override val requiresSigning: Boolean get() = true + override val httpMethod: String get() = "POST" + override val httpEndpoint: String = "room/$room/message" + + override fun buildRequestBody( + serverBaseUrl: String, + x25519PubKeyHex: String + ): Pair { + val caps = storage.getServerCapabilities(serverBaseUrl) + val ed25519SecretKey = loginStateRepository.get() + .requireLoggedInState().accountEd25519KeyPair.secretKey.data + val signature = if (caps?.contains(OpenGroupApi.Capability.BLIND.name.lowercase()) == true) { + BlindKeyAPI.blind15Sign( + ed25519SecretKey = ed25519SecretKey, + serverPubKey = x25519PubKeyHex, + message = Base64.decode(message.base64EncodedData.orEmpty()) + ) + } else { + ED25519.sign( + ed25519PrivateKey = ed25519SecretKey, + message = Base64.decode(message.base64EncodedData.orEmpty()) + ) + } + + return buildJsonRequestBody(Message( + data = message.base64EncodedData.orEmpty(), + timestampSeconds = message.sentTimestamp / 1000.0, + signature = Base64.encodeBytes(signature), + sender = message.sender.orEmpty(), + files = fileIds.takeIf { it.isNotEmpty() } + )) + } + + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): Response { + @Suppress("OPT_IN_USAGE") + val response: Response = response.body.asInputStream().use(json::decodeFromStream) + storage.addReceivedMessageTimestamp(response.postedMills) + return response + } + + @Serializable + private class Message( + val data: String, + @SerialName("timestamp") + val timestampSeconds: Double, + val signature: String, + @SerialName("public_key") + val sender: String, + val files: List? = null, + ) + + @Serializable + class Response( + val id: Long, + + @SerialName("posted") + val postedSeconds: Double, + ) { + val postedMills: Long + get() = (postedSeconds * 1000.0).toLong() + } + + @AssistedFactory + interface Factory { + fun create( + room: String, + message: OpenGroupMessage, + fileIds: List + ): SendMessageApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/api/UnbanUserApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/api/UnbanUserApi.kt new file mode 100644 index 0000000000..598944c6c9 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/api/UnbanUserApi.kt @@ -0,0 +1,47 @@ +package org.session.libsession.messaging.open_groups.api + +import android.net.Uri +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.Serializable +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpBody +import org.thoughtcrime.securesms.api.http.HttpResponse +import okhttp3.MediaType + +class UnbanUserApi @AssistedInject constructor( + @Assisted("user") private val userToBan: String, + @Assisted override val room: String, + deps: CommunityApiDependencies, +) : CommunityApi(deps) { + override val requiresSigning: Boolean get() = true + override val httpMethod: String get() = "POST" + override val httpEndpoint: String = + "/user/${Uri.encode(userToBan)}/unban" + + @Serializable + private data class BanBody(val rooms: List) + + override fun buildRequestBody( + serverBaseUrl: String, + x25519PubKeyHex: String + ): Pair { + // JSON body {"rooms":[room]} + return buildJsonRequestBody(BanBody(rooms = listOf(room))) + } + + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ) = Unit + + @AssistedFactory + interface Factory { + fun create( + @Assisted("user") userToBan: String, + room: String + ): UnbanUserApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt index a16f8d24e8..611ce42722 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt @@ -3,6 +3,7 @@ package org.session.libsession.messaging.sending_receiving import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import network.loki.messenger.libsession_util.ED25519 +import network.loki.messenger.libsession_util.protocol.DecodedPro import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.messages.ProfileUpdateHandler @@ -11,7 +12,8 @@ import org.session.libsession.messaging.utilities.MessageAuthentication.buildDel import org.session.libsession.messaging.utilities.MessageAuthentication.buildGroupInviteSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature -import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsession.network.SnodeClock +import org.session.protos.SessionProtos import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log @@ -26,8 +28,14 @@ class GroupMessageHandler @Inject constructor( private val storage: StorageProtocol, private val groupManagerV2: GroupManagerV2, @param:ManagerScope private val scope: CoroutineScope, + private val clock: SnodeClock, ) { - fun handleGroupUpdated(message: GroupUpdated, groupId: AccountId?, proto: SignalServiceProtos.Content) { + fun handleGroupUpdated( + message: GroupUpdated, + groupId: AccountId?, + proto: SessionProtos.Content, + pro: DecodedPro? + ) { val inner = message.inner if (groupId == null && !inner.hasInviteMessage() && !inner.hasPromoteMessage()) { @@ -35,7 +43,7 @@ class GroupMessageHandler @Inject constructor( } // Update profile if needed - ProfileUpdateHandler.Updates.create(proto)?.let { updates -> + ProfileUpdateHandler.Updates.create(proto, clock.currentTimeMillis(), pro)?.let { updates -> profileUpdateHandler.handleProfileUpdate( senderId = AccountId(message.sender!!), updates = updates, @@ -55,7 +63,7 @@ class GroupMessageHandler @Inject constructor( } } - private fun handleNewLibSessionClosedGroupMessage(message: GroupUpdated, proto: SignalServiceProtos.Content) { + private fun handleNewLibSessionClosedGroupMessage(message: GroupUpdated, proto: SessionProtos.Content) { val storage = storage val ourUserId = storage.getUserPublicKey()!! val invite = message.inner.inviteMessage @@ -117,7 +125,7 @@ class GroupMessageHandler @Inject constructor( } - private fun handlePromotionMessage(message: GroupUpdated, proto: SignalServiceProtos.Content) { + private fun handlePromotionMessage(message: GroupUpdated, proto: SessionProtos.Content) { val promotion = message.inner.promoteMessage val seed = promotion.groupIdentitySeed.toByteArray() val sender = message.sender!! diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt deleted file mode 100644 index af8e0c4d2c..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt +++ /dev/null @@ -1,76 +0,0 @@ -package org.session.libsession.messaging.sending_receiving - -import network.loki.messenger.libsession_util.SessionEncrypt -import network.loki.messenger.libsession_util.util.BlindKeyAPI -import network.loki.messenger.libsession_util.util.KeyPair -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.sending_receiving.MessageReceiver.Error -import org.session.libsignal.utilities.Hex -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.removingIdPrefixIfNeeded - -@Deprecated("This class is deprecated and new code should try to decrypt/decode message using SessionProtocol API") -object MessageDecrypter { - - /** - * Decrypts `ciphertext` using the Session protocol and `x25519KeyPair`. - * - * @param ciphertext the data to decrypt. - * @param x25519KeyPair the key pair to use for decryption. This could be the current user's key pair, or the key pair of a closed group. - * - * @return the padded plaintext. - */ - fun decrypt(ciphertext: ByteArray, x25519KeyPair: KeyPair): Pair { - val recipientX25519PrivateKey = x25519KeyPair.secretKey.data - val recipientX25519PublicKey = x25519KeyPair.pubKey.data.removingIdPrefixIfNeeded() - val (id, data) = SessionEncrypt.decryptIncoming( - x25519PubKey = recipientX25519PublicKey, - x25519PrivKey = recipientX25519PrivateKey, - ciphertext = ciphertext - ) - - return data.data to id - } - - fun decryptBlinded( - message: ByteArray, - isOutgoing: Boolean, - otherBlindedPublicKey: String, - serverPublicKey: String - ): Pair { - val userEdKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() - ?: throw Error.NoUserED25519KeyPair - val blindedKeyPair = BlindKeyAPI.blind15KeyPairOrNull( - ed25519SecretKey = userEdKeyPair.secretKey.data, - serverPubKey = Hex.fromStringCondensed(serverPublicKey), - ) ?: throw Error.DecryptionFailed - val otherKeyBytes = - Hex.fromStringCondensed(otherBlindedPublicKey.removingIdPrefixIfNeeded()) - - val senderKeyBytes: ByteArray - val recipientKeyBytes: ByteArray - - if (isOutgoing) { - senderKeyBytes = blindedKeyPair.pubKey.data - recipientKeyBytes = otherKeyBytes - } else { - senderKeyBytes = otherKeyBytes - recipientKeyBytes = blindedKeyPair.pubKey.data - } - - try { - val (sessionId, plainText) = SessionEncrypt.decryptForBlindedRecipient( - ciphertext = message, - myEd25519Privkey = userEdKeyPair.secretKey.data, - openGroupPubkey = Hex.fromStringCondensed(serverPublicKey), - senderBlindedId = byteArrayOf(0x15) + senderKeyBytes, - recipientBlindId = byteArrayOf(0x15) + recipientKeyBytes, - ) - - return plainText.data to sessionId - } catch (e: Exception) { - Log.e("MessageDecrypter", "Failed to decrypt blinded message", e) - throw Error.DecryptionFailed - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt deleted file mode 100644 index d026b4fb26..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.session.libsession.messaging.sending_receiving - -import network.loki.messenger.libsession_util.SessionEncrypt -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.sending_receiving.MessageSender.Error -import org.session.libsignal.utilities.Hex -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.removingIdPrefixIfNeeded - -@Deprecated("This class is deprecated and new code should try to encrypt/encode message using SessionProtocol API") -object MessageEncrypter { - - /** - * Encrypts `plaintext` using the Session protocol for `hexEncodedX25519PublicKey`. - * - * @param plaintext the plaintext to encrypt. Must already be padded. - * @param recipientHexEncodedX25519PublicKey the X25519 public key to encrypt for. Could be the Account ID of a user, or the public key of a closed group. - * - * @return the encrypted message. - */ - internal fun encrypt(plaintext: ByteArray, recipientHexEncodedX25519PublicKey: String): ByteArray { - val userED25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair() - val recipientX25519PublicKey = Hex.fromStringCondensed(recipientHexEncodedX25519PublicKey.removingIdPrefixIfNeeded()) - - try { - return SessionEncrypt.encryptForRecipient( - userED25519KeyPair.secretKey.data, - recipientX25519PublicKey, - plaintext - ).data - } catch (exception: Exception) { - Log.d("Loki", "Couldn't encrypt message due to error: $exception.") - throw Error.EncryptionFailed() - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt index 73eed4bb7b..ee64c19de6 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt @@ -1,11 +1,10 @@ package org.session.libsession.messaging.sending_receiving -import dagger.Lazy import network.loki.messenger.libsession_util.SessionEncrypt +import network.loki.messenger.libsession_util.pro.ProProof import network.loki.messenger.libsession_util.protocol.DecodedEnvelope import network.loki.messenger.libsession_util.protocol.DecodedPro import network.loki.messenger.libsession_util.protocol.SessionProtocol -import network.loki.messenger.libsession_util.util.BitSet import network.loki.messenger.libsession_util.util.asSequence import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.messages.Message @@ -19,19 +18,22 @@ import org.session.libsession.messaging.messages.control.TypingIndicator import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.withGroupConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.exceptions.NonRetryableException -import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix -import org.thoughtcrime.securesms.pro.ProStatusManager +import org.session.protos.SessionProtos +import org.thoughtcrime.securesms.pro.ProBackendConfig import java.util.concurrent.TimeUnit import javax.inject.Inject +import javax.inject.Provider import javax.inject.Singleton import kotlin.math.abs @@ -41,19 +43,23 @@ class MessageParser @Inject constructor( private val storage: StorageProtocol, private val snodeClock: SnodeClock, private val prefs: TextSecurePreferences, + private val proBackendConfig: Provider, ) { - //TODO: Obtain proBackendKey from somewhere - private val proBackendKey = ByteArray(32) - // A faster way to check if the user is blocked than to go through RecipientRepository private fun isUserBlocked(accountId: AccountId): Boolean { return configFactory.withUserConfigs { it.contacts.get(accountId.hexString) } ?.blocked == true } + class ParseResult( + val message: Message, + val proto: SessionProtos.Content, + val pro: DecodedPro? + ) + - private fun createMessageFromProto(proto: SignalServiceProtos.Content, isGroupMessage: Boolean): Message { + private fun createMessageFromProto(proto: SessionProtos.Content, isGroupMessage: Boolean): Message { val message = ReadReceipt.fromProto(proto) ?: TypingIndicator.fromProto(proto) ?: DataExtractionNotification.fromProto(proto) ?: @@ -79,7 +85,7 @@ class MessageParser @Inject constructor( currentUserId: AccountId, currentUserBlindedIDs: List, senderIdPrefix: IdPrefix - ): Pair { + ): ParseResult { return parseMessage( sender = AccountId(senderIdPrefix, decodedEnvelope.senderX25519PubKey.data), contentPlaintext = decodedEnvelope.contentPlainText.data, @@ -103,12 +109,12 @@ class MessageParser @Inject constructor( isForGroup: Boolean, currentUserId: AccountId, currentUserBlindedIDs: List, - ): Pair { - val proto = SignalServiceProtos.Content.parseFrom(contentPlaintext) + ): ParseResult { + val proto = SessionProtos.Content.parseFrom(contentPlaintext) // Check signature - if (proto.hasSigTimestampMs()) { - val diff = abs(proto.sigTimestampMs - messageTimestampMs) + if (proto.hasSigTimestamp()) { + val diff = abs(proto.sigTimestamp - messageTimestampMs) if ( (!relaxSignatureCheck && diff != 0L ) || (relaxSignatureCheck && diff > TimeUnit.HOURS.toMillis(6))) { @@ -133,14 +139,16 @@ class MessageParser @Inject constructor( message.sender = sender.hexString message.recipient = currentUserId.hexString message.sentTimestamp = messageTimestampMs - message.receivedTimestamp = snodeClock.currentTimeMills() + message.receivedTimestamp = snodeClock.currentTimeMillis() message.isSenderSelf = isSenderSelf // Only process pro features post pro launch if (prefs.forcePostPro()) { - (message as? VisibleMessage)?.proFeatures = buildSet { - pro?.proMessageFeatures?.asSequence()?.let(::addAll) - pro?.proProfileFeatures?.asSequence()?.let(::addAll) + if (pro?.status == ProProof.STATUS_VALID) { + (message as? VisibleMessage)?.proFeatures = buildSet { + addAll(pro.proMessageFeatures.asSequence()) + addAll(pro.proProfileFeatures.asSequence()) + } } } @@ -164,7 +172,11 @@ class MessageParser @Inject constructor( } storage.addReceivedMessageTimestamp(messageTimestampMs) - return message to proto + return ParseResult( + message = message, + proto = proto, + pro = pro + ) } @@ -173,11 +185,11 @@ class MessageParser @Inject constructor( serverHash: String?, currentUserEd25519PrivKey: ByteArray, currentUserId: AccountId, - ): Pair { + ): ParseResult { val envelop = SessionProtocol.decodeFor1o1( myEd25519PrivKey = currentUserEd25519PrivKey, payload = data, - proBackendPubKey = proBackendKey, + proBackendPubKey = proBackendConfig.get().ed25519PubKey, ) return parseMessage( @@ -188,8 +200,8 @@ class MessageParser @Inject constructor( senderIdPrefix = IdPrefix.STANDARD, currentUserId = currentUserId, currentUserBlindedIDs = emptyList(), - ).also { (message, _) -> - message.serverHash = serverHash + ).also { result -> + result.message.serverHash = serverHash } } @@ -199,7 +211,7 @@ class MessageParser @Inject constructor( groupId: AccountId, currentUserEd25519PrivKey: ByteArray, currentUserId: AccountId, - ): Pair { + ): ParseResult { val keys = configFactory.withGroupConfigs(groupId) { it.groupKeys.groupKeys() } @@ -209,7 +221,7 @@ class MessageParser @Inject constructor( myEd25519PrivKey = currentUserEd25519PrivKey, groupEd25519PublicKey = groupId.pubKeyBytes, groupEd25519PrivateKeys = keys.toTypedArray(), - proBackendPubKey = proBackendKey + proBackendPubKey = proBackendConfig.get().ed25519PubKey, ) return parseMessage( @@ -220,8 +232,8 @@ class MessageParser @Inject constructor( senderIdPrefix = IdPrefix.STANDARD, currentUserId = currentUserId, currentUserBlindedIDs = emptyList(), - ).also { (message, _) -> - message.serverHash = serverHash + ).also { result -> + result.message.serverHash = serverHash } } @@ -229,15 +241,15 @@ class MessageParser @Inject constructor( msg: OpenGroupApi.Message, currentUserId: AccountId, currentUserBlindedIDs: List, - ): Pair? { + ): ParseResult? { if (msg.data.isNullOrBlank()) { return null } val decoded = SessionProtocol.decodeForCommunity( payload = Base64.decode(msg.data), - timestampMs = snodeClock.currentTimeMills(), - proBackendPubKey = proBackendKey, + timestampMs = msg.posted?.toEpochMilli() ?: 0L, + proBackendPubKey = proBackendConfig.get().ed25519PubKey, ) val sender = AccountId(msg.sessionId) @@ -250,10 +262,10 @@ class MessageParser @Inject constructor( isForGroup = false, currentUserId = currentUserId, sender = sender, - messageTimestampMs = (msg.posted * 1000).toLong(), + messageTimestampMs = msg.posted?.toEpochMilli() ?: 0L, currentUserBlindedIDs = currentUserBlindedIDs, - ).also { (message, _) -> - message.openGroupServerMessageID = msg.id + ).also { result -> + result.message.openGroupServerMessageID = msg.id } } @@ -263,7 +275,7 @@ class MessageParser @Inject constructor( currentUserEd25519PrivKey: ByteArray, currentUserId: AccountId, currentUserBlindedIDs: List, - ): Pair { + ): ParseResult { val (senderId, plaintext) = SessionEncrypt.decryptForBlindedRecipient( ciphertext = Base64.decode(msg.message), myEd25519Privkey = currentUserEd25519PrivKey, @@ -274,8 +286,8 @@ class MessageParser @Inject constructor( val decoded = SessionProtocol.decodeForCommunity( payload = plaintext.data, - timestampMs = snodeClock.currentTimeMills(), - proBackendPubKey = proBackendKey, + timestampMs = msg.postedAt?.toEpochMilli() ?: 0L, + proBackendPubKey = proBackendConfig.get().ed25519PubKey, ) val sender = Address.Standard(AccountId(senderId)) @@ -288,7 +300,7 @@ class MessageParser @Inject constructor( isForGroup = false, currentUserId = currentUserId, sender = sender.accountId, - messageTimestampMs = (msg.postedAt * 1000), + messageTimestampMs = msg.postedAt?.toEpochMilli() ?: 0L, currentUserBlindedIDs = currentUserBlindedIDs, ) } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt deleted file mode 100644 index e2b42f3976..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt +++ /dev/null @@ -1,225 +0,0 @@ -package org.session.libsession.messaging.sending_receiving - -import network.loki.messenger.libsession_util.util.BlindKeyAPI -import network.loki.messenger.libsession_util.util.KeyPair -import org.session.libsession.database.StorageProtocol -import org.session.libsession.messaging.messages.Message -import org.session.libsession.messaging.messages.control.CallMessage -import org.session.libsession.messaging.messages.control.DataExtractionNotification -import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate -import org.session.libsession.messaging.messages.control.GroupUpdated -import org.session.libsession.messaging.messages.control.MessageRequestResponse -import org.session.libsession.messaging.messages.control.ReadReceipt -import org.session.libsession.messaging.messages.control.TypingIndicator -import org.session.libsession.messaging.messages.control.UnsendRequest -import org.session.libsession.messaging.messages.visible.VisibleMessage -import org.session.libsession.snode.SnodeAPI -import org.session.libsignal.crypto.PushTransportDetails -import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.protos.SignalServiceProtos.Envelope -import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.Hex -import org.session.libsignal.utilities.IdPrefix -import org.session.libsignal.utilities.Log -import java.util.concurrent.TimeUnit -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.math.abs - -@Deprecated("This class only exists so the old BatchMessageReceiver can function. New code should use MessageHandler directly.") -@Singleton -class MessageReceiver @Inject constructor( - private val storage: StorageProtocol, -) { - - internal sealed class Error(message: String) : Exception(message) { - object DuplicateMessage: Error("Duplicate message.") - object InvalidMessage: Error("Invalid message.") - object UnknownMessage: Error("Unknown message type.") - object UnknownEnvelopeType: Error("Unknown envelope type.") - object DecryptionFailed : Exception("Couldn't decrypt message.") - object InvalidSignature: Error("Invalid message signature.") - object NoData: Error("Received an empty envelope.") - object SenderBlocked: Error("Received a message from a blocked user.") - object NoThread: Error("Couldn't find thread for message.") - object SelfSend: Error("Message addressed at self.") - object InvalidGroupPublicKey: Error("Invalid group public key.") - object NoGroupThread: Error("No thread exists for this group.") - object NoGroupKeyPair: Error("Missing group key pair.") - object NoUserED25519KeyPair : Error("Couldn't find user ED25519 key pair.") - object ExpiredMessage: Error("Message has already expired, prevent adding") - - internal val isRetryable: Boolean = when (this) { - is DuplicateMessage, is InvalidMessage, is UnknownMessage, - is UnknownEnvelopeType, is InvalidSignature, is NoData, - is SenderBlocked, is SelfSend, - is ExpiredMessage, is NoGroupThread -> false - else -> true - } - } - - internal fun parse( - data: ByteArray, - openGroupServerID: Long?, - isOutgoing: Boolean? = null, - otherBlindedPublicKey: String? = null, - openGroupPublicKey: String? = null, - currentClosedGroups: Set?, - closedGroupSessionId: String? = null, - ): Pair { - val userPublicKey = storage.getUserPublicKey() - val isOpenGroupMessage = (openGroupServerID != null) - var plaintext: ByteArray? = null - var sender: String? = null - var groupPublicKey: String? = null - // Parse the envelope - val envelope = Envelope.parseFrom(data) ?: throw Error.InvalidMessage - // Decrypt the contents - val envelopeContent = envelope.content ?: run { - throw Error.NoData - } - - if (isOpenGroupMessage) { - plaintext = envelopeContent.toByteArray() - sender = envelope.source - } else { - when (envelope.type) { - SignalServiceProtos.Envelope.Type.SESSION_MESSAGE -> { - if (IdPrefix.fromValue(envelope.source)?.isBlinded() == true) { - openGroupPublicKey ?: throw Error.InvalidGroupPublicKey - otherBlindedPublicKey ?: throw Error.DecryptionFailed - val decryptionResult = MessageDecrypter.decryptBlinded( - envelopeContent.toByteArray(), - isOutgoing ?: false, - otherBlindedPublicKey, - openGroupPublicKey - ) - plaintext = decryptionResult.first - sender = decryptionResult.second - } else { - val userX25519KeyPair = storage.getUserX25519KeyPair() - val decryptionResult = MessageDecrypter.decrypt(envelopeContent.toByteArray(), userX25519KeyPair) - plaintext = decryptionResult.first - sender = decryptionResult.second - } - } - SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE -> { - val hexEncodedGroupPublicKey = closedGroupSessionId ?: envelope.source - val sessionId = AccountId(hexEncodedGroupPublicKey) - if (sessionId.prefix == IdPrefix.GROUP) { - plaintext = envelopeContent.toByteArray() - sender = envelope.source - groupPublicKey = hexEncodedGroupPublicKey - } else { - if (!storage.isLegacyClosedGroup(hexEncodedGroupPublicKey)) { - throw Error.InvalidGroupPublicKey - } - val encryptionKeyPairs = storage.getClosedGroupEncryptionKeyPairs(hexEncodedGroupPublicKey) - if (encryptionKeyPairs.isEmpty()) { - throw Error.NoGroupKeyPair - } - // Loop through all known group key pairs in reverse order (i.e. try the latest key pair first (which'll more than - // likely be the one we want) but try older ones in case that didn't work) - var encryptionKeyPair = encryptionKeyPairs.removeAt(encryptionKeyPairs.lastIndex) - fun decrypt() { - try { - val decryptionResult = MessageDecrypter.decrypt(envelopeContent.toByteArray(), - KeyPair( - pubKey = encryptionKeyPair.publicKey.serialize(), - secretKey = encryptionKeyPair.privateKey.serialize() - )) - plaintext = decryptionResult.first - sender = decryptionResult.second - } catch (e: Exception) { - if (encryptionKeyPairs.isNotEmpty()) { - encryptionKeyPair = encryptionKeyPairs.removeAt(encryptionKeyPairs.lastIndex) - decrypt() - } else { - Log.e("Loki", "Failed to decrypt group message", e) - throw e - } - } - } - groupPublicKey = hexEncodedGroupPublicKey - decrypt() - } - } - else -> { - throw Error.UnknownEnvelopeType - } - } - } - // Parse the proto - val proto = SignalServiceProtos.Content.parseFrom(PushTransportDetails.getStrippedPaddingMessageBody(plaintext)) - - // Verify the signature timestamp inside the content is the same as in envelope. - // If the message is from an open group, 6 hours of difference is allowed. - if (proto.hasSigTimestampMs()) { - val isCommunityOrCommunityInbox = openGroupServerID != null || otherBlindedPublicKey != null - - if ( - (isCommunityOrCommunityInbox && abs(proto.sigTimestampMs - envelope.timestampMs) > TimeUnit.HOURS.toMillis(6)) || - (!isCommunityOrCommunityInbox && proto.sigTimestampMs != envelope.timestampMs) - ) { - throw Error.InvalidSignature - } - } - - // Parse the message - val message: Message = ReadReceipt.fromProto(proto) ?: - TypingIndicator.fromProto(proto) ?: - DataExtractionNotification.fromProto(proto) ?: - ExpirationTimerUpdate.fromProto(proto, closedGroupSessionId != null) ?: - UnsendRequest.fromProto(proto) ?: - MessageRequestResponse.fromProto(proto) ?: - CallMessage.fromProto(proto) ?: - GroupUpdated.fromProto(proto) ?: - VisibleMessage.fromProto(proto) ?: throw Error.UnknownMessage - - // Don't process the envelope any further if the sender is blocked (still visible in community chats) - if (!isOpenGroupMessage && isBlocked(sender!!) && message.shouldDiscardIfBlocked()) { - throw Error.SenderBlocked - } - val isUserBlindedSender = sender == openGroupPublicKey?.let { - BlindKeyAPI.blind15KeyPairOrNull( - ed25519SecretKey = storage.getUserED25519KeyPair()!!.secretKey.data, - serverPubKey = Hex.fromStringCondensed(it), - ) - }?.let { AccountId(IdPrefix.BLINDED, it.pubKey.data).hexString } - val isUserSender = sender == userPublicKey - - if (isUserSender || isUserBlindedSender) { - // Ignore self send if needed - if (!message.isSelfSendValid) throw Error.SelfSend - message.isSenderSelf = true - } - // Guard against control messages in open groups - if (isOpenGroupMessage && message !is VisibleMessage) { - throw Error.InvalidMessage - } - // Finish parsing - message.sender = sender - message.recipient = userPublicKey - message.sentTimestamp = envelope.timestampMs - message.receivedTimestamp = if (envelope.hasServerTimestampMs()) envelope.serverTimestampMs else SnodeAPI.nowWithOffset - message.groupPublicKey = groupPublicKey - message.openGroupServerMessageID = openGroupServerID - // Validate - var isValid = message.isValid() - if (message is VisibleMessage && !isValid && proto.dataMessage.attachmentsCount != 0) { isValid = true } - if (!isValid) { - throw Error.InvalidMessage - } - // If the message failed to process the first time around we retry it later (if the error is retryable). In this case the timestamp - // will already be in the database but we don't want to treat the message as a duplicate. The isRetry flag is a simple workaround - // for this issue. - if (groupPublicKey != null && groupPublicKey !in (currentClosedGroups ?: emptySet()) && IdPrefix.fromValue(groupPublicKey) != IdPrefix.GROUP) { - throw Error.NoGroupThread - } - if (storage.isDuplicateMessage(envelope.timestampMs)) { throw Error.DuplicateMessage } - storage.addReceivedMessageTimestamp(envelope.timestampMs) - // Return - return Pair(message, proto) - } - -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt index a1db7543b2..99e517673e 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt @@ -1,19 +1,22 @@ package org.session.libsession.messaging.sending_receiving -import network.loki.messenger.libsession_util.util.BitSet +import network.loki.messenger.libsession_util.protocol.DecodedPro import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.ProfileUpdateHandler import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.signal.IncomingMediaMessage import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.updateContact import org.session.libsession.utilities.upsertContact -import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsession.utilities.withMutableUserConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.Log +import org.session.protos.SessionProtos import org.thoughtcrime.securesms.database.BlindMappingRepository import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.RecipientRepository @@ -31,6 +34,7 @@ class MessageRequestResponseHandler @Inject constructor( private val smsDatabase: SmsDatabase, private val threadDatabase: ThreadDatabase, private val blindMappingRepository: BlindMappingRepository, + private val clock: SnodeClock, ) { fun handleVisibleMessage( @@ -69,7 +73,8 @@ class MessageRequestResponseHandler @Inject constructor( fun handleExplicitRequestResponseMessage( ctx: ReceivedMessageProcessor.MessageProcessingContext?, message: MessageRequestResponse, - proto: SignalServiceProtos.Content, + proto: SessionProtos.Content, + pro: DecodedPro?, ) { val (sender, receiver) = fetchSenderAndReceiver(message) ?: return // Always handle explicit request response @@ -82,7 +87,7 @@ class MessageRequestResponseHandler @Inject constructor( // Always process the profile update if any. We don't need // to process profile for other kind of messages as they should be handled elsewhere - ProfileUpdateHandler.Updates.create(proto)?.let { updates -> + ProfileUpdateHandler.Updates.create(proto, clock.currentTimeMillis(), pro)?.let { updates -> profileUpdateHandler.get().handleProfileUpdate( senderId = (sender.address as Address.Standard).accountId, updates = updates, diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index 590dec0032..2777700af3 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -6,12 +6,10 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope +import network.loki.messenger.libsession_util.Namespace import network.loki.messenger.libsession_util.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.PRIORITY_VISIBLE -import network.loki.messenger.libsession_util.Namespace -import network.loki.messenger.libsession_util.ReadableUserProfile import network.loki.messenger.libsession_util.protocol.SessionProtocol -import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.StorageProtocol @@ -22,31 +20,40 @@ import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.control.GroupUpdated -import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.visible.LinkPreview import org.session.libsession.messaging.messages.visible.Quote import org.session.libsession.messaging.messages.visible.VisibleMessage -import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability import org.session.libsession.messaging.open_groups.OpenGroupMessage -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.messaging.open_groups.api.CommunityApiExecutor +import org.session.libsession.messaging.open_groups.api.CommunityApiRequest +import org.session.libsession.messaging.open_groups.api.SendDirectMessageApi +import org.session.libsession.messaging.open_groups.api.SendMessageApi +import org.session.libsession.messaging.open_groups.api.execute +import org.session.libsession.network.SnodeClock import org.session.libsession.snode.SnodeMessage import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol -import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsession.utilities.withGroupConfigs +import org.session.libsession.utilities.withMutableUserConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log +import org.session.protos.SessionProtos +import org.thoughtcrime.securesms.api.snode.StoreMessageApi +import org.thoughtcrime.securesms.api.swarm.SwarmApiExecutor +import org.thoughtcrime.securesms.api.swarm.SwarmApiRequest +import org.thoughtcrime.securesms.api.swarm.execute +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.pro.copyFromLibSession -import org.thoughtcrime.securesms.pro.db.ProDatabase import org.thoughtcrime.securesms.service.ExpiringMessageManager -import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.cancellation.CancellationException @@ -61,9 +68,14 @@ class MessageSender @Inject constructor( private val messageDataProvider: MessageDataProvider, private val messageSendJobFactory: MessageSendJob.Factory, private val messageExpirationManager: ExpiringMessageManager, - private val proDatabase: ProDatabase, private val snodeClock: SnodeClock, + private val communityApiExecutor: CommunityApiExecutor, + private val sendCommunityMessageApiFactory: SendMessageApi.Factory, + private val sendCommunityDirectMessageApiFactory: SendDirectMessageApi.Factory, + private val swarmApiExecutor: SwarmApiExecutor, + private val storeSnodeMessageApiFactory: StoreMessageApi.Factory, @param:ManagerScope private val scope: CoroutineScope, + private val loginStateRepository: LoginStateRepository, ) { // Error @@ -84,25 +96,25 @@ class MessageSender @Inject constructor( } - private fun SignalServiceProtos.DataMessage.Builder.copyProfileFromConfig() { + private fun SessionProtos.DataMessage.Builder.copyProfileFromConfig() { configFactory.withUserConfigs { val pic = it.userProfile.getPic() profileBuilder.setDisplayName(it.userProfile.getName().orEmpty()) .setProfilePicture(pic.url) - .setLastProfileUpdateSeconds(it.userProfile.getProfileUpdatedSeconds()) + .setLastUpdateSeconds(it.userProfile.getProfileUpdatedSeconds()) setProfileKey(ByteString.copyFrom(pic.keyAsByteArray)) } } - private fun SignalServiceProtos.MessageRequestResponse.Builder.copyProfileFromConfig() { + private fun SessionProtos.MessageRequestResponse.Builder.copyProfileFromConfig() { configFactory.withUserConfigs { val pic = it.userProfile.getPic() profileBuilder.setDisplayName(it.userProfile.getName().orEmpty()) .setProfilePicture(pic.url) - .setLastProfileUpdateSeconds(it.userProfile.getProfileUpdatedSeconds()) + .setLastUpdateSeconds(it.userProfile.getProfileUpdatedSeconds()) setProfileKey(ByteString.copyFrom(pic.keyAsByteArray)) } @@ -117,15 +129,19 @@ class MessageSender @Inject constructor( } } - private fun buildProto(msg: Message): SignalServiceProtos.Content { + private fun buildProto(msg: Message): SessionProtos.Content { try { - val builder = SignalServiceProtos.Content.newBuilder() + val builder = SessionProtos.Content.newBuilder() msg.toProto(builder, messageDataProvider) // Attach pro proof - configFactory.withUserConfigs { it.userProfile.getProConfig() }?.proProof?.let { proof -> - builder.proMessageBuilder.proofBuilder.copyFromLibSession(proof) + val proProof = configFactory.withUserConfigs { it.userProfile.getProConfig() }?.proProof + if (proProof != null && proProof.expiryMs > snodeClock.currentTimeMillis()) { + builder.proMessageBuilder.proofBuilder.copyFromLibSession(proProof) + } else { + // If we don't have any valid pro proof, clear the pro message + builder.clearProMessage() } // Attach the user's profile if needed @@ -152,7 +168,7 @@ class MessageSender @Inject constructor( "Missing user key" } // Set the timestamp, sender and recipient - val messageSendTime = snodeClock.currentTimeMills() + val messageSendTime = snodeClock.currentTimeMillis() if (message.sentTimestamp == null) { message.sentTimestamp = messageSendTime // Visible messages will already have their sent timestamp set @@ -183,27 +199,34 @@ class MessageSender @Inject constructor( throw Error.InvalidMessage() } + val proRotatingEd25519PrivKey = configFactory.withUserConfigs { configs -> + configs.userProfile.getProConfig() + }?.rotatingPrivateKey?.data + + val messagePlaintext = buildProto(message).toByteArray() + + val messageContent = when (destination) { is Destination.Contact -> { SessionProtocol.encodeFor1o1( - plaintext = buildProto(message).toByteArray(), + plaintext = messagePlaintext, myEd25519PrivKey = userEd25519PrivKey, timestampMs = message.sentTimestamp!!, recipientPubKey = Hex.fromStringCondensed(destination.publicKey), - proRotatingEd25519PrivKey = null, + proRotatingEd25519PrivKey = proRotatingEd25519PrivKey, ) } is Destination.ClosedGroup -> { SessionProtocol.encodeForGroup( - plaintext = buildProto(message).toByteArray(), + plaintext = messagePlaintext, myEd25519PrivKey = userEd25519PrivKey, timestampMs = message.sentTimestamp!!, groupEd25519PublicKey = Hex.fromStringCondensed(destination.publicKey), groupEd25519PrivateKey = configFactory.withGroupConfigs(AccountId(destination.publicKey)) { it.groupKeys.groupEncKey() }, - proRotatingEd25519PrivKey = null + proRotatingEd25519PrivKey = proRotatingEd25519PrivKey, ) } @@ -236,14 +259,28 @@ class MessageSender @Inject constructor( "Unable to authorize group message send" } - SnodeAPI.sendMessage( - auth = groupAuth, - message = snodeMessage, - namespace = Namespace.GROUP_MESSAGES(), + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = destination.publicKey, + api = storeSnodeMessageApiFactory.create( + message = snodeMessage, + auth = groupAuth, + namespace = Namespace.GROUP_MESSAGES(), + ) + ) ) } is Destination.Contact -> { - SnodeAPI.sendMessage(snodeMessage, auth = null, namespace = Namespace.DEFAULT()) + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = destination.publicKey, + api = storeSnodeMessageApiFactory.create( + message = snodeMessage, + auth = null, + namespace = Namespace.DEFAULT() + ) + ) + ) } is Destination.OpenGroup, is Destination.OpenGroupInbox -> throw IllegalStateException("Destination should not be an open group.") @@ -295,7 +332,7 @@ class MessageSender @Inject constructor( // Open Groups private suspend fun sendToOpenGroupDestination(destination: Destination, message: Message) { if (message.sentTimestamp == null) { - message.sentTimestamp = snodeClock.currentTimeMills() + message.sentTimestamp = snodeClock.currentTimeMillis() } // Attach the blocks message requests info configFactory.withUserConfigs { configs -> @@ -303,25 +340,24 @@ class MessageSender @Inject constructor( message.blocksMessageRequests = !configs.userProfile.getCommunityMessageRequests() } } - val userEdKeyPair = storage.getUserED25519KeyPair()!! var serverCapabilities: List var blindedPublicKey: ByteArray? = null + val loggedInState = loginStateRepository.requireLoggedInState() when (destination) { is Destination.OpenGroup -> { serverCapabilities = storage.getServerCapabilities(destination.server).orEmpty() storage.getOpenGroupPublicKey(destination.server)?.let { - blindedPublicKey = BlindKeyAPI.blind15KeyPairOrNull( - ed25519SecretKey = userEdKeyPair.secretKey.data, - serverPubKey = Hex.fromStringCondensed(it), - )?.pubKey?.data + blindedPublicKey = loggedInState + .getBlindedKeyPair(serverUrl = destination.server, serverPubKeyHex = it) + .pubKey.data } } is Destination.OpenGroupInbox -> { serverCapabilities = storage.getServerCapabilities(destination.server).orEmpty() - blindedPublicKey = BlindKeyAPI.blind15KeyPairOrNull( - ed25519SecretKey = userEdKeyPair.secretKey.data, - serverPubKey = Hex.fromStringCondensed(destination.serverPublicKey), - )?.pubKey?.data + blindedPublicKey = loggedInState + .getBlindedKeyPair(serverUrl = destination.server, + serverPubKeyHex = destination.serverPublicKey) + .pubKey.data } is Destination.ClosedGroup, @@ -330,7 +366,7 @@ class MessageSender @Inject constructor( val messageSender = if (serverCapabilities.contains(Capability.BLIND.name.lowercase()) && blindedPublicKey != null) { AccountId(IdPrefix.BLINDED, blindedPublicKey).hexString } else { - AccountId(IdPrefix.UN_BLINDED, userEdKeyPair.pubKey.data).hexString + AccountId(IdPrefix.UN_BLINDED, loggedInState.accountEd25519KeyPair.pubKey.data).hexString } message.sender = messageSender @@ -347,7 +383,9 @@ class MessageSender @Inject constructor( } val plaintext = SessionProtocol.encodeForCommunity( plaintext = content.toByteArray(), - proRotatingEd25519PrivKey = null + proRotatingEd25519PrivKey = configFactory.withUserConfigs { configs -> + configs.userProfile.getProConfig() + }?.rotatingPrivateKey?.data, ) val openGroupMessage = OpenGroupMessage( @@ -356,17 +394,19 @@ class MessageSender @Inject constructor( base64EncodedData = Base64.encodeBytes(plaintext), ) - val response = OpenGroupApi.sendMessage( - openGroupMessage, - destination.roomToken, - destination.server, - destination.whisperTo, - destination.whisperMods, - destination.fileIds + val response = communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = destination.server, + api = sendCommunityMessageApiFactory.create( + room = destination.roomToken, + message = openGroupMessage, + fileIds = destination.fileIds + ) + ), ) - message.openGroupServerMessageID = response.serverID - handleSuccessfulMessageSend(message, destination, openGroupSentTimestamp = response.sentTimestamp) + message.openGroupServerMessageID = response.id + handleSuccessfulMessageSend(message, destination, openGroupSentTimestamp = response.postedMills) return } is Destination.OpenGroupInbox -> { @@ -377,22 +417,24 @@ class MessageSender @Inject constructor( } val ciphertext = SessionProtocol.encodeForCommunityInbox( plaintext = content.toByteArray(), - myEd25519PrivKey = userEdKeyPair.secretKey.data, + myEd25519PrivKey = loggedInState.accountEd25519KeyPair.secretKey.data, timestampMs = message.sentTimestamp!!, recipientPubKey = Hex.fromStringCondensed(destination.blindedPublicKey), communityServerPubKey = Hex.fromStringCondensed(destination.serverPublicKey), proRotatingEd25519PrivKey = null, ) - val base64EncodedData = Base64.encodeBytes(ciphertext) - val response = OpenGroupApi.sendDirectMessage( - base64EncodedData, - destination.blindedPublicKey, - destination.server - ) + val response = communityApiExecutor.execute(CommunityApiRequest( + serverBaseUrl = destination.server, + api = sendCommunityDirectMessageApiFactory.create( + recipient = destination.blindedPublicKey.toAddress() as Address.Blinded, + messageContent = Base64.encodeBytes(ciphertext) + ) + )) message.openGroupServerMessageID = response.id - handleSuccessfulMessageSend(message, destination, openGroupSentTimestamp = TimeUnit.SECONDS.toMillis(response.postedAt)) + handleSuccessfulMessageSend(message, destination, + openGroupSentTimestamp = response.postedAt?.toEpochMilli() ?: 0L) return } else -> throw IllegalStateException("Invalid destination.") diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt deleted file mode 100644 index 979e83c8cb..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ /dev/null @@ -1,829 +0,0 @@ -package org.session.libsession.messaging.sending_receiving - -import android.content.Context -import android.text.TextUtils -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import network.loki.messenger.R -import network.loki.messenger.libsession_util.PRIORITY_HIDDEN -import network.loki.messenger.libsession_util.PRIORITY_VISIBLE -import network.loki.messenger.libsession_util.ED25519 -import network.loki.messenger.libsession_util.util.BaseCommunityInfo -import network.loki.messenger.libsession_util.util.BlindKeyAPI -import network.loki.messenger.libsession_util.util.ExpiryMode -import network.loki.messenger.libsession_util.util.Util -import org.session.libsession.database.MessageDataProvider -import org.session.libsession.database.StorageProtocol -import org.session.libsession.database.userAuth -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.groups.GroupManagerV2 -import org.session.libsession.messaging.jobs.AttachmentDownloadJob -import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.messages.Message -import org.session.libsession.messaging.messages.ProfileUpdateHandler -import org.session.libsession.messaging.messages.control.CallMessage -import org.session.libsession.messaging.messages.control.DataExtractionNotification -import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate -import org.session.libsession.messaging.messages.control.GroupUpdated -import org.session.libsession.messaging.messages.control.MessageRequestResponse -import org.session.libsession.messaging.messages.control.ReadReceipt -import org.session.libsession.messaging.messages.control.TypingIndicator -import org.session.libsession.messaging.messages.control.UnsendRequest -import org.session.libsession.messaging.messages.visible.Attachment -import org.session.libsession.messaging.messages.visible.VisibleMessage -import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment -import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage -import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview -import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier -import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel -import org.session.libsession.messaging.utilities.MessageAuthentication.buildDeleteMemberContentSignature -import org.session.libsession.messaging.utilities.MessageAuthentication.buildGroupInviteSignature -import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeSignature -import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature -import org.session.libsession.messaging.utilities.WebRtcUtils -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.Address.Companion.toAddress -import org.session.libsession.utilities.ConfigFactoryProtocol -import org.session.libsession.utilities.GroupRecord -import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID -import org.session.libsession.utilities.SSKEnvironment -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.recipients.MessageType -import org.session.libsession.utilities.recipients.Recipient -import org.session.libsession.utilities.recipients.RecipientData -import org.session.libsession.utilities.recipients.getType -import org.session.libsession.utilities.updateContact -import org.session.libsession.utilities.upsertContact -import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.Hex -import org.session.libsignal.utilities.IdPrefix -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.guava.Optional -import org.thoughtcrime.securesms.database.ConfigDatabase -import org.thoughtcrime.securesms.database.RecipientRepository -import org.thoughtcrime.securesms.database.model.MessageId -import org.thoughtcrime.securesms.database.model.ReactionRecord -import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.pro.ProStatusManager -import org.thoughtcrime.securesms.sskenvironment.ReadReceiptManager -import java.security.SignatureException -import javax.inject.Inject -import javax.inject.Provider -import javax.inject.Singleton -import kotlin.math.min - -internal fun MessageReceiver.isBlocked(publicKey: String): Boolean { - val recipient = MessagingModuleConfiguration.shared.recipientRepository.getRecipientSync(Address.fromSerialized(publicKey)) - return recipient?.blocked == true -} - -@Deprecated(replaceWith = ReplaceWith("ReceivedMessageProcessor"), message = "Use ReceivedMessageProcessor instead") -@Singleton -class ReceivedMessageHandler @Inject constructor( - @param:ApplicationContext private val context: Context, - private val storage: StorageProtocol, - private val readReceiptManager: ReadReceiptManager, - private val typingIndicators: SSKEnvironment.TypingIndicatorsProtocol, - private val messageDataProvider: MessageDataProvider, - private val messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol, - private val notificationManager: MessageNotifier, - private val groupManagerV2: GroupManagerV2, - private val proStatusManager: ProStatusManager, - private val visibleMessageContextFactory: VisibleMessageHandlerContext.Factory, - private val attachmentDownloadJobFactory: AttachmentDownloadJob.Factory, - private val profileUpdateHandler: Provider, - @param:ManagerScope private val scope: CoroutineScope, - private val configFactory: ConfigFactoryProtocol, - private val messageRequestResponseHandler: Provider, - private val prefs: TextSecurePreferences, -) { - - suspend fun handle( - message: Message, - proto: SignalServiceProtos.Content, - threadId: Long, - threadAddress: Address.Conversable, - ) { - // Do nothing if the message was outdated - if (messageIsOutdated(message, threadId)) { return } - - when (message) { - is ReadReceipt -> handleReadReceipt(message) - is TypingIndicator -> handleTypingIndicator(message) - is GroupUpdated -> handleGroupUpdated( - message = message, - closedGroup = (threadAddress as? Address.Group)?.accountId, - proto = proto - ) - is ExpirationTimerUpdate -> { - // For groupsv2, there are dedicated mechanisms for handling expiration timers, and - // we want to avoid the 1-to-1 message format which is unauthenticated in a group settings. - if (threadAddress is Address.Group) { - Log.d("MessageReceiver", "Ignoring expiration timer update for closed group") - } // also ignore it for communities since they do not support disappearing messages - else if (threadAddress is Address.Community) { - Log.d("MessageReceiver", "Ignoring expiration timer update for communities") - } else { - handleExpirationTimerUpdate(message) - } - } - is DataExtractionNotification -> handleDataExtractionNotification(message) - is UnsendRequest -> handleUnsendRequest(message) - is MessageRequestResponse -> messageRequestResponseHandler.get().handleExplicitRequestResponseMessage(null, message, proto) - is VisibleMessage -> handleVisibleMessage( - message = message, - proto = proto, - context = visibleMessageContextFactory.create(threadId, threadAddress), - runThreadUpdate = true, - runProfileUpdate = true - ) - is CallMessage -> handleCallMessage(message) - } - } - - private fun messageIsOutdated(message: Message, threadId: Long): Boolean { - when (message) { - is ReadReceipt -> return false // No visible artifact created so better to keep for more reliable read states - is UnsendRequest -> return false // We should always process the removal of messages just in case - } - - // Determine the state of the conversation and the validity of the message - val userPublicKey = storage.getUserPublicKey()!! - val threadRecipient = storage.getRecipientForThread(threadId) - val conversationExists = threadRecipient != null - val canPerformChange = storage.canPerformConfigChange( - if (threadRecipient?.address?.toString() == userPublicKey) ConfigDatabase.USER_PROFILE_VARIANT else ConfigDatabase.CONTACTS_VARIANT, - userPublicKey, - message.sentTimestamp!! - ) - - // If the thread is visible or the message was sent more recently than the last config message (minus - // buffer period) then we should process the message, if not then the message is outdated - return (!conversationExists && !canPerformChange) - } - - private fun handleReadReceipt(message: ReadReceipt) { - readReceiptManager.processReadReceipts( - message.sender!!, - message.timestamps!!, - message.receivedTimestamp!! - ) - } - - private fun handleCallMessage(message: CallMessage) { - // TODO: refactor this out to persistence, just to help debug the flow and send/receive in synchronous testing - WebRtcUtils.SIGNAL_QUEUE.trySend(message) - } - - private fun handleTypingIndicator(message: TypingIndicator) { - when (message.kind!!) { - TypingIndicator.Kind.STARTED -> showTypingIndicatorIfNeeded(message.sender!!) - TypingIndicator.Kind.STOPPED -> hideTypingIndicatorIfNeeded(message.sender!!) - } - } - - private fun showTypingIndicatorIfNeeded(senderPublicKey: String) { - // We don't want to show other people's indicators if the toggle is off - if(!prefs.isTypingIndicatorsEnabled()) return - - val address = Address.fromSerialized(senderPublicKey) - val threadID = storage.getThreadId(address) ?: return - typingIndicators.didReceiveTypingStartedMessage(threadID, address, 1) - } - - private fun hideTypingIndicatorIfNeeded(senderPublicKey: String) { - val address = Address.fromSerialized(senderPublicKey) - val threadID = storage.getThreadId(address) ?: return - typingIndicators.didReceiveTypingStoppedMessage(threadID, address, 1, false) - } - - private fun cancelTypingIndicatorsIfNeeded(senderPublicKey: String) { - val address = Address.fromSerialized(senderPublicKey) - val threadID = storage.getThreadId(address) ?: return - typingIndicators.didReceiveIncomingMessage(threadID, address, 1) - } - - private fun handleExpirationTimerUpdate(message: ExpirationTimerUpdate) { - messageExpirationManager.run { - insertExpirationTimerMessage(message) - onMessageReceived(message) - } - } - - private fun handleDataExtractionNotification(message: DataExtractionNotification) { - // We don't handle data extraction messages for groups (they shouldn't be sent, but just in case we filter them here too) - if (message.groupPublicKey != null) return - val senderPublicKey = message.sender!! - - val notification: DataExtractionNotificationInfoMessage = when(message.kind) { - is DataExtractionNotification.Kind.MediaSaved -> DataExtractionNotificationInfoMessage(DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED) - else -> return - } - storage.insertDataExtractionNotificationMessage(senderPublicKey, notification, message.sentTimestamp!!) - } - - - fun handleUnsendRequest(message: UnsendRequest): MessageId? { - val userPublicKey = storage.getUserPublicKey() - val userAuth = storage.userAuth ?: return null - val isLegacyGroupAdmin: Boolean = message.groupPublicKey?.let { key -> - var admin = false - val groupID = doubleEncodeGroupID(key) - val group = storage.getGroup(groupID) - if(group != null) { - admin = group.admins.map { it.toString() }.contains(message.sender) - } - admin - } ?: false - - // First we need to determine the validity of the UnsendRequest - // It is valid if: - val requestIsValid = message.sender == message.author || // the sender is the author of the message - message.author == userPublicKey || // the sender is the current user - isLegacyGroupAdmin // sender is an admin of legacy group - - if (!requestIsValid) { return null } - - val timestamp = message.timestamp ?: return null - val author = message.author ?: return null - val messageToDelete = storage.getMessageByTimestamp(timestamp, author, false) ?: return null - val messageIdToDelete = messageToDelete.messageId - val messageType = messageToDelete.individualRecipient?.getType() - - // send a /delete rquest for 1on1 messages - if (messageType == MessageType.ONE_ON_ONE) { - messageDataProvider.getServerHashForMessage(messageIdToDelete)?.let { serverHash -> - scope.launch(Dispatchers.IO) { // using scope as we are slowly migrating to coroutines but we can't migrate everything at once - try { - SnodeAPI.deleteMessage(author, userAuth, listOf(serverHash)) - } catch (e: Exception) { - Log.e("Loki", "Failed to delete message", e) - } - } - } - } - - // the message is marked as deleted locally - // except for 'note to self' where the message is completely deleted - if (messageType == MessageType.NOTE_TO_SELF){ - messageDataProvider.deleteMessage(messageIdToDelete) - } else { - messageDataProvider.markMessageAsDeleted( - messageIdToDelete, - displayedMessage = context.getString(R.string.deleteMessageDeletedGlobally) - ) - } - - // delete reactions - storage.deleteReactions(messageToDelete.messageId) - - // update notification - if (!messageToDelete.isOutgoing) { - notificationManager.updateNotification(context) - } - - return messageIdToDelete - } - - suspend fun handleVisibleMessage( - message: VisibleMessage, - proto: SignalServiceProtos.Content, - context: VisibleMessageHandlerContext, - runThreadUpdate: Boolean, - runProfileUpdate: Boolean - ): MessageId? { - val userPublicKey = context.storage.getUserPublicKey() - val senderAddress = message.sender!!.toAddress() - - // Do nothing if the message was outdated - if (messageIsOutdated(message, context.threadId)) { return null } - - messageRequestResponseHandler.get().handleVisibleMessage(null, message) - - // Handle group invite response if new closed group - val threadRecipientAddress = context.threadAddress - if (threadRecipientAddress is Address.Group && senderAddress is Address.Standard) { - scope.launch { - try { - groupManagerV2 - .handleInviteResponse( - threadRecipientAddress.accountId, - senderAddress.accountId, - approved = true - ) - } catch (e: Exception) { - Log.e("Loki", "Failed to handle invite response", e) - } - } - } - // Parse quote if needed - var quoteModel: QuoteModel? = null - var quoteMessageBody: String? = null - if (message.quote != null && proto.dataMessage.hasQuote()) { - val quote = proto.dataMessage.quote - - val author = if (quote.author == context.userBlindedKey) { - Address.fromSerialized(userPublicKey!!) - } else { - Address.fromSerialized(quote.author) - } - - val messageInfo = messageDataProvider.getMessageForQuote(context.threadId, quote.id, author) - quoteMessageBody = messageInfo?.third - quoteModel = if (messageInfo != null) { - val attachments = if (messageInfo.second) messageDataProvider.getAttachmentsAndLinkPreviewFor(messageInfo.first) else ArrayList() - QuoteModel(quote.id, author,null,false, attachments) - } else { - QuoteModel(quote.id, author,null, true, PointerAttachment.forPointers(proto.dataMessage.quote.attachmentsList)) - } - } - // Parse link preview if needed - val linkPreviews: MutableList = mutableListOf() - if (message.linkPreview != null && proto.dataMessage.previewCount > 0) { - for (preview in proto.dataMessage.previewList) { - val thumbnail = PointerAttachment.forPointer(preview.image) - val url = Optional.fromNullable(preview.url) - val title = Optional.fromNullable(preview.title) - val hasContent = !TextUtils.isEmpty(title.or("")) || thumbnail.isPresent - if (hasContent) { - val linkPreview = LinkPreview(url.get(), title.or(""), thumbnail) - linkPreviews.add(linkPreview) - } else { - Log.w("Loki", "Discarding an invalid link preview. hasContent: $hasContent") - } - } - } - // Parse attachments if needed - val attachments = proto.dataMessage.attachmentsList.map(Attachment::fromProto).filter { it.isValid() } - - // Cancel any typing indicators if needed - cancelTypingIndicatorsIfNeeded(message.sender!!) - - // Parse reaction if needed - val threadIsGroup = context.threadRecipient.isGroupOrCommunityRecipient - message.reaction?.let { reaction -> - if (reaction.react == true) { - reaction.serverId = message.openGroupServerMessageID?.toString() ?: message.serverHash.orEmpty() - reaction.dateSent = message.sentTimestamp ?: 0 - reaction.dateReceived = message.receivedTimestamp ?: 0 - context.storage.addReaction( - threadId = context.threadId, - reaction = reaction, - messageSender = senderAddress.address, - notifyUnread = !threadIsGroup - ) - } else { - context.storage.removeReaction( - emoji = reaction.emoji!!, - messageTimestamp = reaction.timestamp!!, - threadId = context.threadId, - author = senderAddress.address, - notifyUnread = threadIsGroup - ) - } - } ?: run { - // A user is mentioned if their public key is in the body of a message or one of their messages - // was quoted - - // Verify the incoming message length and truncate it if needed, before saving it to the db - val maxChars = proStatusManager.getIncomingMessageMaxLength(message) - val messageText = message.text?.let { Util.truncateCodepoints(it, maxChars) } // truncate to max char limit for this message - message.text = messageText - message.hasMention = listOfNotNull(userPublicKey, context.userBlindedKey) - .any { key -> - messageText?.contains("@$key") == true || key == (quoteModel?.author?.toString() ?: "") - } - - // Persist the message - message.threadID = context.threadId - - // clean up the message - For example we do not want any expiration data on messages for communities - if(message.openGroupServerMessageID != null){ - message.expiryMode = ExpiryMode.NONE - } - - val messageID = context.storage.persist( - threadRecipient = context.threadRecipient, - message = message, - quotes = quoteModel, - linkPreview = linkPreviews, - attachments = attachments, - runThreadUpdate = runThreadUpdate - ) ?: return null - - // If we have previously "hidden" the sender, we should flip the flag back to visible, - // and this should only be done only for 1:1 messages - if (senderAddress is Address.Standard && senderAddress.address != userPublicKey - && context.threadAddress is Address.Standard) { - val existingContact = - configFactory.withUserConfigs { it.contacts.get(senderAddress.accountId.hexString) } - - if (existingContact != null && existingContact.priority == PRIORITY_HIDDEN) { - Log.d(TAG, "Flipping thread for ${senderAddress.debugString} to visible") - configFactory.withMutableUserConfigs { configs -> - configs.contacts.updateContact(senderAddress) { - priority = PRIORITY_VISIBLE - } - } - } else if (existingContact == null || !existingContact.approvedMe) { - // If we don't have the contact, create a new one with approvedMe = true - Log.d(TAG, "Creating new contact for ${senderAddress.debugString} with approvedMe = true") - configFactory.withMutableUserConfigs { configs -> - configs.contacts.upsertContact(senderAddress) { - approvedMe = true - } - } - } - } - - // Update profile if needed: - // - must be done after the message is persisted) - // - must be done after neccessary contact is created - if (runProfileUpdate && senderAddress is Address.WithAccountId) { - val updates = ProfileUpdateHandler.Updates.create(proto) - - if (updates != null) { - profileUpdateHandler.get().handleProfileUpdate( - senderId = senderAddress.accountId, - updates = updates, - fromCommunity = (context.threadRecipient.data as? RecipientData.Community)?.let { data -> - BaseCommunityInfo(baseUrl = data.serverUrl, room = data.room, pubKeyHex = data.serverPubKey) - }, - ) - } - } - - // Parse & persist attachments - // Start attachment downloads if needed - if (messageID.mms && (context.threadRecipient.autoDownloadAttachments == true || senderAddress.address == userPublicKey)) { - context.storage.getAttachmentsForMessage(messageID.id).iterator().forEach { attachment -> - attachment.attachmentId?.let { id -> - JobQueue.shared.add(attachmentDownloadJobFactory.create( - attachmentID = id.rowId, - mmsMessageId = messageID.id - )) - } - } - } - message.openGroupServerMessageID?.let { - context.storage.setOpenGroupServerMessageID( - messageID = messageID, - serverID = it, - threadID = context.threadId - ) - } - message.id = messageID - context.messageExpirationManager.onMessageReceived(message) - return messageID - } - return null - } - - private fun handleGroupUpdated(message: GroupUpdated, closedGroup: AccountId?, proto: SignalServiceProtos.Content) { - val inner = message.inner - if (closedGroup == null && - !inner.hasInviteMessage() && !inner.hasPromoteMessage()) { - throw NullPointerException("Message wasn't polled from a closed group!") - } - - // Update profile if needed - ProfileUpdateHandler.Updates.create(proto)?.let { updates -> - profileUpdateHandler.get().handleProfileUpdate( - senderId = AccountId(message.sender!!), - updates = updates, - fromCommunity = null // Groupv2 is not a community - ) - } - - when { - inner.hasInviteMessage() -> handleNewLibSessionClosedGroupMessage(message, proto) - inner.hasInviteResponse() -> handleInviteResponse(message, closedGroup!!) - inner.hasPromoteMessage() -> handlePromotionMessage(message, proto) - inner.hasInfoChangeMessage() -> handleGroupInfoChange(message, closedGroup!!) - inner.hasMemberChangeMessage() -> handleMemberChange(message, closedGroup!!) - inner.hasMemberLeftMessage() -> handleMemberLeft(message, closedGroup!!) - inner.hasMemberLeftNotificationMessage() -> handleMemberLeftNotification(message, closedGroup!!) - inner.hasDeleteMemberContent() -> handleDeleteMemberContent(message, closedGroup!!) - } - } - - private fun handleDeleteMemberContent(message: GroupUpdated, closedGroup: AccountId) { - val deleteMemberContent = message.inner.deleteMemberContent - val adminSig = if (deleteMemberContent.hasAdminSignature()) deleteMemberContent.adminSignature.toByteArray()!! else byteArrayOf() - - val hasValidAdminSignature = adminSig.isNotEmpty() && runCatching { - verifyAdminSignature( - closedGroup, - adminSig, - buildDeleteMemberContentSignature( - memberIds = deleteMemberContent.memberSessionIdsList.asSequence().map(::AccountId).asIterable(), - messageHashes = deleteMemberContent.messageHashesList, - timestamp = message.sentTimestamp!!, - ) - ) - }.isSuccess - - scope.launch { - try { - groupManagerV2.handleDeleteMemberContent( - groupId = closedGroup, - deleteMemberContent = deleteMemberContent, - timestamp = message.sentTimestamp!!, - sender = AccountId(message.sender!!), - senderIsVerifiedAdmin = hasValidAdminSignature - ) - } catch (e: Exception) { - Log.e("GroupUpdated", "Failed to handle delete member content", e) - } - } - } - - private fun handleMemberChange(message: GroupUpdated, closedGroup: AccountId) { - val memberChange = message.inner.memberChangeMessage - val type = memberChange.type - val timestamp = message.sentTimestamp!! - verifyAdminSignature(closedGroup, - memberChange.adminSignature.toByteArray(), - buildMemberChangeSignature(type, timestamp) - ) - storage.insertGroupInfoChange(message, closedGroup) - } - - private fun handleMemberLeft(message: GroupUpdated, closedGroup: AccountId) { - scope.launch(Dispatchers.Default) { - try { - groupManagerV2.handleMemberLeftMessage( - AccountId(message.sender!!), closedGroup - ) - } catch (e: Exception) { - Log.e("GroupUpdated", "Failed to handle member left message", e) - } - } - } - - private fun handleMemberLeftNotification(message: GroupUpdated, closedGroup: AccountId) { - storage.insertGroupInfoChange(message, closedGroup) - } - - private fun handleGroupInfoChange(message: GroupUpdated, closedGroup: AccountId) { - val inner = message.inner - val infoChanged = inner.infoChangeMessage ?: return - if (!infoChanged.hasAdminSignature()) return Log.e("GroupUpdated", "Info changed message doesn't contain admin signature") - val adminSignature = infoChanged.adminSignature - val type = infoChanged.type - val timestamp = message.sentTimestamp!! - verifyAdminSignature(closedGroup, adminSignature.toByteArray(), buildInfoChangeSignature(type, timestamp)) - - groupManagerV2.handleGroupInfoChange(message, closedGroup) - } - - private fun handlePromotionMessage(message: GroupUpdated, proto: SignalServiceProtos.Content) { - val promotion = message.inner.promoteMessage - val seed = promotion.groupIdentitySeed.toByteArray() - val sender = message.sender!! - val adminId = AccountId(sender) - scope.launch { - try { - groupManagerV2 - .handlePromotion( - groupId = AccountId(IdPrefix.GROUP, ED25519.generate(seed).pubKey.data), - groupName = promotion.name, - adminKeySeed = seed, - promoter = adminId, - promoterName = if (proto.hasDataMessage() && proto.dataMessage.hasProfile() && proto.dataMessage.profile.hasDisplayName()) - proto.dataMessage.profile.displayName - else null, - promoteMessageHash = message.serverHash!!, - promoteMessageTimestamp = message.sentTimestamp!!, - ) - } catch (e: Exception) { - Log.e("GroupUpdated", "Failed to handle promotion message", e) - } - } - } - - private fun handleInviteResponse(message: GroupUpdated, closedGroup: AccountId) { - val sender = message.sender!! - // val profile = message // maybe we do need data to be the inner so we can access profile - val approved = message.inner.inviteResponse.isApproved - scope.launch { - try { - groupManagerV2.handleInviteResponse(closedGroup, AccountId(sender), approved) - } catch (e: Exception) { - Log.e("GroupUpdated", "Failed to handle invite response", e) - } - } - } - - private fun handleNewLibSessionClosedGroupMessage(message: GroupUpdated, proto: SignalServiceProtos.Content) { - val storage = storage - val ourUserId = storage.getUserPublicKey()!! - val invite = message.inner.inviteMessage - val groupId = AccountId(invite.groupSessionId) - verifyAdminSignature( - groupSessionId = groupId, - signatureData = invite.adminSignature.toByteArray(), - messageToValidate = buildGroupInviteSignature(AccountId(ourUserId), message.sentTimestamp!!) - ) - - val sender = message.sender!! - val adminId = AccountId(sender) - scope.launch { - try { - groupManagerV2 - .handleInvitation( - groupId = groupId, - groupName = invite.name, - authData = invite.memberAuthData.toByteArray(), - inviter = adminId, - inviterName = if (proto.hasDataMessage() && proto.dataMessage.hasProfile() && proto.dataMessage.profile.hasDisplayName()) - proto.dataMessage.profile.displayName - else null, - inviteMessageHash = message.serverHash!!, - inviteMessageTimestamp = message.sentTimestamp!!, - ) - } catch (e: Exception) { - Log.e("GroupUpdated", "Failed to handle invite message", e) - } - } - } - - - /** - * Does nothing on successful signature verification, throws otherwise. - * Assumes the signer is using the ed25519 group key signing key - * @param groupSessionId the AccountId of the group to check the signature against - * @param signatureData the byte array supplied to us through a protobuf message from the admin - * @param messageToValidate the expected values used for this signature generation, often something like `INVITE||{inviteeSessionId}||{timestamp}` - * @throws SignatureException if signature cannot be verified with given parameters - */ - private fun verifyAdminSignature(groupSessionId: AccountId, signatureData: ByteArray, messageToValidate: ByteArray) { - val groupPubKey = groupSessionId.pubKeyBytes - if (!ED25519.verify(signature = signatureData, ed25519PublicKey = groupPubKey, message = messageToValidate)) { - throw SignatureException("Verification failed for signature data") - } - } - - private fun isValidGroupUpdate(group: GroupRecord, sentTimestamp: Long, senderPublicKey: String): Boolean { - val oldMembers = group.members.map { it.toString() } - // Check that the message isn't from before the group was created - if (group.formationTimestamp > sentTimestamp) { - Log.d("Loki", "Ignoring closed group update from before thread was created.") - return false - } - // Check that the sender is a member of the group (before the update) - if (senderPublicKey !in oldMembers) { - Log.d("Loki", "Ignoring closed group info message from non-member.") - return false - } - return true - } - - companion object { - private const val TAG = "ReceivedMessageHandler" - } - -} - - - - -// region Control Messages - - -//endregion - -private fun SignalServiceProtos.Content.ExpirationType.expiryMode(durationSeconds: Long) = takeIf { durationSeconds > 0 }?.let { - when (it) { - SignalServiceProtos.Content.ExpirationType.DELETE_AFTER_READ -> ExpiryMode.AfterRead(durationSeconds) - SignalServiceProtos.Content.ExpirationType.DELETE_AFTER_SEND, SignalServiceProtos.Content.ExpirationType.UNKNOWN -> ExpiryMode.AfterSend(durationSeconds) - else -> ExpiryMode.NONE - } -} ?: ExpiryMode.NONE - - -class VisibleMessageHandlerContext @AssistedInject constructor( - @param:ApplicationContext val context: Context, - @Assisted val threadAddress: Address.Conversable, - @Assisted val threadId: Long, - val storage: StorageProtocol, - val groupManagerV2: GroupManagerV2, - val messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol, - val messageDataProvider: MessageDataProvider, - val recipientRepository: RecipientRepository, -) { - val userBlindedKey: String? by lazy { - (threadRecipient.data as? RecipientData.Community)?.let { - val blindedKey = BlindKeyAPI.blind15KeyPairOrNull( - ed25519SecretKey = storage.getUserED25519KeyPair()!!.secretKey.data, - serverPubKey = Hex.fromStringCondensed(it.serverPubKey), - ) ?: return@let null - - AccountId( - IdPrefix.BLINDED, blindedKey.pubKey.data - ).hexString - } - } - - val threadRecipient: Recipient by lazy { - recipientRepository.getRecipientSync(threadAddress) - } - - val userPublicKey: String? by lazy { - storage.getUserPublicKey() - } - - - @AssistedFactory - interface Factory { - fun create(threadId: Long, threadAddress: Address.Conversable): VisibleMessageHandlerContext - } -} - - -/** - * Constructs reaction records for a given open group message. - * - * If the open group message exists in our database, we'll construct a list of reaction records - * that is specified in the [reactions]. - * - * Note that this function does not know or check if the local message has any reactions, - * you'll be responsible for that. In simpler words, [out] only contains reactions that are given - * to this function, it will not include any existing reactions in the database. - * - * @param openGroupMessageServerID The server ID of this message - * @param context The context containing necessary data for processing reactions - * @param reactions A map of emoji to [OpenGroupApi.Reaction] objects, representing the reactions for the message - * @param out A mutable map that will be populated with [ReactionRecord]s, keyed by [MessageId] - */ -fun constructReactionRecords( - openGroupMessageServerID: Long, - context: VisibleMessageHandlerContext, - reactions: Map?, - out: MutableMap> -) { - if (reactions.isNullOrEmpty()) return - if (context.threadAddress !is Address.Community) return - val messageId = context.messageDataProvider.getMessageID(openGroupMessageServerID, context.threadId) ?: return - - val outList = out.getOrPut(messageId) { arrayListOf() } - - for ((emoji, reaction) in reactions) { - val shouldAddUserReaction = reaction.you || reaction.reactors.contains(context.userPublicKey) - val reactorIds = reaction.reactors.filter { it != context.userBlindedKey && it != context.userPublicKey } - val count = if (reaction.you) reaction.count - 1 else reaction.count - // Add the first reaction (with the count) - reactorIds.firstOrNull()?.let { reactor -> - outList += ReactionRecord( - messageId = messageId, - author = reactor, - emoji = emoji, - serverId = openGroupMessageServerID.toString(), - count = count, - sortId = reaction.index, - ) - } - - // Add all other reactions - val maxAllowed = if (shouldAddUserReaction) 4 else 5 - val lastIndex = min(maxAllowed, reactorIds.size) - reactorIds.slice(1 until lastIndex).map { reactor -> - outList += ReactionRecord( - messageId = messageId, - author = reactor, - emoji = emoji, - serverId = openGroupMessageServerID.toString(), - count = 0, // Only want this on the first reaction - sortId = reaction.index, - ) - } - - // Add the current user reaction (if applicable and not already included) - if (shouldAddUserReaction) { - outList += ReactionRecord( - messageId = messageId, - author = context.userPublicKey!!, - emoji = emoji, - serverId = openGroupMessageServerID.toString(), - count = 1, - sortId = reaction.index, - ) - } - } -} - -//endregion - -// region Closed Groups - - - -// endregion diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt index cdb269e165..4f88ad205a 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt @@ -3,10 +3,10 @@ package org.session.libsession.messaging.sending_receiving import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.libsession_util.PRIORITY_HIDDEN +import network.loki.messenger.libsession_util.protocol.DecodedPro import network.loki.messenger.libsession_util.util.BaseCommunityInfo import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.KeyPair @@ -28,7 +28,6 @@ import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier import org.session.libsession.messaging.utilities.WebRtcUtils -import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol @@ -39,9 +38,14 @@ import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.recipients.MessageType import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.getType -import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log +import org.session.protos.SessionProtos +import org.thoughtcrime.securesms.api.snode.DeleteMessageApi +import org.thoughtcrime.securesms.api.swarm.SwarmApiExecutor +import org.thoughtcrime.securesms.api.swarm.SwarmApiRequest +import org.thoughtcrime.securesms.api.swarm.execute import org.thoughtcrime.securesms.database.BlindMappingRepository import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.Storage @@ -75,6 +79,8 @@ class ReceivedMessageProcessor @Inject constructor( private val visibleMessageHandler: Provider, private val blindMappingRepository: BlindMappingRepository, private val messageParser: MessageParser, + private val swarmApiExecutor: SwarmApiExecutor, + private val deleteMessageApiFactory: DeleteMessageApi.Factory ) { private val threadMutexes = ConcurrentHashMap() @@ -129,7 +135,8 @@ class ReceivedMessageProcessor @Inject constructor( context: MessageProcessingContext, threadAddress: Address.Conversable, message: Message, - proto: SignalServiceProtos.Content, + proto: SessionProtos.Content, + pro: DecodedPro?, ) = withThreadLock(threadAddress) { // The logic to check if the message should be discarded due to being from a hidden contact. if (threadAddress is Address.Standard && @@ -149,9 +156,9 @@ class ReceivedMessageProcessor @Inject constructor( threadDatabase.getOrCreateThreadIdFor(threadAddress) .also { context.threadIDs[threadAddress] = it } } else { - threadDatabase.getThreadIdIfExistsFor(threadAddress) + storage.getThreadId(threadAddress) .also { id -> - if (id == -1L) { + if (id == null) { log { "Dropping message for non-existing thread ${threadAddress.debugString}" } return@withThreadLock } else { @@ -166,7 +173,8 @@ class ReceivedMessageProcessor @Inject constructor( is GroupUpdated -> groupMessageHandler.get().handleGroupUpdated( message = message, groupId = (threadAddress as? Address.Group)?.accountId, - proto = proto + proto = proto, + pro = pro, ) is ExpirationTimerUpdate -> { @@ -185,7 +193,7 @@ class ReceivedMessageProcessor @Inject constructor( is DataExtractionNotification -> handleDataExtractionNotification(message) is UnsendRequest -> handleUnsendRequest(message) is MessageRequestResponse -> messageRequestResponseHandler.get() - .handleExplicitRequestResponseMessage(context, message, proto) + .handleExplicitRequestResponseMessage(context, message, proto, pro) is VisibleMessage -> { if (message.isSenderSelf && @@ -195,15 +203,18 @@ class ReceivedMessageProcessor @Inject constructor( context.maxOutgoingMessageTimestamp = message.sentTimestamp!! } - visibleMessageHandler.get().handleVisibleMessage( - ctx = context, - message = message, - threadId = threadId, - threadAddress = threadAddress, - proto = proto, - runThreadUpdate = false, - runProfileUpdate = true, - ) + threadId?.let { + visibleMessageHandler.get().handleVisibleMessage( + ctx = context, + message = message, + threadId = it, + threadAddress = threadAddress, + proto = proto, + runThreadUpdate = false, + runProfileUpdate = true, + pro = pro, + ) + } } is CallMessage -> handleCallMessage(message) @@ -217,7 +228,7 @@ class ReceivedMessageProcessor @Inject constructor( communityServerPubKeyHex: String, message: OpenGroupApi.DirectMessage ) { - val (message, proto) = messageParser.parseCommunityDirectMessage( + val parseResult = messageParser.parseCommunityDirectMessage( msg = message, currentUserId = context.currentUserId, currentUserEd25519PrivKey = context.currentUserEd25519KeyPair.secretKey.data, @@ -225,14 +236,15 @@ class ReceivedMessageProcessor @Inject constructor( communityServerPubKeyHex = communityServerPubKeyHex, ) - val threadAddress = message.senderOrSync.toAddress() as Address.Conversable + val threadAddress = parseResult.message.senderOrSync.toAddress() as Address.Conversable withThreadLock(threadAddress) { processSwarmMessage( context = context, threadAddress = threadAddress, - message = message, - proto = proto + message = parseResult.message, + proto = parseResult.proto, + pro = parseResult.pro ) } } @@ -243,7 +255,7 @@ class ReceivedMessageProcessor @Inject constructor( communityServerPubKeyHex: String, msg: OpenGroupApi.DirectMessage ) { - val (message, proto) = messageParser.parseCommunityDirectMessage( + val parseResult = messageParser.parseCommunityDirectMessage( msg = msg, currentUserId = context.currentUserId, currentUserEd25519PrivKey = context.currentUserEd25519KeyPair.secretKey.data, @@ -260,8 +272,9 @@ class ReceivedMessageProcessor @Inject constructor( processSwarmMessage( context = context, threadAddress = threadAddress, - message = message, - proto = proto + message = parseResult.message, + proto = parseResult.proto, + pro = parseResult.pro ) } } @@ -275,15 +288,16 @@ class ReceivedMessageProcessor @Inject constructor( msg = message, currentUserId = context.currentUserId, currentUserBlindedIDs = context.getCurrentUserBlindedIDsByThread(threadAddress) - )?.let { (msg, proto) -> + )?.let { parseResult -> processSwarmMessage( context = context, threadAddress = threadAddress, - message = msg, - proto = proto + message = parseResult.message, + proto = parseResult.proto, + pro = parseResult.pro ) - msg.id + parseResult.message.id } // For community, we have a different way of handling reaction, this is outside of @@ -381,7 +395,7 @@ class ReceivedMessageProcessor @Inject constructor( * Return true if this message should result in the creation of a thread. */ private fun shouldCreateThread(message: Message): Boolean { - return message is VisibleMessage + return message is VisibleMessage || message is GroupUpdated } private fun handleExpirationTimerUpdate(message: ExpirationTimerUpdate) { @@ -443,9 +457,17 @@ class ReceivedMessageProcessor @Inject constructor( // send a /delete rquest for 1on1 messages if (messageType == MessageType.ONE_ON_ONE) { messageDataProvider.getServerHashForMessage(messageIdToDelete)?.let { serverHash -> - scope.launch(Dispatchers.IO) { // using scope as we are slowly migrating to coroutines but we can't migrate everything at once + scope.launch { // using scope as we are slowly migrating to coroutines but we can't migrate everything at once try { - SnodeAPI.deleteMessage(author, userAuth, listOf(serverHash)) + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = userAuth.accountId.hexString, + api = deleteMessageApiFactory.create( + messageHashes = listOf(serverHash), + swarmAuth = userAuth + ) + ) + ) } catch (e: Exception) { Log.e("Loki", "Failed to delete message", e) } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt index 57ea0d6182..eea91d02d9 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import network.loki.messenger.libsession_util.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.protocol.DecodedPro import network.loki.messenger.libsession_util.util.BaseCommunityInfo import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.Util @@ -18,6 +19,7 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.SSKEnvironment @@ -25,9 +27,11 @@ import org.session.libsession.utilities.isGroupOrCommunity import org.session.libsession.utilities.recipients.RecipientData import org.session.libsession.utilities.updateContact import org.session.libsession.utilities.upsertContact -import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsession.utilities.withMutableUserConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional +import org.session.protos.SessionProtos import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.dependencies.ConfigFactory @@ -48,13 +52,15 @@ class VisibleMessageHandler @Inject constructor( private val attachmentDownloadJobFactory: AttachmentDownloadJob.Factory, private val messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol, private val typingIndicators: SSKEnvironment.TypingIndicatorsProtocol, + private val clock: SnodeClock, ){ fun handleVisibleMessage( ctx: ReceivedMessageProcessor.MessageProcessingContext, message: VisibleMessage, + pro: DecodedPro?, threadId: Long, threadAddress: Address.Conversable, - proto: SignalServiceProtos.Content, + proto: SessionProtos.Content, runThreadUpdate: Boolean, runProfileUpdate: Boolean, ): MessageId? { @@ -203,7 +209,11 @@ class VisibleMessageHandler @Inject constructor( // - must be done after the message is persisted) // - must be done after neccessary contact is created if (runProfileUpdate && senderAddress is Address.WithAccountId) { - val updates = ProfileUpdateHandler.Updates.create(proto) + val updates = ProfileUpdateHandler.Updates.create( + content = proto, + nowMills = clock.currentTimeMillis(), + pro = pro + ) if (updates != null) { profileUpdateHandler.get().handleProfileUpdate( diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/PointerAttachment.java b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/PointerAttachment.java index 8d0d5b50e0..d14f2bd748 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/PointerAttachment.java +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/PointerAttachment.java @@ -8,7 +8,7 @@ import org.session.libsignal.utilities.guava.Optional; import org.session.libsignal.messages.SignalServiceAttachment; import org.session.libsignal.utilities.Base64; -import org.session.libsignal.protos.SignalServiceProtos; +import org.session.protos.SessionProtos; import java.util.LinkedList; import java.util.List; @@ -53,11 +53,11 @@ public static List forPointers(Optional forPointers(List pointers) { + public static List forPointers(List pointers) { List results = new LinkedList<>(); if (pointers != null) { - for (SignalServiceProtos.DataMessage.Quote.QuotedAttachment pointer : pointers) { + for (SessionProtos.DataMessage.Quote.QuotedAttachment pointer : pointers) { Optional result = forPointer(pointer); if (result.isPresent()) { @@ -98,7 +98,7 @@ public static Optional forPointer(Optional } - public static Optional forPointer(SignalServiceProtos.AttachmentPointer pointer) { + public static Optional forPointer(SessionProtos.AttachmentPointer pointer) { return Optional.of(new PointerAttachment(pointer.getContentType(), AttachmentState.PENDING.getValue(), (long)pointer.getSize(), @@ -115,8 +115,8 @@ public static Optional forPointer(SignalServiceProtos.AttachmentPoin pointer.getUrl())); } - public static Optional forPointer(SignalServiceProtos.DataMessage.Quote.QuotedAttachment pointer) { - SignalServiceProtos.AttachmentPointer thumbnail = pointer.getThumbnail(); + public static Optional forPointer(SessionProtos.DataMessage.Quote.QuotedAttachment pointer) { + SessionProtos.AttachmentPointer thumbnail = pointer.getThumbnail(); return Optional.of(new PointerAttachment(pointer.getContentType(), AttachmentState.PENDING.getValue(), diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachmentStream.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachmentStream.kt index 4af1d16acc..e2beca19b8 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachmentStream.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachmentStream.kt @@ -8,7 +8,7 @@ package org.session.libsession.messaging.sending_receiving.attachments import android.util.Size import com.google.protobuf.ByteString import org.session.libsignal.utilities.guava.Optional -import org.session.libsignal.protos.SignalServiceProtos +import org.session.protos.SessionProtos import org.session.libsignal.messages.SignalServiceAttachment as SAttachment import java.io.InputStream import kotlin.math.round @@ -35,8 +35,8 @@ class SessionServiceAttachmentStream(val inputStream: InputStream?, contentType: return false } - fun toProto(): SignalServiceProtos.AttachmentPointer? { - val builder = SignalServiceProtos.AttachmentPointer.newBuilder() + fun toProto(): SessionProtos.AttachmentPointer? { + val builder = SessionProtos.AttachmentPointer.newBuilder() builder.contentType = this.contentType builder.fileName = this.filename @@ -47,7 +47,7 @@ class SessionServiceAttachmentStream(val inputStream: InputStream?, contentType: builder.size = this.length.toInt() builder.key = this.key builder.digest = ByteString.copyFrom(this.digest.get()) - builder.flags = if (this.voiceNote) SignalServiceProtos.AttachmentPointer.Flags.VOICE_MESSAGE.number else 0 + builder.flags = if (this.voiceNote) SessionProtos.AttachmentPointer.Flags.VOICE_MESSAGE.number else 0 //TODO I did copy the behavior of iOS below, not sure if that's relevant here... if (this.shouldHaveImageSize()) { diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Server.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/NotificationServer.kt similarity index 80% rename from app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Server.kt rename to app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/NotificationServer.kt index 0497fe2220..3d5ab2a326 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Server.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/NotificationServer.kt @@ -1,6 +1,6 @@ package org.session.libsession.messaging.sending_receiving.notifications -enum class Server(val url: String, val publicKey: String) { +enum class NotificationServer(val url: String, val publicKey: String) { LATEST("https://push.getsession.org", "d7557fe563e2610de876c0ac7341b62f3c82d5eea4b62c702392ea4368f51b3b"), LEGACY("https://live.apns.getsession.org", "642a6585919742e5a2d4dc51244964fbcd8bcab2b75612407de58b810740d049") } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt deleted file mode 100644 index 4a0509049f..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt +++ /dev/null @@ -1,78 +0,0 @@ -package org.session.libsession.messaging.sending_receiving.notifications - -import android.annotation.SuppressLint -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.GlobalScope -import nl.komponents.kovenant.Promise -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.OnionResponse -import org.session.libsession.snode.Version -import org.session.libsession.snode.utilities.asyncPromise -import org.session.libsession.snode.utilities.await -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.utilities.JsonUtil -import org.session.libsignal.utilities.emptyPromise -import org.session.libsignal.utilities.retryWithUniformInterval - -@SuppressLint("StaticFieldLeak") -object PushRegistryV1 { - val context = MessagingModuleConfiguration.shared.context - private const val MAX_RETRY_COUNT = 4 - - private val server = Server.LEGACY - - @Suppress("OPT_IN_USAGE") - private val scope: CoroutineScope = GlobalScope - - // Legacy Closed Groups - - fun subscribeGroup( - closedGroupSessionId: String, - isPushEnabled: Boolean = TextSecurePreferences.isPushEnabled(context), - publicKey: String = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!! - ) = if (isPushEnabled) { - performGroupOperation("subscribe_closed_group", closedGroupSessionId, publicKey) - } else emptyPromise() - - fun unsubscribeGroup( - closedGroupPublicKey: String, - isPushEnabled: Boolean = TextSecurePreferences.isPushEnabled(context), - publicKey: String = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!! - ) = if (isPushEnabled) { - performGroupOperation("unsubscribe_closed_group", closedGroupPublicKey, publicKey) - } else emptyPromise() - - private fun performGroupOperation( - operation: String, - closedGroupPublicKey: String, - publicKey: String - ): Promise<*, Exception> = scope.asyncPromise { - val parameters = mapOf("closedGroupPublicKey" to closedGroupPublicKey, "pubKey" to publicKey) - val url = "${server.url}/$operation" - val body = JsonUtil.toJson(parameters).toRequestBody("application/json".toMediaType()) - val request = Request.Builder().url(url).post(body).build() - - retryWithUniformInterval(MAX_RETRY_COUNT) { - sendOnionRequest(request) - .await() - .checkError() - } - } - - private fun OnionResponse.checkError() { - check(code != null && code != 0) { - "error: $message." - } - } - - private fun sendOnionRequest(request: Request): Promise = OnionRequestAPI.sendOnionRequest( - request, - server.url, - server.publicKey, - Version.V2 - ) -} 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 e83caa4d04..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 @@ -1,48 +1,37 @@ package org.session.libsession.messaging.sending_receiving.pollers -import com.fasterxml.jackson.core.type.TypeReference 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.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.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import kotlinx.serialization.json.Json import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.OpenGroupDeleteJob import org.session.libsession.messaging.jobs.TrimThreadJob -import org.session.libsession.messaging.messages.Message.Companion.senderOrSync -import org.session.libsession.messaging.open_groups.Endpoint import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.messaging.open_groups.OpenGroupApi.BatchRequest -import org.session.libsession.messaging.open_groups.OpenGroupApi.BatchRequestInfo -import org.session.libsession.messaging.open_groups.OpenGroupApi.BatchResponse import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability import org.session.libsession.messaging.open_groups.OpenGroupApi.DirectMessage -import org.session.libsession.messaging.open_groups.OpenGroupApi.getOrFetchServerCapabilities -import org.session.libsession.messaging.open_groups.OpenGroupApi.parallelBatch -import org.session.libsession.messaging.sending_receiving.MessageParser +import org.session.libsession.messaging.open_groups.api.CommunityApiExecutor +import org.session.libsession.messaging.open_groups.api.CommunityApiRequest +import org.session.libsession.messaging.open_groups.api.GetCapsApi +import org.session.libsession.messaging.open_groups.api.GetDirectMessagesApi +import org.session.libsession.messaging.open_groups.api.GetRoomMessagesApi +import org.session.libsession.messaging.open_groups.api.PollRoomApi +import org.session.libsession.messaging.open_groups.api.execute import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor import org.session.libsession.utilities.Address -import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol -import org.session.libsignal.utilities.Base64 -import org.session.libsignal.utilities.HTTP.Verb.GET -import org.session.libsignal.utilities.JsonUtil +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.CommunityDatabase import org.thoughtcrime.securesms.util.AppVisibilityManager - -private typealias PollRequestToken = Channel>> +import org.thoughtcrime.securesms.util.NetworkConnectivity +import javax.inject.Provider /** * A [OpenGroupPoller] is responsible for polling all communities on a particular server. @@ -54,88 +43,38 @@ 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, private val communityDatabase: CommunityDatabase, private val receivedMessageProcessor: ReceivedMessageProcessor, - private val messageParser: MessageParser, + private val communityApiExecutor: CommunityApiExecutor, + private val getRoomMessagesFactory: GetRoomMessagesApi.Factory, + 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() - - @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) - } - } - } - } - } - } + override val successfulPollIntervalSeconds: Int + get() = 4 - /** - * 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, - pollInfoJson: Map<*, *>, + pollInfoJsonText: String, ) { - communityDatabase.patchRoomInfo(address, JsonUtil.toJson(pollInfoJson)) + communityDatabase.patchRoomInfo(address, pollInfoJsonText) } @@ -144,136 +83,114 @@ 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 .mapNotNull { c -> c.community.takeIf { it.baseUrl == server }?.room } - if (rooms.isEmpty()) { - return emptyList() + val serverKey = allCommunities.firstOrNull { + it.community.baseUrl == server + }?.community?.pubKeyHex + + if (rooms.isEmpty() || serverKey.isNullOrBlank()) { + return } - poll(rooms) - .asSequence() - .filterNot { it.body == null } - .forEach { response -> - when (response.endpoint) { - is Endpoint.RoomPollInfo -> { - handleRoomPollInfo(Address.Community(server, response.endpoint.roomToken), response.body as Map<*, *>) - } - is Endpoint.RoomMessagesRecent -> { - handleMessages(response.endpoint.roomToken, response.body as List) - } - is Endpoint.RoomMessagesSince -> { - handleMessages(response.endpoint.roomToken, response.body as List) - } - is Endpoint.Inbox, is Endpoint.InboxSince -> { - handleInboxMessages( response.body as List) - } - is Endpoint.Outbox, is Endpoint.OutboxSince -> { - handleOutboxMessages( response.body as List) - } - else -> { /* We don't care about the result of any other calls (won't be polled for) */} - } + coroutineScope { + var caps = storage.getServerCapabilities(server) + if (caps == null) { + val fetched = communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = server, + serverPubKey = serverKey, + api = getCapsApi.get(), + ) + ) + storage.setServerCapabilities(server, fetched.capabilities) + caps = fetched.capabilities } - return rooms - } - - @Suppress("UNCHECKED_CAST") - suspend fun poll(rooms: List): List> { - val lastInboxMessageId = storage.getLastInboxMessageId(server) - val lastOutboxMessageId = storage.getLastOutboxMessageId(server) - val requests = mutableListOf>() - - val serverCapabilities = getOrFetchServerCapabilities(server) - - rooms.forEach { room -> - val address = Address.Community(serverUrl = server, room = room) - val latestRoomPollInfo = communityDatabase.getRoomInfo(address) - val infoUpdates = latestRoomPollInfo?.details?.infoUpdates ?: 0 - val lastMessageServerId = storage.getLastMessageServerID(room, server) ?: 0L - requests.add( - BatchRequestInfo( - request = BatchRequest( - method = GET, - path = "/room/$room/pollInfo/$infoUpdates" - ), - endpoint = Endpoint.RoomPollInfo(room, infoUpdates), - responseType = object : TypeReference>(){} - ) - ) - requests.add( - if (lastMessageServerId == 0L) { - BatchRequestInfo( - request = BatchRequest( - method = GET, - path = "/room/$room/messages/recent?t=r&reactors=5" - ), - endpoint = Endpoint.RoomMessagesRecent(room), - responseType = object : TypeReference>(){} + for (room in rooms) { + val address = Address.Community(serverUrl = server, room = room) + val latestRoomPollInfo = communityDatabase.getRoomInfo(address) + val infoUpdates = latestRoomPollInfo?.details?.infoUpdates ?: 0 + val lastMessageServerId = storage.getLastMessageServerID(room, server) + + // Poll room info + launch { + val roomInfo = communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = server, + serverPubKey = serverKey, + api = pollRoomInfoFactory.create( + room = room, + infoUpdates = infoUpdates + ) + ) ) - } else { - BatchRequestInfo( - request = BatchRequest( - method = GET, - path = "/room/$room/messages/since/$lastMessageServerId?t=r&reactors=5" - ), - endpoint = Endpoint.RoomMessagesSince(room, lastMessageServerId), - responseType = object : TypeReference>(){} + + handleRoomPollInfo( + address = address, + pollInfoJsonText = json.encodeToString(roomInfo) ) } - ) - } - if (serverCapabilities.contains(Capability.BLIND.name.lowercase())) { - if (storage.isCheckingCommunityRequests()) { - requests.add( - if (lastInboxMessageId == null) { - BatchRequestInfo( - request = BatchRequest( - method = GET, - path = "/inbox" - ), - endpoint = Endpoint.Inbox, - responseType = object : TypeReference>() {} + + // Poll room messages + launch { + val messages = communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = server, + serverPubKey = serverKey, + api = getRoomMessagesFactory.create( + room = room, + sinceSeqNo = lastMessageServerId, + ) ) - } else { - BatchRequestInfo( - request = BatchRequest( - method = GET, - path = "/inbox/since/$lastInboxMessageId" - ), - endpoint = Endpoint.InboxSince(lastInboxMessageId), - responseType = object : TypeReference>() {} + ) + + handleMessages(roomToken = room, messages = messages) + } + } + + // Handling direct messages only if blinded capability is supported + if (caps.contains(Capability.BLIND.name.lowercase())) { + // We'll only poll our index if we are accepting community requests + if (storage.isCheckingCommunityRequests()) { + // Poll inbox messages + launch { + val inboxMessages = communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = server, + serverPubKey = serverKey, + api = getDirectMessageFactory.create( + inboxOrOutbox = true, + sinceLastId = storage.getLastInboxMessageId(server), + ) + ) ) + + handleInboxMessages(messages = inboxMessages) } - ) - } + } - requests.add( - if (lastOutboxMessageId == null) { - BatchRequestInfo( - request = BatchRequest( - method = GET, - path = "/outbox" - ), - endpoint = Endpoint.Outbox, - responseType = object : TypeReference>() {} - ) - } else { - BatchRequestInfo( - request = BatchRequest( - method = GET, - path = "/outbox/since/$lastOutboxMessageId" - ), - endpoint = Endpoint.OutboxSince(lastOutboxMessageId), - responseType = object : TypeReference>() {} + // Poll outbox messages regardless because these are messages we sent + launch { + val outboxMessages = communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = server, + serverPubKey = serverKey, + api = getDirectMessageFactory.create( + inboxOrOutbox = false, + sinceLastId = storage.getLastOutboxMessageId(server), + ) + ) ) + + handleOutboxMessages(messages = outboxMessages) } - ) + } } - return parallelBatch(server, requests) } @@ -302,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 ) @@ -334,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 } @@ -351,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) } } } @@ -368,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 } @@ -385,20 +302,18 @@ 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(server: String, scope: CoroutineScope, pollerSemaphore: Semaphore): OpenGroupPoller + fun create( + server: String, + scope: CoroutineScope, + pollerSemaphore: Semaphore + ): OpenGroupPoller } } \ No newline at end of file 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 373c5b8fe7..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 @@ -15,9 +16,9 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.sync.Semaphore import org.session.libsession.utilities.ConfigFactoryProtocol -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.userConfigsChanged +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.dependencies.ManagerScope @@ -42,7 +43,6 @@ private const val TAG = "OpenGroupPollerManager" class OpenGroupPollerManager @Inject constructor( pollerFactory: OpenGroupPoller.Factory, configFactory: ConfigFactoryProtocol, - preferences: TextSecurePreferences, loginStateRepository: LoginStateRepository, @ManagerScope scope: CoroutineScope ) : OnAppStartupComponent { @@ -90,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 } @@ -100,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 160137f53a..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 @@ -30,89 +13,62 @@ import org.session.libsession.database.userAuth 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.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.snode.model.RetrieveMessageResponse -import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigMessage import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.api.snode.AlterTtlApi +import org.thoughtcrime.securesms.api.snode.RetrieveMessageApi +import org.thoughtcrime.securesms.api.swarm.SwarmApiExecutor +import org.thoughtcrime.securesms.api.swarm.SwarmApiRequest +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 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, private val messageParser: MessageParser, + private val retrieveMessageFactory: RetrieveMessageApi.Factory, + 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(), @@ -129,97 +85,26 @@ class Poller @AssistedInject constructor( preferences.migratedToMultiPartConfig = true } - val pollPool = hashSetOf() // pollPool is the list of snodes we can use while rotating snodes from our swarm - 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 = !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 { - // check if the polling pool is empty - if (pollPool.isEmpty()) { - // if it is empty, fill it with the snodes from our swarm - pollPool.addAll(SnodeAPI.getSwarm(userPublicKey).await()) - } - - // randomly get a snode from the pool - val currentNode = pollPool.random() - - // remove that snode from the pool - pollPool.remove(currentNode) + // 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() } - 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,12 +113,12 @@ 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 } try { - val (message, proto) = messageParser.parse1o1Message( + val result = messageParser.parse1o1Message( data = message.data, serverHash = message.hash, currentUserEd25519PrivKey = ctx.currentUserEd25519KeyPair.secretKey.data, @@ -241,14 +126,15 @@ class Poller @AssistedInject constructor( ) processor.processSwarmMessage( - threadAddress = message.senderOrSync.toAddress() as Address.Conversable, - message = message, - proto = proto, + threadAddress = result.message.senderOrSync.toAddress() as Address.Conversable, + message = result.message, + proto = result.proto, context = ctx, + pro = result.pro, ) } catch (ec: Exception) { Log.e( - TAG, + logTag, "Error while processing personal message with hash ${message.hash}", ec ) @@ -259,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 } @@ -288,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") } @@ -301,22 +187,25 @@ class Poller @AssistedInject constructor( // Get messages call wrapped in an async val fetchMessageTask = if (!pollOnlyUserProfileConfig) { - val request = SnodeAPI.buildAuthenticatedRetrieveBatchRequest( + val retrieveMessageApi = retrieveMessageFactory.create( + namespace = Namespace.DEFAULT(), lastHash = lokiApiDatabase.getLastMessageHashValue( snode = snode, publicKey = userAuth.accountId.hexString, namespace = Namespace.DEFAULT() ), auth = userAuth, - maxSize = -2) + maxSize = -2 + ) this.async { runCatching { - SnodeAPI.sendBatchRequest( - snode = snode, - publicKey = userPublicKey, - request = request, - responseType = RetrieveMessageResponse.serializer() + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = userAuth.accountId.hexString, + api = retrieveMessageApi, + swarmNodeOverride = snode, + ) ) } } @@ -337,7 +226,7 @@ class Poller @AssistedInject constructor( .map { type -> val config = configs.getConfig(type) hashesToExtend += config.activeHashes() - val request = SnodeAPI.buildAuthenticatedRetrieveBatchRequest( + val retrieveApi = retrieveMessageFactory.create( lastHash = lokiApiDatabase.getLastMessageHashValue( snode = snode, publicKey = userAuth.accountId.hexString, @@ -350,11 +239,12 @@ class Poller @AssistedInject constructor( this.async { type to runCatching { - SnodeAPI.sendBatchRequest( - snode = snode, - publicKey = userPublicKey, - request = request, - responseType = RetrieveMessageResponse.serializer() + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = userAuth.accountId.hexString, + api = retrieveApi, + swarmNodeOverride = snode, + ), ) } } @@ -364,18 +254,20 @@ class Poller @AssistedInject constructor( if (hashesToExtend.isNotEmpty()) { launch { try { - SnodeAPI.sendBatchRequest( - snode, - userPublicKey, - SnodeAPI.buildAuthenticatedAlterTtlBatchRequest( - messageHashes = hashesToExtend.toList(), - auth = userAuth, - newExpiry = snodeClock.currentTimeMills() + 14.days.inWholeMilliseconds, - extend = true + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = userAuth.accountId.hexString, + api = alterTtlApiFactory.create( + messageHashes = hashesToExtend, + auth = userAuth, + alterType = AlterTtlApi.AlterType.Extend, + newExpiry = snodeClock.currentTimeMillis() + 14.days.inWholeMilliseconds + ), + swarmNodeOverride = snode, ) ) } catch (e: Exception) { - Log.e(TAG, "Error while extending TTL for hashes", e) + Log.e(logTag, "Error while extending TTL for hashes", e) } } } @@ -386,11 +278,6 @@ class Poller @AssistedInject constructor( for (task in configFetchTasks) { val (configType, result) = task.await() - if (result.isFailure) { - Log.e(TAG, "Error while fetching config for $configType", result.exceptionOrNull()) - continue - } - val messages = result.getOrThrow().messages processConfig(messages = messages, forConfig = configType) @@ -407,21 +294,16 @@ class Poller @AssistedInject constructor( // Process the messages if we requested them if (fetchMessageTask != null) { - val result = fetchMessageTask.await() - if (result.isFailure) { - Log.e(TAG, "Error while fetching messages", result.exceptionOrNull()) - } else { - val messages = result.getOrThrow().messages - processPersonalMessages(messages) - - messages.maxByOrNull { it.timestamp }?.let { newest -> - lokiApiDatabase.setLastMessageHashValue( - snode = snode, - publicKey = userPublicKey, - newValue = newest.hash, - namespace = Namespace.DEFAULT() - ) - } + val messages = fetchMessageTask.await().getOrThrow().messages + processPersonalMessages(messages) + + messages.maxByOrNull { it.timestamp }?.let { newest -> + lokiApiDatabase.setLastMessageHashValue( + snode = snode, + publicKey = userPublicKey, + newValue = newest.hash, + namespace = Namespace.DEFAULT() + ) } } } 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 7ca2d936de..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 @@ -1,20 +1,11 @@ package org.session.libsession.messaging.sending_receiving.pollers -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import org.thoughtcrime.securesms.auth.LoginStateRepository -import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import org.thoughtcrime.securesms.auth.AuthAwareComponent +import org.thoughtcrime.securesms.auth.LoggedInState import javax.inject.Inject import javax.inject.Singleton @@ -26,30 +17,18 @@ import javax.inject.Singleton */ @Singleton class PollerManager @Inject constructor( - provider: Poller.Factory, - loginStateRepository: LoginStateRepository, -) : OnAppStartupComponent { - @OptIn(DelicateCoroutinesApi::class) - private val currentPoller: StateFlow = channelFlow { - loginStateRepository - .loggedInState - .map { it != null } - .distinctUntilChanged() - .collectLatest { loggedIn -> - if (loggedIn) { - coroutineScope { - val poller = provider.create(this) - send(poller) - awaitCancellation() - } - } else { - send(null) - } - } - }.stateIn(GlobalScope, SharingStarted.Eagerly, null) + private val provider: Poller.Factory, +) : AuthAwareComponent { + private val currentPoller = MutableStateFlow(null) + + override suspend fun doWhileLoggedIn(loggedInState: LoggedInState): Unit = coroutineScope { + val poller = provider.create(this) + currentPoller.value = poller + } + 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. @@ -57,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/messaging/utilities/MessageAuthentication.kt b/app/src/main/java/org/session/libsession/messaging/utilities/MessageAuthentication.kt index 2bd34ed5f3..ec8bc5921e 100644 --- a/app/src/main/java/org/session/libsession/messaging/utilities/MessageAuthentication.kt +++ b/app/src/main/java/org/session/libsession/messaging/utilities/MessageAuthentication.kt @@ -1,11 +1,11 @@ package org.session.libsession.messaging.utilities -import org.session.libsignal.protos.SignalServiceProtos +import org.session.protos.SessionProtos import org.session.libsignal.utilities.AccountId object MessageAuthentication { fun buildInfoChangeSignature( - changeType: SignalServiceProtos.DataMessage.GroupUpdateInfoChangeMessage.Type, + changeType: SessionProtos.GroupUpdateInfoChangeMessage.Type, timestamp: Long): ByteArray { return "INFO_CHANGE${changeType.number}$timestamp".toByteArray() } @@ -24,7 +24,7 @@ object MessageAuthentication { } fun buildMemberChangeSignature( - changeType: SignalServiceProtos.DataMessage.GroupUpdateMemberChangeMessage.Type, + changeType: SessionProtos.GroupUpdateMemberChangeMessage.Type, timestamp: Long ): ByteArray { return "MEMBER_CHANGE${changeType.number}$timestamp".toByteArray() diff --git a/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt b/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt index 3fa4a53f15..b069d5c28c 100644 --- a/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt +++ b/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt @@ -1,12 +1,12 @@ package org.session.libsession.messaging.utilities -import org.session.libsignal.protos.SignalServiceProtos.Envelope -import org.session.libsignal.protos.WebSocketProtos.WebSocketMessage +import org.session.libsignal.protos.WebSocketProtos +import org.session.protos.SessionProtos.Envelope object MessageWrapper { fun unwrap(data: ByteArray): Envelope { - val webSocketMessage = WebSocketMessage.parseFrom(data) + val webSocketMessage = WebSocketProtos.WebSocketMessage.parseFrom(data) val envelopeAsData = webSocketMessage.request.body return Envelope.parseFrom(envelopeAsData) } diff --git a/app/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageData.kt b/app/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageData.kt index 5432d5efb6..5a75131c1b 100644 --- a/app/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageData.kt +++ b/app/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageData.kt @@ -6,8 +6,8 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo import com.fasterxml.jackson.core.JsonParseException import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsignal.messages.SignalServiceGroup -import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInfoChangeMessage -import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMemberChangeMessage.Type +import org.session.protos.SessionProtos.GroupUpdateInfoChangeMessage +import org.session.protos.SessionProtos.GroupUpdateMemberChangeMessage.Type import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import java.util.Collections @@ -132,7 +132,7 @@ class UpdateMessageData () { GroupUpdateInfoChangeMessage.Type.NAME -> Kind.GroupNameChange(infoChange.updatedName) GroupUpdateInfoChangeMessage.Type.AVATAR -> Kind.GroupAvatarUpdated GroupUpdateInfoChangeMessage.Type.DISAPPEARING_MESSAGES -> Kind.GroupExpirationUpdated( - updatedExpiration = infoChange.updatedExpirationSeconds.toLong(), + updatedExpiration = infoChange.updatedExpiration.toLong(), updatingAdmin = groupUpdated.sender.orEmpty() ) else -> null diff --git a/app/src/main/java/org/session/libsession/network/NetworkModule.kt b/app/src/main/java/org/session/libsession/network/NetworkModule.kt new file mode 100644 index 0000000000..5b71cafad0 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/NetworkModule.kt @@ -0,0 +1,24 @@ +package org.session.libsession.network + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.session.libsession.network.snode.SnodePathStorage +import org.session.libsession.network.snode.SnodePoolStorage +import org.session.libsession.network.snode.SwarmStorage +import org.thoughtcrime.securesms.database.SnodeDatabase + +@Module +@InstallIn(SingletonComponent::class) +abstract class NetworkModule { + + @Binds + abstract fun providePathStorage(storage: SnodeDatabase): SnodePathStorage + + @Binds + abstract fun provideSwarmStorage(storage: SnodeDatabase): SwarmStorage + + @Binds + abstract fun provideSnodePoolStorage(storage: SnodeDatabase): SnodePoolStorage +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/SnodeClock.kt b/app/src/main/java/org/session/libsession/network/SnodeClock.kt new file mode 100644 index 0000000000..4f094fea4b --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/SnodeClock.kt @@ -0,0 +1,222 @@ +// SnodeClock.kt +package org.session.libsession.network + +import android.os.SystemClock +import dagger.Lazy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import org.session.libsession.network.snode.SnodeDirectory +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.api.snode.GetInfoApi +import org.thoughtcrime.securesms.api.snode.SnodeApiExecutor +import org.thoughtcrime.securesms.api.snode.SnodeApiRequest +import org.thoughtcrime.securesms.api.snode.execute +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import java.util.Date +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +/** + * A class that manages the network time by querying the network time from a random snode. + * The primary goal of this class is to provide a time that is not tied to current system time + * and not prone to time changes locally. + */ +@Singleton +class SnodeClock @Inject constructor( + @param:ManagerScope private val scope: CoroutineScope, + private val snodeDirectory: SnodeDirectory, + private val snodeApiExecutor: Lazy, + private val getInfoApi: Provider, +): OnAppStartupComponent { + + private val instantState = MutableStateFlow(null) + + // Concurrency & Throttling controls + private val syncMutex = Mutex() + private var activeSyncJob: Deferred? = null + + // Explicitly tracking "uptime" to handle device sleep/clock changes correctly + private var lastSuccessfulSyncUptimeMs: Long = 0L + + // 10 Minutes in milliseconds + private val minSyncIntervalMs = 10 * 60 * 1000L + + override fun onPostAppStarted() { + scope.launch { + resyncClock() + } + } + + /** + * Resync by querying 3 random snodes and setting time to the median of their adjusted times. + * * Rules: + * 1. If a sync is already running, this call waits for it and returns that result (coalescing). + * 2. If a sync happened < 10 mins ago, returns false immediately. + * 3. Returns true only if a fresh sync succeeded. + */ + suspend fun resyncClock(): Boolean = coroutineScope { + val jobToAwait = syncMutex.withLock { + + // 1. If a job is already running, join it. + if (activeSyncJob?.isActive == true) { + return@withLock activeSyncJob + } + + // 2. Check throttling + val now = SystemClock.elapsedRealtime() + val timeSinceLastSync = now - lastSuccessfulSyncUptimeMs + + if (timeSinceLastSync < minSyncIntervalMs) { + Log.d("SnodeClock", "Resync throttled (last sync ${timeSinceLastSync / 1000}s ago)") + return@withLock null + } + + // 3. Start a new job on the ManagerScope + val newJob = scope.async { + performNetworkSync() + } + activeSyncJob = newJob + + // 4. Cleanup when done + newJob.invokeOnCompletion { + scope.launch { + syncMutex.withLock { + // Only null it out if it hasn't been replaced by a newer job (rare but possible) + if (activeSyncJob === newJob) { + activeSyncJob = null + } + } + } + } + + newJob + } + + // If jobToAwait is null, we were throttled. + return@coroutineScope jobToAwait?.await() ?: false + } + + /** + * The actual logic to query Snodes. + * This is private and only called by the controlled job inside resyncClock. + */ + private suspend fun performNetworkSync(): Boolean { + return runCatching { + withTimeout(8_000L) { + val nodes = pickDistinctRandomSnodes(count = 3) + + val samples: List> = supervisorScope { + nodes.map { node -> + async { + runCatching { + val requestStarted = SystemClock.elapsedRealtime() + var networkTime = snodeApiExecutor.get().execute( + SnodeApiRequest( + snode = snodeDirectory.getRandomSnode(), + api = getInfoApi.get(), + ) + ).timestamp.toEpochMilli() + val requestEnded = SystemClock.elapsedRealtime() + + // Adjust for latency + networkTime -= (requestEnded - requestStarted) / 2 + requestStarted to networkTime + }.getOrNull() + } + }.awaitAll().filterNotNull() + } + + // Check for empty samples to prevent IndexOutOfBoundsException + if (samples.isEmpty()) { + Log.w("SnodeClock", "Resync failed: Unable to reach any Snodes.") + return@withTimeout false + } + + val nowUptime = SystemClock.elapsedRealtime() + val candidateNowTimes = samples.map { (uptimeAtStart, adjustedAtStart) -> + adjustedAtStart + (nowUptime - uptimeAtStart) + }.sorted() + + // Calculate median + val medianNow = candidateNowTimes[candidateNowTimes.size / 2] + + // Commit state + instantState.value = Instant(systemUptime = nowUptime, networkTime = medianNow) + + // Update throttling timer on SUCCESS only, protected by Mutex + syncMutex.withLock { + lastSuccessfulSyncUptimeMs = SystemClock.elapsedRealtime() + } + + Log.d("SnodeClock", "Resynced. Network time: ${Date(medianNow)}, system time: ${Date()}") + true + } + }.getOrElse { t -> + Log.w("SnodeClock", "Resync failed with exception", t) + false + } + } + + private suspend fun pickDistinctRandomSnodes(count: Int): List { + val out = LinkedHashSet(count) + var guard = 0 + // Added a sanity check for pool size to prevent infinite loops if pool is tiny + val poolSize = snodeDirectory.getSnodePool().size + + while (out.size < count && out.size < poolSize && guard++ < 20) { + out += snodeDirectory.getRandomSnode() + } + return out.toList() + } + + /** + * Get the current time in milliseconds. If the network time is not available yet, this method + * will return the current system time. + */ + fun currentTimeMillis(): Long { + return instantState.value?.now() ?: System.currentTimeMillis() + } + + fun currentTimeSeconds(): Long = currentTimeMillis() / 1000 + + fun currentTime(): java.time.Instant = java.time.Instant.ofEpochMilli(currentTimeMillis()) + + /** + * Delay until the specified instant. If the instant is in the past or now, this method returns + * immediately. + * + * @return true if delayed, false if the instant is in the past + */ + suspend fun delayUntil(instant: java.time.Instant): Boolean { + val now = currentTimeMillis() + val target = instant.toEpochMilli() + return if (target > now) { + delay(target - now) + true + } else { + target == now + } + } + + private class Instant( + val systemUptime: Long, + val networkTime: Long, + ) { + fun now(): Long { + val elapsed = SystemClock.elapsedRealtime() - systemUptime + return networkTime + elapsed + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/model/BatchResponse.kt b/app/src/main/java/org/session/libsession/network/model/BatchResponse.kt new file mode 100644 index 0000000000..6636b286f5 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/model/BatchResponse.kt @@ -0,0 +1,17 @@ +package org.session.libsession.snode.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class BatchResponse(val results: List) { + @Serializable + data class Item( + val code: Int, + val body: JsonElement, + ) { + val isSuccessful: Boolean + get() = code in 200..299 + + } +} diff --git a/app/src/main/java/org/session/libsession/network/model/FailureDecision.kt b/app/src/main/java/org/session/libsession/network/model/FailureDecision.kt new file mode 100644 index 0000000000..4aa19a37fc --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/model/FailureDecision.kt @@ -0,0 +1,5 @@ +package org.session.libsession.network.model + +enum class FailureDecision { + Retry, Fail, +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt b/app/src/main/java/org/session/libsession/network/model/MessageResponses.kt similarity index 95% rename from app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt rename to app/src/main/java/org/session/libsession/network/model/MessageResponses.kt index 35ec0f25cf..90a410afa1 100644 --- a/app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt +++ b/app/src/main/java/org/session/libsession/network/model/MessageResponses.kt @@ -4,6 +4,7 @@ import android.util.Base64 import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.session.libsession.utilities.serializable.InstantAsMillisSerializer +import org.thoughtcrime.securesms.api.snode.SnodeApiResponse import java.time.Instant @Serializable diff --git a/app/src/main/java/org/session/libsession/network/model/OnionDestination.kt b/app/src/main/java/org/session/libsession/network/model/OnionDestination.kt new file mode 100644 index 0000000000..322f269652 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/model/OnionDestination.kt @@ -0,0 +1,16 @@ +package org.session.libsession.network.model + +import org.session.libsignal.utilities.Snode + +sealed class OnionDestination(val description: String) { + class SnodeDestination(val snode: Snode) : + OnionDestination("Service node ${snode.ip}:${snode.port}") + + class ServerDestination( + val host: String, + val target: String, + val x25519PublicKey: String, + val scheme: String, + val port: Int + ) : OnionDestination(host) +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/model/OnionError.kt b/app/src/main/java/org/session/libsession/network/model/OnionError.kt new file mode 100644 index 0000000000..d706c3324b --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/model/OnionError.kt @@ -0,0 +1,90 @@ +package org.session.libsession.network.model + +import org.session.libsignal.utilities.ByteArraySlice +import org.session.libsignal.utilities.Snode + +data class ErrorStatus( + val code: Int, + val message: String? = null, + val body: ByteArraySlice? = null +) { + val bodyText: String? + get() = body?.decodeToString() +} + +sealed class OnionError( + val status: ErrorStatus? = null, + val destination: OnionDestination?, + cause: Throwable? = null +) : Exception("Onion error with status code ${status?.code}. Message: ${status?.message}. Destination: ${if(destination is OnionDestination.SnodeDestination) "Snode: "+destination.snode.address else if(destination is OnionDestination.ServerDestination) "Server: "+destination.host else "Unknown"}", cause) { + + /** + * We got an issue building the path or encoding the payload + */ + class EncodingError(destination: OnionDestination, cause: Throwable) + : OnionError(destination = destination, cause = cause) + + /** + * We couldn't even talk to the guard node. + * Typical causes: offline, DNS failure, TCP connect fails, TLS failure. + */ + class GuardUnreachable(val guard: Snode, destination: OnionDestination, cause: Throwable) + : OnionError(destination = destination, cause = cause) + + /** + * The onion chain broke mid-path: one hop reported that the next node was not found. + * failedPublicKey is the ed25519 key of the missing snode if known. + */ + class IntermediateNodeUnreachable( + val offendingSnode: Snode?, + status: ErrorStatus, + destination: OnionDestination, + ) : OnionError(destination = destination, status = status) + + /** + * The snode reported not being ready + */ + class SnodeNotReady( + val offendingSnode: Snode?, + status: ErrorStatus, + destination: OnionDestination, + ) : OnionError(destination = destination, status = status) + + /** + * A snode reported a timeout + */ + class PathTimedOut( + status: ErrorStatus, + destination: OnionDestination, + ) : OnionError(destination = destination, status = status) + + /** + * We couldn't reach the destination from the final snode in the path + */ + class DestinationUnreachable(destination: OnionDestination, status: ErrorStatus) + : OnionError(destination = destination, status = status) + + /** + * The error happened, as far as we can tell, along the path on the way to the destination + */ + class PathError(val guardNode: Snode, status: ErrorStatus, destination: OnionDestination,) + : OnionError(status = status, destination = destination) + + /** + * If we get an invalid response along the path (differs from the InvalidResponse which comes from a 200 payload) + */ + class InvalidHopResponse(val node: Snode, status: ErrorStatus, destination: OnionDestination,) + : OnionError(status = status, destination = destination) + + /** + * The onion payload returned something that we couldn't decode as a valid onion response. + */ + class InvalidResponse(destination: OnionDestination, cause: Throwable) + : OnionError(cause = cause, destination = destination) + + /** + * Fallback for anything we haven't classified yet. + */ + class Unknown(destination: OnionDestination, cause: Throwable) + : OnionError(cause = cause, destination = destination) +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/model/OnionResponse.kt b/app/src/main/java/org/session/libsession/network/model/OnionResponse.kt new file mode 100644 index 0000000000..668c4e91b0 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/model/OnionResponse.kt @@ -0,0 +1,10 @@ +package org.session.libsession.network.model + +import org.session.libsignal.utilities.ByteArraySlice + +data class OnionResponse( + val info: Map<*, *>, + val body: ByteArraySlice? = null +) { + val message: String? get() = info["message"] as? String +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/snode/OwnedSwarmAuth.kt b/app/src/main/java/org/session/libsession/network/model/OwnedSwarmAuth.kt similarity index 100% rename from app/src/main/java/org/session/libsession/snode/OwnedSwarmAuth.kt rename to app/src/main/java/org/session/libsession/network/model/OwnedSwarmAuth.kt diff --git a/app/src/main/java/org/session/libsession/network/model/PathStatus.kt b/app/src/main/java/org/session/libsession/network/model/PathStatus.kt new file mode 100644 index 0000000000..52bbf509e0 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/model/PathStatus.kt @@ -0,0 +1,7 @@ +package org.session.libsession.network.model + +enum class PathStatus { + READY, // green + BUILDING, // orange + ERROR // red (offline, no path, repeated failures, etc.) +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/snode/SnodeMessage.kt b/app/src/main/java/org/session/libsession/network/model/SnodeMessage.kt similarity index 100% rename from app/src/main/java/org/session/libsession/snode/SnodeMessage.kt rename to app/src/main/java/org/session/libsession/network/model/SnodeMessage.kt diff --git a/app/src/main/java/org/session/libsession/snode/SwarmAuth.kt b/app/src/main/java/org/session/libsession/network/model/SwarmAuth.kt similarity index 100% rename from app/src/main/java/org/session/libsession/snode/SwarmAuth.kt rename to app/src/main/java/org/session/libsession/network/model/SwarmAuth.kt diff --git a/app/src/main/java/org/session/libsession/network/model/Types.kt b/app/src/main/java/org/session/libsession/network/model/Types.kt new file mode 100644 index 0000000000..994895c64e --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/model/Types.kt @@ -0,0 +1,5 @@ +package org.session.libsession.network.model + +import org.session.libsignal.utilities.Snode + +typealias Path = List \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/onion/OnionBuilder.kt b/app/src/main/java/org/session/libsession/network/onion/OnionBuilder.kt new file mode 100644 index 0000000000..470cb1d789 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/onion/OnionBuilder.kt @@ -0,0 +1,46 @@ +package org.session.libsession.network.onion + +import org.session.libsession.network.model.OnionDestination +import org.session.libsession.network.model.Path +import org.session.libsignal.utilities.Snode +import javax.inject.Inject + +open class OnionBuilder @Inject constructor(private val onionRequestEncryption: OnionRequestEncryption) { + + data class BuiltOnion( + val guard: Snode, + val ciphertext: ByteArray, + val ephemeralPublicKey: ByteArray, + val destinationSymmetricKey: ByteArray + ) + + fun build( + path: Path, + destination: OnionDestination, + payload: ByteArray, + onionRequestVersion: OnionRequestVersion + ): BuiltOnion { + require(path.isNotEmpty()) { "Path must not be empty" } + + val destinationResult = + onionRequestEncryption.encryptPayloadForDestination(payload, destination, onionRequestVersion) + + val encryptionResult = path.foldRight( + destination to destinationResult + ) { hop, (previousDestination, previousEncryptionResult) -> + OnionDestination.SnodeDestination(hop) to onionRequestEncryption.encryptHop( + lhs = OnionDestination.SnodeDestination(hop), + rhs = previousDestination, + previousEncryptionResult = previousEncryptionResult, + ) + }.second + + return BuiltOnion( + guard = path.first(), + ciphertext = encryptionResult.ciphertext, + ephemeralPublicKey = encryptionResult.ephemeralPublicKey, + destinationSymmetricKey = destinationResult.symmetricKey + ) + } +} + diff --git a/app/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt b/app/src/main/java/org/session/libsession/network/onion/OnionRequestEncryption.kt similarity index 66% rename from app/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt rename to app/src/main/java/org/session/libsession/network/onion/OnionRequestEncryption.kt index 6e3c7bde8f..226ba45611 100644 --- a/app/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt +++ b/app/src/main/java/org/session/libsession/network/onion/OnionRequestEncryption.kt @@ -1,18 +1,17 @@ -package org.session.libsession.snode +package org.session.libsession.network.onion -import org.session.libsession.snode.OnionRequestAPI.Destination +import org.session.libsession.network.model.OnionDestination import org.session.libsession.utilities.AESGCM import org.session.libsession.utilities.AESGCM.EncryptionResult import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.toHexString -import java.io.ByteArrayOutputStream -import java.nio.Buffer import java.nio.ByteBuffer import java.nio.ByteOrder +import javax.inject.Inject -object OnionRequestEncryption { +open class OnionRequestEncryption @Inject constructor() { - internal fun encode(ciphertext: ByteArray, json: Map<*, *>): ByteArray { + fun encode(ciphertext: ByteArray, json: Map<*, *>): ByteArray { // The encoding of V2 onion requests looks like: | 4 bytes: size N of ciphertext | N bytes: ciphertext | json as utf8 | val jsonAsData = JsonUtil.toJson(json).toByteArray() val output = ByteArray(4 + ciphertext.size + jsonAsData.size) @@ -29,23 +28,23 @@ object OnionRequestEncryption { /** * Encrypts `payload` for `destination` and returns the result. Use this to build the core of an onion request. */ - internal fun encryptPayloadForDestination( + fun encryptPayloadForDestination( payload: ByteArray, - destination: Destination, - version: Version + destination: OnionDestination, + onionRequestVersion: OnionRequestVersion ): EncryptionResult { - val plaintext = if (version == Version.V4) { + val plaintext = if (onionRequestVersion == OnionRequestVersion.V4) { payload } else { // Wrapping isn't needed for file server or open group onion requests when (destination) { - is Destination.Snode -> encode(payload, mapOf("headers" to "")) - is Destination.Server -> payload + is OnionDestination.SnodeDestination -> encode(payload, mapOf("headers" to "")) + is OnionDestination.ServerDestination -> payload } } val x25519PublicKey = when (destination) { - is Destination.Snode -> destination.snode.publicKeySet!!.x25519Key - is Destination.Server -> destination.x25519PublicKey + is OnionDestination.SnodeDestination -> destination.snode.publicKeySet!!.x25519Key + is OnionDestination.ServerDestination -> destination.x25519PublicKey } return AESGCM.encrypt(plaintext, x25519PublicKey) } @@ -53,13 +52,13 @@ object OnionRequestEncryption { /** * Encrypts the previous encryption result (i.e. that of the hop after this one) for this hop. Use this to build the layers of an onion request. */ - internal fun encryptHop(lhs: Destination, rhs: Destination, previousEncryptionResult: EncryptionResult): EncryptionResult { + fun encryptHop(lhs: OnionDestination, rhs: OnionDestination, previousEncryptionResult: EncryptionResult): EncryptionResult { val payload: MutableMap = when (rhs) { - is Destination.Snode -> { + is OnionDestination.SnodeDestination -> { mutableMapOf("destination" to rhs.snode.publicKeySet!!.ed25519Key) } - is Destination.Server -> { + is OnionDestination.ServerDestination -> { mutableMapOf( "host" to rhs.host, "target" to rhs.target, @@ -71,11 +70,11 @@ object OnionRequestEncryption { } payload["ephemeral_key"] = previousEncryptionResult.ephemeralPublicKey.toHexString() val x25519PublicKey = when (lhs) { - is Destination.Snode -> { + is OnionDestination.SnodeDestination -> { lhs.snode.publicKeySet!!.x25519Key } - is Destination.Server -> { + is OnionDestination.ServerDestination -> { lhs.x25519PublicKey } } diff --git a/app/src/main/java/org/session/libsession/network/onion/OnionRequestVersion.kt b/app/src/main/java/org/session/libsession/network/onion/OnionRequestVersion.kt new file mode 100644 index 0000000000..fd45722e5d --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/onion/OnionRequestVersion.kt @@ -0,0 +1,7 @@ +package org.session.libsession.network.onion + + +enum class OnionRequestVersion(val value: String) { + V3("/loki/v3/lsrpc"), + V4("/oxen/v4/lsrpc"); +} diff --git a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt new file mode 100644 index 0000000000..44efd0c0ee --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt @@ -0,0 +1,455 @@ +package org.session.libsession.network.onion + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import org.session.libsession.network.model.Path +import org.session.libsession.network.model.PathStatus +import org.session.libsession.network.snode.SnodeDirectory +import org.session.libsession.network.snode.SnodePathStorage +import org.session.libsession.network.snode.SnodePoolStorage +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.crypto.secureRandom +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.AutoRetryApiExecutor +import org.thoughtcrime.securesms.api.onion.OnionSessionApiExecutor +import org.thoughtcrime.securesms.api.snode.GetInfoApi +import org.thoughtcrime.securesms.api.snode.SnodeApiExecutor +import org.thoughtcrime.securesms.api.snode.SnodeApiRequest +import org.thoughtcrime.securesms.api.snode.execute +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds + +@Singleton +open class PathManager @Inject constructor( + private val scope: CoroutineScope, + private val directory: SnodeDirectory, + private val storage: SnodePathStorage, + private val snodePoolStorage: SnodePoolStorage, + private val prefs: TextSecurePreferences, + private val snodeApiExecutor: Provider, + private val getInfoApi: Provider, +) { + companion object { + private const val STRIKE_THRESHOLD = 3 + private const val PATH_ROTATE_INTERVAL_MS = 10 * 60 * 1000L // 10min + } + + private val pathSize: Int = 3 + private val targetPathCount: Int = 2 + + private val _paths = MutableStateFlow( + sanitizePaths(storage.getOnionRequestPaths()) + ) + val paths: StateFlow> = _paths.asStateFlow() + + // Used for synchronization + private val buildMutex = Mutex() + private val _isBuilding = MutableStateFlow(false) + + // path rotation + private val isRotating = AtomicBoolean(false) + + // ----------------------------- + // Flow Setup + // ----------------------------- + + @OptIn(FlowPreview::class) + val status: StateFlow = + combine(_paths, _isBuilding) { paths, building -> + when { + building -> PathStatus.BUILDING + paths.isEmpty() -> PathStatus.ERROR + else -> PathStatus.READY + } + } + .debounce(250) + .stateIn( + scope, + SharingStarted.Eagerly, + if (_paths.value.isEmpty()) PathStatus.ERROR else PathStatus.READY + ) + + init { + // persist to DB whenever paths change + scope.launch { + _paths.drop(1).collectLatest { paths -> + if (paths.isEmpty()) storage.clearOnionRequestPaths() + else storage.setOnionRequestPaths(paths) + } + } + } + + // ----------------------------- + // Public API + // ----------------------------- + + suspend fun getPath(exclude: Snode? = null): Path { + directory.refreshPoolIfStaleAsync() + rotatePathsIfStale() + + val current = _paths.value + if (current.size >= targetPathCount && current.any { exclude == null || !it.contains(exclude) }) { + return selectPath(current, exclude) + } + + Log.w("Onion Request", "We only have ${current.size}/$targetPathCount paths, need to rebuild path.") + + // Wait for rebuild to finish if one is happening, or start one + rebuildPaths(reusablePaths = current) + + val rebuilt = _paths.value + if (rebuilt.isEmpty()) throw IllegalStateException("No paths after rebuild") + return selectPath(rebuilt, exclude) + } + + private fun rotatePathsIfStale() { + val now = System.currentTimeMillis() + val last = prefs.getLastPathRotation() + + // if we have never done a path rotation, mark now as the starting time + // so we can rotate on the next tick + if (last == 0L){ + prefs.setLastPathRotation(now) + return + } + + // no rotation needed if it's been less than our interval time + if (now - last < PATH_ROTATE_INTERVAL_MS) return + + if (!isRotating.compareAndSet(false, true)) return + scope.launch { + try { + rotatePaths() + } finally { + isRotating.set(false) + } + } + } + + private suspend fun testPath(pathCandidate: Path): Boolean { + try { + return withTimeout(5.seconds) { + // Run an snode API to test the path but disable path manipulation, retries, etc. + snodeApiExecutor.get() + .execute( + req = SnodeApiRequest( + snode = snodePoolStorage.getSnodePool().first { it !in pathCandidate }, + api = getInfoApi.get() + ), + ctx = ApiExecutorContext() + .set(OnionSessionApiExecutor.OnionPathOverridesKey, pathCandidate) + .set(AutoRetryApiExecutor.DisableRetryKey, Unit) + ) + + Log.d("Onion Request", "Path test succeeded for candidate path $pathCandidate") + true + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + Log.w("Onion Request", "Path test failed for $pathCandidate", e) + return false + } + } + + /** + * Rotates existing paths by keeping the guard node but replacing the rest. + * Validates connectivity via /info before committing. + */ + private suspend fun rotatePaths() { + Log.d("Onion Request", "Start rotating paths...") + + // Phase 1: decide + build candidates under lock + val candidates: List = buildMutex.withLock { + val now = System.currentTimeMillis() + val last = prefs.getLastPathRotation() + if (now - last < PATH_ROTATE_INTERVAL_MS) return@withLock emptyList() + + val current = _paths.value + if (current.isEmpty()) return@withLock emptyList() + + if (current.size < targetPathCount) { + // Don't rotate if we're already degraded - rebuildPaths will handle this. + return@withLock emptyList() + } + + // Keep the same guards + val guards = current.take(targetPathCount).map { it.first() } + + val pool = directory.ensurePoolPopulated() + + // do not reuse path snodes + val avoid = current.flatten().toSet() + + var unused = pool.minus(avoid) + + val neededPerPath = pathSize - 1 + val totalNeeded = targetPathCount * neededPerPath + if (unused.size < totalNeeded) { + // Not enough to rotate cleanly, skip silently. + return@withLock emptyList() + } + + val rotated = guards.map { guard -> + val rest = (0 until neededPerPath).map { + val next = unused.secureRandom() + unused = unused - next + next + } + listOf(guard) + rest + } + + sanitizePaths(rotated) + } + + if (candidates.isEmpty()) return + + // Phase 2: test out our paths + val working = ArrayList(targetPathCount) + for (p in candidates) { + if (testPath(p)) { + working += p + } + if (working.size >= targetPathCount) break + } + + if (working.isEmpty()) return + + // Phase 3: commit under lock (guards must match current guards) + buildMutex.withLock { + val current = sanitizePaths(_paths.value) + if (current.isEmpty()) return@withLock + + val currentGuards = current.take(targetPathCount).map { it.first() }.toSet() + val newGuards = working.map { it.first() }.toSet() + + if (currentGuards != newGuards) { + // Guards changed (likely rebuild happened) + Log.i("Onion Request", "Rotation aborted: Guards changed during validation (likely due to concurrent repair).") + return@withLock + } + + Log.d("Onion Request", "New rotated paths validated, committing as: $working") + + val committed = sanitizePaths(working.take(targetPathCount)) + _paths.value = committed + prefs.setLastPathRotation(System.currentTimeMillis()) + } + } + + suspend fun rebuildPaths(reusablePaths: List) { + buildMutex.withLock { + // Double-check: Did someone populate paths while we were waiting for the lock? + // If yes, we can skip building. + val freshPaths = _paths.value + if (freshPaths.size >= targetPathCount && arePathsDisjoint(freshPaths)) { + return + } + + _isBuilding.value = true + Log.w("Onion Request", "Rebuilding paths...") + try { + val pool = directory.ensurePoolPopulated() + + val safeReusable = sanitizePaths(reusablePaths) + val reusableGuards = safeReusable.map { it.first() }.toSet() + + val guardSnodes = directory.getGuardSnodes( + existingGuards = reusableGuards, + targetGuardCount = targetPathCount + ) + + var unused = pool + .minus(guardSnodes) + .minus(safeReusable.flatten().toSet()) + + val newPaths = guardSnodes + .minus(reusableGuards) + .map { guard -> + val rest = (0 until pathSize - 1).map { + val next = unused.secureRandom() + unused = unused - next + next + } + listOf(guard) + rest + } + + val allPaths = (safeReusable + newPaths).take(targetPathCount) + val sanitized = sanitizePaths(allPaths) + _paths.value = sanitized + + Log.w("Onion Request", "Paths rebuilt successfully. Current path count: ${sanitized.size}") + } finally { + _isBuilding.value = false + } + } + } + + /** + * Called when we know a specific snode is bad. + * + * Rules: + * - Striking a snode ALSO strikes the containing path(s). + * - Third strike means drop snode immediately. + * - Dropping a snode swaps it out in any path(s) that contain it (drops path only if unrepairable). + * - Dropping a snode also removes it from pool and (if pubkey known) swarm. + * + * @return true if the snode was punished/removed, false if it was not found in pool. + */ + suspend fun handleBadSnode( + snode: Snode, + forceRemove: Boolean = false + ) { + buildMutex.withLock { + val shouldRemoveSnode = forceRemove || + snodePoolStorage.increaseSnodeStrike(snode, 1) == STRIKE_THRESHOLD + + // First we need to punish the path that contains this snode + val pathWithStrikes = storage.increaseOnionRequestPathStrikeContainingSnode( + snodeEd25519PubKey = snode.ed25519Key, + increment = 1 + ) + + when { + // If containing path hit remove threshold, remove the path without trying to repair + pathWithStrikes != null && pathWithStrikes.second >= STRIKE_THRESHOLD -> { + storage.removePath(pathWithStrikes.first) + } + + // If containing path did not hit remove threshold, and we need to remove the snode, + // we try to repair the path by swapping out the bad snode + pathWithStrikes != null && shouldRemoveSnode -> { + val replacementSnode = storage.findRandomUnusedSnodesForNewPath(1) + .firstOrNull() + + val containingPath = pathWithStrikes.first + + if (replacementSnode == null) { + // No replacement available, remove the path + storage.removePath(containingPath) + } else { + val repairedPath = pathWithStrikes.first.map { node -> + if (node == snode) replacementSnode else node + } + + // Swap in the repaired path + storage.replaceOnionRequestPath( + oldPath = containingPath, + newPath = repairedPath + ) + } + } + } + + if (shouldRemoveSnode) { + // Now we should be able to drop the snode from pool and swarm + snodePoolStorage.removeSnode(snode.ed25519Key) + } + + _paths.value = storage.getOnionRequestPaths() + + } + } + + /** + * Called when an entire path is considered unreliable. + * + * Rules: + * - Third strike means drop path immediately. + * - Dropping a path strikes each node in the path (which can cascade into node drops). + */ + suspend fun handleBadPath(path: Path) { + buildMutex.withLock { + val newPathStrike = storage.increaseOnionRequestPathStrike(path, 1) + + if (newPathStrike == null) { + Log.w("Onion Request", "Attempted to strike path not in storage, ignoring") + return + } + + // First, strike each node in the path and find out what nodes need to be removed + val nodesToRemove = path.filter { node -> + val nodeStrike = snodePoolStorage.increaseSnodeStrike(node, 1) + nodeStrike != null && nodeStrike >= STRIKE_THRESHOLD + } + + // Find out if we need to remove the path + if (newPathStrike >= STRIKE_THRESHOLD || nodesToRemove.size == path.size) { + Log.w("Onion Request", "Path hit strike threshold or all snodes need removing, dropping path") + storage.removePath(path) + } else if (nodesToRemove.isNotEmpty()) { + // We have partial nodes to remove, so we will look at their replacements in paths + val newNodes = storage.findRandomUnusedSnodesForNewPath(nodesToRemove.size) + if (newNodes.size < nodesToRemove.size) { + Log.w("Onion Request", "Not enough available snodes to replace bad nodes in path, dropping path") + storage.removePath(path) + } else { + // Swap out the bad nodes in the path + val repairedPath = path.map { node -> + val idx = nodesToRemove.indexOfFirst { it == node } + if (idx != -1) { + newNodes[idx] + } else { + node + } + } + + Log.w("Onion Request", "Repaired path by swapping out bad nodes: ${nodesToRemove.map { it.address }}") + // Update storage with repaired path + storage.replaceOnionRequestPath(oldPath = path, newPath = repairedPath) + } + } + + // It's now safe to remove the snodes from pool + nodesToRemove.forEach { snode -> + snodePoolStorage.removeSnode(snode.ed25519Key) + } + + _paths.value = sanitizePaths(storage.getOnionRequestPaths()) + } + } + + + private fun selectPath(paths: List, exclude: Snode?): Path { + val candidates = if (exclude != null) { + paths.filter { !it.contains(exclude) } + } else paths + + if (candidates.isEmpty()) { + Log.w("Onion Request", "No valid paths excluding requested snode, using any available path") + return paths.secureRandom() + } + return candidates.secureRandom() + } + + private fun sanitizePaths(paths: List): List { + if (paths.isEmpty()) return emptyList() + if (arePathsDisjoint(paths)) return paths + Log.w("Onion Request", "Paths contained overlapping snodes. Dropping backups.") + return paths.take(1) + } + + private fun arePathsDisjoint(paths: List): Boolean { + val all = paths.flatten() + return all.size == all.toSet().size + } +} 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 new file mode 100644 index 0000000000..e19e0ec5c3 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt @@ -0,0 +1,461 @@ +package org.session.libsession.network.snode + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.decodeFromStream +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.session.libsession.utilities.Environment +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.crypto.secureRandom +import org.session.libsignal.crypto.shuffledSequence +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.SessionApiExecutor +import org.thoughtcrime.securesms.api.SessionApiRequest +import org.thoughtcrime.securesms.api.execute +import org.thoughtcrime.securesms.api.http.HttpApiExecutor +import org.thoughtcrime.securesms.api.http.HttpRequest +import org.thoughtcrime.securesms.api.snode.ListSnodeApi +import org.thoughtcrime.securesms.api.snode.SnodeApiExecutor +import org.thoughtcrime.securesms.api.snode.SnodeApiRequest +import org.thoughtcrime.securesms.api.snode.SnodeJsonRequest +import org.thoughtcrime.securesms.api.snode.execute +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import java.security.SecureRandom +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +class SnodeDirectory @Inject constructor( + private val storage: SnodePoolStorage, + private val prefs: TextSecurePreferences, + private val httpExecutor: Provider, + private val snodeAPiExecutor: Provider, + private val listSnodeApi: Provider, + @param:ManagerScope private val scope: CoroutineScope, + @param:ApplicationContext private val appContext: Context, + private val json: Json, +) : OnAppStartupComponent { + + companion object { + private const val MINIMUM_SNODE_POOL_COUNT = 12 + private const val SEED_NODE_PORT = 4443 + + private const val POOL_REFRESH_INTERVAL_MS = 2 * 60 * 60 * 1000L // 2h + + + private val DEV_NET_SEED_NODES = setOf( + "http://sesh-net.local:1280".toHttpUrl() + ) + + private val TEST_NET_SEED_NODES = setOf( + "http://public.loki.foundation:38157".toHttpUrl() + ) + + private val MAIN_NET_SEED_NODES = setOf( + "https://seed1.getsession.org:$SEED_NODE_PORT".toHttpUrl(), + "https://seed2.getsession.org:$SEED_NODE_PORT".toHttpUrl(), + "https://seed3.getsession.org:$SEED_NODE_PORT".toHttpUrl() + ) + + private const val LOCAL_SNODE_POOL_ASSET = "snodes/snode_pool.json" + } + + /** + * Single mutex for any operation that can persist/replace the pool (bootstrap OR refresh). + * This prevents refresh/bootstrap races overwriting each other. + */ + private val poolWriteMutex = Mutex() + + // Refresh state (non-blocking trigger + real exclusion inside mutex) + @Volatile private var snodePoolRefreshing = false + + val seedNodePool: Set get() = when (prefs.getEnvironment()) { + Environment.DEV_NET -> DEV_NET_SEED_NODES + Environment.TEST_NET -> TEST_NET_SEED_NODES + Environment.MAIN_NET -> MAIN_NET_SEED_NODES + } + + override fun onPostAppStarted() { + // Ensure we have a populated snode pool on launch + scope.launch { + try { + ensurePoolPopulated() + Log.d("SnodeDirectory", "Snode pool populated on startup.") + } catch (e: Exception) { + Log.e("SnodeDirectory", "Failed to populate snode pool on startup", e) + } + } + } + + fun getSnodePool(): List = storage.getSnodePool() + + private fun persistSnodePool(newPool: List) { + storage.setSnodePool(newPool) + prefs.setLastSnodePoolRefresh(System.currentTimeMillis()) + } + + /** + * Ensure the snode pool is populated to at least [minCount] elements. + * + * - If the current pool is already large enough, returns it unchanged. + * - Otherwise, bootstraps from a random seed node (get_n_service_nodes), + * persists the new pool, and returns it. + * + * Throws if the seed node returns an empty list or parsing fails. + * Thread-safe: Ensures only one network call happens at a time. + */ + suspend fun ensurePoolPopulated( + minCount: Int = MINIMUM_SNODE_POOL_COUNT + ): List { + val current = getSnodePool() + + if (current.size >= minCount) { + // ensure we set the refresh timestamp in case we are starting the app + // with already cached snodes - set the timestamp to stale to enforce a refresh soon + if (prefs.getLastSnodePoolRefresh() == 0L) { + // Force a refresh on next opportunity + prefs.setLastSnodePoolRefresh(System.currentTimeMillis() - POOL_REFRESH_INTERVAL_MS - 1) + } + + return current + } + + return poolWriteMutex.withLock { + val freshCurrent = getSnodePool() + if (freshCurrent.size >= minCount) return@withLock freshCurrent + + val seeded = fetchSnodePoolFromSeedWithFallback() + if (seeded.isEmpty()) throw IllegalStateException("Seed node returned empty snode pool") + + Log.d("SnodeDirectory", "Persisting snode pool with ${seeded.size} snodes (seed bootstrap).") + persistSnodePool(seeded) + seeded + } + } + + private suspend fun fetchSnodePoolFromSeedWithFallback(): List { + val seeds = seedNodePool.shuffled() + + var lastError: Throwable? = null + + for (target in seeds) { + Log.d("SnodeDirectory", "Fetching snode pool using seed node: $target") + @Suppress("OPT_IN_USAGE") val result = runCatching { + 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() + ) + ) + ) + ).throwIfNotSuccessful().body + + body + .asInputStream() + .use { + json.decodeFromStream(it) + } + .result + }.onFailure { e -> + lastError = e + Log.w("SnodeDirectory", "Seed node failed: $target", e) + } + .getOrNull() + ?.toSnodeList() + + if (!result.isNullOrEmpty()) return result + } + + // All seeds failed -> local fallback + Log.w("SnodeDirectory", "All seed nodes failed; falling back to local snode pool file.", lastError) + + @Suppress("OPT_IN_USAGE") + val parsed: SeedNodeSnodeFetchResult = appContext.assets.open(LOCAL_SNODE_POOL_ASSET) + .use(json::decodeFromStream) + + val nodes = parsed.result.toSnodeList() + + if (nodes.isEmpty()) { + throw IllegalStateException("Local snode pool file parsed empty", lastError) + } + + Log.w("SnodeDirectory", "Successfully parsed snodes from local file.") + return nodes + } + + @Serializable + private class SeedNodeSnodeFetchResult( + val result: ListSnodeApi.Response, + ) + + private suspend fun fetchSnodePoolFromSnode(snode: Snode): List { + return snodeAPiExecutor.get() + .execute(SnodeApiRequest(snode, listSnodeApi.get())) + .toSnodeList() + } + + /** + * Returns a random snode from the generic snode pool. + * + * Uses [ensurePoolPopulated] under the hood, so you still get lazy bootstrap if + * startup population failed or hasn’t run yet. + */ + suspend fun getRandomSnode(): Snode { + val pool = ensurePoolPopulated() + return pool.secureRandom() + } + + fun createSnode( + address: String?, + port: Int?, + ed25519Key: String?, + x25519Key: String?, + ): Snode? { + return Snode( + address?.takeUnless { it == "0.0.0.0" }?.let { "https://$it" } ?: return null, + port ?: return null, + Snode.KeySet(ed25519Key ?: return null, x25519Key ?: return null), + ) + } + + suspend fun getGuardSnodes( + existingGuards: Set, + targetGuardCount: Int + ): Set { + if (existingGuards.size >= targetGuardCount) return existingGuards + + var unused = ensurePoolPopulated().minus(existingGuards) + val needed = targetGuardCount - existingGuards.size + + if (unused.size < needed) { + throw IllegalStateException("Insufficient snodes to build guards") + } + + val newGuards = (0 until needed).map { + val candidate = unused.secureRandom() + unused = unused - candidate + Log.d("Onion Request", "Selected guard snode: $candidate") + candidate + } + + return (existingGuards + newGuards).toSet() + } + + // snode pool refresh logic + + /** + * Non-blocking trigger. + * + * IMPORTANT: does nothing until we have successfully seeded at least once + * (lastRefreshElapsedMs != 0L). + */ + fun refreshPoolIfStaleAsync() { + // Don’t refresh until we’ve successfully seeded at least once + val last = prefs.getLastSnodePoolRefresh() + if (last == 0L) return + + val now = System.currentTimeMillis() + if (snodePoolRefreshing) return + if (now >= last && now - last < POOL_REFRESH_INTERVAL_MS) return + + scope.launch { + refreshPoolFromSnodes( + totalSnodeQueries = 3, + minAppearance = 2, + distinctQuerySubnetPrefix = 24, + ) + } + } + + private suspend fun refreshPoolFromSnodes( + totalSnodeQueries: Int, + minAppearance: Int, + distinctQuerySubnetPrefix: Int?, + ) { + require(totalSnodeQueries >= 1) { "totalSnodeQueries must be >= 1" } + require(minAppearance in 1..totalSnodeQueries) { "minAppearance must be within 1..totalSnodeQueries" } + + poolWriteMutex.withLock { + // Re-check staleness INSIDE the lock to avoid “double refresh” races + val last = prefs.getLastSnodePoolRefresh() + if (last == 0L) return// still not seeded + val now = System.currentTimeMillis() + if (now >= last && now - last < POOL_REFRESH_INTERVAL_MS) return + + if (snodePoolRefreshing) return + snodePoolRefreshing = true + + try { + val current = getSnodePool() + + suspend fun getFromSeed(msg: String){ + val seeded = fetchSnodePoolFromSeedWithFallback() + if (seeded.isNotEmpty()) { + Log.d("SnodeDirectory", "$msg New size=${seeded.size}") + persistSnodePool(seeded) + } + } + + // If pool is too small, refresh from seed + if (current.size < totalSnodeQueries) { + getFromSeed("Refreshing pool from seed (pool too small)") + return + } + + // Choose snodes to query pool from + val snodesToQuery = pickViableSnodesForPoolQuery( + pool = current, + count = totalSnodeQueries, + distinctSubnetPrefix = distinctQuerySubnetPrefix + ) + + // If we don't have enough snodes meeting our requirements, fallback to seed + if (snodesToQuery.size < totalSnodeQueries) { + getFromSeed("Not enough snodes meeting our requirements; refreshing pool from seed instead.") + return + } + + // Fetch pools from responders until we have totalSnodeQueries successful results + val results = mutableListOf>() + + for (snode in snodesToQuery) { + if (results.size >= totalSnodeQueries) break + val fetched = runCatching { fetchSnodePoolFromSnode(snode) } + .onFailure { Log.w("SnodeDirectory", "Error fetching snode pool", it) } + .getOrNull() + if (!fetched.isNullOrEmpty()) results += fetched + } + + if (results.size < totalSnodeQueries) { + getFromSeed( "Refreshing pool from seed (insufficient responder fetches).") + return + } + + val quorum = quorumByEd25519(pools = results, minAppearance = minAppearance) + + if (quorum.isEmpty()) { + getFromSeed("Quorum empty; refreshing pool from seed instead.") + return + } + if (quorum.size < MINIMUM_SNODE_POOL_COUNT) { + getFromSeed("Quorum too small (${quorum.size}); refreshing pool from seed instead.") + return + } + + Log.d( + "SnodeDirectory", + "Refreshing pool via quorum (minAppearance=$minAppearance of $totalSnodeQueries, distinctSubnetPrefix=$distinctQuerySubnetPrefix). New size=${quorum.size}" + ) + persistSnodePool(quorum) + + } finally { + snodePoolRefreshing = false + } + } + } + + private fun pickViableSnodesForPoolQuery( + pool: List, + count: Int, + distinctSubnetPrefix: Int? + ): List { + if (pool.isEmpty() || count <= 0) return emptyList() + + // If subnet diversification disabled, just take first N + if (distinctSubnetPrefix == null) { + return pool + .shuffledSequence() + .take(count) + .toList() + } else { + // Enforce distinct subnet prefix where possible + val picked = ArrayList(count) + val used = mutableSetOf() + for (snode in pool.shuffledSequence()) { + if (picked.size >= count) break + val subnet = subnetPrefixOrNull(snode.ip, distinctSubnetPrefix) ?: continue + if (!used.add(subnet)) continue + picked += snode + } + + return picked + } + } + + /** + * Returns a subnet prefix string like: + * - prefix=24 -> "a.b.c" + * - prefix=16 -> "a.b" + * - prefix=8 -> "a" + * + * Returns null if prefix is null/unsupported or IP isn't IPv4. + */ + private fun subnetPrefixOrNull(ip: String, prefix: Int?): String? { + if (prefix == null) return null + val octetsToKeep = when (prefix) { + 8 -> 1 + 16 -> 2 + 24 -> 3 + else -> return null + } + + val parts = ip.split('.') + if (parts.size != 4) return null + + val octets = parts.map { it.toIntOrNull() ?: return null } + if (octets.any { it !in 0..255 }) return null + + return octets.take(octetsToKeep).joinToString(".") + } + + private fun quorumByEd25519( + pools: List>, + minAppearance: Int + ): List { + if (pools.isEmpty()) return emptyList() + + val voteCount = mutableMapOf() + val bestByKey = mutableMapOf() + + for (pool in pools) { + // using this in spite of Set in case we get bad data with snodes that use the same key with diff address/port + val seenThisPool = mutableSetOf() + + for (snode in pool) { + val key = snode.publicKeySet?.ed25519Key ?: continue + if (!seenThisPool.add(key)) continue // <-- important + + voteCount[key] = (voteCount[key] ?: 0) + 1 + + val currentBest = bestByKey[key] + bestByKey[key] = when { + currentBest == null -> snode + else -> currentBest + } + } + } + + return voteCount.asSequence() + .filter { (_, count) -> count >= minAppearance } + .mapNotNull { (key, _) -> bestByKey[key] } + .toList() + } +} diff --git a/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt b/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt new file mode 100644 index 0000000000..4ceba6c9c3 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt @@ -0,0 +1,50 @@ +package org.session.libsession.network.snode + + +import org.session.libsession.network.model.Path +import org.session.libsignal.utilities.Snode + +interface SnodePathStorage { + fun getOnionRequestPaths(): List + fun setOnionRequestPaths(paths: List) + fun replaceOnionRequestPath(oldPath: Path, newPath: Path) + + /** + * Increase strike count for a request path and return the new strike count. + * + * @param path The request path to increase the strike count for + * @param increment The amount to increase the strike count by. Can be negative to decrease + * strikes. + * @return The new strike count for the request path if the path exists + */ + fun increaseOnionRequestPathStrike(path: Path, increment: Int): Int? + fun increaseOnionRequestPathStrikeContainingSnode(snodeEd25519PubKey: String, increment: Int): Pair? + + fun findRandomUnusedSnodesForNewPath(n: Int): List + + fun clearOnionRequestPaths() + fun removePath(path: Path) +} + +interface SwarmStorage { + fun getSwarm(publicKey: String): List + fun setSwarm(publicKey: String, swarm: Collection) + fun dropSnodeFromSwarm(publicKey: String, snodeEd25519PubKey: String) +} + +interface SnodePoolStorage { + fun getSnodePool(): List + fun removeSnode(ed25519PubKey: String): Snode? + fun setSnodePool(newValue: Collection) + + fun removeSnodesWithStrikesGreaterThan(n: Int): Int + + /** + * Increase strike count for a snode and return the new strike count + * + * @param snode The snode to increase the strike count for + * @param increment The amount to increase the strike count by. Can be negative to decrease strikes. + * @return The new strike count for the snode, or null if the snode does not exist in the pool + */ + fun increaseSnodeStrike(snode: Snode, increment: Int): Int? +} diff --git a/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt new file mode 100644 index 0000000000..b501f32d57 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt @@ -0,0 +1,112 @@ +package org.session.libsession.network.snode + +import org.session.libsignal.crypto.shuffledRandom +import org.session.libsignal.utilities.JsonUtil +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.api.snode.GetSwarmApi +import org.thoughtcrime.securesms.api.snode.SnodeApiExecutor +import org.thoughtcrime.securesms.api.snode.SnodeApiRequest +import org.thoughtcrime.securesms.api.snode.execute +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +class SwarmDirectory @Inject constructor( + private val storage: SwarmStorage, + private val snodeDirectory: SnodeDirectory, + private val snodeApiExecutor: Provider, + private val getSwarmFactory: GetSwarmApi.Factory, +) { + private val minimumSwarmSize: Int = 3 + + suspend fun getSwarm(publicKey: String): List { + val cached = storage.getSwarm(publicKey) + if (cached.size >= minimumSwarmSize) { + return cached + } + + val fresh = fetchSwarm(publicKey) + storage.setSwarm(publicKey, fresh) + return fresh + } + + suspend fun fetchSwarm(publicKey: String): List { + val pool = snodeDirectory.ensurePoolPopulated() + require(pool.isNotEmpty()) { + "Snode pool is empty" + } + + val response = snodeApiExecutor.get().execute( + SnodeApiRequest( + snode = pool.random(), + api = getSwarmFactory.create(publicKey) + ) + ) + + return response.snodes + .mapNotNull { it.toSnode() } + } + + /** + * Picks one snode from the user's swarm for a given account. + * We deliberately randomise to avoid hammering a single node. + */ + suspend fun getSingleTargetSnode(publicKey: String): Snode { + val swarm = getSwarm(publicKey) + require(swarm.isNotEmpty()) { "Swarm is empty for pubkey=$publicKey" } + return swarm.shuffledRandom().random() + } + + fun dropSnodeFromSwarmIfNeeded(snode: Snode, swarmPublicKey: String) { + val current = storage.getSwarm(swarmPublicKey) + if (snode !in current) return + + val updated = current - snode + storage.setSwarm(swarmPublicKey, updated) + } + + /** + * Expected response shape: + * { "snodes": [ { "ip": "...", "port": "443", "pubkey_ed25519": "...", "pubkey_x25519": "..." }, ... ] } + */ + @Suppress("UNCHECKED_CAST") + private fun parseSnodes(rawResponse: Map<*, *>): List { + val list = rawResponse["snodes"] as? List<*> ?: emptyList() + return list.asSequence() + .mapNotNull { it as? Map<*, *> } + .mapNotNull { raw -> + snodeDirectory.createSnode( + address = raw["ip"] as? String, + port = (raw["port"] as? String)?.toInt(), + ed25519Key = raw["pubkey_ed25519"] as? String, + x25519Key = raw["pubkey_x25519"] as? String + ) + } + .toList() + } + + /** + * Handles 421: snode says it's no longer associated with this pubkey. + * + * Old behaviour: if response contains snodes -> replace cached swarm. + * Otherwise invalidate (caller may also drop the target snode from cached swarm). + * + * @return true if swarm was updated from body JSON, false otherwise. + */ + fun updateSwarmFromResponse(swarmPublicKey: String, errorResponseBody: String?): Boolean { + if (errorResponseBody == null || errorResponseBody.isEmpty()) return false + + val json: Map<*, *> = try { + JsonUtil.fromJson(errorResponseBody, Map::class.java) as Map<*, *> + } catch (_: Throwable) { + return false + } + + val snodes = parseSnodes(json).toSet() + if (snodes.isEmpty()) return false + + storage.setSwarm(swarmPublicKey, snodes) + return true + } +} diff --git a/app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt b/app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt deleted file mode 100644 index 116d5fbc2c..0000000000 --- a/app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt +++ /dev/null @@ -1,696 +0,0 @@ -package org.session.libsession.snode - -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import nl.komponents.kovenant.Deferred -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.all -import nl.komponents.kovenant.deferred -import nl.komponents.kovenant.functional.bind -import nl.komponents.kovenant.functional.map -import okhttp3.Request -import org.session.libsession.messaging.file_server.FileServerApi -import org.session.libsession.snode.utilities.asyncPromise -import org.session.libsession.utilities.AESGCM -import org.session.libsession.utilities.AESGCM.EncryptionResult -import org.session.libsession.utilities.getBodyForOnionRequest -import org.session.libsession.utilities.getHeadersForOnionRequest -import org.session.libsignal.crypto.secureRandom -import org.session.libsignal.crypto.secureRandomOrNull -import org.session.libsignal.database.LokiAPIDatabaseProtocol -import org.session.libsignal.utilities.Base64 -import org.session.libsignal.utilities.ByteArraySlice -import org.session.libsignal.utilities.ByteArraySlice.Companion.view -import org.session.libsignal.utilities.ForkInfo -import org.session.libsignal.utilities.HTTP -import org.session.libsignal.utilities.JsonUtil -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.Snode -import org.session.libsignal.utilities.recover -import org.session.libsignal.utilities.toHexString - -private typealias Path = List - -/** - * See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information. - */ - - -object OnionRequestAPI { - private var buildPathsPromise: Promise, Exception>? = null - private val database: LokiAPIDatabaseProtocol - get() = SnodeModule.shared.storage - private val pathFailureCount = mutableMapOf() - private val snodeFailureCount = mutableMapOf() - - var guardSnodes = setOf() - - private val mutablePaths = MutableStateFlow(database.getOnionRequestPaths()) - - val paths: StateFlow> get() = mutablePaths - val hasPath: StateFlow = mutablePaths - .drop(1) - .map { it.isNotEmpty() } - .stateIn(GlobalScope, SharingStarted.Eagerly, paths.value.isNotEmpty()) - - private val NON_PENALIZING_STATUSES = setOf(403, 404, 406, 425) - - init { - // Listen for the changes in paths and persist it to the db - GlobalScope.launch { - mutablePaths - .drop(1) // Drop the first result where it just comes from the db - .collectLatest { - if (it.isEmpty()) { - database.clearOnionRequestPaths() - } else { - database.setOnionRequestPaths(it) - } - } - } - } - - // region Settings - /** - * The number of snodes (including the guard snode) in a path. - */ - private const val pathSize = 3 - /** - * The number of times a path can fail before it's replaced. - */ - private const val pathFailureThreshold = 3 - /** - * The number of times a snode can fail before it's replaced. - */ - private const val snodeFailureThreshold = 3 - /** - * The number of guard snodes required to maintain `targetPathCount` paths. - */ - private val targetGuardSnodeCount - get() = targetPathCount // One per path - /** - * The number of paths to maintain. - */ - const val targetPathCount = 2 // A main path and a backup path for the case where the target snode is in the main path - // endregion - - class HTTPRequestFailedBlindingRequiredException(statusCode: Int, json: Map<*, *>, destination: String): HTTPRequestFailedAtDestinationException(statusCode, json, destination) - open class HTTPRequestFailedAtDestinationException(statusCode: Int, json: Map<*, *>, val destination: String) - : HTTP.HTTPRequestFailedException(statusCode, json, "HTTP request failed at destination ($destination) with status code $statusCode.") - class InsufficientSnodesException : Exception("Couldn't find enough snodes to build a path.") - - private data class OnionBuildingResult( - val guardSnode: Snode, - val finalEncryptionResult: EncryptionResult, - val destinationSymmetricKey: ByteArray - ) - - internal sealed class Destination(val description: String) { - class Snode(val snode: org.session.libsignal.utilities.Snode) : Destination("Service node ${snode.ip}:${snode.port}") - class Server(val host: String, val target: String, val x25519PublicKey: String, val scheme: String, val port: Int) : Destination("$host") - } - - // region Private API - /** - * Tests the given snode. The returned promise errors out if the snode is faulty; the promise is fulfilled otherwise. - */ - private fun testSnode(snode: Snode): Promise { - return GlobalScope.asyncPromise { // No need to block the shared context for this - val url = "${snode.address}:${snode.port}/get_stats/v1" - val response = HTTP.execute(HTTP.Verb.GET, url, 3).decodeToString() - val json = JsonUtil.fromJson(response, Map::class.java) - val version = json["version"] as? String - require(version != null) { "Missing snode version." } - require(version >= "2.0.7") { "Unsupported snode version: $version." } - } - } - - /** - * Finds `targetGuardSnodeCount` guard snodes to use for path building. The returned promise errors out if not - * enough (reliable) snodes are available. - */ - private fun getGuardSnodes(reusableGuardSnodes: List): Promise, Exception> { - if (guardSnodes.count() >= targetGuardSnodeCount) { - return Promise.of(guardSnodes) - } else { - Log.d("Loki", "Populating guard snode cache.") - return SnodeAPI.getRandomSnode().bind { // Just used to populate the snode pool - var unusedSnodes = SnodeAPI.snodePool.minus(reusableGuardSnodes) - val reusableGuardSnodeCount = reusableGuardSnodes.count() - if (unusedSnodes.count() < (targetGuardSnodeCount - reusableGuardSnodeCount)) { throw InsufficientSnodesException() } - fun getGuardSnode(): Promise { - val candidate = unusedSnodes.secureRandomOrNull() - ?: return Promise.ofFail(InsufficientSnodesException()) - unusedSnodes = unusedSnodes.minus(candidate) - Log.d("Loki", "Testing guard snode: $candidate.") - // Loop until a reliable guard snode is found - val deferred = deferred() - testSnode(candidate).success { - deferred.resolve(candidate) - }.fail { - deferred.reject(it) - } - return deferred.promise - } - val promises = (0 until (targetGuardSnodeCount - reusableGuardSnodeCount)).map { getGuardSnode() } - all(promises).map { guardSnodes -> - val guardSnodesAsSet = (guardSnodes + reusableGuardSnodes).toSet() - OnionRequestAPI.guardSnodes = guardSnodesAsSet - guardSnodesAsSet - } - } - } - } - - /** - * Builds and returns `targetPathCount` paths. The returned promise errors out if not - * enough (reliable) snodes are available. - */ - private fun buildPaths(reusablePaths: List): Promise, Exception> { - val existingBuildPathsPromise = buildPathsPromise - if (existingBuildPathsPromise != null) { return existingBuildPathsPromise } - Log.d("Loki", "Building onion request paths.") - val promise = SnodeAPI.getRandomSnode().bind { // Just used to populate the snode pool - val reusableGuardSnodes = reusablePaths.map { it[0] } - getGuardSnodes(reusableGuardSnodes).map { guardSnodes -> - var unusedSnodes = SnodeAPI.snodePool.minus(guardSnodes).minus(reusablePaths.flatten()) - val reusableGuardSnodeCount = reusableGuardSnodes.count() - val pathSnodeCount = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount) - if (unusedSnodes.count() < pathSnodeCount) { throw InsufficientSnodesException() } - // Don't test path snodes as this would reveal the user's IP to them - guardSnodes.minus(reusableGuardSnodes).map { guardSnode -> - val result = listOf( guardSnode ) + (0 until (pathSize - 1)).mapIndexed() { index, _ -> - var pathSnode = unusedSnodes.secureRandom() - - // remove the snode from the unused list and return it - unusedSnodes = unusedSnodes.minus(pathSnode) - pathSnode - } - Log.d("Loki", "Built new onion request path: $result.") - result - } - }.map { paths -> - mutablePaths.value = paths + reusablePaths - paths - } - } - promise.success { buildPathsPromise = null } - promise.fail { buildPathsPromise = null } - buildPathsPromise = promise - return promise - } - - /** - * Returns a `Path` to be used for building an onion request. Builds new paths as needed. - */ - private fun getPath(snodeToExclude: Snode?): Promise { - if (pathSize < 1) { throw Exception("Can't build path of size zero.") } - val paths = this.paths.value - val guardSnodes = mutableSetOf() - if (paths.isNotEmpty()) { - guardSnodes.add(paths[0][0]) - if (paths.count() >= 2) { - guardSnodes.add(paths[1][0]) - } - } - OnionRequestAPI.guardSnodes = guardSnodes - fun getPath(paths: List): Path { - return if (snodeToExclude != null) { - paths.filter { !it.contains(snodeToExclude) }.secureRandom() - } else { - paths.secureRandom() - } - } - when { - paths.count() >= targetPathCount -> { - return Promise.of(getPath(paths)) - } - paths.isNotEmpty() -> { - return if (paths.any { !it.contains(snodeToExclude) }) { - buildPaths(paths) // Re-build paths in the background - Promise.of(getPath(paths)) - } else { - buildPaths(paths).map { newPaths -> - getPath(newPaths) - } - } - } - else -> { - return buildPaths(listOf()).map { newPaths -> - getPath(newPaths) - } - } - } - } - - private fun dropGuardSnode(snode: Snode) { - guardSnodes = guardSnodes.filter { it != snode }.toSet() - } - - private fun dropSnode(snode: Snode) { - // We repair the path here because we can do it sync. In the case where we drop a whole - // path we leave the re-building up to getPath() because re-building the path in that case - // is async. - snodeFailureCount[snode] = 0 - val oldPaths = mutablePaths.value.toMutableList() - val pathIndex = oldPaths.indexOfFirst { it.contains(snode) } - if (pathIndex == -1) { return } - val path = oldPaths[pathIndex].toMutableList() - val snodeIndex = path.indexOf(snode) - if (snodeIndex == -1) { return } - path.removeAt(snodeIndex) - val unusedSnodes = SnodeAPI.snodePool.minus(oldPaths.flatten()) - if (unusedSnodes.isEmpty()) { throw InsufficientSnodesException() } - path.add(unusedSnodes.secureRandom()) - // Don't test the new snode as this would reveal the user's IP - oldPaths.removeAt(pathIndex) - val newPaths = oldPaths + listOf( path ) - mutablePaths.value = newPaths - } - - private fun dropPath(path: Path) { - pathFailureCount[path] = 0 - val paths = mutablePaths.value.toMutableList() - val pathIndex = paths.indexOf(path) - if (pathIndex == -1) { return } - paths.removeAt(pathIndex) - mutablePaths.value = paths - } - - /** - * Builds an onion around `payload` and returns the result. - */ - private fun buildOnionForDestination( - payload: ByteArray, - destination: Destination, - version: Version - ): Promise { - lateinit var guardSnode: Snode - lateinit var destinationSymmetricKey: ByteArray // Needed by LokiAPI to decrypt the response sent back by the destination - lateinit var encryptionResult: EncryptionResult - val snodeToExclude = when (destination) { - is Destination.Snode -> destination.snode - is Destination.Server -> null - } - return getPath(snodeToExclude).map { path -> - guardSnode = path.first() - // Encrypt in reverse order, i.e. the destination first - OnionRequestEncryption.encryptPayloadForDestination(payload, destination, version).let { r -> - destinationSymmetricKey = r.symmetricKey - // Recursively encrypt the layers of the onion (again in reverse order) - encryptionResult = r - @Suppress("NAME_SHADOWING") var path = path - var rhs = destination - fun addLayer(): EncryptionResult { - return if (path.isEmpty()) { - encryptionResult - } else { - val lhs = Destination.Snode(path.last()) - path = path.dropLast(1) - OnionRequestEncryption.encryptHop(lhs, rhs, encryptionResult).let { r -> - encryptionResult = r - rhs = lhs - addLayer() - } - } - } - addLayer() - } - }.map { OnionBuildingResult(guardSnode, encryptionResult, destinationSymmetricKey) } - } - - /** - * Sends an onion request to `destination`. Builds new paths as needed. - */ - private fun sendOnionRequest( - destination: Destination, - payload: ByteArray, - version: Version - ): Promise { - val deferred = deferred() - var guardSnode: Snode? = null - buildOnionForDestination(payload, destination, version).success { result -> - guardSnode = result.guardSnode - val nonNullGuardSnode = result.guardSnode - val url = "${nonNullGuardSnode.address}:${nonNullGuardSnode.port}/onion_req/v2" - val finalEncryptionResult = result.finalEncryptionResult - val onion = finalEncryptionResult.ciphertext - if (destination is Destination.Server && onion.count().toDouble() > 0.75 * FileServerApi.MAX_FILE_SIZE.toDouble()) { - Log.d("Loki", "Approaching request size limit: ~${onion.count()} bytes.") - } - @Suppress("NAME_SHADOWING") val parameters = mapOf( - "ephemeral_key" to finalEncryptionResult.ephemeralPublicKey.toHexString() - ) - val body: ByteArray - try { - body = OnionRequestEncryption.encode(onion, parameters) - } catch (exception: Exception) { - return@success deferred.reject(exception) - } - val destinationSymmetricKey = result.destinationSymmetricKey - GlobalScope.launch { - try { - val response = HTTP.execute(HTTP.Verb.POST, url, body) - handleResponse(response, destinationSymmetricKey, destination, version, deferred) - } catch (exception: Exception) { - deferred.reject(exception) - } - } - }.fail { exception -> - deferred.reject(exception) - } - val promise = deferred.promise - promise.fail { exception -> - if (exception is HTTP.HTTPRequestFailedException) { - val checkedGuardSnode = guardSnode - val path = - if (checkedGuardSnode == null) null - else paths.value.firstOrNull { it.contains(checkedGuardSnode) } - - fun handleUnspecificError() { - if (path == null) { return } - var pathFailureCount = OnionRequestAPI.pathFailureCount[path] ?: 0 - pathFailureCount += 1 - if (pathFailureCount >= pathFailureThreshold) { - guardSnode?.let { dropGuardSnode(it) } - path.forEach { snode -> - @Suppress("ThrowableNotThrown") - SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, null) // Intentionally don't throw - } - dropPath(path) - } else { - OnionRequestAPI.pathFailureCount[path] = pathFailureCount - } - } - val json = exception.json - val message = json?.get("result") as? String - val prefix = "Next node not found: " - if (message != null && message.startsWith(prefix)) { - val ed25519PublicKey = message.substringAfter(prefix) - val snode = path?.firstOrNull { it.publicKeySet!!.ed25519Key == ed25519PublicKey } - if (snode != null) { - var snodeFailureCount = OnionRequestAPI.snodeFailureCount[snode] ?: 0 - snodeFailureCount += 1 - if (snodeFailureCount >= snodeFailureThreshold) { - @Suppress("ThrowableNotThrown") - SnodeAPI.handleSnodeError(exception.statusCode, json, snode, null) // Intentionally don't throw - try { - dropSnode(snode) - } catch (exception: Exception) { - handleUnspecificError() - } - } else { - OnionRequestAPI.snodeFailureCount[snode] = snodeFailureCount - } - } else { - handleUnspecificError() - } - } else if(exception.statusCode in NON_PENALIZING_STATUSES){ - // error codes that shouldn't penalize our path or drop snodes - // 404 is probably file server missing a file, don't rebuild path or mark a snode as bad here - Log.d("Loki","Request returned a non penalizing code ${exception.statusCode} with message: $message") - } - // we do not want to penalize the path/nodes when: - // - the exit node reached the server but the destination returned 5xx or 400 - // - the exit node couldn't reach its destination with a 5xx or 400, but the destination was a community (which we can know from the server's name being in the error message) - else if (destination is Destination.Server && - (exception.statusCode in 500..504 || exception.statusCode == 400) && - (exception is HTTPRequestFailedAtDestinationException || exception.body?.contains(destination.host) == true)) { - Log.d("Loki","Destination server error - Non path penalizing. Request returned code ${exception.statusCode} with message: $message") - } else if (message == "Loki Server error") { - Log.d("Loki", "message was $message") - } else { // Only drop snode/path if not receiving above two exception cases - handleUnspecificError() - } - } - } - return promise - } - // endregion - - // region Internal API - /** - * Sends an onion request to `snode`. Builds new paths as needed. - */ - internal fun sendOnionRequest( - method: Snode.Method, - parameters: Map<*, *>, - snode: Snode, - version: Version, - publicKey: String? = null - ): Promise { - val payload = mapOf( - "method" to method.rawValue, - "params" to parameters - ) - val payloadData = JsonUtil.toJson(payload).toByteArray() - return sendOnionRequest(Destination.Snode(snode), payloadData, version).recover { exception -> - val error = when (exception) { - is HTTPRequestFailedAtDestinationException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey) - is HTTP.HTTPRequestFailedException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey) - else -> null - } - if (error != null) { throw error } - throw exception - } - } - - /** - * Sends an onion request to `server`. Builds new paths as needed. - * - * `publicKey` is the hex encoded public key of the user the call is associated with. This is needed for swarm cache maintenance. - */ - fun sendOnionRequest( - request: Request, - server: String, - x25519PublicKey: String, - version: Version = Version.V4 - ): Promise { - val url = request.url - val payload = generatePayload(request, server, version) - val destination = Destination.Server(url.host, version.value, x25519PublicKey, url.scheme, url.port) - return sendOnionRequest(destination, payload, version).recover { exception -> - Log.d("Loki", "Couldn't reach server: $url due to error: $exception.") - throw exception - } - } - - private fun generatePayload(request: Request, server: String, version: Version): ByteArray { - val headers = request.getHeadersForOnionRequest().toMutableMap() - val url = request.url - val urlAsString = url.toString() - val body = request.getBodyForOnionRequest() ?: "null" - val endpoint = when { - server.count() < urlAsString.count() -> urlAsString.substringAfter(server) - else -> "" - } - return if (version == Version.V4) { - if (request.body != null && - headers.keys.find { it.equals("Content-Type", true) } == null) { - headers["Content-Type"] = "application/json" - } - val requestPayload = mapOf( - "endpoint" to endpoint, - "method" to request.method, - "headers" to headers - ) - val requestData = JsonUtil.toJson(requestPayload).toByteArray() - val prefixData = "l${requestData.size}:".toByteArray(Charsets.US_ASCII) - val suffixData = "e".toByteArray(Charsets.US_ASCII) - if (request.body != null) { - val bodyData = if (body is ByteArray) body else body.toString().toByteArray() - val bodyLengthData = "${bodyData.size}:".toByteArray(Charsets.US_ASCII) - prefixData + requestData + bodyLengthData + bodyData + suffixData - } else { - prefixData + requestData + suffixData - } - } else { - val payload = mapOf( - "body" to body, - "endpoint" to endpoint.removePrefix("/"), - "method" to request.method, - "headers" to headers - ) - JsonUtil.toJson(payload).toByteArray() - } - } - - private fun handleResponse( - response: ByteArray, - destinationSymmetricKey: ByteArray, - destination: Destination, - version: Version, - deferred: Deferred - ) { - if (version == Version.V4) { - try { - if (response.size <= AESGCM.ivSize) return deferred.reject(Exception("Invalid response")) - // The data will be in the form of `l123:jsone` or `l123:json456:bodye` so we need to break the data into - // parts to properly process it - val plaintext = AESGCM.decrypt(response, symmetricKey = destinationSymmetricKey) - if (!byteArrayOf(plaintext.first()).contentEquals("l".toByteArray())) return deferred.reject(Exception("Invalid response")) - val infoSepIdx = plaintext.indexOfFirst { byteArrayOf(it).contentEquals(":".toByteArray()) } - val infoLenSlice = plaintext.slice(1 until infoSepIdx) - val infoLength = infoLenSlice.toByteArray().toString(Charsets.US_ASCII).toIntOrNull() - if (infoLenSlice.size <= 1 || infoLength == null) return deferred.reject(Exception("Invalid response")) - val infoStartIndex = "l$infoLength".length + 1 - val infoEndIndex = infoStartIndex + infoLength - val info = plaintext.slice(infoStartIndex until infoEndIndex) - val responseInfo = JsonUtil.fromJson(info.toByteArray(), Map::class.java) - when (val statusCode = responseInfo["code"].toString().toInt()) { - // Custom handle a clock out of sync error (v4 returns '425' but included the '406' just in case) - 406, 425 -> { - @Suppress("NAME_SHADOWING") - val exception = HTTPRequestFailedAtDestinationException( - statusCode, - mapOf("result" to "Your clock is out of sync with the service node network."), - destination.description - ) - return deferred.reject(exception) - } - // Handle error status codes - !in 200..299 -> { - val responseBody = if (destination is Destination.Server && statusCode == 400) plaintext.getBody(infoLength, infoEndIndex) else null - val requireBlinding = "Invalid authentication: this server requires the use of blinded ids" - val exception = if (responseBody != null && responseBody.decodeToString() == requireBlinding) { - HTTPRequestFailedBlindingRequiredException(400, responseInfo, destination.description) - } else HTTPRequestFailedAtDestinationException( - statusCode, - responseInfo, - destination.description - ) - - return deferred.reject(exception) - } - } - - val responseBody = plaintext.getBody(infoLength, infoEndIndex) - - // If there is no data in the response, i.e. only `l123:jsone`, then just return the ResponseInfo - if (responseBody.isEmpty()) { - return deferred.resolve(OnionResponse(responseInfo, null)) - } - return deferred.resolve(OnionResponse(responseInfo, responseBody)) - } catch (exception: Exception) { - deferred.reject(exception) - } - } else { - val json = try { - JsonUtil.fromJson(response, Map::class.java) - } catch (exception: Exception) { - mapOf( "result" to response.decodeToString()) - } - val base64EncodedIVAndCiphertext = json["result"] as? String ?: return deferred.reject(Exception("Invalid JSON")) - val ivAndCiphertext = Base64.decode(base64EncodedIVAndCiphertext) - try { - val plaintext = AESGCM.decrypt( - ivAndCiphertext, - symmetricKey = destinationSymmetricKey - ) - try { - @Suppress("NAME_SHADOWING") val json = - JsonUtil.fromJson(plaintext.toString(Charsets.UTF_8), Map::class.java) - val statusCode = json["status_code"] as? Int ?: json["status"] as Int - when { - statusCode == 406 -> { - @Suppress("NAME_SHADOWING") - val body = - mapOf("result" to "Your clock is out of sync with the service node network.") - val exception = HTTPRequestFailedAtDestinationException( - statusCode, - body, - destination.description - ) - return deferred.reject(exception) - } - json["body"] != null -> { - @Suppress("NAME_SHADOWING") - val body = if (json["body"] is Map<*, *>) { - json["body"] as Map<*, *> - } else { - val bodyAsString = json["body"] as String - JsonUtil.fromJson(bodyAsString, Map::class.java) - } - - if (body.containsKey("hf")) { - @Suppress("UNCHECKED_CAST") - val currentHf = body["hf"] as List - if (currentHf.size < 2) { - Log.e("Loki", "Response contains fork information but doesn't have a hard and soft number") - } else { - val hf = currentHf[0] - val sf = currentHf[1] - val newForkInfo = ForkInfo(hf, sf) - if (newForkInfo > SnodeAPI.forkInfo) { - SnodeAPI.forkInfo = ForkInfo(hf,sf) - } else if (newForkInfo < SnodeAPI.forkInfo) { - Log.w("Loki", "Got a new snode info fork version that was $newForkInfo, less than current known ${SnodeAPI.forkInfo}") - } - } - } - if (statusCode != 200) { - val exception = HTTPRequestFailedAtDestinationException( - statusCode, - body, - destination.description - ) - return deferred.reject(exception) - } - deferred.resolve(OnionResponse(body, JsonUtil.toJson(body).toByteArray().view())) - } - else -> { - if (statusCode != 200) { - val exception = HTTPRequestFailedAtDestinationException( - statusCode, - json, - destination.description - ) - return deferred.reject(exception) - } - deferred.resolve(OnionResponse(json, JsonUtil.toJson(json).toByteArray().view())) - } - } - } catch (exception: Exception) { - deferred.reject(Exception("Invalid JSON: ${plaintext.toString(Charsets.UTF_8)}.")) - } - } catch (exception: Exception) { - deferred.reject(exception) - } - } - } - - private fun ByteArray.getBody(infoLength: Int, infoEndIndex: Int): ByteArraySlice { - // If there is no data in the response, i.e. only `l123:jsone`, then just return the ResponseInfo - val infoLengthStringLength = infoLength.toString().length - if (size <= infoLength + infoLengthStringLength + 2/*l and e bytes*/) { - return ByteArraySlice.EMPTY - } - // Extract the response data as well - val dataSlice = view(infoEndIndex + 1 until size - 1) - val dataSepIdx = dataSlice.asList().indexOfFirst { it.toInt() == ':'.code } - return dataSlice.view(dataSepIdx + 1 until dataSlice.len) - } - - // endregion -} - -enum class Version(val value: String) { - V2("/loki/v2/lsrpc"), - V3("/loki/v3/lsrpc"), - V4("/oxen/v4/lsrpc"); -} - -data class OnionResponse( - val info: Map<*, *>, - val body: ByteArraySlice? = null -) { - val code: Int? get() = info["code"] as? Int - val message: String? get() = info["message"] as? String -} diff --git a/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt b/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt deleted file mode 100644 index b1a39c7f7a..0000000000 --- a/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ /dev/null @@ -1,933 +0,0 @@ -@file:Suppress("NAME_SHADOWING") - -package org.session.libsession.snode - -import android.os.SystemClock -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.launch -import kotlinx.coroutines.selects.onTimeout -import kotlinx.coroutines.selects.select -import kotlinx.serialization.DeserializationStrategy -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.decodeFromStream -import network.loki.messenger.libsession_util.ED25519 -import network.loki.messenger.libsession_util.Hash -import network.loki.messenger.libsession_util.SessionEncrypt -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.functional.bind -import nl.komponents.kovenant.functional.map -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.snode.model.BatchResponse -import org.session.libsession.snode.model.StoreMessageResponse -import org.session.libsession.snode.utilities.asyncPromise -import org.session.libsession.snode.utilities.await -import org.session.libsession.snode.utilities.retrySuspendAsPromise -import org.session.libsession.utilities.Environment -import org.session.libsession.utilities.mapValuesNotNull -import org.session.libsession.utilities.toByteArray -import org.session.libsignal.crypto.secureRandom -import org.session.libsignal.crypto.shuffledRandom -import org.session.libsignal.database.LokiAPIDatabaseProtocol -import org.session.libsignal.utilities.Base64 -import org.session.libsignal.utilities.HTTP -import org.session.libsignal.utilities.Hex -import org.session.libsignal.utilities.JsonUtil -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.Snode -import org.session.libsignal.utilities.prettifiedDescription -import org.session.libsignal.utilities.retryWithUniformInterval -import java.util.Locale -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.set -import kotlin.properties.Delegates.observable - -object SnodeAPI { - internal val database: LokiAPIDatabaseProtocol - get() = SnodeModule.shared.storage - - private var snodeFailureCount: MutableMap = mutableMapOf() - - // the list of "generic" nodes we use to make non swarm specific api calls - internal var snodePool: Set - get() = database.getSnodePool() - set(newValue) { database.setSnodePool(newValue) } - - @Deprecated("Use a dependency injected SnodeClock.currentTimeMills() instead") - @JvmStatic - val nowWithOffset - get() = MessagingModuleConfiguration.shared.clock.currentTimeMills() - - internal var forkInfo by observable(database.getForkInfo()) { _, oldValue, newValue -> - if (newValue > oldValue) { - Log.d("Loki", "Setting new fork info new: $newValue, old: $oldValue") - database.setForkInfo(newValue) - } - } - - // Settings - private const val maxRetryCount = 6 - private const val minimumSnodePoolCount = 12 - private const val minimumSwarmSnodeCount = 3 - // Use port 4433 to enforce pinned certificates - private val seedNodePort = 4443 - - private val seedNodePool = when (SnodeModule.shared.environment) { - Environment.DEV_NET -> setOf("http://sesh-net.local:1280") - Environment.TEST_NET -> setOf("http://public.loki.foundation:38157") - Environment.MAIN_NET -> setOf( - "https://seed1.getsession.org:$seedNodePort", - "https://seed2.getsession.org:$seedNodePort", - "https://seed3.getsession.org:$seedNodePort", - ) - } - - private const val snodeFailureThreshold = 3 - private const val useOnionRequests = true - - const val KEY_BODY = "body" - const val KEY_CODE = "code" - const val KEY_RESULTS = "results" - private const val KEY_IP = "public_ip" - private const val KEY_PORT = "storage_port" - private const val KEY_X25519 = "pubkey_x25519" - private const val KEY_ED25519 = "pubkey_ed25519" - private const val KEY_VERSION = "storage_server_version" - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - // Error - sealed class Error(val description: String) : Exception(description) { - object Generic : Error("An error occurred.") - object ClockOutOfSync : Error("Your clock is out of sync with the Service Node network.") - object NoKeyPair : Error("Missing user key pair.") - object SigningFailed : Error("Couldn't sign verification data.") - - // ONS - object DecryptionFailed : Error("Couldn't decrypt ONS name.") - object HashingFailed : Error("Couldn't compute ONS name hash.") - object ValidationFailed : Error("ONS name validation failed.") - } - - // Batch - data class SnodeBatchRequestInfo( - val method: String, - val params: Map, - @Transient - val namespace: Int?, - ) // assume signatures, pubkey and namespaces are attached in parameters if required - - // Internal API - internal fun invoke( - method: Snode.Method, - snode: Snode, - parameters: Map, - publicKey: String? = null, - version: Version = Version.V3 - ): RawResponsePromise = when { - useOnionRequests -> OnionRequestAPI.sendOnionRequest(method, parameters, snode, version, publicKey).map { - JsonUtil.fromJson(it.body ?: throw Error.Generic, Map::class.java) - } - - else -> scope.asyncPromise { - HTTP.execute( - HTTP.Verb.POST, - url = "${snode.address}:${snode.port}/storage_rpc/v1", - parameters = buildMap { - this["method"] = method.rawValue - this["params"] = parameters - } - ).toString().let { - JsonUtil.fromJson(it, Map::class.java) - } - }.fail { e -> - when (e) { - is HTTP.HTTPRequestFailedException -> handleSnodeError(e.statusCode, e.json, snode, publicKey) - else -> Log.d("Loki", "Unhandled exception: $e.") - } - } - } - - private suspend fun invokeSuspend( - method: Snode.Method, - snode: Snode, - parameters: Map, - responseDeserializationStrategy: DeserializationStrategy, - publicKey: String? = null, - version: Version = Version.V3 - ): Res = when { - useOnionRequests -> { - val resp = OnionRequestAPI.sendOnionRequest(method, parameters, snode, version, publicKey).await() - (resp.body ?: throw Error.Generic).inputStream().use { inputStream -> - MessagingModuleConfiguration.shared.json.decodeFromStream( - deserializer = responseDeserializationStrategy, - stream = inputStream - ) - } - } - - else -> HTTP.execute( - HTTP.Verb.POST, - url = "${snode.address}:${snode.port}/storage_rpc/v1", - parameters = buildMap { - this["method"] = method.rawValue - this["params"] = parameters - } - ).toString().let { - MessagingModuleConfiguration.shared.json.decodeFromString( - deserializer = responseDeserializationStrategy, - string = it - ) - } - } - - private val GET_RANDOM_SNODE_PARAMS = buildMap { - this["method"] = "get_n_service_nodes" - this["params"] = buildMap { - this["active_only"] = true - this["fields"] = sequenceOf(KEY_IP, KEY_PORT, KEY_X25519, KEY_ED25519, KEY_VERSION).associateWith { true } - } - } - - internal fun getRandomSnode(): Promise = - snodePool.takeIf { it.size >= minimumSnodePoolCount }?.secureRandom()?.let { Promise.of(it) } ?: scope.asyncPromise { - val target = seedNodePool.random() - Log.d("Loki", "Populating snode pool using: $target.") - val url = "$target/json_rpc" - val response = HTTP.execute(HTTP.Verb.POST, url, GET_RANDOM_SNODE_PARAMS, useSeedNodeConnection = true) - val json = runCatching { JsonUtil.fromJson(response, Map::class.java) }.getOrNull() - ?: buildMap { this["result"] = response.toString() } - val intermediate = json["result"] as? Map<*, *> ?: throw Error.Generic - .also { Log.d("Loki", "Failed to update snode pool, intermediate was null.") } - val rawSnodes = intermediate["service_node_states"] as? List<*> ?: throw Error.Generic - .also { Log.d("Loki", "Failed to update snode pool, rawSnodes was null.") } - - rawSnodes.asSequence().mapNotNull { it as? Map<*, *> }.mapNotNull { rawSnode -> - createSnode( - address = rawSnode[KEY_IP] as? String, - port = rawSnode[KEY_PORT] as? Int, - ed25519Key = rawSnode[KEY_ED25519] as? String, - x25519Key = rawSnode[KEY_X25519] as? String, - version = (rawSnode[KEY_VERSION] as? List<*>) - ?.filterIsInstance() - ?.let(Snode::Version) - ).also { if (it == null) Log.d("Loki", "Failed to parse: ${rawSnode.prettifiedDescription()}.") } - }.toSet().also { - Log.d("Loki", "Persisting snode pool to database.") - snodePool = it - }.takeUnless { it.isEmpty() }?.secureRandom() ?: throw SnodeAPI.Error.Generic - } - - private fun createSnode(address: String?, port: Int?, ed25519Key: String?, x25519Key: String?, version: Snode.Version? = Snode.Version.ZERO): Snode? { - return Snode( - address?.takeUnless { it == "0.0.0.0" }?.let { "https://$it" } ?: return null, - port ?: return null, - Snode.KeySet(ed25519Key ?: return null, x25519Key ?: return null), - version ?: return null - ) - } - - internal fun dropSnodeFromSwarmIfNeeded(snode: Snode, publicKey: String) { - database.getSwarm(publicKey)?.takeIf { snode in it }?.let { - database.setSwarm(publicKey, it - snode) - } - } - - fun getSingleTargetSnode(publicKey: String): Promise { - // SecureRandom should be cryptographically secure - return getSwarm(publicKey).map { it.shuffledRandom().random() } - } - - // Public API - suspend fun getAccountID(onsName: String): String { - val validationCount = 3 - val accountIDByteCount = 33 - // Hash the ONS name using BLAKE2b - val onsName = onsName.lowercase(Locale.US) - // Ask 3 different snodes for the Account ID associated with the given name hash - val parameters = buildMap { - this["endpoint"] = "ons_resolve" - this["params"] = buildMap { - this["type"] = 0 - this["name_hash"] = Base64.encodeBytes(Hash.hash32(onsName.toByteArray())) - } - } - - return List(validationCount) { - scope.async { - retryWithUniformInterval( - maxRetryCount = maxRetryCount, - ) { - val snode = getRandomSnode().await() - invoke(Snode.Method.OxenDaemonRPCCall, snode, parameters).await() - } - } - }.awaitAll().map { json -> - val intermediate = json["result"] as? Map<*, *> ?: throw Error.Generic - val hexEncodedCiphertext = intermediate["encrypted_value"] as? String ?: throw Error.Generic - val ciphertext = Hex.fromStringCondensed(hexEncodedCiphertext) - val nonce = (intermediate["nonce"] as? String)?.let(Hex::fromStringCondensed) - SessionEncrypt.decryptOnsResponse( - lowercaseName = onsName, - ciphertext = ciphertext, - nonce = nonce - ) - }.takeIf { it.size == validationCount && it.toSet().size == 1 }?.first() - ?: throw Error.ValidationFailed - } - - // the list of snodes that represent the swarm for that pubkey - fun getSwarm(publicKey: String): Promise, Exception> = - database.getSwarm(publicKey)?.takeIf { it.size >= minimumSwarmSnodeCount }?.let(Promise.Companion::of) - ?: getRandomSnode().bind { - invoke(Snode.Method.GetSwarm, it, parameters = buildMap { this["pubKey"] = publicKey }, publicKey) - }.map { - parseSnodes(it).toSet() - }.success { - database.setSwarm(publicKey, it) - } - - /** - * Fetch swarm nodes for the specific public key. - * - * Note: this differs from [getSwarm] in that it doesn't store the swarm nodes in the database. - * This always fetches from network. - */ - suspend fun fetchSwarmNodes(publicKey: String): List { - val randomNode = getRandomSnode().await() - val response = invoke( - method = Snode.Method.GetSwarm, - snode = randomNode, parameters = buildMap { this["pubKey"] = publicKey }, - publicKey = publicKey - ).await() - - return parseSnodes(response) - } - - /** - * Build parameters required to call authenticated storage API. - * - * @param auth The authentication data required to sign the request - * @param namespace The namespace of the messages you want to retrieve. Null if not relevant. - * @param verificationData A function that returns the data to be signed. The function takes the namespace text and timestamp as arguments. - * @param timestamp The timestamp to be used in the request. Default is the current time. - * @param builder A lambda that allows the user to add additional parameters to the request. - */ - private fun buildAuthenticatedParameters( - auth: SwarmAuth, - namespace: Int?, - verificationData: ((namespaceText: String, timestamp: Long) -> Any)? = null, - timestamp: Long = nowWithOffset, - builder: MutableMap.() -> Unit = {} - ): Map { - return buildMap { - // Build user provided parameter first - this.builder() - - if (verificationData != null) { - // Namespace shouldn't be in the verification data if it's null or 0. - val namespaceText = when (namespace) { - null, 0 -> "" - else -> namespace.toString() - } - - val verifyData = when (val verify = verificationData(namespaceText, timestamp)) { - is String -> verify.toByteArray() - is ByteArray -> verify - else -> throw IllegalArgumentException("verificationData must return a String or ByteArray") - } - - putAll(auth.sign(verifyData)) - put("timestamp", timestamp) - } - - put("pubkey", auth.accountId.hexString) - if (namespace != null && namespace != 0) { - put("namespace", namespace) - } - - auth.ed25519PublicKeyHex?.let { put("pubkey_ed25519", it) } - } - } - - fun buildAuthenticatedStoreBatchInfo( - namespace: Int, - message: SnodeMessage, - auth: SwarmAuth, - ): SnodeBatchRequestInfo { - check(message.recipient == auth.accountId.hexString) { - "Message sent to ${message.recipient} but authenticated with ${auth.accountId.hexString}" - } - - val params = buildAuthenticatedParameters( - namespace = namespace, - auth = auth, - verificationData = { ns, t -> "${Snode.Method.SendMessage.rawValue}$ns$t" }, - ) { - putAll(message.toJSON()) - } - - return SnodeBatchRequestInfo( - Snode.Method.SendMessage.rawValue, - params, - namespace - ) - } - - fun buildAuthenticatedUnrevokeSubKeyBatchRequest( - groupAdminAuth: OwnedSwarmAuth, - subAccountTokens: List, - ): SnodeBatchRequestInfo { - val params = buildAuthenticatedParameters( - namespace = null, - auth = groupAdminAuth, - verificationData = { _, t -> - subAccountTokens.fold( - "${Snode.Method.UnrevokeSubAccount.rawValue}$t".toByteArray() - ) { acc, subAccount -> acc + subAccount } - } - ) { - put("unrevoke", subAccountTokens.map(Base64::encodeBytes)) - } - - return SnodeBatchRequestInfo( - Snode.Method.UnrevokeSubAccount.rawValue, - params, - null - ) - } - - fun buildAuthenticatedRevokeSubKeyBatchRequest( - groupAdminAuth: OwnedSwarmAuth, - subAccountTokens: List, - ): SnodeBatchRequestInfo { - val params = buildAuthenticatedParameters( - namespace = null, - auth = groupAdminAuth, - verificationData = { _, t -> - subAccountTokens.fold( - "${Snode.Method.RevokeSubAccount.rawValue}$t".toByteArray() - ) { acc, subAccount -> acc + subAccount } - } - ) { - put("revoke", subAccountTokens.map(Base64::encodeBytes)) - } - - return SnodeBatchRequestInfo( - Snode.Method.RevokeSubAccount.rawValue, - params, - null - ) - } - - /** - * Message hashes can be shared across multiple namespaces (for a single public key destination) - * @param publicKey the destination's identity public key to delete from (05...) - * @param ed25519PubKey the destination's ed25519 public key to delete from. Only required for user messages. - * @param messageHashes a list of stored message hashes to delete from all namespaces on the server - * @param required indicates that *at least one* message in the list is deleted from the server, otherwise it will return 404 - */ - fun buildAuthenticatedDeleteBatchInfo( - auth: SwarmAuth, - messageHashes: List, - required: Boolean = false - ): SnodeBatchRequestInfo { - val params = buildAuthenticatedParameters( - namespace = null, - auth = auth, - verificationData = { _, _ -> - buildString { - append(Snode.Method.DeleteMessage.rawValue) - messageHashes.forEach(this::append) - } - } - ) { - put("messages", messageHashes) - put("required", required) - } - - return SnodeBatchRequestInfo( - Snode.Method.DeleteMessage.rawValue, - params, - null - ) - } - - fun buildAuthenticatedRetrieveBatchRequest( - auth: SwarmAuth, - lastHash: String?, - namespace: Int = 0, - maxSize: Int? = null - ): SnodeBatchRequestInfo { - val params = buildAuthenticatedParameters( - namespace = namespace, - auth = auth, - verificationData = { ns, t -> "${Snode.Method.Retrieve.rawValue}$ns$t" }, - ) { - put("last_hash", lastHash.orEmpty()) - if (maxSize != null) { - put("max_size", maxSize) - } - } - - return SnodeBatchRequestInfo( - Snode.Method.Retrieve.rawValue, - params, - namespace - ) - } - - fun buildAuthenticatedAlterTtlBatchRequest( - auth: SwarmAuth, - messageHashes: List, - newExpiry: Long, - shorten: Boolean = false, - extend: Boolean = false - ): SnodeBatchRequestInfo { - val params = - buildAlterTtlParams(auth, messageHashes, newExpiry, extend, shorten) - return SnodeBatchRequestInfo( - Snode.Method.Expire.rawValue, - params, - null - ) - } - - private data class RequestInfo( - val snode: Snode, - val publicKey: String, - val request: SnodeBatchRequestInfo, - val responseType: DeserializationStrategy<*>, - val callback: SendChannel>, - val requestTime: Long = SystemClock.elapsedRealtime(), - ) - - private val batchedRequestsSender: SendChannel - - init { - val batchRequests = Channel() - batchedRequestsSender = batchRequests - - val batchWindowMills = 100L - - data class BatchKey(val snodeAddress: String, val publicKey: String) - - scope.launch { - val batches = hashMapOf>() - - while (true) { - val batch = select?> { - // If we receive a request, add it to the batch - batchRequests.onReceive { - batches.getOrPut(BatchKey(it.snode.address, it.publicKey)) { mutableListOf() }.add(it) - null - } - - // If we have anything in the batch, look for the one that is about to expire - // and wait for it to expire, remove it from the batches and send it for - // processing. - if (batches.isNotEmpty()) { - val earliestBatch = batches.minBy { it.value.first().requestTime } - val deadline = earliestBatch.value.first().requestTime + batchWindowMills - onTimeout( - timeMillis = (deadline - SystemClock.elapsedRealtime()).coerceAtLeast(0) - ) { - batches.remove(earliestBatch.key) - } - } - } - - if (batch != null) { - launch batch@{ - val snode = batch.first().snode - val responses = try { - getBatchResponse( - snode = snode, - publicKey = batch.first().publicKey, - requests = batch.map { it.request }, - sequence = false - ) - } catch (e: Exception) { - for (req in batch) { - runCatching { - req.callback.send(Result.failure(e)) - } - } - return@batch - } - - // For each response, parse the result, match it with the request then send - // back through the request's callback. - for ((req, resp) in batch.zip(responses.results)) { - val result = runCatching { - if (!resp.isSuccessful) { - throw BatchResponse.Error(resp) - } - - MessagingModuleConfiguration.shared.json.decodeFromJsonElement( - req.responseType, resp.body)!! - } - - runCatching { - req.callback.send(result) - } - } - - // Close all channels in the requests just in case we don't have paired up - // responses. - for (req in batch) { - req.callback.close() - } - } - } - } - } - } - - suspend fun sendBatchRequest( - snode: Snode, - publicKey: String, - request: SnodeBatchRequestInfo, - responseType: DeserializationStrategy, - ): T { - val callback = Channel>(capacity = 1) - @Suppress("UNCHECKED_CAST") - batchedRequestsSender.send(RequestInfo( - snode = snode, - publicKey = publicKey, - request = request, - responseType = responseType, - callback = callback as SendChannel - )) - try { - return callback.receive().getOrThrow() - } catch (e: CancellationException) { - // Close the channel if the coroutine is cancelled, so the batch processing won't - // handle this one (best effort only) - callback.close() - throw e - } - } - - suspend fun sendBatchRequest( - snode: Snode, - publicKey: String, - request: SnodeBatchRequestInfo, - ): JsonElement { - return sendBatchRequest(snode, publicKey, request, JsonElement.serializer()) - } - - suspend fun getBatchResponse( - snode: Snode, - publicKey: String, - requests: List, - sequence: Boolean = false - ): BatchResponse { - return invokeSuspend( - method = if (sequence) Snode.Method.Sequence else Snode.Method.Batch, - snode = snode, - parameters = mapOf("requests" to requests), - responseDeserializationStrategy = BatchResponse.serializer(), - publicKey = publicKey - ).also { resp -> - // If there's a unsuccessful response, go through specific logic to handle - // potential snode errors. - val firstError = resp.results.firstOrNull { !it.isSuccessful } - if (firstError != null) { - handleSnodeError( - statusCode = firstError.code, - json = if (firstError.body is JsonObject) { - JsonUtil.fromJson(firstError.body.toString(), Map::class.java) - } else { - null - }, - snode = snode, - publicKey = publicKey - ) - } - } - } - - fun alterTtl( - auth: SwarmAuth, - messageHashes: List, - newExpiry: Long, - extend: Boolean = false, - shorten: Boolean = false - ): RawResponsePromise = scope.retrySuspendAsPromise(maxRetryCount) { - val params = buildAlterTtlParams(auth, messageHashes, newExpiry, extend, shorten) - val snode = getSingleTargetSnode(auth.accountId.hexString).await() - invoke(Snode.Method.Expire, snode, params, auth.accountId.hexString).await() - } - - private fun buildAlterTtlParams( - auth: SwarmAuth, - messageHashes: List, - newExpiry: Long, - extend: Boolean = false, - shorten: Boolean = false - ): Map { - val shortenOrExtend = if (extend) "extend" else if (shorten) "shorten" else "" - - return buildAuthenticatedParameters( - namespace = null, - auth = auth, - verificationData = { _, _ -> - buildString { - append("expire") - append(shortenOrExtend) - append(newExpiry.toString()) - messageHashes.forEach(this::append) - } - } - ) { - this["expiry"] = newExpiry - this["messages"] = messageHashes - when { - extend -> this["extend"] = true - shorten -> this["shorten"] = true - } - } - } - - fun getNetworkTime(snode: Snode): Promise, Exception> = - invoke(Snode.Method.Info, snode, emptyMap()).map { rawResponse -> - val timestamp = rawResponse["timestamp"] as? Long ?: -1 - snode to timestamp - } - - /** - * Note: After this method returns, [auth] will not be used by any of async calls and it's afe - * for the caller to clean up the associated resources if needed. - */ - suspend fun sendMessage( - message: SnodeMessage, - auth: SwarmAuth?, - namespace: Int = 0 - ): StoreMessageResponse { - return retryWithUniformInterval(maxRetryCount = maxRetryCount) { - val params = if (auth != null) { - check(auth.accountId.hexString == message.recipient) { - "Message sent to ${message.recipient} but authenticated with ${auth.accountId.hexString}" - } - - val timestamp = nowWithOffset - - buildAuthenticatedParameters( - auth = auth, - namespace = namespace, - verificationData = { ns, t -> "${Snode.Method.SendMessage.rawValue}$ns$t" }, - timestamp = timestamp - ) { - put("sig_timestamp", timestamp) - putAll(message.toJSON()) - } - } else { - buildMap { - putAll(message.toJSON()) - if (namespace != 0) { - put("namespace", namespace) - } - } - } - - sendBatchRequest( - snode = getSingleTargetSnode(message.recipient).await(), - publicKey = message.recipient, - request = SnodeBatchRequestInfo( - method = Snode.Method.SendMessage.rawValue, - params = params, - namespace = namespace - ), - responseType = StoreMessageResponse.serializer() - ) - } - } - - suspend fun deleteMessage(publicKey: String, swarmAuth: SwarmAuth, serverHashes: List) { - retryWithUniformInterval { - val snode = getSingleTargetSnode(publicKey).await() - val params = buildAuthenticatedParameters( - auth = swarmAuth, - namespace = null, - verificationData = { _, _ -> - buildString { - append(Snode.Method.DeleteMessage.rawValue) - serverHashes.forEach(this::append) - } - } - ) { - this["messages"] = serverHashes - } - val rawResponse = invoke( - Snode.Method.DeleteMessage, - snode, - params, - publicKey - ).await() - - // thie next step is to verify the nodes on our swarm and check that the message was deleted - // on at least one of them - val swarms = rawResponse["swarm"] as? Map ?: throw (Error.Generic) - - val deletedMessages = swarms.mapValuesNotNull { (hexSnodePublicKey, rawJSON) -> - (rawJSON as? Map)?.let { json -> - val isFailed = json["failed"] as? Boolean ?: false - val statusCode = json[KEY_CODE] as? String - val reason = json["reason"] as? String - - if (isFailed) { - Log.e( - "Loki", - "Failed to delete messages from: $hexSnodePublicKey due to error: $reason ($statusCode)." - ) - false - } else { - // Hashes of deleted messages - val hashes = json["deleted"] as List - val signature = json["signature"] as String - // The signature looks like ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] ) - val message = sequenceOf(swarmAuth.accountId.hexString) - .plus(serverHashes) - .plus(hashes) - .toByteArray() - - ED25519.verify( - ed25519PublicKey = Hex.fromStringCondensed(hexSnodePublicKey), - signature = Base64.decode(signature), - message = message, - ) - } - } - } - - // if all the nodes returned false (the message was not deleted) then we consider this a failed scenario - if (deletedMessages.entries.all { !it.value }) throw (Error.Generic) - } - } - - // Parsing - private fun parseSnodes(rawResponse: Any): List = - (rawResponse as? Map<*, *>) - ?.run { get("snodes") as? List<*> } - ?.asSequence() - ?.mapNotNull { it as? Map<*, *> } - ?.mapNotNull { - createSnode( - address = it["ip"] as? String, - port = (it["port"] as? String)?.toInt(), - ed25519Key = it[KEY_ED25519] as? String, - x25519Key = it[KEY_X25519] as? String - ).apply { - if (this == null) Log.d( - "Loki", - "Failed to parse snode from: ${it.prettifiedDescription()}." - ) - } - }?.toList() ?: listOf().also { - Log.d( - "Loki", - "Failed to parse snodes from: ${rawResponse.prettifiedDescription()}." - ) - } - - fun deleteAllMessages(auth: SwarmAuth): Promise, Exception> = - scope.retrySuspendAsPromise(maxRetryCount) { - val snode = getSingleTargetSnode(auth.accountId.hexString).await() - val timestamp = MessagingModuleConfiguration.shared.clock.waitForNetworkAdjustedTime() - - val params = buildAuthenticatedParameters( - auth = auth, - namespace = null, - verificationData = { _, t -> "${Snode.Method.DeleteAll.rawValue}all$t" }, - timestamp = timestamp - ) { - put("namespace", "all") - } - - val rawResponse = invoke(Snode.Method.DeleteAll, snode, params, auth.accountId.hexString).await() - parseDeletions( - auth.accountId.hexString, - timestamp, - rawResponse - ) - } - - - @Suppress("UNCHECKED_CAST") - private fun parseDeletions(userPublicKey: String, timestamp: Long, rawResponse: RawResponse): Map = - (rawResponse["swarm"] as? Map)?.mapValuesNotNull { (hexSnodePublicKey, rawJSON) -> - val json = rawJSON as? Map ?: return@mapValuesNotNull null - if (json["failed"] as? Boolean == true) { - val reason = json["reason"] as? String - val statusCode = json[KEY_CODE] as? String - Log.e("Loki", "Failed to delete all messages from: $hexSnodePublicKey due to error: $reason ($statusCode).") - false - } else { - val hashes = (json["deleted"] as Map>).flatMap { (_, hashes) -> hashes }.sorted() // Hashes of deleted messages - val signature = json["signature"] as String - // The signature looks like ( PUBKEY_HEX || TIMESTAMP || DELETEDHASH[0] || ... || DELETEDHASH[N] ) - val message = sequenceOf(userPublicKey, "$timestamp").plus(hashes).toByteArray() - ED25519.verify( - ed25519PublicKey = Hex.fromStringCondensed(hexSnodePublicKey), - signature = Base64.decode(signature), - message = message, - ) - } - } ?: mapOf() - - // endregion - - // Error Handling - internal fun handleSnodeError(statusCode: Int, json: Map<*, *>?, snode: Snode, publicKey: String? = null): Throwable? = runCatching { - fun handleBadSnode() { - val oldFailureCount = snodeFailureCount[snode] ?: 0 - val newFailureCount = oldFailureCount + 1 - snodeFailureCount[snode] = newFailureCount - Log.d("Loki", "Couldn't reach snode at $snode; setting failure count to $newFailureCount.") - if (newFailureCount >= snodeFailureThreshold) { - Log.d("Loki", "Failure threshold reached for: $snode; dropping it.") - publicKey?.let { dropSnodeFromSwarmIfNeeded(snode, it) } - snodePool = (snodePool - snode).also { Log.d("Loki", "Snode pool count: ${it.count()}.") } - snodeFailureCount -= snode - } - } - when (statusCode) { - // Usually indicates that the snode isn't up to date - 400, 500, 502, 503 -> handleBadSnode() - 406 -> { - Log.d("Loki", "The user's clock is out of sync with the service node network.") - throw Error.ClockOutOfSync - } - 421 -> { - // The snode isn't associated with the given public key anymore - if (publicKey == null) Log.d("Loki", "Got a 421 without an associated public key.") - else json?.let(::parseSnodes) - ?.takeIf { it.isNotEmpty() } - ?.let { database.setSwarm(publicKey, it.toSet()) } - ?: dropSnodeFromSwarmIfNeeded(snode, publicKey).also { Log.d("Loki", "Invalidating swarm for: $publicKey.") } - } - 404 -> { - Log.d("Loki", "404, probably no file found") - throw Error.Generic - } - else -> { - handleBadSnode() - Log.d("Loki", "Unhandled response code: ${statusCode}.") - throw Error.Generic - } - } - }.exceptionOrNull() -} - -// Type Aliases -typealias RawResponse = Map<*, *> -typealias RawResponsePromise = Promise diff --git a/app/src/main/java/org/session/libsession/snode/SnodeClock.kt b/app/src/main/java/org/session/libsession/snode/SnodeClock.kt deleted file mode 100644 index fc2e4939fe..0000000000 --- a/app/src/main/java/org/session/libsession/snode/SnodeClock.kt +++ /dev/null @@ -1,103 +0,0 @@ -package org.session.libsession.snode - -import android.os.SystemClock -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import org.session.libsession.snode.utilities.await -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent -import java.util.Date -import javax.inject.Inject -import javax.inject.Singleton - -/** - * A class that manages the network time by querying the network time from a random snode. The - * primary goal of this class is to provide a time that is not tied to current system time and not - * prone to time changes locally. - * - * Before the first network query is successfully, calling [currentTimeMills] will return the current - * system time. - */ -@Singleton -class SnodeClock @Inject constructor( - @param:ManagerScope private val scope: CoroutineScope -) : OnAppStartupComponent { - private val instantState = MutableStateFlow(null) - private var job: Job? = null - - override fun onPostAppStarted() { - require(job == null) { "Already started" } - - job = scope.launch { - while (true) { - try { - val node = SnodeAPI.getRandomSnode().await() - val requestStarted = SystemClock.elapsedRealtime() - - var networkTime = SnodeAPI.getNetworkTime(node).await().second - val requestEnded = SystemClock.elapsedRealtime() - - // Adjust the network time to account for the time it took to make the request - // so that the network time equals to the time when the request was started - networkTime -= (requestEnded - requestStarted) / 2 - - val inst = Instant(requestStarted, networkTime) - - Log.d("SnodeClock", "Network time: ${Date(inst.now())}, system time: ${Date()}") - - instantState.value = inst - } catch (e: Exception) { - Log.e("SnodeClock", "Failed to get network time. Retrying in a few seconds", e) - } finally { - // Retry frequently if we haven't got any result before - val delayMills = if (instantState.value == null) { - 3_000L - } else { - 3600_000L - } - - delay(delayMills) - } - } - } - } - - /** - * Wait for the network adjusted time to come through. - */ - suspend fun waitForNetworkAdjustedTime(): Long { - return instantState.filterNotNull().first().now() - } - - /** - * Get the current time in milliseconds. If the network time is not available yet, this method - * will return the current system time. - */ - fun currentTimeMills(): Long { - return instantState.value?.now() ?: System.currentTimeMillis() - } - - fun currentTimeSeconds(): Long { - return currentTimeMills() / 1000 - } - - fun currentTime(): java.time.Instant { - return java.time.Instant.ofEpochMilli(currentTimeMills()) - } - - private class Instant( - val systemUptime: Long, - val networkTime: Long, - ) { - fun now(): Long { - val elapsed = SystemClock.elapsedRealtime() - systemUptime - return networkTime + elapsed - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/snode/SnodeModule.kt b/app/src/main/java/org/session/libsession/snode/SnodeModule.kt deleted file mode 100644 index 993c73d0b6..0000000000 --- a/app/src/main/java/org/session/libsession/snode/SnodeModule.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.session.libsession.snode - -import android.app.Application -import dagger.Lazy -import org.session.libsession.utilities.Environment -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.database.LokiAPIDatabaseProtocol -import org.session.libsignal.utilities.Broadcaster -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class SnodeModule @Inject constructor( - val storage: LokiAPIDatabaseProtocol, - prefs: TextSecurePreferences, -) { - val environment: Environment = prefs.getEnvironment() - - companion object { - lateinit var sharedLazy: Lazy - - @Deprecated("Use properly DI components instead") - val shared: SnodeModule get() = sharedLazy.get() - } -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/snode/StorageProtocol.kt b/app/src/main/java/org/session/libsession/snode/StorageProtocol.kt deleted file mode 100644 index b555acddbd..0000000000 --- a/app/src/main/java/org/session/libsession/snode/StorageProtocol.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.session.libsession.snode - -import org.session.libsignal.utilities.Snode - -interface SnodeStorageProtocol { - - fun getSnodePool(): Set - fun setSnodePool(newValue: Set) - fun getOnionRequestPaths(): List> - fun clearOnionRequestPaths() - fun setOnionRequestPaths(newValue: List>) - fun getSwarm(publicKey: String): Set? - fun setSwarm(publicKey: String, newValue: Set) - fun getLastMessageHashValue(snode: Snode, publicKey: String): String? - fun setLastMessageHashValue(snode: Snode, publicKey: String, newValue: String) - fun getReceivedMessageHashValues(publicKey: String): Set? - fun setReceivedMessageHashValues(publicKey: String, newValue: Set) -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt b/app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt deleted file mode 100644 index d3fa2acd19..0000000000 --- a/app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.session.libsession.snode.model - -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement - -@Serializable - -data class BatchResponse(val results: List, ) { - @Serializable - data class Item( - val code: Int, - val body: JsonElement, - ) { - val isSuccessful: Boolean - get() = code in 200..299 - - val isServerError: Boolean - get() = code in 500..599 - - val isSnodeNoLongerPartOfSwarm: Boolean - get() = code == 421 - } - - data class Error(val item: Item) - : RuntimeException("Batch request failed with code ${item.code}") { - init { - require(!item.isSuccessful) { - "This response item does not represent an error state" - } - } - } -} diff --git a/app/src/main/java/org/session/libsession/snode/utilities/OKHTTPUtilities.kt b/app/src/main/java/org/session/libsession/snode/utilities/OKHTTPUtilities.kt deleted file mode 100644 index f6b44398d2..0000000000 --- a/app/src/main/java/org/session/libsession/snode/utilities/OKHTTPUtilities.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.session.libsession.utilities - -import okhttp3.MultipartBody -import okhttp3.Request -import okio.Buffer -import org.session.libsignal.utilities.Base64 -import java.io.IOException -import java.util.Locale - -internal fun Request.getHeadersForOnionRequest(): Map { - val result = mutableMapOf() - val contentType = body?.contentType() - if (contentType != null) { - result["content-type"] = contentType.toString() - } - val headers = headers - for (name in headers.names()) { - val value = headers[name] - if (value != null) { - if (value.lowercase(Locale.US) == "true" || value.lowercase(Locale.US) == "false") { - result[name] = value.toBoolean() - } else if (value.toIntOrNull() != null) { - result[name] = value.toInt() - } else { - result[name] = value - } - } - } - return result -} - -internal fun Request.getBodyForOnionRequest(): Any? { - try { - val copyOfThis = newBuilder().build() - val buffer = Buffer() - val body = copyOfThis.body ?: return null - body.writeTo(buffer) - val bodyAsData = buffer.readByteArray() - if (body is MultipartBody) { - val base64EncodedBody: String = Base64.encodeBytes(bodyAsData) - return mapOf( "fileUpload" to base64EncodedBody ) - } else if (body.contentType()?.toString() == "application/octet-stream") { - return bodyAsData - } else { - val charset = body.contentType()?.charset() ?: Charsets.UTF_8 - return bodyAsData?.toString(charset) - } - } catch (e: IOException) { - return null - } -} diff --git a/app/src/main/java/org/session/libsession/snode/utilities/PromiseUtil.kt b/app/src/main/java/org/session/libsession/snode/utilities/PromiseUtil.kt deleted file mode 100644 index 17e6f696b9..0000000000 --- a/app/src/main/java/org/session/libsession/snode/utilities/PromiseUtil.kt +++ /dev/null @@ -1,73 +0,0 @@ -package org.session.libsession.snode.utilities - -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.deferred -import org.session.libsignal.utilities.Log -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine - -suspend inline fun Promise.await(): T { - return suspendCoroutine { cont -> - success(cont::resume) - fail(cont::resumeWithException) - } -} - -fun Promise.successBackground(callback: (value: V) -> Unit): Promise { - GlobalScope.launch { - try { - callback(this@successBackground.await()) - } catch (e: Exception) { - Log.d("Loki", "Failed to execute task in background: ${e.message}.") - } - } - return this -} - -fun CoroutineScope.asyncPromise(context: CoroutineContext = EmptyCoroutineContext, block: suspend () -> T): Promise { - val defer = deferred() - launch(context) { - try { - defer.resolve(block()) - } catch (e: Exception) { - defer.reject(e) - } - } - - return defer.promise -} - -fun CoroutineScope.retrySuspendAsPromise( - maxRetryCount: Int, - retryIntervalMills: Long = 1_000L, - body: suspend () -> T -): Promise { - return asyncPromise { - var retryCount = 0 - while (true) { - try { - return@asyncPromise body() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - if (retryCount == maxRetryCount) { - throw e - } else { - retryCount += 1 - delay(retryIntervalMills) - } - } - } - - @Suppress("UNREACHABLE_CODE") - throw IllegalStateException("Unreachable code") - } -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/utilities/AESGCM.kt b/app/src/main/java/org/session/libsession/utilities/AESGCM.kt index 2758a4a1e6..525fa2fb7e 100644 --- a/app/src/main/java/org/session/libsession/utilities/AESGCM.kt +++ b/app/src/main/java/org/session/libsession/utilities/AESGCM.kt @@ -16,11 +16,11 @@ import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec @WorkerThread -internal object AESGCM { +object AESGCM { internal val gcmTagSize = 128 internal val ivSize = 12 - internal data class EncryptionResult( + data class EncryptionResult( internal val ciphertext: ByteArray, internal val symmetricKey: ByteArray, internal val ephemeralPublicKey: ByteArray diff --git a/app/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt b/app/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt index 37545d464b..3a58174cee 100644 --- a/app/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt +++ b/app/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt @@ -34,11 +34,56 @@ import java.time.Instant interface ConfigFactoryProtocol { val configUpdateNotifications: Flow - fun withUserConfigs(cb: (UserConfigs) -> T): T - fun withMutableUserConfigs(cb: (MutableUserConfigs) -> T): T - fun mergeUserConfigs(userConfigType: UserConfigType, messages: List) + /** + * Dangerously access the user configs. You must call the returned release function + * to release the lock after you are done with the configs. + * + * **Warning:** Improper use of this function may lead to deadlocks or data corruption. + * It's better to use [withUserConfigs] instead. + * + * @return A pair of the user configs and a release function. + */ + fun dangerouslyAccessUserConfigs(): Pair Unit> + + /** + * Dangerously access the mutable user configs. You must call the returned release function + * to release the lock after you are done with the configs. The release function must be called + * as soon as possible and must be called in the same thread as this function. + * + * **Warning:** Improper use of this function may lead to deadlocks or data corruption. + * It's better to use [withMutableUserConfigs] instead. + * + * @return A pair of the mutable user configs and a release function. + */ + fun dangerouslyAccessMutableUserConfigs(): Pair Unit> + + /** + * Dangerously access the group configs for the given group ID. You must call the returned + * release function to release the lock after you are done with the configs. The release + * function must be called as soon as possible and must be called in the same thread as + * this function. + * + * **Warning:** Improper use of this function may lead to deadlocks or data corruption. + * It's better to use [withGroupConfigs] instead. + * + * @return A pair of the group configs and a release function. + */ + fun dangerouslyAccessGroupConfigs(groupId: AccountId): Pair Unit> + + /** + * Dangerously access the mutable group configs for the given group ID. You must call the + * returned release function to release the lock after you are done with the configs. The + * release function must be called as soon as possible and must be called in the same thread + * as this function. + * + * **Warning:** Improper use of this function may lead to deadlocks or data corruption. + * It's better to use [withMutableGroupConfigs] instead. + * + * @return A pair of the mutable group configs and a release function. + */ + fun dangerouslyAccessMutableGroupConfigs(groupId: AccountId): Pair Unit> - fun withGroupConfigs(groupId: AccountId, cb: (GroupConfigs) -> T): T + fun mergeUserConfigs(userConfigType: UserConfigType, messages: List) /** * Create a new group config instance. Note this does not save the group configs to the database. @@ -54,11 +99,6 @@ interface ConfigFactoryProtocol { */ fun saveGroupConfigs(groupId: AccountId, groupConfigs: MutableGroupConfigs) - /** - * @param recreateConfigInstances If true, the group configs will be recreated before calling the callback. This is useful when you have received an admin key or otherwise. - */ - fun withMutableGroupConfigs(groupId: AccountId, cb: (MutableGroupConfigs) -> T): T - fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean fun getConfigTimestamp(userConfigType: UserConfigType, publicKey: String): Long @@ -114,6 +154,42 @@ enum class UserConfigType(val namespace: Int) { USER_GROUPS(Namespace.USER_GROUPS()), } +inline fun ConfigFactoryProtocol.withUserConfigs(cb: (UserConfigs) -> T): T { + val (configs, release) = dangerouslyAccessUserConfigs() + return try { + cb(configs) + } finally { + release() + } +} + +inline fun ConfigFactoryProtocol.withMutableUserConfigs(cb: (MutableUserConfigs) -> T): T { + val (configs, release) = dangerouslyAccessMutableUserConfigs() + return try { + cb(configs) + } finally { + release() + } +} + +inline fun ConfigFactoryProtocol.withGroupConfigs(groupId: AccountId, cb: (GroupConfigs) -> T): T { + val (configs, release) = dangerouslyAccessGroupConfigs(groupId) + return try { + cb(configs) + } finally { + release() + } +} + +inline fun ConfigFactoryProtocol.withMutableGroupConfigs(groupId: AccountId, cb: (MutableGroupConfigs) -> T): T { + val (configs, release) = dangerouslyAccessMutableGroupConfigs(groupId) + return try { + cb(configs) + } finally { + release() + } +} + val ConfigFactoryProtocol.currentUserName: String get() = withUserConfigs { it.userProfile.getName().orEmpty() } val ConfigFactoryProtocol.currentUserProfile: UserPic? get() = withUserConfigs { configs -> configs.userProfile.getPic().takeIf { it.url.isNotBlank() } diff --git a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt index 8079e389d4..ce67a46aa4 100644 --- a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -4,10 +4,8 @@ import android.content.Context import android.hardware.Camera import android.net.Uri import android.provider.Settings -import androidx.annotation.ArrayRes import androidx.annotation.StyleRes import androidx.camera.core.CameraSelector -import androidx.core.app.NotificationCompat import androidx.core.content.edit import androidx.preference.PreferenceManager.getDefaultSharedPreferences import dagger.hilt.android.qualifiers.ApplicationContext @@ -37,6 +35,7 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.DEBUG_SH import org.session.libsession.utilities.TextSecurePreferences.Companion.ENVIRONMENT import org.session.libsession.utilities.TextSecurePreferences.Companion.FOLLOW_SYSTEM_SETTINGS import org.session.libsession.utilities.TextSecurePreferences.Companion.FORCED_SHORT_TTL +import org.session.libsession.utilities.TextSecurePreferences.Companion.HAS_CHECKED_DOZE_WHITELIST import org.session.libsession.utilities.TextSecurePreferences.Companion.HAS_COPIED_DONATION_URL import org.session.libsession.utilities.TextSecurePreferences.Companion.HAS_DONATED import org.session.libsession.utilities.TextSecurePreferences.Companion.HAS_HIDDEN_MESSAGE_REQUESTS @@ -44,7 +43,9 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.HAS_SEEN import org.session.libsession.utilities.TextSecurePreferences.Companion.HAS_SEEN_PRO_EXPIRING import org.session.libsession.utilities.TextSecurePreferences.Companion.HAVE_SHOWN_A_NOTIFICATION_ABOUT_TOKEN_PAGE import org.session.libsession.utilities.TextSecurePreferences.Companion.HIDE_PASSWORD +import org.session.libsession.utilities.TextSecurePreferences.Companion.LAST_PATH_ROTATION import org.session.libsession.utilities.TextSecurePreferences.Companion.LAST_SEEN_DONATION_CTA +import org.session.libsession.utilities.TextSecurePreferences.Companion.LAST_SNODE_POOL_REFRESH import org.session.libsession.utilities.TextSecurePreferences.Companion.LAST_VACUUM_TIME import org.session.libsession.utilities.TextSecurePreferences.Companion.LAST_VERSION_CHECK import org.session.libsession.utilities.TextSecurePreferences.Companion.LEGACY_PREF_KEY_SELECTED_UI_MODE @@ -67,8 +68,6 @@ import org.thoughtcrime.securesms.pro.toProMessageFeatures import org.thoughtcrime.securesms.pro.toProProfileFeatures import java.io.IOException import java.time.ZonedDateTime -import java.util.Arrays -import java.util.Date import javax.inject.Inject import javax.inject.Singleton @@ -114,7 +113,6 @@ interface TextSecurePreferences { fun setHasSeenGIFMetaDataWarning() fun isGifSearchInGridLayout(): Boolean fun setIsGifSearchInGridLayout(isGrid: Boolean) - fun getNotificationPriority(): Int fun getMessageBodyTextSize(): Int fun setPreferredCameraDirection(value: CameraSelector) fun getPreferredCameraDirection(): CameraSelector @@ -167,8 +165,6 @@ interface TextSecurePreferences { fun setStringSetPreference(key: String, value: Set) fun getHasViewedSeed(): Boolean fun setHasViewedSeed(hasViewedSeed: Boolean) - fun getLastSnodePoolRefreshDate(): Long - fun setLastSnodePoolRefreshDate(date: Date) fun getLastOpenTimeDate(): Long fun setLastOpenDate() fun hasSeenLinkPreviewSuggestionDialog(): Boolean @@ -233,6 +229,8 @@ interface TextSecurePreferences { fun setSubscriptionProvider(provider: String) fun getSubscriptionProvider(): String? + fun hasCheckedDozeWhitelist(): Boolean + fun setHasCheckedDozeWhitelist(hasChecked: Boolean) fun hasDonated(): Boolean fun setHasDonated(hasDonated: Boolean) fun hasCopiedDonationURL(): Boolean @@ -253,6 +251,11 @@ interface TextSecurePreferences { fun showDonationCTAFromPositiveReviewDebug(): String? fun setShowDonationCTAFromPositiveReviewDebug(show: String?) + fun getLastSnodePoolRefresh(): Long + fun setLastSnodePoolRefresh(epochMs: Long) + fun getLastPathRotation(): Long + fun setLastPathRotation(epochMs: Long) + var deprecationStateOverride: String? var deprecatedTimeOverride: ZonedDateTime? var deprecatingStartTimeOverride: ZonedDateTime? @@ -302,7 +305,6 @@ interface TextSecurePreferences { @Deprecated("No longer used, kept for migration purposes") const val REPEAT_ALERTS_PREF = "pref_repeat_alerts" const val NOTIFICATION_PRIVACY_PREF = "pref_notification_privacy" - const val NOTIFICATION_PRIORITY_PREF = "pref_notification_priority" const val DIRECT_CAPTURE_CAMERA_ID = "pref_direct_capture_camera_id" const val READ_RECEIPTS_PREF = "pref_read_receipts" const val INCOGNITO_KEYBORAD_PREF = "pref_incognito_keyboard" @@ -408,6 +410,8 @@ interface TextSecurePreferences { const val SUBSCRIPTION_PROVIDER = "session_subscription_provider" const val DEBUG_AVATAR_REUPLOAD = "debug_avatar_reupload" + const val HAS_CHECKED_DOZE_WHITELIST = "has_checked_doze_whitelist" + // Donation const val HAS_DONATED = "has_donated" const val HAS_COPIED_DONATION_URL = "has_copied_donation_url" @@ -420,6 +424,9 @@ interface TextSecurePreferences { const val DEBUG_SEEN_DONATION_CTA_AMOUNT = "debug_seen_donation_cta_amount" const val DEBUG_SHOW_DONATION_CTA_FROM_POSITIVE_REVIEW = "debug_show_donation_cta_from_positive_review" + const val LAST_SNODE_POOL_REFRESH = "last_snode_pool_refresh" + const val LAST_PATH_ROTATION = "last_path_rotation" + @JvmStatic fun isPushEnabled(context: Context): Boolean { return getBooleanPreference(context, IS_PUSH_ENABLED, false) @@ -668,14 +675,6 @@ interface TextSecurePreferences { return getLongPreference(context, PROFILE_PIC_EXPIRY, 0) } - fun getLastSnodePoolRefreshDate(context: Context?): Long { - return getLongPreference(context!!, "last_snode_pool_refresh_date", 0) - } - - fun setLastSnodePoolRefreshDate(context: Context?, date: Date) { - setLongPreference(context!!, "last_snode_pool_refresh_date", date.time) - } - @JvmStatic fun removeHasHiddenMessageRequests(context: Context) { removePreference(context, HAS_HIDDEN_MESSAGE_REQUESTS) @@ -726,7 +725,7 @@ class AppTextSecurePreferences @Inject constructor( @param:ApplicationContext private val context: Context, private val json: Json, ): TextSecurePreferences { - private val postProLaunchState = MutableStateFlow(getBooleanPreference(SET_FORCE_POST_PRO, false)) + private val postProLaunchState = MutableStateFlow(getBooleanPreference(SET_FORCE_POST_PRO, if (BuildConfig.BUILD_TYPE != "release") true else false)) private val hiddenPasswordState = MutableStateFlow(getBooleanPreference(HIDE_PASSWORD, false)) override var migratedToGroupV2Config: Boolean @@ -907,11 +906,6 @@ class AppTextSecurePreferences @Inject constructor( setBooleanPreference(TextSecurePreferences.GIF_GRID_LAYOUT, isGrid) } - override fun getNotificationPriority(): Int { - return getStringPreference( - TextSecurePreferences.NOTIFICATION_PRIORITY_PREF, NotificationCompat.PRIORITY_HIGH.toString())!!.toInt() - } - override fun getMessageBodyTextSize(): Int { return getStringPreference(TextSecurePreferences.MESSAGE_BODY_TEXT_SIZE_PREF, "16")!!.toInt() } @@ -1153,12 +1147,20 @@ class AppTextSecurePreferences @Inject constructor( setBooleanPreference("has_viewed_seed", hasViewedSeed) } - override fun getLastSnodePoolRefreshDate(): Long { - return getLongPreference("last_snode_pool_refresh_date", 0) + override fun getLastSnodePoolRefresh(): Long { + return getLongPreference(LAST_SNODE_POOL_REFRESH, 0) } - override fun setLastSnodePoolRefreshDate(date: Date) { - setLongPreference("last_snode_pool_refresh_date", date.time) + override fun setLastSnodePoolRefresh(epochMs: Long) { + setLongPreference(LAST_SNODE_POOL_REFRESH, epochMs) + } + + override fun getLastPathRotation(): Long { + return getLongPreference(LAST_PATH_ROTATION, 0) + } + + override fun setLastPathRotation(epochMs: Long) { + setLongPreference(LAST_PATH_ROTATION, epochMs) } override fun getLastOpenTimeDate(): Long { @@ -1544,6 +1546,14 @@ class AppTextSecurePreferences @Inject constructor( }) } + override fun hasCheckedDozeWhitelist(): Boolean { + return getBooleanPreference(HAS_CHECKED_DOZE_WHITELIST, false) + } + + override fun setHasCheckedDozeWhitelist(hasChecked: Boolean) { + setBooleanPreference(HAS_CHECKED_DOZE_WHITELIST, hasChecked) + } + override fun hasDonated(): Boolean { return getBooleanPreference(HAS_DONATED, false) } diff --git a/app/src/main/java/org/session/libsession/utilities/recipients/RecipientData.kt b/app/src/main/java/org/session/libsession/utilities/recipients/RecipientData.kt index 26506c7ca9..1691a7342f 100644 --- a/app/src/main/java/org/session/libsession/utilities/recipients/RecipientData.kt +++ b/app/src/main/java/org/session/libsession/utilities/recipients/RecipientData.kt @@ -176,7 +176,6 @@ sealed interface RecipientData { val expiryMode: ExpiryMode, val members: List, val description: String?, - override val proData: ProData?, override val firstMember: Recipient?, // Used primarily to assemble the profile picture for the group. override val secondMember: Recipient?, // Used primarily to assemble the profile picture for the group. ) : RecipientData, GroupLike { @@ -186,6 +185,7 @@ sealed interface RecipientData { val kicked: Boolean get() = groupInfo.kicked val destroyed: Boolean get() = groupInfo.destroyed val shouldPoll: Boolean get() = groupInfo.shouldPoll + override val proData: ProData? get() = null //todo LARGE GROUP hiding group pro status until we enable large groups override val profileUpdatedAt: Instant? get() = null @@ -198,7 +198,8 @@ sealed interface RecipientData { return hasAdmin(user) } - override fun setProData(proData: ProData): Group = copy(proData = proData) + //todo LARGE GROUP hiding group pro status until we enable large groups + override fun setProData(proData: ProData): Group = this //copy(proData = proData) } data class LegacyGroup( diff --git a/app/src/main/java/org/session/libsession/utilities/serializable/InstantAsSecondDoubleSerializer.kt b/app/src/main/java/org/session/libsession/utilities/serializable/InstantAsSecondDoubleSerializer.kt new file mode 100644 index 0000000000..5ab02dce3d --- /dev/null +++ b/app/src/main/java/org/session/libsession/utilities/serializable/InstantAsSecondDoubleSerializer.kt @@ -0,0 +1,28 @@ +package org.session.libsession.utilities.serializable + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.time.Instant + +/** + * Serializes and deserializes [java.time.Instant] as a double representing seconds since epoch in UTC. + */ +class InstantAsSecondDoubleSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("org.session.InstantDouble", PrimitiveKind.DOUBLE) + + override fun serialize( + encoder: Encoder, + value: Instant + ) { + encoder.encodeDouble(value.toEpochMilli() / 1000.0) + } + + override fun deserialize(decoder: Decoder): Instant { + return Instant.ofEpochMilli((decoder.decodeDouble() * 1000.0).toLong()) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsignal/crypto/Random.kt b/app/src/main/java/org/session/libsignal/crypto/Random.kt index c32eb78595..f7a091ffbc 100644 --- a/app/src/main/java/org/session/libsignal/crypto/Random.kt +++ b/app/src/main/java/org/session/libsignal/crypto/Random.kt @@ -1,6 +1,7 @@ package org.session.libsignal.crypto import org.session.libsignal.utilities.Util.SECURE_RANDOM +import java.security.SecureRandom /** * Uses `SecureRandom` to pick an element from this collection. @@ -21,3 +22,13 @@ fun Collection.secureRandom(): T { } fun Collection.shuffledRandom(): List = shuffled(SECURE_RANDOM) + +/** + * Generates a sequence that yields the elements of this list in a random order. + */ +fun List.shuffledSequence(): Sequence { + val random = SecureRandom() + return generateSequence { random.nextInt(size) } + .distinct() + .map { this[it] } +} diff --git a/app/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt b/app/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt index c96bbce767..672133e18a 100644 --- a/app/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt +++ b/app/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt @@ -7,14 +7,6 @@ import java.util.Date interface LokiAPIDatabaseProtocol { - fun getSnodePool(): Set - fun setSnodePool(newValue: Set) - fun getOnionRequestPaths(): List> - fun clearSnodePool() - fun clearOnionRequestPaths() - fun setOnionRequestPaths(newValue: List>) - fun getSwarm(publicKey: String): Set? - fun setSwarm(publicKey: String, newValue: Set) fun getLastMessageHashValue(snode: Snode, publicKey: String, namespace: Int): String? fun setLastMessageHashValue(snode: Snode, publicKey: String, newValue: String, namespace: Int) fun clearLastMessageHashes(publicKey: String) @@ -28,8 +20,6 @@ interface LokiAPIDatabaseProtocol { fun setLastDeletionServerID(room: String, server: String, newValue: Long) fun getOpenGroupPublicKey(server: String): String? fun setOpenGroupPublicKey(server: String, newValue: String) - fun getLastSnodePoolRefreshDate(): Date? - fun setLastSnodePoolRefreshDate(newValue: Date) fun getClosedGroupEncryptionKeyPairs(groupPublicKey: String): List fun getLatestClosedGroupEncryptionKeyPair(groupPublicKey: String): ECKeyPair? fun isClosedGroup(groupPublicKey: String): Boolean diff --git a/app/src/main/java/org/session/libsignal/messages/SignalServiceEnvelope.java b/app/src/main/java/org/session/libsignal/messages/SignalServiceEnvelope.java index 5312bc95cd..56b06e1f3f 100644 --- a/app/src/main/java/org/session/libsignal/messages/SignalServiceEnvelope.java +++ b/app/src/main/java/org/session/libsignal/messages/SignalServiceEnvelope.java @@ -9,7 +9,7 @@ import com.google.protobuf.ByteString; import org.session.libsignal.utilities.SignalServiceAddress; -import org.session.libsignal.protos.SignalServiceProtos.Envelope; +import org.session.protos.SessionProtos.Envelope; /** * This class represents an encrypted Signal Service envelope. @@ -32,8 +32,8 @@ public SignalServiceEnvelope(Envelope proto) { if (proto.getSourceDevice() > 0) { builder.setSourceDevice(proto.getSourceDevice()); } - builder.setTimestampMs(proto.getTimestampMs()); - builder.setServerTimestampMs(proto.getServerTimestampMs()); + builder.setTimestamp(proto.getTimestamp()); + builder.setServerTimestamp(proto.getServerTimestamp()); if (proto.getContent() != null) { builder.setContent(ByteString.copyFrom(proto.getContent().toByteArray())); } @@ -45,8 +45,8 @@ public SignalServiceEnvelope(int type, String sender, int senderDevice, long tim .setType(Envelope.Type.valueOf(type)) .setSource(sender) .setSourceDevice(senderDevice) - .setTimestampMs(timestamp) - .setServerTimestampMs(serverTimestamp); + .setTimestamp(timestamp) + .setServerTimestamp(serverTimestamp); if (content != null) builder.setContent(ByteString.copyFrom(content)); @@ -93,11 +93,11 @@ public int getType() { * @return The timestamp this envelope was sent. */ public long getTimestamp() { - return envelope.getTimestampMs(); + return envelope.getTimestamp(); } public long getServerTimestamp() { - return envelope.getServerTimestampMs(); + return envelope.getServerTimestamp(); } /** diff --git a/app/src/main/java/org/session/libsignal/utilities/ByteArraySlice.kt b/app/src/main/java/org/session/libsignal/utilities/ByteArraySlice.kt index 8bb047bbaf..d98aa05293 100644 --- a/app/src/main/java/org/session/libsignal/utilities/ByteArraySlice.kt +++ b/app/src/main/java/org/session/libsignal/utilities/ByteArraySlice.kt @@ -1,5 +1,11 @@ package org.session.libsignal.utilities +import okhttp3.MediaType +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.ResponseBody +import okio.BufferedSource +import okio.buffer +import okio.source import java.io.ByteArrayInputStream import java.io.InputStream import java.io.OutputStream @@ -13,8 +19,15 @@ class ByteArraySlice private constructor( val len: Int, ) { init { - check(offset in 0..data.size) { "Offset $offset is not within [0..${data.size}]" } - check(len in 0..data.size) { "Length $len is not within [0..${data.size}]" } + // Check negatives first + require(offset >= 0 && len >= 0) { + "Offset ($offset) and length ($len) must be non-negative" + } + + // Check bounds using subtraction to avoid overflow + require(offset <= data.size - len) { + "Slice [$offset..${offset + len}) is out of bounds for size ${data.size}" + } } fun view(range: IntRange): ByteArraySlice { @@ -44,8 +57,12 @@ class ByteArraySlice private constructor( } } - fun decodeToString(): String { - return data.decodeToString(offset, offset + len) + fun decodeToString(throwOnInvalidSequence: Boolean = false): String { + return data.decodeToString( + startIndex = offset, + endIndex = offset + len, + throwOnInvalidSequence = throwOnInvalidSequence + ) } fun inputStream(): InputStream { @@ -90,5 +107,23 @@ class ByteArraySlice private constructor( fun OutputStream.write(view: ByteArraySlice) { write(view.data, view.offset, view.len) } + + fun ByteArraySlice.toResponseBody( + contentType: MediaType? = null + ): ResponseBody { + return object : ResponseBody() { + override fun contentLength(): Long = len.toLong() + override fun contentType() = contentType + override fun source(): BufferedSource { + return inputStream().source().buffer() + } + } + } + + fun ByteArraySlice.toRequestBody( + contentType: MediaType? = null + ): okhttp3.RequestBody { + return data.toRequestBody(contentType, offset, len) + } } } diff --git a/app/src/main/java/org/session/libsignal/utilities/HTTP.kt b/app/src/main/java/org/session/libsignal/utilities/HTTP.kt deleted file mode 100644 index 2ffd693c14..0000000000 --- a/app/src/main/java/org/session/libsignal/utilities/HTTP.kt +++ /dev/null @@ -1,171 +0,0 @@ -package org.session.libsignal.utilities - -import android.util.Log -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit -import kotlinx.coroutines.withContext -import okhttp3.Call -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody -import okhttp3.Response -import org.session.libsignal.utilities.Util.SECURE_RANDOM -import java.security.cert.X509Certificate -import java.util.concurrent.TimeUnit -import javax.net.ssl.SSLContext -import javax.net.ssl.X509TrustManager - - -object HTTP { - var isConnectedToNetwork: (() -> Boolean) = { false } - - private val seedNodeConnection by lazy { - - OkHttpClient().newBuilder() - .callTimeout(timeout, TimeUnit.SECONDS) - .connectTimeout(timeout, TimeUnit.SECONDS) - .readTimeout(timeout, TimeUnit.SECONDS) - .writeTimeout(timeout, TimeUnit.SECONDS) - .build() - } - - private val defaultConnection by lazy { - // Snode to snode communication uses self-signed certificates but clients can safely ignore this - val trustManager = object : X509TrustManager { - - override fun checkClientTrusted(chain: Array?, authorizationType: String?) { } - override fun checkServerTrusted(chain: Array?, authorizationType: String?) { } - override fun getAcceptedIssuers(): Array { return arrayOf() } - } - val sslContext = SSLContext.getInstance("SSL") - sslContext.init(null, arrayOf( trustManager ), SECURE_RANDOM) - OkHttpClient().newBuilder() - .sslSocketFactory(sslContext.socketFactory, trustManager) - .hostnameVerifier { _, _ -> true } - .callTimeout(timeout, TimeUnit.SECONDS) - .connectTimeout(timeout, TimeUnit.SECONDS) - .readTimeout(timeout, TimeUnit.SECONDS) - .writeTimeout(timeout, TimeUnit.SECONDS) - .build() - } - - private fun getDefaultConnection(timeout: Long): OkHttpClient { - // Snode to snode communication uses self-signed certificates but clients can safely ignore this - val trustManager = object : X509TrustManager { - - override fun checkClientTrusted(chain: Array?, authorizationType: String?) { } - override fun checkServerTrusted(chain: Array?, authorizationType: String?) { } - override fun getAcceptedIssuers(): Array { return arrayOf() } - } - val sslContext = SSLContext.getInstance("SSL") - sslContext.init(null, arrayOf( trustManager ), SECURE_RANDOM) - return OkHttpClient().newBuilder() - .sslSocketFactory(sslContext.socketFactory, trustManager) - .hostnameVerifier { _, _ -> true } - .callTimeout(timeout, TimeUnit.SECONDS) - .connectTimeout(timeout, TimeUnit.SECONDS) - .readTimeout(timeout, TimeUnit.SECONDS) - .writeTimeout(timeout, TimeUnit.SECONDS) - .build() - } - - private const val timeout: Long = 120 - - open class HTTPRequestFailedException( - val statusCode: Int, - val json: Map<*, *>? = null, - val body: String? = null, - message: String = "HTTP request failed with status code $statusCode" - ) : kotlin.Exception(message) - class HTTPNoNetworkException : HTTPRequestFailedException(0, null, "No network connection") - - enum class Verb(val rawValue: String) { - GET("GET"), PUT("PUT"), POST("POST"), DELETE("DELETE") - } - - /** - * Sync. Don't call from the main thread. - */ - suspend fun execute(verb: Verb, url: String, timeout: Long = HTTP.timeout, useSeedNodeConnection: Boolean = false): ByteArray { - return execute(verb = verb, url = url, body = null, timeout = timeout, useSeedNodeConnection = useSeedNodeConnection) - } - - /** - * Sync. Don't call from the main thread. - */ - suspend fun execute(verb: Verb, url: String, parameters: Map?, timeout: Long = HTTP.timeout, useSeedNodeConnection: Boolean = false): ByteArray { - return if (parameters != null) { - val body = JsonUtil.toJson(parameters).toByteArray() - execute(verb = verb, url = url, body = body, timeout = timeout, useSeedNodeConnection = useSeedNodeConnection) - } else { - execute(verb = verb, url = url, body = null, timeout = timeout, useSeedNodeConnection = useSeedNodeConnection) - } - } - - /** - * Sync. Don't call from the main thread. - */ - suspend fun execute(verb: Verb, url: String, body: ByteArray?, timeout: Long = HTTP.timeout, useSeedNodeConnection: Boolean = false): ByteArray { - val request = Request.Builder().url(url) - .removeHeader("User-Agent").addHeader("User-Agent", "WhatsApp") // Set a fake value - .removeHeader("Accept-Language").addHeader("Accept-Language", "en-us") // Set a fake value - when (verb) { - Verb.GET -> request.get() - Verb.PUT, Verb.POST -> { - if (body == null) { throw Exception("Invalid request body.") } - val contentType = "application/json; charset=utf-8".toMediaType() - @Suppress("NAME_SHADOWING") val body = RequestBody.create(contentType, body) - if (verb == Verb.PUT) request.put(body) else request.post(body) - } - Verb.DELETE -> request.delete() - } - return try { - when { - // Custom timeout - timeout != HTTP.timeout -> { - if (useSeedNodeConnection) { - throw IllegalStateException("Setting a custom timeout is only allowed for requests to snodes.") - } - getDefaultConnection(timeout) - } - useSeedNodeConnection -> seedNodeConnection - else -> defaultConnection - }.newCall(request.build()).await().use { response -> - when (val statusCode = response.code) { - in 200..299 -> response.body.bytes() - else -> { - Log.d("Loki", "${verb.rawValue} request to $url failed with status code: $statusCode.") - throw HTTPRequestFailedException(statusCode, body = response.body.string()) - } - } - } - } catch (exception: Exception) { - Log.d("Loki", "${verb.rawValue} request to $url failed due to error: ${exception.localizedMessage}.") - - if (!isConnectedToNetwork()) { throw HTTPNoNetworkException() } - - if (exception !is HTTPRequestFailedException) { - - // Override the actual error so that we can correctly catch failed requests in OnionRequestAPI - throw HTTPRequestFailedException( - statusCode = 0, - message = "HTTP request failed due to: ${exception.message}" - ) - } else { - throw exception - } - } - } - - private val httpCallSemaphore = Semaphore(20) - - private suspend fun Call.await(): Response { - return httpCallSemaphore.withPermit { - withContext(Dispatchers.IO) { - execute() - } - } - } -} diff --git a/app/src/main/java/org/session/libsignal/utilities/PromiseUtilities.kt b/app/src/main/java/org/session/libsignal/utilities/PromiseUtilities.kt deleted file mode 100644 index d4f869aa24..0000000000 --- a/app/src/main/java/org/session/libsignal/utilities/PromiseUtilities.kt +++ /dev/null @@ -1,32 +0,0 @@ -@file:JvmName("PromiseUtilities") -package org.session.libsignal.utilities - -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.deferred -import nl.komponents.kovenant.functional.map - -fun emptyPromise() = Promise.of(Unit) - - -fun Promise.recover(callback: (exception: E) -> V): Promise { - val deferred = deferred() - success { - deferred.resolve(it) - }.fail { - try { - val value = callback(it) - deferred.resolve(value) - } catch (e: Throwable) { - deferred.reject(it) - } - } - return deferred.promise -} - - -infix fun Promise.sideEffect( - callback: (value: V) -> Unit -) = map { - callback(it) - it -} diff --git a/app/src/main/java/org/session/libsignal/utilities/Retrying.kt b/app/src/main/java/org/session/libsignal/utilities/Retrying.kt index 4ee17f52a4..dd7ca7859c 100644 --- a/app/src/main/java/org/session/libsignal/utilities/Retrying.kt +++ b/app/src/main/java/org/session/libsignal/utilities/Retrying.kt @@ -1,38 +1,9 @@ package org.session.libsignal.utilities import kotlinx.coroutines.delay -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.deferred import org.session.libsignal.exceptions.NonRetryableException -import java.util.* import kotlin.coroutines.cancellation.CancellationException -@Deprecated("Use retrySuspendAsPromise instead") -fun > retryIfNeeded(maxRetryCount: Int, retryInterval: Long = 1000L, body: () -> T): Promise { - var retryCount = 0 - val deferred = deferred() - val thread = Thread.currentThread() - fun retryIfNeeded() { - body().success { - deferred.resolve(it) - }.fail { - if (retryCount == maxRetryCount) { - deferred.reject(it) - } else { - retryCount += 1 - Timer().schedule(object : TimerTask() { - - override fun run() { - thread.run { retryIfNeeded() } - } - }, retryInterval) - } - } - } - retryIfNeeded() - return deferred.promise -} - suspend fun retryWithUniformInterval(maxRetryCount: Int = 3, retryIntervalMills: Long = 1000L, body: suspend () -> T): T { var retryCount = 0 while (true) { diff --git a/app/src/main/java/org/session/libsignal/utilities/Snode.kt b/app/src/main/java/org/session/libsignal/utilities/Snode.kt index f127bb8691..7d64dea127 100644 --- a/app/src/main/java/org/session/libsignal/utilities/Snode.kt +++ b/app/src/main/java/org/session/libsignal/utilities/Snode.kt @@ -2,6 +2,8 @@ package org.session.libsignal.utilities import android.annotation.SuppressLint import android.util.LruCache +import okhttp3.HttpUrl +import org.thoughtcrime.securesms.api.batch.BatchApiExecutor /** * Create a Snode from a "-" delimited String if valid, null otherwise. @@ -12,12 +14,15 @@ fun Snode(string: String): Snode? { val port = components.getOrNull(1)?.toIntOrNull() ?: return null val ed25519Key = components.getOrNull(2) ?: return null val x25519Key = components.getOrNull(3) ?: return null - val version = components.getOrNull(4)?.let(Snode::Version) ?: Snode.Version.ZERO - return Snode(address, port, Snode.KeySet(ed25519Key, x25519Key), version) + return Snode(address, port, Snode.KeySet(ed25519Key, x25519Key)) } -class Snode(val address: String, val port: Int, val publicKeySet: KeySet?, val version: Version) { +data class Snode(val address: String, val port: Int, val publicKeySet: KeySet?) { + constructor(url: HttpUrl, publicKeySet: KeySet?) : this("${url.scheme}://${url.host}", url.port, publicKeySet) + val ip: String get() = address.removePrefix("https://") + val ed25519Key: String get() = publicKeySet!!.ed25519Key + val x25519Key: String get() = publicKeySet!!.x25519Key enum class Method(val rawValue: String) { GetSwarm("get_snodes_for_pubkey"), @@ -33,6 +38,7 @@ class Snode(val address: String, val port: Int, val publicKeySet: KeySet?, val v GetExpiries("get_expiries"), RevokeSubAccount("revoke_subaccount"), UnrevokeSubAccount("unrevoke_subaccount"), + ActiveSnodesBin("active_nodes_bin"), } data class KeySet(val ed25519Key: String, val x25519Key: String) @@ -40,34 +46,4 @@ class Snode(val address: String, val port: Int, val publicKeySet: KeySet?, val v override fun equals(other: Any?) = other is Snode && address == other.address && port == other.port override fun hashCode(): Int = address.hashCode() xor port.hashCode() override fun toString(): String = "$address:$port" - - companion object { - private val CACHE = LruCache(100) - - @SuppressLint("NotConstructor") - @Synchronized - fun Version(value: String) = CACHE[value] ?: Snode.Version(value).also { CACHE.put(value, it) } - - fun Version(parts: List) = Version(parts.joinToString(".")) - } - - @JvmInline - value class Version(val value: ULong) { - companion object { - val ZERO = Version(0UL) - private const val MASK_BITS = 16 - private const val MASK = 0xFFFFUL - } - - internal constructor(value: String): this( - value.splitToSequence(".") - .take(4) - .map { it.toULongOrNull() ?: 0UL } - .foldIndexed(0UL) { i, acc, it -> - it.coerceAtMost(MASK) shl (3 - i) * MASK_BITS or acc - } - ) - - operator fun compareTo(other: Version): Int = value.compareTo(other.value) - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt b/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt deleted file mode 100644 index 34ab960021..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.thoughtcrime.securesms - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asExecutor -import nl.komponents.kovenant.Kovenant -import nl.komponents.kovenant.jvm.asDispatcher -import org.session.libsignal.utilities.Log -import java.util.concurrent.Executors - -object AppContext { - - fun configureKovenant() { - Kovenant.context { - callbackContext.dispatcher = Executors.newSingleThreadExecutor().asDispatcher() - workerContext.dispatcher = Dispatchers.IO.asExecutor().asDispatcher() - multipleCompletion = { v1, v2 -> - Log.d("Loki", "Promise resolved more than once (first with $v1, then with $v2); ignoring $v2.") - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt index 09ff1eec29..d4756864d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt @@ -37,19 +37,14 @@ import network.loki.messenger.BuildConfig import network.loki.messenger.R import network.loki.messenger.libsession_util.util.LogLevel import network.loki.messenger.libsession_util.util.Logger -import nl.komponents.kovenant.android.startKovenant -import nl.komponents.kovenant.android.stopKovenant import org.conscrypt.Conscrypt import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.configure import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier -import org.session.libsession.snode.SnodeModule import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences.Companion.pushSuffix -import org.session.libsignal.utilities.HTTP.isConnectedToNetwork import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.AppContext.configureKovenant import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.debugmenu.DebugActivity import org.thoughtcrime.securesms.debugmenu.DebugLogger @@ -58,7 +53,6 @@ import org.thoughtcrime.securesms.dependencies.DatabaseModule.init import org.thoughtcrime.securesms.dependencies.OnAppStartupComponents import org.thoughtcrime.securesms.emoji.EmojiSource.Companion.refresh import org.thoughtcrime.securesms.glide.RemoteFileLoader -import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint import org.thoughtcrime.securesms.logging.AndroidLogger import org.thoughtcrime.securesms.logging.PersistentLogger import org.thoughtcrime.securesms.logging.UncaughtExceptionLogger @@ -86,7 +80,6 @@ import kotlin.concurrent.Volatile class ApplicationContext : Application(), DefaultLifecycleObserver, Configuration.Provider, SingletonImageLoader.Factory { @Inject lateinit var messagingModuleConfiguration: Lazy @Inject lateinit var workerFactory: Lazy - @Inject lateinit var snodeModule: Lazy @Inject lateinit var sskEnvironment: Lazy @Inject lateinit var startupComponents: Lazy @@ -139,23 +132,17 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, Configuratio configure(this) super.onCreate() - startKovenant() initializeSecurityProvider() initializeLogging() initializeCrashHandling() NotificationChannels.create(this) ProcessLifecycleOwner.get().lifecycle.addObserver(this) - configureKovenant() - SnodeModule.sharedLazy = snodeModule SSKEnvironment.sharedLazy = sskEnvironment initializeWebRtc() initializeBlobProvider() refresh() - val networkConstraint = NetworkConstraint.Factory(this).create() - isConnectedToNetwork = { networkConstraint.isMet } - // add our shortcut debug menu if we are not in a release build if (BuildConfig.BUILD_TYPE != "release") { // add the config settings shortcut @@ -194,11 +181,6 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, Configuratio messageNotifier.setVisibleThread(-1) } - override fun onTerminate() { - stopKovenant() // Loki - super.onTerminate() - } - override fun newImageLoader(context: PlatformContext): ImageLoader { return imageLoaderProvider.get() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/InputBarDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/InputBarDialogs.kt index 91d720e821..ee95964ebb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/InputBarDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/InputBarDialogs.kt @@ -3,18 +3,10 @@ package org.thoughtcrime.securesms import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import com.squareup.phrase.Phrase -import network.loki.messenger.R -import org.session.libsession.utilities.NonTranslatableStringConstants -import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY import org.thoughtcrime.securesms.ui.AlertDialog -import org.thoughtcrime.securesms.ui.CTAFeature import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.LongMessageProCTA -import org.thoughtcrime.securesms.ui.SimpleSessionProCTA import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme diff --git a/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt index c5fdda1fb9..1a4764fe39 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt @@ -42,7 +42,7 @@ abstract class InputbarViewModel( count = charsLeft, countFormatted = NumberUtil.getFormattedNumber(charsLeft.toLong()), danger = charsLeft < 0, - showProBadge = proStatusManager.isPostPro() && currentUser.shouldShowProBadge // only show the badge for non pro users POST pro launch + showProBadge = proStatusManager.isPostPro() && !currentUser.isPro // only show the badge for non pro users POST pro launch ) } else { null diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt index a3444934fc..86cc81efba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt @@ -73,7 +73,7 @@ import org.session.libsession.messaging.messages.control.DataExtractionNotificat import org.session.libsession.messaging.messages.control.DataExtractionNotification.Kind.MediaSaved import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment -import org.session.libsession.snode.SnodeAPI.nowWithOffset +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.getColorFromAttr @@ -139,6 +139,9 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), @Inject lateinit var messageSender: MessageSender + @Inject + lateinit var snodeClock: SnodeClock + override val applyDefaultWindowInsets: Boolean get() = false @@ -521,7 +524,7 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), } .onAllGranted { val saveTask = SaveAttachmentTask(this@MediaPreviewActivity) - val saveDate = if (mediaItem.date > 0) mediaItem.date else nowWithOffset + val saveDate = if (mediaItem.date > 0) mediaItem.date else snodeClock.currentTimeMillis() saveTask.executeOnExecutor( AsyncTask.THREAD_POOL_EXECUTOR, SaveAttachmentTask.Attachment( @@ -552,7 +555,7 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), if (conversationAddress == null || conversationAddress?.isGroupOrCommunity == true) return val message = DataExtractionNotification( MediaSaved( - nowWithOffset + snodeClock.currentTimeMillis() ) ) messageSender.send(message, conversationAddress!!) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActionBarActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActionBarActivity.kt index e35fb2034f..ebe5adcd29 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActionBarActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActionBarActivity.kt @@ -185,7 +185,7 @@ abstract class ScreenLockActionBarActivity : BaseActionBarActivity() { // to rewrite the intent to reference a cached copy of the shared file. // Note: We CANNOT just add `Intent.FLAG_GRANT_READ_URI_PERMISSION` to this intent as we // pass it around because we don't have permission to do that (i.e., it doesn't work). - if (intent.action == "android.intent.action.SEND") { + if (intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) { val rewrittenIntent = rewriteShareIntentUris(intent) return getRoutedIntent(ScreenLockActivity::class.java, rewrittenIntent) } else { @@ -225,7 +225,8 @@ abstract class ScreenLockActionBarActivity : BaseActionBarActivity() { private suspend fun rewriteShareIntentUris(originalIntent: Intent): Intent? = withContext(Dispatchers.IO) { val rewrittenIntent = Intent(originalIntent) - // Clear original clipData + // Clear original data + rewrittenIntent.data = null rewrittenIntent.clipData = null rewrittenIntent.removeExtra(Intent.EXTRA_STREAM) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt index eecf19fa99..ee259d5132 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt @@ -74,16 +74,6 @@ class ShareActivity : FullComposeScreenLockActivity() { } private fun initializeMedia() { - val streamExtra = intent.getParcelableExtra(Intent.EXTRA_STREAM) - var charSequenceExtra: CharSequence? = null - try { - charSequenceExtra = intent.getCharSequenceExtra(Intent.EXTRA_TEXT) - } - catch (e: Exception) { - // It's not necessarily an issue if there's no text extra when sharing files - but we do - // have to catch any failed attempt. - } - - viewModel.initialiseMedia(streamExtra, charSequenceExtra, intent) + viewModel.initialiseMedia(intent) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShareViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/ShareViewModel.kt index c500c40ce2..5ad30e9dfd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ShareViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ShareViewModel.kt @@ -43,14 +43,15 @@ import javax.inject.Inject @HiltViewModel class ShareViewModel @Inject constructor( - @param:ApplicationContext private val context: Context, + @ApplicationContext private val context: Context, private val avatarUtils: AvatarUtils, private val deprecationManager: LegacyGroupDeprecationManager, conversationRepository: ConversationRepository, ): ViewModel(){ + private val TAG = ShareViewModel::class.java.simpleName - private var resolvedExtra: Uri? = null + private var resolvedExtras: List = emptyList() private var resolvedPlaintext: CharSequence? = null private var mimeType: String? = null private var isPassingAlongMedia = false @@ -64,8 +65,8 @@ class ShareViewModel @Inject constructor( @OptIn(FlowPreview::class) val contacts: StateFlow> = combine( conversationRepository.observeConversationList(), - mutableSearchQuery.debounce(100L), - ::filterContacts + mutableSearchQuery.debounce(100L), + ::filterContacts ).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) val hasAnyConversations: StateFlow = @@ -79,8 +80,6 @@ class ShareViewModel @Inject constructor( private val _uiState = MutableStateFlow(UIState(false)) val uiState: StateFlow get() = _uiState - - private fun filterContacts( threads: List, query: String, @@ -114,10 +113,9 @@ class ShareViewModel @Inject constructor( .thenByDescending { it.lastMessage?.timestamp } // then order by last message time ).map { thread -> val recipient = thread.recipient - ConversationItem( name = if(recipient.isSelf) context.getString(R.string.noteToSelf) - else recipient.searchName, + else recipient.searchName, address = recipient.address, avatarUIData = avatarUtils.getUIDataFromRecipient(recipient), showProBadge = recipient.shouldShowProBadge @@ -130,35 +128,75 @@ class ShareViewModel @Inject constructor( } fun onPause(): Boolean{ - if (!isPassingAlongMedia && resolvedExtra != null) { - BlobUtils.getInstance().delete(context, resolvedExtra!!) + if (!isPassingAlongMedia && resolvedExtras.isNotEmpty()) { + resolvedExtras.forEach { uri -> + BlobUtils.getInstance().delete(context, uri) + } return true } - return false } - fun initialiseMedia(streamExtra: Uri?, charSequenceExtra: CharSequence?, intent: Intent){ + fun initialiseMedia(intent: Intent){ + // Reset previous state + resolvedExtras = emptyList() + resolvedPlaintext = null + mimeType = null isPassingAlongMedia = false - mimeType = getMimeType(streamExtra, intent.type) + val action = intent.action + val type = intent.type + val incomingUris = ArrayList() + + val clipUris = intent.clipData?.let { cd -> + (0 until cd.itemCount).mapNotNull { cd.getItemAt(it).uri } + }.orEmpty() + + if (clipUris.isNotEmpty()) { + incomingUris.addAll(clipUris) + } else { + if (Intent.ACTION_SEND == action) { + intent.getParcelableExtra(Intent.EXTRA_STREAM)?.let(incomingUris::add) + } else if (Intent.ACTION_SEND_MULTIPLE == action) { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.let(incomingUris::addAll) + } + intent.data?.let(incomingUris::add) + } + + val uris = incomingUris.distinct() + + var charSequenceExtra: CharSequence? = null + try { + charSequenceExtra = intent.getCharSequenceExtra(Intent.EXTRA_TEXT) + } + catch (e: Exception) { + // Ignore + } + + isPassingAlongMedia = false + mimeType = getMimeType(uris.firstOrNull(), type) - if (streamExtra != null && PartAuthority.isLocalUri(streamExtra)) { + if (uris.isNotEmpty() && uris.all { PartAuthority.isLocalUri(it) }) { isPassingAlongMedia = true - resolvedExtra = streamExtra + resolvedExtras = uris handleResolvedMedia(intent) - } else if (charSequenceExtra != null && mimeType != null && mimeType!!.startsWith("text/")) { + } else if ( + uris.isEmpty() && + charSequenceExtra != null && + (mimeType?.startsWith("text/") == true) + ) { resolvedPlaintext = charSequenceExtra handleResolvedMedia(intent) - } else { + } else if (uris.isNotEmpty()) { _uiState.update { it.copy(showLoader = true) } - resolveMedia(intent, streamExtra) + resolveMedia(intent, uris) + } else { + _uiState.update { it.copy(showLoader = false) } } } private fun handleResolvedMedia(intent: Intent) { val address = IntentCompat.getParcelableExtra(intent, ShareActivity.EXTRA_ADDRESS, Address::class.java) - if (address is Address.Conversable) { createConversation(address) } else { @@ -166,26 +204,21 @@ class ShareViewModel @Inject constructor( } } - private fun resolveMedia(intent: Intent, vararg uris: Uri?){ + private fun resolveMedia(intent: Intent, uris: List){ viewModelScope.launch(Dispatchers.Default){ - resolvedExtra = getUri(*uris) + resolvedExtras = uris.mapNotNull { processSingleUri(it) } handleResolvedMedia(intent) } } - private fun getUri(vararg uris: Uri?): Uri? { + private fun processSingleUri(uri: Uri): Uri? { try { - if (uris.size != 1 || uris[0] == null) { - Log.w(TAG, "Invalid URI passed to ResolveMediaTask - bailing.") - return null - } else { - Log.i(TAG, "Resolved URI: " + uris[0]!!.toString() + " - " + uris[0]!!.path) - } + Log.i(TAG, "Resolving URI: " + uri.toString() + " - " + uri.path) - var inputStream = if ("file" == uris[0]!!.scheme) { - FileInputStream(uris[0]!!.path) + val inputStream = if ("file" == uri.scheme) { + FileInputStream(uri.path) } else { - context.contentResolver.openInputStream(uris[0]!!) + context.contentResolver.openInputStream(uri) } if (inputStream == null) { @@ -193,10 +226,9 @@ class ShareViewModel @Inject constructor( return null } - val cursor = context.contentResolver.query(uris[0]!!, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null) + val cursor = context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null) var fileName: String? = null var fileSize: Long? = null - try { if (cursor != null && cursor.moveToFirst()) { try { @@ -210,10 +242,12 @@ class ShareViewModel @Inject constructor( cursor?.close() } + val specificMime = MediaUtil.getMimeType(context, uri) ?: mimeType ?: "application/octet-stream" + return BlobUtils.getInstance() .forData(inputStream, if (fileSize == null) 0 else fileSize) - .withMimeType(mimeType!!) - .withFileName(fileName!!) + .withMimeType(specificMime) + .withFileName(fileName ?: "unknown") .createForMultipleSessionsOnDisk(context, BlobUtils.ErrorListener { e: IOException? -> Log.w(TAG, "Failed to write to disk.", e) }) .get() } catch (ioe: Exception) { @@ -236,22 +270,26 @@ class ShareViewModel @Inject constructor( } } - private fun createConversation(address: Address.Conversable) { val intent = ConversationActivityV2.createIntent( context = context, address = address, ) - intent.applyBaseShare() - isPassingAlongMedia = true _uiEvents.tryEmit(ShareUIEvent.GoToScreen(intent)) } private fun Intent.applyBaseShare() { - if (resolvedExtra != null) { - setDataAndType(resolvedExtra, mimeType) + if (resolvedExtras.isNotEmpty()) { + if (resolvedExtras.size == 1) { + action = Intent.ACTION_SEND + setDataAndType(resolvedExtras.first(), mimeType) + } else { + action = Intent.ACTION_SEND_MULTIPLE + type = mimeType ?: "*/*" + putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(resolvedExtras)) + } } else if (resolvedPlaintext != null) { putExtra(Intent.EXTRA_TEXT, resolvedPlaintext) setType("text/plain") diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/ApiExecutor.kt b/app/src/main/java/org/thoughtcrime/securesms/api/ApiExecutor.kt new file mode 100644 index 0000000000..41fb04333b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/ApiExecutor.kt @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.api + +interface ApiExecutor { + suspend fun send(ctx: ApiExecutorContext, req: Req): Res +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/ApiExecutorContext.kt b/app/src/main/java/org/thoughtcrime/securesms/api/ApiExecutorContext.kt new file mode 100644 index 0000000000..bf73e4dccd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/ApiExecutorContext.kt @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.api + +import androidx.collection.arrayMapOf + +/** + * A general-purpose context for passing data between API executor layers. + * + * To use, define a unique [Key] for data you want to store, and use [get], [set], and [getOrPut] + * to manage values associated with that key. The context is normally created per API request, + * allowing different layers of the request handling process to share information. + * + * Example usage: somewhere down in the middle of all the executor layers, a layer wants + * to store the number of same exception's occurrence, so it can make decision on what to do for + * next + */ +class ApiExecutorContext{ + private var values: MutableMap? = null + + private fun ensureInitialized(): MutableMap { + var current = values + if (current == null) { + current = arrayMapOf() + values = current + } + return current + } + + fun set(key: Key, value: T): ApiExecutorContext { + ensureInitialized()[key] = value + return this + } + + fun remove(key: Key<*>) { + values?.remove(key) + } + + fun getRaw(key: Key<*>): Any? { + return values?.get(key) + } + + inline fun get(key: Key): T? { + val raw = getRaw(key) + if (raw != null) { + return raw as T + } + + return null + } + + inline fun getOrPut(key: Key, defaultValue: () -> T): T { + val existing = get(key) + if (existing != null) { + return existing + } + + val newValue = defaultValue() + set(key, newValue) + return newValue + } + + interface Key +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/ApiModule.kt b/app/src/main/java/org/thoughtcrime/securesms/api/ApiModule.kt new file mode 100644 index 0000000000..1a8cb33b50 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/ApiModule.kt @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.api + +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.sync.Semaphore +import org.session.libsession.network.snode.SnodeDirectory +import org.thoughtcrime.securesms.api.batch.BatchApiExecutor +import org.thoughtcrime.securesms.api.http.HTTP_EXECUTOR_SEMAPHORE_NAME +import org.thoughtcrime.securesms.api.http.HttpApiExecutor +import org.thoughtcrime.securesms.api.http.OkHttpApiExecutor +import org.thoughtcrime.securesms.api.http.SessionHttpApiExecutor +import org.thoughtcrime.securesms.api.http.createRegularNodeOkHttpClient +import org.thoughtcrime.securesms.api.http.createSeedSnodeOkHttpClient +import org.thoughtcrime.securesms.api.onion.OnionSessionApiExecutor +import org.thoughtcrime.securesms.api.server.ServerApiExecutor +import org.thoughtcrime.securesms.api.server.ServerApiExecutorImpl +import org.thoughtcrime.securesms.api.snode.SnodeApiBatcher +import org.thoughtcrime.securesms.api.snode.SnodeApiExecutor +import org.thoughtcrime.securesms.api.snode.SnodeApiExecutorImpl +import org.thoughtcrime.securesms.api.swarm.SwarmApiExecutor +import org.thoughtcrime.securesms.api.swarm.SwarmApiExecutorImpl +import org.thoughtcrime.securesms.dependencies.ManagerScope +import javax.inject.Named +import javax.inject.Provider +import javax.inject.Singleton + + +@Module +@InstallIn(SingletonComponent::class) +abstract class APIModuleBinding { + @Binds + abstract fun bindSessionAPIExecutor(executor: OnionSessionApiExecutor): SessionApiExecutor + + @Binds + abstract fun bindServerApiExecutor(executor: ServerApiExecutorImpl) : ServerApiExecutor +} + +@Module +@InstallIn(SingletonComponent::class) +class APIModule { + + /** + * Provides a batched [SnodeApiExecutor] that groups requests together. + * This executor is not normally used directly, it's served as the base executor for + * different kind of snode API executors that depends on it. + */ + @Provides + @Named("batched_snode_api_executor") + @Singleton + fun provideBatchedSnodeApiExecutor( + executor: SnodeApiExecutorImpl, + batcher: SnodeApiBatcher, + @ManagerScope scope: CoroutineScope, + ): SnodeApiExecutor { + return BatchApiExecutor( + actualExecutor = executor, + batcher = batcher, + scope = scope, + ) + } + + /** + * Provides the default [SnodeApiExecutor] with auto-retry capabilities. + */ + @Provides + @Singleton + fun provideSnodeApiExecutor( + @Named("batched_snode_api_executor") executor: SnodeApiExecutor + ): SnodeApiExecutor { + return AutoRetryApiExecutor( + actualExecutor = executor + ) + } + + @Provides + @Singleton + fun provideSwarmApiExecutor( + @Named("batched_snode_api_executor") executor: SnodeApiExecutor, + swarmApiExecutorFactory: SwarmApiExecutorImpl.Factory + ): SwarmApiExecutor { + return AutoRetryApiExecutor( + actualExecutor = swarmApiExecutorFactory.create(executor) + ) + } + + @Provides + @Singleton + @Named(HTTP_EXECUTOR_SEMAPHORE_NAME) + fun provideHttpExecutorSemaphore(): Semaphore { + return Semaphore(20) + } + + @Provides + @Singleton + fun provideHttpApiExecutor( + @Named(HTTP_EXECUTOR_SEMAPHORE_NAME) semaphore: Semaphore, + snodeDirectory: Provider, + ): HttpApiExecutor { + return SessionHttpApiExecutor( + seedSnodeHttpApiExecutor = OkHttpApiExecutor( + client = createSeedSnodeOkHttpClient().build(), + semaphore = semaphore + ), + regularSnodeHttpApiExecutor = OkHttpApiExecutor( + client = createRegularNodeOkHttpClient().build(), + semaphore = semaphore + ), + snodeDirectory + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/AutoRetryApiExecutor.kt b/app/src/main/java/org/thoughtcrime/securesms/api/AutoRetryApiExecutor.kt new file mode 100644 index 0000000000..9f813877d5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/AutoRetryApiExecutor.kt @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.api + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import org.session.libsession.network.model.FailureDecision +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.api.error.ErrorWithFailureDecision +import org.thoughtcrime.securesms.util.findCause + +/** + * An [ApiExecutor] that automatically retries a request that fails with a [ErrorWithFailureDecision] + * with a [FailureDecision.Retry], up to 3 times, with exponential backoff. + * + * **Note**:, this executor should normally be at the outermost layer of executors, so that it can + * retry the entire request. + */ +class AutoRetryApiExecutor( + private val actualExecutor: ApiExecutor, +) : ApiExecutor { + override suspend fun send(ctx: ApiExecutorContext, req: Req): Res { + var numRetried = 0 + while (true) { + try { + return actualExecutor.send(ctx, req) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + if (e.findCause()?.failureDecision == FailureDecision.Retry && + ctx.get(DisableRetryKey) == null && + numRetried <= 3) { + numRetried += 1 + Log.e(TAG, "Retrying $req $numRetried times due to error", e) + delay(numRetried * 2000L) + } else { + throw e + } + } + } + } + + /** + * A key that can be added to the [ApiExecutorContext] to disable automatic retries. + */ + object DisableRetryKey : ApiExecutorContext.Key + + companion object { + private const val TAG = "AutoRetryApiExecutor" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/SessionApiExecutor.kt b/app/src/main/java/org/thoughtcrime/securesms/api/SessionApiExecutor.kt new file mode 100644 index 0000000000..0a3c5cd8ee --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/SessionApiExecutor.kt @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.api + +import kotlinx.serialization.json.JsonElement +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.api.http.HttpRequest +import org.thoughtcrime.securesms.api.http.HttpResponse +import org.thoughtcrime.securesms.api.snode.SnodeJsonRequest + + +sealed interface SessionApiRequest { + /** + * Send a JSON-RPC request to a specific snode. + */ + data class SnodeJsonRPC( + val snode: Snode, + val request: SnodeJsonRequest, + ) : SessionApiRequest + + /** + * Send/proxy a raw HTTP request to a server. + */ + data class HttpServerRequest( + val request: HttpRequest, + val serverX25519PubKeyHex: String + ) : SessionApiRequest +} + +sealed interface SessionApiResponse { + class JsonRPCResponse( + val code: Int, + val bodyAsText: String?, + val bodyAsJson: JsonElement?, + ) : SessionApiResponse + + class HttpServerResponse(val response: HttpResponse) : SessionApiResponse +} + +/** + * An [ApiExecutor] for sending [SessionApiRequest]s. + */ +typealias SessionApiExecutor = ApiExecutor, SessionApiResponse> + +suspend inline fun SessionApiExecutor.execute( + req: Req, + ctx: ApiExecutorContext = ApiExecutorContext() +): Res where Res : SessionApiResponse, Req : SessionApiRequest { + return send(ctx, req) as Res +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/batch/BatchApiExecutor.kt b/app/src/main/java/org/thoughtcrime/securesms/api/batch/BatchApiExecutor.kt new file mode 100644 index 0000000000..212378af8f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/batch/BatchApiExecutor.kt @@ -0,0 +1,217 @@ +package org.thoughtcrime.securesms.api.batch + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.api.ApiExecutor +import org.thoughtcrime.securesms.api.ApiExecutorContext +import java.time.Instant + +/** + * An [ApiExecutor] that batches requests together based on a [Batcher.batchKey]. + * + * Requests that share the same batch key within a short time window (100ms) are grouped + * together into a single batch request, which is sent using the provided [actualExecutor]. + * The [batcher] is used to transform individual requests into a batched request and + * to deconstruct the batched response back into individual responses. + * + * Requests that do not have a batch key (i.e., [Batcher.batchKey] returns null) + * are sent immediately without batching. + * + */ +@OptIn(ExperimentalCoroutinesApi::class) +class BatchApiExecutor( + private val actualExecutor: ApiExecutor, + private val batcher: Batcher, + private val scope: CoroutineScope, +) : ApiExecutor { + private val batchCommandSender: SendChannel> + + init { + val channel = Channel>(capacity = 100) + batchCommandSender = channel + + scope.launch { + val pendingRequests = linkedMapOf>() + var nextDeadline: Instant? = null + + while (true) { + val command: BatchCommand? = if (nextDeadline == null) { + channel.receive() + } else { + val now = Instant.now() + if (nextDeadline > now) { + withTimeoutOrNull(nextDeadline.toEpochMilli() - now.toEpochMilli()) { + channel.receive() + } + } else { + // Deadline already reached + null + } + } + + var calculateNextDeadline = false + + when (command) { + is BatchCommand.Send -> { + val existingBatch = pendingRequests[command.batchKey] + if (existingBatch == null) { + pendingRequests[command.batchKey] = BatchInfo( + requests = arrayListOf(command), + deadline = Instant.now().plusMillis(100L), + ) + + calculateNextDeadline = true + } else { + existingBatch.requests.add(command) + } + } + + is BatchCommand.Cancel -> { + val existingBatch = pendingRequests[command.batchKey] + if (existingBatch != null) { + existingBatch.requests.removeIf { it.req == command.req } + if (existingBatch.requests.isEmpty()) { + pendingRequests.remove(command.batchKey) + calculateNextDeadline = true + } + } + } + + null -> { + // Deadline reached: it will be the first batch in the queue + executeBatch(pendingRequests.removeFirst()) + calculateNextDeadline = true + } + } + + if (calculateNextDeadline) { + nextDeadline = if (pendingRequests.isEmpty()) { + null + } else { + pendingRequests.values.first().deadline + } + } + } + } + } + + private class BatchInfo( + val requests: ArrayList>, + val deadline: Instant, + ) { + init { + check(requests.isNotEmpty()) { + "BatchInfo must be initialized with at least one request" + } + } + } + + private fun executeBatch(batch: BatchInfo) { + scope.launch { + val requestsToSend = mutableListOf, T>>() + + // Transform each request for batching + for (r in batch.requests) { + val transformed = runCatching { + batcher.transformRequestForBatching(r.ctx, r.req) + } + + if (transformed.isFailure) { + // Notify individual request of failure + r.callback.send(Result.failure(transformed.exceptionOrNull()!!)) + continue + } + + requestsToSend += r to transformed.getOrThrow() + } + + if (requestsToSend.isEmpty()) { + return@launch + } + + val firstRequest = requestsToSend.first().first.req + + Log.d(TAG, "Sending ${requestsToSend.size} batched requests, with first=$firstRequest") + + try { + val resp = actualExecutor.send( + ctx = ApiExecutorContext(), // Blank context for a batched request (each sub-request has its own context) + req = batcher.constructBatchRequest(firstRequest, requestsToSend.map { it.second }) + ) + + val responses = batcher.deconstructBatchResponse( + requests = requestsToSend.map { it.first.ctx to it.first.req }, + response = resp + ) + + check(responses.size == batch.requests.size) { + "Batch response size ${responses.size} does not match request size ${batch.requests.size}" + } + + for (i in batch.requests.indices) { + val request = batch.requests[i] + val response = responses[i] + request.callback.send(response) + } + + } catch (e: Throwable) { + if (e is CancellationException) throw e + + // Notify all requests of the failure + for (request in requestsToSend) { + request.first.callback.send(Result.failure(e)) + } + } + } + } + + private fun LinkedHashMap.removeFirst(): V { + val iterator = this.entries.iterator() + val first = iterator.next() + iterator.remove() + return first.value + } + + override suspend fun send(ctx: ApiExecutorContext, req: Req): Res { + val batchKey = batcher.batchKey(req) + ?: return actualExecutor.send(ctx, req) + + val callback = Channel>(1) + batchCommandSender.send(BatchCommand.Send( + ctx = ctx, + batchKey = batchKey, + req = req, + callback = callback + )) + + try { + @Suppress("UNCHECKED_CAST") + return callback.receive().getOrThrow() as Res + } catch (e: CancellationException) { + // Best effort cancellation + batchCommandSender.trySend(BatchCommand.Cancel(batchKey, req)) + + throw e + } + } + + private interface BatchCommand { + class Send( + val ctx: ApiExecutorContext, + val batchKey: Any, + val req: Req, + val callback: SendChannel>, + ) : BatchCommand + class Cancel(val batchKey: Any, val req: Req) : BatchCommand + } + + companion object { + private const val TAG = "BatchApiExecutor" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/batch/Batcher.kt b/app/src/main/java/org/thoughtcrime/securesms/api/batch/Batcher.kt new file mode 100644 index 0000000000..3d5b2cf16f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/batch/Batcher.kt @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.api.batch + +import org.thoughtcrime.securesms.api.ApiExecutorContext + +interface Batcher { + + /** + * Returns a key that identifies requests that can be batched together. + */ + fun batchKey(req: Req): Any? + + fun transformRequestForBatching(ctx: ApiExecutorContext, req: Req): T + + /** + * Constructs a single batch request from the first request and a list of intermediate requests. + * Note: the construction should not fail because of any problem with individual requests, + * the (individual) failure should be thrown during the transformation phase. + */ + fun constructBatchRequest(firstRequest: Req, intermediateRequests: List): Req + + suspend fun deconstructBatchResponse( + requests: List>, + response: Res + ): List> + + class BatchState( + val index: Int, + val context: ApiExecutorContext, + val request: Req + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/direct/DirectSessionApiExecutor.kt b/app/src/main/java/org/thoughtcrime/securesms/api/direct/DirectSessionApiExecutor.kt new file mode 100644 index 0000000000..15e19c6458 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/direct/DirectSessionApiExecutor.kt @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.api.direct + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.SessionApiExecutor +import org.thoughtcrime.securesms.api.SessionApiRequest +import org.thoughtcrime.securesms.api.SessionApiResponse +import org.thoughtcrime.securesms.api.http.HttpApiExecutor +import org.thoughtcrime.securesms.api.http.HttpRequest +import javax.inject.Inject + +/** + * A [SessionApiExecutor] that directly sends requests using an underlying [HttpApiExecutor]. + */ +class DirectSessionApiExecutor @Inject constructor( + private val httpApiExecutor: HttpApiExecutor, + private val json: Json, +) : SessionApiExecutor { + override suspend fun send( + ctx: ApiExecutorContext, + req: SessionApiRequest<*> + ): SessionApiResponse { + val underlyingRequest = when (req) { + is SessionApiRequest.SnodeJsonRPC -> { + HttpRequest.createFromJson( + url = "${req.snode.address}:${req.snode.port}/storage_rpc/v1".toHttpUrl(), + method = "POST", + jsonText = json.encodeToString(req.request) + ) + } + + is SessionApiRequest.HttpServerRequest -> req.request + } + + val httpResponse = httpApiExecutor.send(ctx, underlyingRequest) + + return when (req) { + is SessionApiRequest.SnodeJsonRPC -> { + val bodyAsText = httpResponse.body.toText() + SessionApiResponse.JsonRPCResponse( + code = httpResponse.statusCode, + bodyAsText = bodyAsText, + bodyAsJson = bodyAsText?.let { + runCatching { json.decodeFromString(it) } + .getOrNull() + }, + ) + } + + is SessionApiRequest.HttpServerRequest -> SessionApiResponse.HttpServerResponse(httpResponse) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/error/ErrorWithFailureDecision.kt b/app/src/main/java/org/thoughtcrime/securesms/api/error/ErrorWithFailureDecision.kt new file mode 100644 index 0000000000..fafe625467 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/error/ErrorWithFailureDecision.kt @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.api.error + +import org.session.libsession.network.model.FailureDecision + +/** + * An [RuntimeException] that indicates an error has occurred and a decision has been made + * along the pathway on how to handle the failure. This should be a final decision on this operation + * so that the upmost layers can make decisions based on this. + */ +class ErrorWithFailureDecision( + cause: Throwable?, + val failureDecision: FailureDecision +) : RuntimeException("$failureDecision: ${cause?.message}", cause) diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/error/UnhandledStatusCodeException.kt b/app/src/main/java/org/thoughtcrime/securesms/api/error/UnhandledStatusCodeException.kt new file mode 100644 index 0000000000..41e3ee461e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/error/UnhandledStatusCodeException.kt @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.api.error + +/** + * An exception indicating that the HTTP status code received is + * erroneous or unrecognized. This exception is normally thrown when none of the + * [org.thoughtcrime.securesms.api.ApiExecutor] layers know how to handle the status code. + * + * Normally this is up to the caller to handle. + */ +class UnhandledStatusCodeException( + val code: Int, + val origin: String, + val bodyText: String? = null +) : RuntimeException("Unhandled HTTP status code $code from $origin: body=\"${bodyText.orEmpty()}\"") { + init { + check(code !in 200..299) { + "HTTP status code $code indicates success, cannot be used with ${this.javaClass.simpleName}" + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/http/HttpApiExecutor.kt b/app/src/main/java/org/thoughtcrime/securesms/api/http/HttpApiExecutor.kt new file mode 100644 index 0000000000..5908df0f9b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/http/HttpApiExecutor.kt @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.api.http + +import org.thoughtcrime.securesms.api.ApiExecutor + +/** + * An [ApiExecutor] for sending [HttpRequest]s. + */ +typealias HttpApiExecutor = ApiExecutor diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/http/HttpBody.kt b/app/src/main/java/org/thoughtcrime/securesms/api/http/HttpBody.kt new file mode 100644 index 0000000000..81f3b4a89c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/http/HttpBody.kt @@ -0,0 +1,107 @@ +package org.thoughtcrime.securesms.api.http + +import okio.utf8Size +import org.session.libsignal.utilities.ByteArraySlice +import org.session.libsignal.utilities.ByteArraySlice.Companion.view +import java.io.InputStream + +sealed interface HttpBody { + val byteLength: Int + + /** + * Returns the body as an InputStream. You are responsible for closing the stream after use. + */ + fun asInputStream(): InputStream + + /** + * Returns the body as a ByteArray, in the most efficient way possible. + */ + fun toBytes(): ByteArray + + /** + * Returns the body as a ByteArraySlice, in the most efficient way possible. + */ + fun toByteArraySlice(): ByteArraySlice { + return toBytes().view() + } + + /** + * Attempts to decode the body as UTF-8 text, returning null if decoding fails. + */ + fun toText(): String? + + class Text(val text: String): HttpBody { + override fun toBytes(): ByteArray { + return text.toByteArray() + } + + override fun toString(): String { + return "Text(${text.take(50)}..., length=${text.length})" + } + + override fun asInputStream(): InputStream { + return text.byteInputStream() + } + + override val byteLength: Int + get() = text.utf8Size().toInt() + + override fun toText(): String { + return text + } + } + + class Bytes(val bytes: ByteArray): HttpBody { + override fun toString(): String { + return "Bytes(length=${bytes.size}, asText=${toText()?.take(50)})" + } + + override fun toBytes(): ByteArray { + return bytes + } + + override fun asInputStream(): InputStream { + return bytes.inputStream() + } + + override val byteLength: Int + get() = bytes.size + + override fun toText(): String? { + return runCatching { + bytes.decodeToString(throwOnInvalidSequence = true) + }.getOrNull() + } + } + + class ByteSlice(val slice: ByteArraySlice): HttpBody { + override fun toBytes(): ByteArray { + return slice.copyToBytes() + } + + override fun toString(): String { + return "ByteSlice(length=${slice.len}, asText=${toText()?.take(50)})" + } + + override fun asInputStream(): InputStream { + return slice.inputStream() + } + + override val byteLength: Int + get() = slice.len + + override fun toByteArraySlice(): ByteArraySlice { + return slice + } + + override fun toText(): String? { + return runCatching { + slice.decodeToString(throwOnInvalidSequence = true) + }.getOrNull() + } + } + + companion object { + fun empty(): HttpBody = Bytes(byteArrayOf()) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/http/HttpClients.kt b/app/src/main/java/org/thoughtcrime/securesms/api/http/HttpClients.kt new file mode 100644 index 0000000000..302a4549e8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/http/HttpClients.kt @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.api.http + +import okhttp3.OkHttpClient +import org.session.libsignal.utilities.Util.SECURE_RANDOM +import java.security.cert.X509Certificate +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext +import javax.net.ssl.X509TrustManager + + +private const val DEFAULT_TIMEOUT_SECONDS = 120L + +const val HTTP_EXECUTOR_SEMAPHORE_NAME = "HttpExecutorSemaphore" + +fun createSeedSnodeOkHttpClient(): OkHttpClient.Builder { + return OkHttpClient().newBuilder() + .callTimeout(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .connectTimeout(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS) +} + +fun createRegularNodeOkHttpClient(): OkHttpClient.Builder { + // Snode to snode communication uses self-signed certificates but clients can safely ignore this + val trustManager = object : X509TrustManager { + + override fun checkClientTrusted(chain: Array?, authorizationType: String?) { } + override fun checkServerTrusted(chain: Array?, authorizationType: String?) { } + override fun getAcceptedIssuers(): Array { return arrayOf() } + } + val sslContext = SSLContext.getInstance("SSL") + sslContext.init(null, arrayOf( trustManager ), SECURE_RANDOM) + return OkHttpClient().newBuilder() + .sslSocketFactory(sslContext.socketFactory, trustManager) + .hostnameVerifier { _, _ -> true } + .callTimeout(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .connectTimeout(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/http/HttpRequest.kt b/app/src/main/java/org/thoughtcrime/securesms/api/http/HttpRequest.kt new file mode 100644 index 0000000000..d2cc5e7ebe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/http/HttpRequest.kt @@ -0,0 +1,38 @@ +package org.thoughtcrime.securesms.api.http + +import okhttp3.HttpUrl + +data class HttpRequest( + val url: HttpUrl, + val method: String, + val headers: Map, + val body: HttpBody?, +) { + init { + check(method != "GET" || body == null) { "GET request cannot have a body" } + } + + fun getHeader(name: String): String? { + return headers.entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value + } + + companion object { + fun createFromJson( + url: HttpUrl, + method: String, + jsonText: String + ): HttpRequest { + val body = HttpBody.Text(jsonText) + + return HttpRequest( + url = url, + method = method, + headers = mapOf( + "Content-Type" to "application/json", + "Content-Length" to body.byteLength.toString() + ), + body = body, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/http/HttpResponse.kt b/app/src/main/java/org/thoughtcrime/securesms/api/http/HttpResponse.kt new file mode 100644 index 0000000000..a1ea1315bd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/http/HttpResponse.kt @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.api.http + +import org.thoughtcrime.securesms.api.error.UnhandledStatusCodeException + +data class HttpResponse( + val statusCode: Int, + val headers: Map, + val body: HttpBody, +) { + fun getHeader(name: String): String? { + return headers.entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value + } + + fun throwIfNotSuccessful(): HttpResponse { + if (statusCode !in 200..299) { + throw UnhandledStatusCodeException( + code = statusCode, + origin = "Unknown", + bodyText = body.toText() + ) + } + + return this + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/http/OkHttpApiExecutor.kt b/app/src/main/java/org/thoughtcrime/securesms/api/http/OkHttpApiExecutor.kt new file mode 100644 index 0000000000..bd79820754 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/http/OkHttpApiExecutor.kt @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.api.http + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import org.session.libsignal.utilities.ByteArraySlice.Companion.toRequestBody +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.api.ApiExecutorContext + +class OkHttpApiExecutor( + private val client: OkHttpClient, + private val semaphore: Semaphore, +) : HttpApiExecutor { + override suspend fun send(ctx: ApiExecutorContext, req: HttpRequest): HttpResponse { + return semaphore.withPermit { + withContext(Dispatchers.IO) { + client.newCall(req.toOkHttpRequest()).execute().toHttpResponse() + } + } + } + + private fun HttpRequest.toOkHttpRequest(): okhttp3.Request { + val httpBody = when (body) { + is HttpBody.Text -> body.text.toRequestBody() + is HttpBody.Bytes -> body.bytes.toRequestBody() + is HttpBody.ByteSlice -> body.slice.toRequestBody() + null -> null + } + + val builder = okhttp3.Request.Builder() + .url(url) + .method(method, httpBody) + + for ((key, value) in this.headers) { + builder.addHeader(key, value) + } + + return builder.build() + } + + private fun okhttp3.Response.toHttpResponse(): HttpResponse { + val bytes = body.bytes() + + // Can we convert it to text? + val text = if (body.contentType()?.type == "text" || + body.contentType()?.subtype == "json" || + body.contentType()?.subtype == "xml" + ) { + runCatching { + bytes.decodeToString(throwOnInvalidSequence = true) + }.getOrNull() + } else { + null + } + + return HttpResponse( + statusCode = this.code, + headers = headers.toMap(), + body = if (text != null) { + HttpBody.Text(text) + } else { + HttpBody.Bytes(bytes) + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/http/SessionHttpApiExecutor.kt b/app/src/main/java/org/thoughtcrime/securesms/api/http/SessionHttpApiExecutor.kt new file mode 100644 index 0000000000..26eeb41553 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/http/SessionHttpApiExecutor.kt @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.api.http + +import org.session.libsession.network.snode.SnodeDirectory +import org.thoughtcrime.securesms.api.ApiExecutorContext +import javax.inject.Provider + +/** + * An [HttpApiExecutor] that choose a different underlying executor based on the url: + * - For seed/official URLs, a certificate-pinned executor is used + * - For regular snode URLs, a standard executor is used + */ +class SessionHttpApiExecutor( + private val seedSnodeHttpApiExecutor: HttpApiExecutor, + private val regularSnodeHttpApiExecutor: HttpApiExecutor, + private val snodeDirectory: Provider, +) : HttpApiExecutor { + + override suspend fun send( + ctx: ApiExecutorContext, + req: HttpRequest + ): HttpResponse { + val normalisedUrl = req.url.newBuilder() + .encodedPath("/") + .encodedQuery(null) + .encodedFragment(null) + .build() + + return if (snodeDirectory.get().seedNodePool.contains(normalisedUrl)) { + seedSnodeHttpApiExecutor.send(ctx, req) + } else { + regularSnodeHttpApiExecutor.send(ctx, req) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/onion/OnionSessionApiErrorManager.kt b/app/src/main/java/org/thoughtcrime/securesms/api/onion/OnionSessionApiErrorManager.kt new file mode 100644 index 0000000000..1910b48e4c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/onion/OnionSessionApiErrorManager.kt @@ -0,0 +1,102 @@ +package org.thoughtcrime.securesms.api.onion + +import org.session.libsession.network.model.FailureDecision +import org.session.libsession.network.model.OnionDestination +import org.session.libsession.network.model.OnionError +import org.session.libsession.network.model.Path +import org.session.libsession.network.onion.PathManager +import org.thoughtcrime.securesms.util.NetworkConnectivity +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class OnionSessionApiErrorManager @Inject constructor( + private val pathManager: PathManager, + private val connectivity: NetworkConnectivity +) { + + suspend fun onFailure( + error: OnionError, + path: Path, + shouldPunishPath: Boolean, + ): FailureDecision { + //todo ONION investigate why we got stuck in a invalid cyphertext state + + // -------------------------------------------------------------------- + // 1) "Found anywhere" rules (path OR destination) - currently no custom handling here + // as we now default to non penalising path logic + // -------------------------------------------------------------------- + + + // -------------------------------------------------------------------- + // 2) Errors along the path (not destination) + // -------------------------------------------------------------------- + when (error) { + // we got an error building the request. Warrants retrying + is OnionError.EncodingError -> { + return FailureDecision.Retry + } + + is OnionError.GuardUnreachable -> { + // We couldn't reach the guard, yet we seem to have network connectivity: + // punish the node and try again + if(connectivity.networkAvailable.value) { + pathManager.handleBadSnode(error.guard) + return FailureDecision.Retry + } + + // otherwise fail + return FailureDecision.Fail + } + + is OnionError.IntermediateNodeUnreachable -> { + // drop the bad snode, including cascading clean ups + if (error.offendingSnode != null) { + pathManager.handleBadSnode(snode = error.offendingSnode, forceRemove = true) + } + + // Only retry if we actually changed the path used by this request + return if (error.offendingSnode != null) FailureDecision.Retry else FailureDecision.Fail + } + + is OnionError.SnodeNotReady -> { + // penalise the snode and retry + return if(error.offendingSnode != null) { + pathManager.handleBadSnode(error.offendingSnode) + FailureDecision.Retry + } else { + FailureDecision.Fail + } + } + + is OnionError.PathTimedOut, is OnionError.InvalidHopResponse -> { + return if (shouldPunishPath) { + // we don't have enough information to penalise a specific snode, + // so we penalise the whole path and try again + pathManager.handleBadPath(path) + FailureDecision.Retry + } else { + FailureDecision.Fail + } + } + + is OnionError.DestinationUnreachable -> { + if (error.destination is OnionDestination.SnodeDestination) { + pathManager.handleBadSnode(error.destination.snode, forceRemove = true) + } + + return FailureDecision.Retry + } + is OnionError.InvalidResponse, + is OnionError.PathError, + is OnionError.Unknown -> { + return FailureDecision.Fail + } + } + + // -------------------------------------------------------------------- + // 3) Destination payload rules - currently this doesn't handle + // DestinatioErrors directly. The clients' error manager do. + // -------------------------------------------------------------------- + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/onion/OnionSessionApiExecutor.kt b/app/src/main/java/org/thoughtcrime/securesms/api/onion/OnionSessionApiExecutor.kt new file mode 100644 index 0000000000..2a59e4c68b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/onion/OnionSessionApiExecutor.kt @@ -0,0 +1,441 @@ +package org.thoughtcrime.securesms.api.onion + +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromStream +import okhttp3.HttpUrl.Companion.toHttpUrl +import okio.IOException +import okio.utf8Size +import org.session.libsession.network.model.ErrorStatus +import org.session.libsession.network.model.OnionDestination +import org.session.libsession.network.model.OnionError +import org.session.libsession.network.model.Path +import org.session.libsession.network.onion.OnionBuilder +import org.session.libsession.network.onion.OnionRequestEncryption +import org.session.libsession.network.onion.OnionRequestVersion +import org.session.libsession.network.onion.PathManager +import org.session.libsession.utilities.AESGCM +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.ByteArraySlice +import org.session.libsignal.utilities.ByteArraySlice.Companion.view +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.SessionApiExecutor +import org.thoughtcrime.securesms.api.SessionApiRequest +import org.thoughtcrime.securesms.api.SessionApiResponse +import org.thoughtcrime.securesms.api.error.ErrorWithFailureDecision +import org.thoughtcrime.securesms.api.http.HttpApiExecutor +import org.thoughtcrime.securesms.api.http.HttpBody +import org.thoughtcrime.securesms.api.http.HttpRequest +import org.thoughtcrime.securesms.api.http.HttpResponse +import java.io.ByteArrayOutputStream +import javax.inject.Inject + + +/** + * An implementation of [SessionApiExecutor] that routes requests over onion paths. + * + * Responsibilities: + * - Pick a path (via [PathManager]) + * - Build onion request (via [OnionBuilder]) + * - Encrypt onion request payload (via [OnionRequestEncryption]) + * - Send request via [HttpApiExecutor] + * - Handle exception specific to onion requests (via [OnionSessionApiErrorManager]) + * + * To override the path used for a specific request, set [OnionPathOverridesKey] in the + * [ApiExecutorContext] passed to [send]. + * + * Note: requests to seed nodes are sent directly, as onion routing to seed nodes is currently not + * supported. + */ +class OnionSessionApiExecutor @Inject constructor( + private val httpApiExecutor: HttpApiExecutor, + private val pathManager: PathManager, + private val json: Json, + private val onionSessionApiErrorManager: OnionSessionApiErrorManager, + private val onionRequestEncryption: OnionRequestEncryption, + private val onionBuilder: OnionBuilder, +) : SessionApiExecutor { + override suspend fun send( + ctx: ApiExecutorContext, + req: SessionApiRequest<*> + ): SessionApiResponse { + val onionRequestVersion: OnionRequestVersion + val onionDestination: OnionDestination + val payload: ByteArray + + when (req) { + is SessionApiRequest.SnodeJsonRPC -> { + Log.d("OnionSessionApiExecutor", "Sending Onion request to Snode destination. Method: ${req.request.method} -- Destination: ${req.snode}") + + onionRequestVersion = OnionRequestVersion.V3 + onionDestination = OnionDestination.SnodeDestination(req.snode) + payload = json.encodeToString(req.request).toByteArray() + } + + is SessionApiRequest.HttpServerRequest -> { + Log.d("OnionSessionApiExecutor", "Sending Onion request to Server destination. Url: ${req.request.url}") + onionRequestVersion = OnionRequestVersion.V4 + onionDestination = OnionDestination.ServerDestination( + host = req.request.url.host, + port = req.request.url.port, + x25519PublicKey = req.serverX25519PubKeyHex, + scheme = req.request.url.scheme, + target = OnionRequestVersion.V4.value, + ) + payload = generateV4HttpServerPayload(req.request) + } + } + + val pathOverrides = ctx.get(OnionPathOverridesKey) + val path = pathOverrides ?: pathManager.getPath() + + val builtOnion = try { + onionBuilder.build( + path = path, + destination = onionDestination, + payload = payload, + onionRequestVersion = onionRequestVersion + ) + } catch (e: Exception) { + throw OnionError.EncodingError( + destination = onionDestination, + cause = e + ) + } + + val body = try { + onionRequestEncryption.encode( + ciphertext = builtOnion.ciphertext, + json = mapOf( + "ephemeral_key" to builtOnion.ephemeralPublicKey.toHexString(), + ) + ) + } catch (e: Exception) { + throw OnionError.EncodingError( + destination = onionDestination, + cause = e + ) + } + + val guard = builtOnion.guard + val url = "${guard.address}:${guard.port}/onion_req/v2".toHttpUrl() + + val httpRequest = HttpRequest( + url = url, + method = "POST", + headers = mapOf(), + body = HttpBody.Bytes(body) + ) + + val result = runCatching { + httpApiExecutor.send( + ctx = ctx, + req = httpRequest + ) + }.mapCatching { resp -> + if (resp.statusCode in 200..299) { + try { + when (onionRequestVersion) { + OnionRequestVersion.V3 -> handleV3Response(resp.body, builtOnion) + OnionRequestVersion.V4 -> handleV4Response(resp.body, builtOnion) + } + } catch (e: Throwable) { + throw OnionError.InvalidResponse(onionDestination, e) + } + } else { + throw mapHttpError( + path = path, + httpResponseCode = resp.statusCode, + httpResponseBody = resp.body.toText(), + destination = onionDestination, + ) + } + } + + return when { + result.isSuccess -> result.getOrThrow() + else -> { + val error = when (val e = result.exceptionOrNull()!!) { + is CancellationException -> throw e + is IOException -> OnionError.GuardUnreachable( + guard = path.first(), + destination = onionDestination, + cause = e + ) + is OnionError -> e + else -> OnionError.Unknown(onionDestination, e) + } + + throw ErrorWithFailureDecision( + cause = error, + failureDecision = onionSessionApiErrorManager.onFailure( + error = error, + path = path, + // When path overrides are used, we skip any path punishment logic, + // as that path is not selected by us. + shouldPunishPath = pathOverrides == null, + ) + ) + } + } + } + + /** + * Errors thrown by the guard / path hop BEFORE we get an onion-encrypted reply. + */ + @VisibleForTesting + internal fun mapHttpError( + httpResponseCode: Int, + httpResponseBody: String?, + path: Path, + destination: OnionDestination, + ): OnionError { + val guardSnode = path.first() + + Log.d("OnionSessionApiExecutor", "Networking Error, got a non 200 response code: $httpResponseCode, body: $httpResponseBody") + + // ---- 502: hop can't find/contact next hop ---- + val nextNodeNotFound = "Next node not found: " + val nextNodeUnreachable = "Next node is currently unreachable: " + + // to extract the key from the error message + fun parseNextHopPk(msg: String): String? = when { + msg.startsWith(nextNodeNotFound) -> msg.removePrefix(nextNodeNotFound).trim() + msg.startsWith(nextNodeUnreachable) -> msg.removePrefix(nextNodeUnreachable).trim() + else -> null + } + + val failedPk = httpResponseBody?.let(::parseNextHopPk) + if (httpResponseCode == 502 && failedPk != null) { + val destPk = + (destination as? OnionDestination.SnodeDestination)?.snode?.publicKeySet?.ed25519Key + + return if (destPk != null && failedPk == destPk) { + OnionError.DestinationUnreachable( + status = ErrorStatus( + code = httpResponseCode, + message = httpResponseBody, + body = null + ), + destination = destination + ) + } else { + OnionError.IntermediateNodeUnreachable( + offendingSnode = path.firstOrNull { it.ed25519Key == failedPk }, + status = ErrorStatus( + code = httpResponseCode, + message = httpResponseBody, + body = null + ), + destination = destination + ) + } + } + + // ---- 503: "Snode not ready" ---- + if (httpResponseCode == 503) { + val snodeNotReadyPrefix = "Snode not ready: " + val snodeNotReady = httpResponseBody?.startsWith(snodeNotReadyPrefix) == true + + val guardNotReady = + httpResponseBody?.startsWith("Service node is not ready:") == true || + httpResponseBody?.startsWith("Server busy, try again later") == true + + if (guardNotReady) { + return OnionError.SnodeNotReady( + offendingSnode = guardSnode, + status = ErrorStatus( + code = httpResponseCode, + message = httpResponseBody, + body = null + ), + destination = destination, + ) + } else if (snodeNotReady) { + val pk = httpResponseBody.removePrefix(snodeNotReadyPrefix).trim() + return OnionError.SnodeNotReady( + offendingSnode = path.firstOrNull { it.ed25519Key == pk }, + status = ErrorStatus( + code = httpResponseCode, + message = httpResponseBody, + body = null + ), + destination = destination + ) + } + } + + // ---- 504: timeouts along path ---- + if (httpResponseCode == 504 && httpResponseBody?.contains( + "Request time out", + ignoreCase = true + ) == true + ) { + return OnionError.PathTimedOut( + status = ErrorStatus( + code = httpResponseCode, + message = httpResponseBody, + body = null + ), + destination = destination + ) + } + + // ---- 500: invalid response from next hop ---- + if (httpResponseCode == 500 && httpResponseBody?.contains( + "Invalid response from snode", + ignoreCase = true + ) == true + ) { + return OnionError.InvalidHopResponse( + node = guardSnode, + status = ErrorStatus( + code = httpResponseCode, + message = httpResponseBody, + body = null + ), + destination = destination + ) + } + + //todo ONION currently we have no handling of 5xx for snode destination as it's unclear how to best handle them + + // Default: generic path error + return OnionError.PathError( + guardNode = guardSnode, + status = ErrorStatus(code = httpResponseCode, message = httpResponseBody, body = null), + destination = destination + ) + } + + private fun generateV4HttpServerPayload(req: HttpRequest): ByteArray { + val meta = json.encodeToString(V4RequestMeta(req)) + + return ByteArrayOutputStream().use { outputStream -> + outputStream.writer().use { writer -> + writer.write("l${meta.utf8Size()}:") + writer.write(meta) + + if (req.body != null) { + writer.write("${req.body.byteLength}:") + writer.flush() // Flush before writing raw bytes + req.body.asInputStream().use { it.copyTo(outputStream) } + } + + writer.write("e") + } + + outputStream.toByteArray() + } + } + + @Serializable + private class V4RequestMeta( + val endpoint: String, + val method: String, + val headers: Map, + ) { + constructor(request: HttpRequest) + : this( + endpoint = buildString { + append(request.url.encodedPath) + if (request.url.encodedQuery != null) { + append("?") + append(request.url.encodedQuery) + } + }, + method = request.method, + headers = request.headers.toMap() + ) + } + + @OptIn(ExperimentalSerializationApi::class) + private fun handleV4Response( + body: HttpBody, + builtOnion: OnionBuilder.BuiltOnion + ): SessionApiResponse.HttpServerResponse { + val decrypted = AESGCM.decrypt( + body.toBytes(), + symmetricKey = builtOnion.destinationSymmetricKey + ) + + val infoSepIdx = decrypted.indexOfFirst { it == ':'.code.toByte() } + check(infoSepIdx > 1) { + "Error decoding payload" + } + + val infoLenSlice = decrypted.slice(1 until infoSepIdx) + val infoLength = infoLenSlice.toByteArray().toString(Charsets.US_ASCII).toInt() + + val infoStartIndex = "l$infoLength".length + 1 + val infoEndIndex = infoStartIndex + infoLength + check(infoEndIndex <= decrypted.size) { + "Error decoding payload" + } + + val info: V4ResponseInfo = decrypted.view(infoStartIndex until infoEndIndex) + .inputStream() + .use(json::decodeFromStream) + + val bodySlice = decrypted.getBody(infoLength, infoEndIndex) + + return SessionApiResponse.HttpServerResponse( + HttpResponse( + statusCode = info.code, + headers = info.headers.orEmpty(), + body = HttpBody.ByteSlice(bodySlice) + ) + ) + } + + private fun ByteArray.getBody(infoLength: Int, infoEndIndex: Int): ByteArraySlice { + val infoLengthStringLength = infoLength.toString().length + if (size <= infoLength + infoLengthStringLength + 2) return ByteArraySlice.EMPTY + + val dataSlice = view(infoEndIndex + 1 until size - 1) + val dataSepIdx = dataSlice.asList().indexOfFirst { it.toInt() == ':'.code } + if (dataSepIdx == -1) return ByteArraySlice.EMPTY + + return dataSlice.view(dataSepIdx + 1 until dataSlice.len) + } + + @OptIn(ExperimentalSerializationApi::class) + private fun handleV3Response( + body: HttpBody, + builtOnion: OnionBuilder.BuiltOnion + ): SessionApiResponse.JsonRPCResponse { + val ivAndCipherText = Base64.decode(body.toBytes()) + + val response: V3Response = + AESGCM.decrypt(ivAndCipherText, symmetricKey = builtOnion.destinationSymmetricKey) + .inputStream() + .use(json::decodeFromStream) + + return SessionApiResponse.JsonRPCResponse( + code = response.status, + bodyAsJson = runCatching { + json.decodeFromString(response.body) + }.getOrNull(), + bodyAsText = response.body, + ) + } + + @Serializable + private class V3Response( + val status: Int, + val body: String, + ) + + @Serializable + private class V4ResponseInfo( + val code: Int, + val headers: Map? = emptyMap() + ) + + object OnionPathOverridesKey : ApiExecutorContext.Key +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/server/JsonServerApi.kt b/app/src/main/java/org/thoughtcrime/securesms/api/server/JsonServerApi.kt new file mode 100644 index 0000000000..fa22c0b1c0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/server/JsonServerApi.kt @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.api.server + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromStream +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpBody +import org.thoughtcrime.securesms.api.http.HttpRequest +import org.thoughtcrime.securesms.api.http.HttpResponse + +abstract class JsonServerApi( + protected val json: Json, + errorManager: ServerApiErrorManager +): ServerApi(errorManager) { + abstract val httpMethod: String + abstract val httpEndpoint: String + abstract val responseSerializer: DeserializationStrategy + + abstract fun buildJsonPayload(): JsonElement? + + open fun transformResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + resp: RespType): RespType = resp + + final override fun buildRequest( + baseUrl: String, + x25519PubKeyHex: String + ): HttpRequest { + val body = buildJsonPayload()?.let { + HttpBody.Text(json.encodeToString( it)) + } + + check(!httpMethod.equals("GET", ignoreCase = true) || body == null) { + "GET requests cannot have a body" + } + + return HttpRequest( + url = "$baseUrl/$httpEndpoint".toHttpUrl(), + method = httpMethod, + headers = if (body != null) mapOf( + "Content-Type" to "application/json" + ) else emptyMap(), + body = body + ) + } + + @Suppress("OPT_IN_USAGE") + final override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): RespType { + val rep = response.body.asInputStream().use { + json.decodeFromStream(responseSerializer, it) + } + + return transformResponse(executorContext, baseUrl, rep) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApi.kt b/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApi.kt new file mode 100644 index 0000000000..c3e18bd217 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApi.kt @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.api.server + +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.error.ErrorWithFailureDecision +import org.thoughtcrime.securesms.api.http.HttpRequest +import org.thoughtcrime.securesms.api.http.HttpResponse + +abstract class ServerApi( + private val errorManager: ServerApiErrorManager, +) { + abstract fun buildRequest(baseUrl: String, x25519PubKeyHex: String): HttpRequest + + open suspend fun processResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): ResponseType { + if (response.statusCode !in 200..299) { + handleErrorResponse( + executorContext = executorContext, + baseUrl = baseUrl, + response = response + ) + } else { + return handleSuccessResponse(executorContext, baseUrl, response) + } + } + + protected open suspend fun handleErrorResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): Nothing { + val failureContext = executorContext.getOrPut(ServerClientFailureContextKey) { + ServerClientFailureContext( + previousErrorCode = null + ) + } + + val (error, decision) = errorManager.onFailure( + errorCode = response.statusCode, + serverBaseUrl = baseUrl, + bodyAsText = response.body.toText(), + ctx = failureContext, + ) + + Log.d("ServerApi", "Network error for a Server endpoint ($baseUrl), with status:${response.statusCode} - error: $error") + + executorContext.set( + key = ServerClientFailureContextKey, + value = failureContext.copy(previousErrorCode = response.statusCode) + ) + + if (decision != null) { + throw ErrorWithFailureDecision( + cause = error, + failureDecision = decision + ) + } else { + throw error + } + } + + + abstract suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): ResponseType + + private object ServerClientFailureContextKey : + ApiExecutorContext.Key +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApiErrorManager.kt b/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApiErrorManager.kt new file mode 100644 index 0000000000..11b5d4a6fe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApiErrorManager.kt @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms.api.server + +import org.session.libsession.network.SnodeClock +import org.session.libsession.network.model.FailureDecision +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.api.error.UnhandledStatusCodeException +import javax.inject.Inject +import javax.inject.Singleton + + +@Singleton +class ServerApiErrorManager @Inject constructor( + private val snodeClock: SnodeClock, +) { + + /** + * Handles server api error and returning a mapped [Throwable] and optionally, its decision for error + * handling. + * + */ + suspend fun onFailure(errorCode: Int, + bodyAsText: String?, + serverBaseUrl: String, + ctx: ServerClientFailureContext): Pair { + // 425 is 'Clock out of sync' for a server destination + if (errorCode == 425) { + // if this is the first time we got a COS, retry, since we should have resynced the clock + Log.w("Onion Request", "Clock out of sync (code: $errorCode) for destination server ${serverBaseUrl} - Local Snode clock at ${snodeClock.currentTime()} - First time? ${ctx.previousErrorCode == null}") + return RuntimeException("Clock out of sync received from $serverBaseUrl") to if (ctx.previousErrorCode == 425) { + FailureDecision.Fail + } else { + // reset the clock + val resync = runCatching { + snodeClock.resyncClock() + }.getOrDefault(false) + + // only retry if we were able to resync the clock + if(resync) FailureDecision.Retry else FailureDecision.Fail + } + } + + return UnhandledStatusCodeException( + code = errorCode, + origin = serverBaseUrl, + bodyText = bodyAsText + ) to null + } +} + +data class ServerClientFailureContext( + val previousErrorCode: Int? = null, +) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApiExecutor.kt b/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApiExecutor.kt new file mode 100644 index 0000000000..3ba3467a64 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/server/ServerApiExecutor.kt @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.api.server + +import org.session.libsession.messaging.file_server.FileServer +import org.thoughtcrime.securesms.api.ApiExecutor +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.SessionApiExecutor +import org.thoughtcrime.securesms.api.SessionApiRequest +import org.thoughtcrime.securesms.api.execute +import javax.inject.Inject + +class ServerApiRequest( + val serverBaseUrl: String, + val serverX25519PubKeyHex: String, + val api: ServerApi, +) { + constructor(fileServer: FileServer, api: ServerApi) : this( + serverBaseUrl = fileServer.url.toString(), + serverX25519PubKeyHex = fileServer.x25519PubKeyHex, + api = api, + ) + + override fun toString(): String { + return "ServerApiRequest(api=${api::class.java.simpleName}, serverBaseUrl='$serverBaseUrl')" + } +} + +typealias ServerApiResponse = Any + +typealias ServerApiExecutor = ApiExecutor, ServerApiResponse> + +class ServerApiExecutorImpl @Inject constructor( + private val apiExecutor: SessionApiExecutor, +) : ServerApiExecutor { + override suspend fun send( + ctx: ApiExecutorContext, + req: ServerApiRequest<*> + ): ServerApiResponse { + val resp = apiExecutor.execute( + ctx = ctx, + req = SessionApiRequest.HttpServerRequest( + req.api.buildRequest( + baseUrl = req.serverBaseUrl, + x25519PubKeyHex = req.serverX25519PubKeyHex, + ), + serverX25519PubKeyHex = req.serverX25519PubKeyHex, + ) + ) + + return req.api.processResponse( + executorContext = ctx, + baseUrl = req.serverBaseUrl, + response = resp.response + ) + } +} + +suspend inline fun ServerApiExecutor.execute( + req: ServerApiRequest, + ctx: ApiExecutorContext = ApiExecutorContext() +): ResponseType { + return send(ctx, req) as ResponseType +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/snode/AbstractSnodeApi.kt b/app/src/main/java/org/thoughtcrime/securesms/api/snode/AbstractSnodeApi.kt new file mode 100644 index 0000000000..d2b5c8a77e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/snode/AbstractSnodeApi.kt @@ -0,0 +1,103 @@ +package org.thoughtcrime.securesms.api.snode + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.session.libsession.snode.SwarmAuth +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.error.ErrorWithFailureDecision + +abstract class AbstractSnodeApi( + private val snodeApiErrorManager: SnodeApiErrorManager, +) : SnodeApi { + final override fun buildRequest(ctx: ApiExecutorContext): SnodeJsonRequest { + return SnodeJsonRequest( + method = methodName, + params = buildParams(ctx) + ) + } + + abstract val methodName: String + abstract fun buildParams(ctx: ApiExecutorContext): JsonElement + + final override suspend fun handleResponse( + ctx: ApiExecutorContext, + snode: Snode, + code: Int, + body: JsonElement? + ): RespType { + if (code in 200..299) { + return deserializeSuccessResponse(ctx, checkNotNull(body) { + "Expected non-null body for successful response" + }) + } else { + val failureContext = ctx.getOrPut(SnodeClientFailureKey) { + SnodeClientFailureContext( + previousErrorCode = null + ) + } + + val (error, decision) = snodeApiErrorManager.onFailure( + errorCode = code, + bodyText = (body as? JsonPrimitive)?.let { p -> + p.content.takeIf { p.isString } + }, + snode = snode, + ctx = failureContext + ) + + Log.d("SnodeApi", "Network error for a Snode endpoint ($snode), with status:${code} - error: $error") + + ctx.set(SnodeClientFailureKey, failureContext.copy(previousErrorCode = code)) + + if (decision != null) { + throw ErrorWithFailureDecision( + cause = error, + failureDecision = decision, + ) + } else { + throw error + } + } + } + + protected abstract fun deserializeSuccessResponse(ctx: ApiExecutorContext, body: JsonElement): RespType +} + +fun buildAuthenticatedParameters( + auth: SwarmAuth, + namespace: Int?, + timestamp: Long, + verificationData: ((namespaceText: String, timestamp: Long) -> Any)? = null, + builder: MutableMap.() -> Unit = {} +): JsonObject { + return JsonObject(buildMap { + this.builder() + + if (verificationData != null) { + val namespaceText = when (namespace) { + null, 0 -> "" + else -> namespace.toString() + } + + val verifyData = when (val v = verificationData(namespaceText, timestamp)) { + is String -> v.toByteArray() + is ByteArray -> v + else -> throw IllegalArgumentException("verificationData must return String or ByteArray") + } + + auth.sign(verifyData) + .forEach { (key, value) -> + this[key] = JsonPrimitive(value) + } + + put("timestamp", JsonPrimitive(timestamp)) + } + + put("pubkey", JsonPrimitive(auth.accountId.hexString)) + if (namespace != null && namespace != 0) put("namespace", JsonPrimitive(namespace)) + auth.ed25519PublicKeyHex?.let { put("pubkey_ed25519", JsonPrimitive(it)) } + }) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/snode/AlterTtlApi.kt b/app/src/main/java/org/thoughtcrime/securesms/api/snode/AlterTtlApi.kt new file mode 100644 index 0000000000..a1efe6be25 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/snode/AlterTtlApi.kt @@ -0,0 +1,66 @@ +package org.thoughtcrime.securesms.api.snode + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import org.session.libsession.network.SnodeClock +import org.session.libsession.snode.SwarmAuth +import org.thoughtcrime.securesms.api.ApiExecutorContext + +class AlterTtlApi @AssistedInject constructor( + @Assisted private val messageHashes: Collection, + @Assisted private val auth: SwarmAuth, + @Assisted private val alterType: AlterType, + @Assisted private val newExpiry: Long, + errorManager: SnodeApiErrorManager, + private val snodeClock: SnodeClock, +) : AbstractSnodeApi(errorManager) { + override fun deserializeSuccessResponse(ctx: ApiExecutorContext, body: JsonElement) {} + + override val methodName: String + get() = "expire" + + override fun buildParams(ctx: ApiExecutorContext): JsonElement { + return buildAuthenticatedParameters( + auth = auth, + namespace = null, + timestamp = snodeClock.currentTimeMillis(), + verificationData = { _, _ -> + buildString { + append(methodName) + append(alterType.value) + append(newExpiry.toString()) + messageHashes.forEach(this::append) + } + } + ) { + put("expiry", JsonPrimitive(newExpiry)) + put("messages", JsonArray(messageHashes.map(::JsonPrimitive))) + when (alterType) { + AlterType.Extend -> put("extend", JsonPrimitive(true)) + AlterType.Shorten -> put("shorten", JsonPrimitive(true)) + AlterType.Unspecified -> {} + } + } + + } + + enum class AlterType(val value: String) { + Extend("extend"), + Shorten("shorten"), + Unspecified("") + } + + @AssistedFactory + interface Factory { + fun create( + messageHashes: Collection, + auth: SwarmAuth, + alterType: AlterType, + newExpiry: Long + ): AlterTtlApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/snode/BatchApi.kt b/app/src/main/java/org/thoughtcrime/securesms/api/snode/BatchApi.kt new file mode 100644 index 0000000000..c941fd8042 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/snode/BatchApi.kt @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.api.snode + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.encodeToJsonElement +import org.session.libsession.snode.model.BatchResponse +import org.thoughtcrime.securesms.api.ApiExecutorContext + +class BatchApi @AssistedInject constructor( + @Assisted val requests: List, + private val json: Json, + errorManager: SnodeApiErrorManager, +) : AbstractSnodeApi(errorManager) { + override val methodName: String get() = "batch" + + override fun buildParams(ctx: ApiExecutorContext): JsonElement { + return json.encodeToJsonElement(Request(requests)) + } + + @Serializable + private class Request( + val requests: List + ) + + class Response( + val responses: List, + ) + + override fun deserializeSuccessResponse(ctx: ApiExecutorContext, body: JsonElement): Response { + val items = json.decodeFromJsonElement(BatchResponse.serializer(), body).results + return Response( + responses = items + ) + } + + @AssistedFactory + abstract class Factory { + abstract fun create(requests: List): BatchApi + + fun createFromApis(apis: List>): BatchApi { + return create(apis.map { it.buildRequest(ApiExecutorContext()) }) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/snode/DeleteAllMessageApi.kt b/app/src/main/java/org/thoughtcrime/securesms/api/snode/DeleteAllMessageApi.kt new file mode 100644 index 0000000000..20bd9fc564 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/snode/DeleteAllMessageApi.kt @@ -0,0 +1,96 @@ +package org.thoughtcrime.securesms.api.snode + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.long +import network.loki.messenger.libsession_util.ED25519 +import org.session.libsession.network.SnodeClock +import org.session.libsession.snode.SwarmAuth +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Hex +import org.thoughtcrime.securesms.api.ApiExecutorContext + +class DeleteAllMessageApi @AssistedInject constructor( + @Assisted private val auth: SwarmAuth, + errorManager: SnodeApiErrorManager, + private val snodeClock: SnodeClock, + private val json: Json, +) : AbstractSnodeApi>(errorManager) { + override val methodName: String get() = "delete_all" + + override fun deserializeSuccessResponse(ctx: ApiExecutorContext, body: JsonElement): Map { + val timestamp = requireNotNull(ctx.get(SignedRequestTimestampKey)) { + "Missing signed request timestamp in context. " + + "Are you sure you are giving us the same context you used to build the request?" + } + + return json.decodeFromJsonElement(body) + .swarm + .mapValues { (snodePubKeyHex, state) -> + !state.failed && state.verify( + snodePubKeyHex = snodePubKeyHex, + timestamp = timestamp, + userPublicKey = auth.accountId.hexString + ) + } + } + + override fun buildParams(ctx: ApiExecutorContext): JsonElement { + val timestamp = snodeClock.currentTimeMillis() + ctx.set(SignedRequestTimestampKey, timestamp) + + return buildAuthenticatedParameters( + auth = auth, + namespace = null, + verificationData = { _, t -> "${methodName}all$t" }, + timestamp = timestamp + ) { + put("namespace", JsonPrimitive("all")) + } + } + + @Serializable + private class Response( + val swarm: Map + ) + + @Serializable + private class SnodeDeleteState( + val failed: Boolean = false, + val code: Int? = null, + val reason: String? = null, + val deleted: Map> = emptyMap(), + val signature: String? = null + ) { + fun verify(snodePubKeyHex: String, + userPublicKey: String, + timestamp: Long): Boolean { + val hashes = deleted.flatMap { it.value } + val message = buildString { + append(userPublicKey) + append("$timestamp") + hashes.forEach(this::append) + }.toByteArray() + + return ED25519.verify( + ed25519PublicKey = Hex.fromStringCondensed(snodePubKeyHex), + signature = Base64.decode(signature), + message = message + ) + } + } + + private object SignedRequestTimestampKey : ApiExecutorContext.Key + + @AssistedFactory + interface Factory { + fun create(auth: SwarmAuth): DeleteAllMessageApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/snode/DeleteMessageApi.kt b/app/src/main/java/org/thoughtcrime/securesms/api/snode/DeleteMessageApi.kt new file mode 100644 index 0000000000..b65e81f7e4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/snode/DeleteMessageApi.kt @@ -0,0 +1,121 @@ +package org.thoughtcrime.securesms.api.snode + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.decodeFromJsonElement +import network.loki.messenger.libsession_util.ED25519 +import org.session.libsession.network.SnodeClock +import org.session.libsession.snode.SwarmAuth +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Hex +import org.thoughtcrime.securesms.api.ApiExecutorContext + +class DeleteMessageApi @AssistedInject constructor( + @Assisted private val swarmAuth: SwarmAuth, + @Assisted private val messageHashes: Collection, + private val json: Json, + private val snodeClock: SnodeClock, + errorManager: SnodeApiErrorManager +) : AbstractSnodeApi(errorManager) { + + override fun deserializeSuccessResponse(ctx: ApiExecutorContext, body: JsonElement): SuccessResponse { + val response: Response = json.decodeFromJsonElement(body) + + val totalSuccessSnodes = response.swarm + .asSequence() + .count { (snodePubKeyHex, deleteState) -> + !deleteState.failed && deleteState.verifyDeletedMessages( + requestedMessageHashes = messageHashes, + snodePubKey = snodePubKeyHex, + swarmPubKey = swarmAuth.accountId.hexString + ) + } + + check(totalSuccessSnodes > 0) { + "No snodes reported successful deletion of messages" + } + + return SuccessResponse( + numSnodeSuccessDeleted = totalSuccessSnodes, + numSnodeTotal = response.swarm.size + ) + } + + override val methodName: String + get() = "delete" + + override fun buildParams(ctx: ApiExecutorContext): JsonElement { + return buildAuthenticatedParameters( + auth = swarmAuth, + namespace = null, + timestamp = snodeClock.currentTimeMillis(), + verificationData = { _, _ -> + buildString { + append(methodName) + messageHashes.forEach(this::append) + } + } + ) { + this["messages"] = JsonArray(messageHashes.map(::JsonPrimitive)) + } + } + + data class SuccessResponse( + val numSnodeSuccessDeleted: Int, + val numSnodeTotal: Int + ) { + init { + check(numSnodeSuccessDeleted in 0..numSnodeTotal) { + "numSnodeSuccessDeleted must be between 0 and numSnodeTotal" + } + } + } + + @Serializable + private class Response( + val swarm: Map = emptyMap() + ) + + @Serializable + private class SnodeDeletionState( + val failed: Boolean = false, + val code: Int? = null, + val reason: String? = null, + val deleted: List = emptyList(), + val signature: String? = null + ) { + fun verifyDeletedMessages( + requestedMessageHashes: Collection, + snodePubKey: String, + swarmPubKey: String, + ): Boolean { + if (deleted.isEmpty() || signature.isNullOrBlank()) return false + + val message = buildString { + append(swarmPubKey) + requestedMessageHashes.forEach(this::append) + deleted.forEach(this::append) + }.toByteArray() + + return ED25519.verify( + ed25519PublicKey = Hex.fromStringCondensed(snodePubKey), + signature = Base64.decode(signature), + message = message + ) + } + } + + @AssistedFactory + interface Factory { + fun create( + swarmAuth: SwarmAuth, + messageHashes: Collection + ): DeleteMessageApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/snode/GetInfoApi.kt b/app/src/main/java/org/thoughtcrime/securesms/api/snode/GetInfoApi.kt new file mode 100644 index 0000000000..195ebfa09e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/snode/GetInfoApi.kt @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.api.snode + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import org.session.libsession.utilities.serializable.InstantAsMillisSerializer +import org.thoughtcrime.securesms.api.ApiExecutorContext +import java.time.Instant +import javax.inject.Inject + +class GetInfoApi @Inject constructor( + errorManager: SnodeApiErrorManager, + private val json: Json, +) : AbstractSnodeApi(errorManager) { + override fun deserializeSuccessResponse( + ctx: ApiExecutorContext, + body: JsonElement + ): InfoResponse { + return json.decodeFromJsonElement(body) + } + + override val methodName: String get() = "info" + override fun buildParams(ctx: ApiExecutorContext): JsonElement = JsonObject(emptyMap()) + + + @Serializable + class InfoResponse( + @Serializable(with = InstantAsMillisSerializer::class) + val timestamp: Instant + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/snode/GetSwarmApi.kt b/app/src/main/java/org/thoughtcrime/securesms/api/snode/GetSwarmApi.kt new file mode 100644 index 0000000000..752e82a6e5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/snode/GetSwarmApi.kt @@ -0,0 +1,60 @@ +package org.thoughtcrime.securesms.api.snode + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.api.ApiExecutorContext + +class GetSwarmApi @AssistedInject constructor( + @Assisted private val pubKey: String, + private val json: Json, + snodeApiErrorManager: SnodeApiErrorManager, +) : AbstractSnodeApi( + snodeApiErrorManager = snodeApiErrorManager, +) { + override val methodName: String + get() = "get_snodes_for_pubkey" + + override fun buildParams(ctx: ApiExecutorContext): JsonElement { + return JsonObject( + mapOf("pubkey" to JsonPrimitive(pubKey)) + ) + } + + override fun deserializeSuccessResponse(ctx: ApiExecutorContext, body: JsonElement): Response { + return json.decodeFromJsonElement(Response.serializer(), body) + } + + @Serializable + class Response(val snodes: List) + + @Serializable + class SnodeInfo( + val ip: String, + val port: Int, + @SerialName("pubkey_ed25519") + val ed25519PubKey: String, + @SerialName("pubkey_x25519") + val x25519PubKey: String, + ) { + fun toSnode(): Snode? { + return Snode( + ip.takeUnless { it == "0.0.0.0" }?.let { "https://$it" } ?: return null, + port, + Snode.KeySet(ed25519PubKey, x25519PubKey), + ) + } + } + + @AssistedFactory + interface Factory { + fun create(pubKey: String): GetSwarmApi + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..db0fe7da31 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/snode/ListSnodeApi.kt @@ -0,0 +1,140 @@ +package org.thoughtcrime.securesms.api.snode + +import android.util.Base64 +import android.util.Base64InputStream +import androidx.compose.ui.platform.LocalGraphicsContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import network.loki.messenger.libsession_util.Curve25519 +import okio.EOFException +import okio.buffer +import okio.source +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.api.ApiExecutorContext +import java.net.Inet4Address +import javax.inject.Inject + +class ListSnodeApi @Inject constructor( + errorManager: SnodeApiErrorManager, +) : AbstractSnodeApi(errorManager) { + override fun deserializeSuccessResponse( + ctx: ApiExecutorContext, + body: JsonElement + ): Response { + val b64 = (body as JsonPrimitive).content + + val ed25519PubKeyBuffer = ByteArray(32) + val zeroEd25519PubKey = ByteArray(ed25519PubKeyBuffer.size) + val ipBytes = ByteArray(4) + val versionBytes = ByteArray(3) + + val snodes = mutableListOf() + + // Decode the base64 string and read the snode info out from the binary as: + // - 32 byte Ed25519 pubkey + // - 8 byte u64 swarm ID, in network order. + // - 4 bytes public ip, in network (big-endian) order (i.e. 1.2.3.4 is "\x01\x02\x03\x04") + // - 2 byte https port, in network order (i.e. port 259 is "\x01\x03") + // - 2 byte OMQ (TCP)/QUIC (UDP) port, in network order + // - 3 byte storage server version, e.g. 1.2.3 is "\x01\x02\x03" + b64.byteInputStream().use { rawStream -> + Base64InputStream(rawStream, Base64.DEFAULT) + .source() + .buffer() + .use { bufferedSource -> + while (true) { + try { + bufferedSource.readFully(ed25519PubKeyBuffer) + val swarmId = bufferedSource.readLong() + bufferedSource.readFully(ipBytes) + val httpsPort = bufferedSource.readShort().toUShort().toInt() + val omqPort = bufferedSource.readShort().toUShort().toInt() + bufferedSource.readFully(versionBytes) + + val x25519PubKey = runCatching { + if (!ed25519PubKeyBuffer.contentEquals(zeroEd25519PubKey)) { + Curve25519.pubKeyFromED25519(ed25519PubKeyBuffer) + } else { + error("Invalid ed25519 pubkey") + } + }.onFailure { + Log.w("ListSnodeApi", "Invalid ed25519 pub key", it) + }.getOrNull() ?: continue + + snodes += SnodeInfo( + ip = Inet4Address.getByAddress(ipBytes).hostAddress!!, + port = httpsPort, + ed25519PubKey = Hex.toStringCondensed(ed25519PubKeyBuffer), + x25519PubKey = Hex.toStringCondensed(x25519PubKey) + ) + } catch (_: EOFException) { + break + } + } + } + } + + + return Response(snodes) + } + + override val methodName: String get() = "active_nodes_bin" + override fun buildParams(ctx: ApiExecutorContext): JsonElement = buildRequestJson() + + @Serializable + class Response( + @SerialName("service_node_states") + val nodes: List + ) { + fun toSnodeList(): List { + return nodes.mapNotNull { it.toSnode() } + } + } + + @Serializable + class SnodeInfo( + @SerialName(KEY_IP) + val ip: String? = null, + @SerialName(KEY_PORT) + val port: Int? = null, + @SerialName(KEY_ED25519) + val ed25519PubKey: String, + @SerialName(KEY_X25519) + val x25519PubKey: String, + ) { + fun toSnode(): Snode? { + return Snode( + 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), + ) + } + } + + companion object { + private const val KEY_IP = "public_ip" + private const val KEY_PORT = "storage_port" + private const val KEY_X25519 = "pubkey_x25519" + private const val KEY_ED25519 = "pubkey_ed25519" + + fun buildRequestJson(): JsonElement { + return JsonObject(mapOf( + "active_only" to JsonPrimitive(true), + "fields" to JsonArray( + listOf( + JsonPrimitive(KEY_IP), + JsonPrimitive(KEY_PORT), + JsonPrimitive(KEY_X25519), + JsonPrimitive(KEY_ED25519), + ) + ) + )) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/snode/OnsResolveApi.kt b/app/src/main/java/org/thoughtcrime/securesms/api/snode/OnsResolveApi.kt new file mode 100644 index 0000000000..86e84c96b3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/snode/OnsResolveApi.kt @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.api.snode + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import network.loki.messenger.libsession_util.Hash +import network.loki.messenger.libsession_util.SessionEncrypt +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Hex +import org.thoughtcrime.securesms.api.ApiExecutorContext + +class OnsResolveApi @AssistedInject constructor( + @Assisted private val name: String, + errorManager: SnodeApiErrorManager, + private val json: Json, +) : AbstractSnodeApi(errorManager) { + override val methodName: String get() = "oxend_request" + + override fun buildParams(ctx: ApiExecutorContext): JsonElement { + val normalizedName = name.lowercase() + + return json.encodeToJsonElement(OxendRequest( + endpoint = "ons_resolve", + params = JsonObject(mapOf( + "type" to JsonPrimitive(0), + "name_hash" to JsonPrimitive(Base64.encodeBytes(Hash.hash32(normalizedName.toByteArray()))) + )) + )) + } + + override fun deserializeSuccessResponse(ctx: ApiExecutorContext, body: JsonElement): String { + val response = json.decodeFromJsonElement(body) + + val ciphertext = Hex.fromStringCondensed(response.result.encryptedValue) + val nonce = Hex.fromStringCondensed(response.result.nonce) + + return SessionEncrypt.decryptOnsResponse( + lowercaseName = name.lowercase(), + ciphertext = ciphertext, + nonce = nonce + ) + } + + + @Serializable + private class OxendRequest( + val endpoint: String, + val params: JsonObject, + ) + + @Serializable + private class OxendResponse( + val result: OnsRecord + ) + + @Serializable + private class OnsRecord( + @SerialName("encrypted_value") + val encryptedValue: String, + val nonce: String, + ) + + @AssistedFactory + interface Factory { + fun create(name: String): OnsResolveApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/snode/RetrieveMessageApi.kt b/app/src/main/java/org/thoughtcrime/securesms/api/snode/RetrieveMessageApi.kt new file mode 100644 index 0000000000..f6fab3a505 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/snode/RetrieveMessageApi.kt @@ -0,0 +1,56 @@ +package org.thoughtcrime.securesms.api.snode + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.decodeFromJsonElement +import org.session.libsession.network.SnodeClock +import org.session.libsession.snode.SwarmAuth +import org.session.libsession.snode.model.RetrieveMessageResponse +import org.thoughtcrime.securesms.api.ApiExecutorContext + +class RetrieveMessageApi @AssistedInject constructor( + @Assisted private val namespace: Int, + @Assisted private val auth: SwarmAuth, + @Assisted private val lastHash: String?, + @Assisted private val maxSize: Int?, + private val snodeClock: SnodeClock, + private val json: Json, + snodeApiErrorManager: SnodeApiErrorManager, +) : AbstractSnodeApi( + snodeApiErrorManager = snodeApiErrorManager, +) { + override val methodName: String + get() = "retrieve" + + override fun buildParams(ctx: ApiExecutorContext): JsonElement { + return buildAuthenticatedParameters( + auth = auth, + namespace = namespace, + timestamp = snodeClock.currentTimeMillis(), + verificationData = { namespaceText, timestamp -> + "${methodName}${namespaceText}$timestamp" + } + ) { + lastHash?.let { put("last_hash", JsonPrimitive(it)) } + maxSize?.let { put("max_size", JsonPrimitive(it)) } + } + } + + override fun deserializeSuccessResponse(ctx: ApiExecutorContext, body: JsonElement): RetrieveMessageResponse { + return json.decodeFromJsonElement(body) + } + + @AssistedFactory + interface Factory { + fun create( + namespace: Int, + auth: SwarmAuth, + lastHash: String?, + maxSize: Int? = null + ): RetrieveMessageApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/snode/RevokeSubKeyApi.kt b/app/src/main/java/org/thoughtcrime/securesms/api/snode/RevokeSubKeyApi.kt new file mode 100644 index 0000000000..e431436daf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/snode/RevokeSubKeyApi.kt @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.api.snode + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import org.session.libsession.network.SnodeClock +import org.session.libsession.snode.SwarmAuth +import org.session.libsignal.utilities.Base64 +import org.thoughtcrime.securesms.api.ApiExecutorContext + +class RevokeSubKeyApi @AssistedInject constructor( + @Assisted private val auth: SwarmAuth, + @Assisted private val subAccountTokens: List, + errorManager: SnodeApiErrorManager, + private val snodeClock: SnodeClock, +) : AbstractSnodeApi(errorManager) { + override fun deserializeSuccessResponse(ctx: ApiExecutorContext, body: JsonElement) = Unit + override val methodName: String get() = "revoke_subaccount" + + override fun buildParams(ctx: ApiExecutorContext): JsonElement { + return buildAuthenticatedParameters( + auth = auth, + timestamp = snodeClock.currentTimeMillis(), + namespace = null, + verificationData = { _, t -> + subAccountTokens.fold( + "$methodName$t".toByteArray() + ) { acc, subAccount -> acc + subAccount } + } + ) { + put("revoke", JsonArray(subAccountTokens.map { + JsonPrimitive(Base64.encodeBytes(it)) + })) + } + } + + @AssistedFactory + interface Factory { + fun create( + auth: SwarmAuth, + subAccountTokens: List, + ): RevokeSubKeyApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/snode/SnodeApi.kt b/app/src/main/java/org/thoughtcrime/securesms/api/snode/SnodeApi.kt new file mode 100644 index 0000000000..7ec37d28a3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/snode/SnodeApi.kt @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.api.snode + +import kotlinx.serialization.json.JsonElement +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.api.ApiExecutorContext + +typealias SnodeApiResponse = Any + +interface SnodeApi { + fun buildRequest(ctx: ApiExecutorContext): SnodeJsonRequest + suspend fun handleResponse( + ctx: ApiExecutorContext, + snode: Snode, + code: Int, + body: JsonElement? + ): RespType +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/snode/SnodeApiBatcher.kt b/app/src/main/java/org/thoughtcrime/securesms/api/snode/SnodeApiBatcher.kt new file mode 100644 index 0000000000..f3fcc25cb1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/snode/SnodeApiBatcher.kt @@ -0,0 +1,56 @@ +package org.thoughtcrime.securesms.api.snode + +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.batch.Batcher +import javax.inject.Inject + +class SnodeApiBatcher @Inject constructor( + private val batchAPIFactory: BatchApi.Factory, +) : Batcher, SnodeApiResponse, SnodeJsonRequest> { + override fun constructBatchRequest( + firstRequest: SnodeApiRequest<*>, + intermediateRequests: List + ): SnodeApiRequest<*> { + return SnodeApiRequest( + snode = firstRequest.snode, + api = batchAPIFactory.create(intermediateRequests) + ) + } + + override fun transformRequestForBatching( + ctx: ApiExecutorContext, + req: SnodeApiRequest<*> + ): SnodeJsonRequest { + return req.api.buildRequest(ctx) + } + + override fun batchKey(req: SnodeApiRequest<*>): Any? { + // Shouldn't batch the batch requests themselves + if (req.api is BatchApi) { + return null + } + + return req.snode.ed25519Key + } + + override suspend fun deconstructBatchResponse( + requests: List>>, + response: SnodeApiResponse + ): List> { + response as BatchApi.Response + + return requests.indices.map { i -> + val (ctx, request) = requests[i] + val result = response.responses[i] + + runCatching { + request.api.handleResponse( + ctx = ctx, + snode = request.snode, + code = result.code, + body = result.body, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/snode/SnodeApiErrorManager.kt b/app/src/main/java/org/thoughtcrime/securesms/api/snode/SnodeApiErrorManager.kt new file mode 100644 index 0000000000..ce3a25b470 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/snode/SnodeApiErrorManager.kt @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.api.snode + +import org.session.libsession.network.SnodeClock +import org.session.libsession.network.model.FailureDecision +import org.session.libsession.network.onion.PathManager +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.error.UnhandledStatusCodeException +import javax.inject.Inject +import javax.inject.Singleton + + +@Singleton +class SnodeApiErrorManager @Inject constructor( + private val pathManager: PathManager, + private val snodeClock: SnodeClock, +) { + + /** + * Inspect the error code coming from an [SnodeApi], + * returning a [Throwable] for error propagating and a [FailureDecision] if an error handling + * decision is made. + */ + suspend fun onFailure(snode: Snode, + errorCode: Int, + bodyText: String?, + ctx: SnodeClientFailureContext): Pair { + // 406 is 'Clock out of sync' for a snode destination + if (errorCode == 406) { + // if this is the first time we got a COS, retry, since we should have resynced the clock + Log.w("Onion Request", "Clock out of sync (code: $errorCode) for destination snode ${snode.address} - Local Snode clock at ${snodeClock.currentTime()} - First time? ${ctx.previousErrorCode == null}") + if (ctx.previousErrorCode == 406) { + // if we already got a COS, and syncing the clock wasn't enough + // we should consider the destination snode faulty. Drop from pool and swarm swarm and retry + // handleBadSnode will handle removing the snode from the paths/pool/swarm and clean up the strikes + // if needed + pathManager.handleBadSnode(snode = snode, forceRemove = true) + return RuntimeException("Clock out of sync received from $snode") to FailureDecision.Retry + } else { + // reset the clock + val resync = runCatching { + snodeClock.resyncClock() + }.getOrDefault(false) + + // only retry if we were able to resync the clock + return RuntimeException("Clock out of sync received from $snode") to (if (resync) FailureDecision.Retry else FailureDecision.Fail) + } + } + + // Unparseable data: 502 + "oxend returned unparsable data" + if (errorCode == 502 && bodyText?.contains("oxend returned unparsable data", ignoreCase = true) == true) { + // penalise the destination snode and retry + pathManager.handleBadSnode(snode = snode, forceRemove = true) + return RuntimeException("Unparseable data") to FailureDecision.Retry + } + + // Destination snode not ready + if(errorCode == 503 && bodyText?.contains("Snode not ready", ignoreCase = true) == true){ + // penalise the destination snode and retry + pathManager.handleBadSnode(snode = snode) + return RuntimeException("Snode not ready") to FailureDecision.Retry + } + + return UnhandledStatusCodeException(errorCode, "Snode ${snode.address}", bodyText) to null + } +} + +object SnodeClientFailureKey : ApiExecutorContext.Key + +data class SnodeClientFailureContext( + val previousErrorCode: Int? = null, +) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/snode/SnodeApiExecutor.kt b/app/src/main/java/org/thoughtcrime/securesms/api/snode/SnodeApiExecutor.kt new file mode 100644 index 0000000000..af7853eb31 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/snode/SnodeApiExecutor.kt @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.api.snode + +import kotlinx.serialization.ExperimentalSerializationApi +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.api.ApiExecutor +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.SessionApiExecutor +import org.thoughtcrime.securesms.api.SessionApiRequest +import org.thoughtcrime.securesms.api.execute +import javax.inject.Inject + +data class SnodeApiRequest( + val snode: Snode, + val api: SnodeApi +) { + override fun toString(): String { + return "SnodeApiRequest(${api::class.java.simpleName}, snode=${snode})" + } +} + +/** + * An [ApiExecutor] for sending [SnodeApi]s over to snodes. + */ +typealias SnodeApiExecutor = ApiExecutor, SnodeApiResponse> + +/** + * Default implementation of [SnodeApiExecutor]. + */ +class SnodeApiExecutorImpl @Inject constructor( + private val executor: SessionApiExecutor, +) : SnodeApiExecutor { + @OptIn(ExperimentalSerializationApi::class) + override suspend fun send( + ctx: ApiExecutorContext, + req: SnodeApiRequest<*> + ): SnodeApiResponse { + val request = req.api.buildRequest(ctx) + val response = executor.execute( + ctx = ctx, + req = SessionApiRequest.SnodeJsonRPC( + snode = req.snode, + request = request + )) + + return req.api.handleResponse( + ctx = ctx, + snode = req.snode, + code = response.code, + body = response.bodyAsJson + ) + } +} + +suspend inline fun SnodeApiExecutor.execute( + req: SnodeApiRequest, + ctx: ApiExecutorContext = ApiExecutorContext(), +): Res where Res : SnodeApiResponse, Req : SnodeApi { + return send(ctx, req) as Res +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/snode/SnodeJsonRequest.kt b/app/src/main/java/org/thoughtcrime/securesms/api/snode/SnodeJsonRequest.kt new file mode 100644 index 0000000000..cad3331340 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/snode/SnodeJsonRequest.kt @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.api.snode + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** + * A generic JSON-RPC request format to be sent to a service node. + */ +@Serializable +class SnodeJsonRequest( + val method: String, + val params: JsonElement, +) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/snode/StoreMessageApi.kt b/app/src/main/java/org/thoughtcrime/securesms/api/snode/StoreMessageApi.kt new file mode 100644 index 0000000000..9407110d01 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/snode/StoreMessageApi.kt @@ -0,0 +1,64 @@ +package org.thoughtcrime.securesms.api.snode + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.session.libsession.network.SnodeClock +import org.session.libsession.snode.SnodeMessage +import org.session.libsession.snode.SwarmAuth +import org.session.libsession.snode.model.StoreMessageResponse +import org.thoughtcrime.securesms.api.ApiExecutorContext + +class StoreMessageApi @AssistedInject constructor( + @Assisted private val message: SnodeMessage, + @Assisted private val auth: SwarmAuth?, + @Assisted private val namespace: Int, + errorManager: SnodeApiErrorManager, + private val snodeClock: SnodeClock, + private val json: Json, +) : AbstractSnodeApi( + snodeApiErrorManager = errorManager +) { + override fun deserializeSuccessResponse(ctx: ApiExecutorContext, body: JsonElement): StoreMessageResponse { + return json.decodeFromJsonElement(StoreMessageResponse.serializer(), body) + } + + override val methodName: String + get() = "store" + + override fun buildParams(ctx: ApiExecutorContext): JsonElement { + return if (auth != null) { + check(auth.accountId.hexString == message.recipient) { + "Message sent to ${message.recipient} but authenticated with ${auth.accountId.hexString}" + } + + val timestamp = snodeClock.currentTimeMillis() + + buildAuthenticatedParameters( + auth = auth, + namespace = namespace, + verificationData = { ns, t -> "$methodName$ns$t" }, + timestamp = timestamp + ) { + put("sig_timestamp", JsonPrimitive(timestamp)) + putAll(message.toJSON().mapValues { JsonPrimitive(it.value) }) + } + } else { + JsonObject( + buildMap { + putAll(message.toJSON().mapValues { JsonPrimitive(it.value) }) + if (namespace != 0) put("namespace", JsonPrimitive(namespace)) + } + ) + } + } + + @AssistedFactory + interface Factory { + fun create(message: SnodeMessage, auth: SwarmAuth?, namespace: Int): StoreMessageApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/snode/UnrevokeSubKeyApi.kt b/app/src/main/java/org/thoughtcrime/securesms/api/snode/UnrevokeSubKeyApi.kt new file mode 100644 index 0000000000..839c25d234 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/snode/UnrevokeSubKeyApi.kt @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.api.snode + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import org.session.libsession.network.SnodeClock +import org.session.libsession.snode.SwarmAuth +import org.session.libsignal.utilities.Base64 +import org.thoughtcrime.securesms.api.ApiExecutorContext + +class UnrevokeSubKeyApi @AssistedInject constructor( + @Assisted private val auth: SwarmAuth, + @Assisted private val subAccountTokens: List, + errorManager: SnodeApiErrorManager, + private val snodeClock: SnodeClock, +) : AbstractSnodeApi(errorManager) { + override fun deserializeSuccessResponse(ctx: ApiExecutorContext, body: JsonElement) = Unit + override val methodName: String get() = "unrevoke_subaccount" + + override fun buildParams(ctx: ApiExecutorContext): JsonElement { + return buildAuthenticatedParameters( + auth = auth, + timestamp = snodeClock.currentTimeMillis(), + namespace = null, + verificationData = { _, t -> + subAccountTokens.fold( + "$methodName$t".toByteArray() + ) { acc, subAccount -> acc + subAccount } + } + ) { + put("unrevoke", JsonArray(subAccountTokens.map { + JsonPrimitive(Base64.encodeBytes(it)) + })) + } + } + + @AssistedFactory + interface Factory { + fun create( + auth: SwarmAuth, + subAccountTokens: List, + ): UnrevokeSubKeyApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/swarm/SwarmApiExecutor.kt b/app/src/main/java/org/thoughtcrime/securesms/api/swarm/SwarmApiExecutor.kt new file mode 100644 index 0000000000..44b2e4ff63 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/swarm/SwarmApiExecutor.kt @@ -0,0 +1,113 @@ +package org.thoughtcrime.securesms.api.swarm + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import org.session.libsession.network.model.FailureDecision +import org.session.libsession.network.snode.SwarmDirectory +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.api.ApiExecutor +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.error.ErrorWithFailureDecision +import org.thoughtcrime.securesms.api.error.UnhandledStatusCodeException +import org.thoughtcrime.securesms.api.snode.SnodeApi +import org.thoughtcrime.securesms.api.snode.SnodeApiExecutor +import org.thoughtcrime.securesms.api.snode.SnodeApiRequest +import org.thoughtcrime.securesms.api.snode.SnodeApiResponse + +class SwarmApiRequest( + val swarmPubKeyHex: String, + val api: SnodeApi, + + /** + * When set, this snode will be used for the swarm request. If the snode is later found + * to be not part of the swarm, it will be removed from our swarm storage, and the executor + * will not try to pick a different one for retries unless this key is removed from the context. + */ + val swarmNodeOverride: Snode? = null, +) + +/** + * An [ApiExecutor] that routes [SnodeApi]s to a swarm of snodes, handling snode selection + * and removal of snodes that are no longer part of the swarm. + */ +typealias SwarmApiExecutor = ApiExecutor, SnodeApiResponse> + +suspend inline fun SwarmApiExecutor.execute( + req: SwarmApiRequest, + ctx: ApiExecutorContext = ApiExecutorContext(), +): Res where Res : SnodeApiResponse, Req : SnodeApi { + return send(ctx, req) as Res +} + +/** + * Default implementation of [SwarmApiExecutor]. + */ +class SwarmApiExecutorImpl @AssistedInject constructor( + @Assisted private val snodeApiExecutor: SnodeApiExecutor, + private val swarmDirectory: SwarmDirectory, + private val swarmSnodeSelector: SwarmSnodeSelector, +) : SwarmApiExecutor { + override suspend fun send( + ctx: ApiExecutorContext, + req: SwarmApiRequest<*> + ): SnodeApiResponse { + val lastUsedSnode = ctx.get(LastUsedSnodeKey) + + // Pick a snode from the swarm if we don't already have one cached (across retry) + val snode = req.swarmNodeOverride ?: lastUsedSnode ?: run { + val targetSnode = swarmSnodeSelector.selectSnode(req.swarmPubKeyHex) + Log.d(TAG, "Selected snode $targetSnode for publicKey=${req.swarmPubKeyHex}") + ctx.set(LastUsedSnodeKey, targetSnode) + targetSnode + } + + try { + return snodeApiExecutor.send(ctx, SnodeApiRequest(snode, req.api)) + } catch (e: UnhandledStatusCodeException) { + if (e.code == 421) { + Log.d( + TAG, + "Snode $snode is no longer part of swarm for publicKey=${req.swarmPubKeyHex}, updating swarm" + ) + val updated = swarmDirectory.updateSwarmFromResponse( + swarmPublicKey = req.swarmPubKeyHex, + errorResponseBody = e.bodyText, + ) + + if (!updated) { + swarmDirectory.dropSnodeFromSwarmIfNeeded( + snode = snode, + swarmPublicKey = req.swarmPubKeyHex + ) + } + + // drop the cached snode so we pick a new one upon retry + ctx.remove(LastUsedSnodeKey) + + throw ErrorWithFailureDecision( + cause = RuntimeException("Snode $snode dropped from swarm"), + failureDecision = if (req.swarmNodeOverride == null) FailureDecision.Retry else FailureDecision.Fail, + ) + } else { + throw e + } + } + } + + /** + * Stores the last used snode for the swarm request, to be reused across retries. + */ + private object LastUsedSnodeKey : ApiExecutorContext.Key + + @AssistedFactory + interface Factory { + fun create(snodeApiExecutor: SnodeApiExecutor): SwarmApiExecutorImpl + } + + companion object { + private const val TAG = "SwarmApiExecutor" + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/api/swarm/SwarmSnodeSelector.kt b/app/src/main/java/org/thoughtcrime/securesms/api/swarm/SwarmSnodeSelector.kt new file mode 100644 index 0000000000..cabb9c36f1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/api/swarm/SwarmSnodeSelector.kt @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.api.swarm + +import androidx.collection.arraySetOf +import org.session.libsession.network.snode.SwarmDirectory +import org.session.libsignal.utilities.Snode +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +private typealias SwarmPubKeyHex = String + +/** + * An algorithm for selecting a snode from a swarm, ensuring that swarm snodes are used evenly and + * randomly across multiple [selectSnode]. + */ +class SwarmSnodeSelector @Inject constructor( + private val swarmDirectory: SwarmDirectory, +) { + private class SwarmSelectionState( + val usedSnodeEd25519PubKey: MutableSet, + ) + + private val selectionStates = ConcurrentHashMap() + + suspend fun selectSnode(swarmPubKey: String): Snode { + val selectionState = selectionStates.getOrPut(swarmPubKey) { + SwarmSelectionState(arraySetOf()) + } + + val swarmNodes = swarmDirectory.getSwarm(swarmPubKey) + + check(swarmNodes.isNotEmpty()) { + "Swarm is empty for pubkey=$swarmPubKey" + } + + return synchronized(selectionState) { + val available = swarmNodes.filterNot { it.ed25519Key in selectionState.usedSnodeEd25519PubKey } + + val selected = if (available.isEmpty()) { + // All snodes have been used, reset and start over + selectionState.usedSnodeEd25519PubKey.clear() + swarmNodes.random() + } else { + available.random() + } + + selectionState.usedSnodeEd25519PubKey += selected.ed25519Key + selected + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarDownloadManager.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarDownloadManager.kt index ac2bffe579..feb801b262 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarDownloadManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarDownloadManager.kt @@ -8,15 +8,20 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.HttpUrl.Companion.toHttpUrl import org.session.libsession.avatars.AvatarHelper -import org.session.libsession.messaging.file_server.FileServerApi -import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.messaging.file_server.FileDownloadApi +import org.session.libsession.messaging.file_server.FileServerApis +import org.session.libsession.messaging.open_groups.api.CommunityApiExecutor +import org.session.libsession.messaging.open_groups.api.CommunityApiRequest +import org.session.libsession.messaging.open_groups.api.CommunityFileDownloadApi +import org.session.libsession.messaging.open_groups.api.execute import org.session.libsession.utilities.AESGCM import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.RemoteFile +import org.session.libsession.utilities.withGroupConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.ByteArraySlice @@ -24,10 +29,14 @@ import org.session.libsignal.utilities.ByteArraySlice.Companion.view import org.session.libsignal.utilities.ByteArraySlice.Companion.write import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.api.error.UnhandledStatusCodeException +import org.thoughtcrime.securesms.api.server.ServerApiExecutor +import org.thoughtcrime.securesms.api.server.ServerApiRequest +import org.thoughtcrime.securesms.api.server.execute import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.RecipientSettingsDatabase import org.thoughtcrime.securesms.util.DateUtils.Companion.millsToInstant -import org.thoughtcrime.securesms.util.getRootCause +import org.thoughtcrime.securesms.util.findCause import java.io.File import java.io.InputStream import java.security.MessageDigest @@ -43,12 +52,14 @@ class AvatarDownloadManager @Inject constructor( @param:ApplicationContext private val context: Context, private val localEncryptedFileOutputStreamFactory: LocalEncryptedFileOutputStream.Factory, private val localEncryptedFileInputStreamFactory: LocalEncryptedFileInputStream.Factory, - private val prefs: TextSecurePreferences, private val recipientSettingsDatabase: RecipientSettingsDatabase, private val configFactory: ConfigFactoryProtocol, - private val fileServerApi: FileServerApi, private val attachmentProcessor: AttachmentProcessor, private val loginStateRepository: LoginStateRepository, + private val serverApiExecutor: ServerApiExecutor, + private val fileDownloadApiFactory: FileDownloadApi.Factory, + private val communityApiExecutor: CommunityApiExecutor, + private val communityFileDownloadApiFactory: CommunityFileDownloadApi.Factory, ) { /** * A map of mutexes to synchronize downloads for each remote file. @@ -111,8 +122,8 @@ class AvatarDownloadManager @Inject constructor( val (bytes, meta) = try { downloadAndDecryptFile(file) } catch (e: Exception) { - if (e.getRootCause() != null || - e.getRootCause()?.statusCode == 404 + if (e.findCause() != null || + e.findCause()?.code == 404 ) { Log.w(TAG, "Download failed permanently for file $file", e) // Write an empty file with a permanent error metadata if the download failed permanently. @@ -231,26 +242,31 @@ class AvatarDownloadManager @Inject constructor( } } - val result = fileServerApi.parseAttachmentUrl(file.url.toHttpUrl()) + val result = FileServerApis.parseAttachmentUrl(file.url.toHttpUrl()) - val response = fileServerApi.download( - fileId = result.fileId, - fileServer = result.fileServer, + val response = serverApiExecutor.execute( + ServerApiRequest( + fileServer = result.fileServer, + api = fileDownloadApiFactory.create( + fileId = result.fileId + ) + ) ) + val data = response.data.toByteArraySlice() Log.d(TAG, "Downloaded file from file server: $file") // Decrypt data val decrypted = if (result.usesDeterministicEncryption) { attachmentProcessor.decryptDeterministically( - ciphertext = response.body, + ciphertext = data, key = file.key.data ) } else { AESGCM.decrypt( - ivAndCiphertext = response.body.data, - offset = response.body.offset, - len = response.body.len, + ivAndCiphertext = data.data, + offset = data.offset, + len = data.len, symmetricKey = file.key.data ).view() } @@ -260,11 +276,16 @@ class AvatarDownloadManager @Inject constructor( } is RemoteFile.Community -> { - val data = OpenGroupApi.download( - fileId = file.fileId, - room = file.roomId, - server = file.communityServerBaseUrl - ) + val data = communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = file.communityServerBaseUrl, + api = communityFileDownloadApiFactory.create( + room = file.roomId, + fileId = file.fileId, + requiresSigning = true, + ) + ) + ).toByteArraySlice() data to FileMetadata() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt index 0c1aca9c25..f8a272a5aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt @@ -21,17 +21,23 @@ import okhttp3.HttpUrl.Companion.toHttpUrl import okio.BufferedSource import okio.buffer import okio.source -import org.session.libsession.messaging.file_server.FileServerApi -import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.messaging.file_server.FileRenewApi +import org.session.libsession.messaging.file_server.FileServerApis import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemoteFile +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.api.error.UnhandledStatusCodeException +import org.thoughtcrime.securesms.api.server.ServerApiExecutor +import org.thoughtcrime.securesms.api.server.ServerApiRequest +import org.thoughtcrime.securesms.api.server.execute import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.DateUtils.Companion.secondsToInstant import org.thoughtcrime.securesms.util.ImageUtils +import org.thoughtcrime.securesms.util.findCause import java.time.Duration import java.time.Instant import java.util.concurrent.TimeUnit @@ -51,7 +57,8 @@ class AvatarReuploadWorker @AssistedInject constructor( private val configFactory: ConfigFactoryProtocol, private val avatarUploadManager: Lazy, private val localEncryptedFileInputStreamFactory: LocalEncryptedFileInputStream.Factory, - private val fileServerApi: FileServerApi + private val serverApiExecutor: ServerApiExecutor, + private val renewApiFactory: FileRenewApi.Factory, ) : CoroutineWorker(context, params) { /** @@ -119,21 +126,22 @@ class AvatarReuploadWorker @AssistedInject constructor( } // Otherwise, we only need to renew the same avatar on the server - val parsed = fileServerApi.parseAttachmentUrl(profile.url.toHttpUrl()) + val parsed = FileServerApis.parseAttachmentUrl(profile.url.toHttpUrl()) log("Renewing user avatar on ${parsed.fileServer}") try { - fileServerApi.renew( - fileId = parsed.fileId, - fileServer = parsed.fileServer, - ) + serverApiExecutor.execute(ServerApiRequest( + serverBaseUrl = parsed.fileServer.url.toString(), + serverX25519PubKeyHex = parsed.fileServer.x25519PubKeyHex, + api = renewApiFactory.create(fileId = parsed.fileId) + )) } catch (e: CancellationException) { throw e } catch (e: Exception) { // When renew fails, we will try to re-upload the avatar if: // 1. The file is expired (we have the record of this file's expiry time), or // 2. The last update was more than 12 days ago. - if ((e is NonRetryableException || e is OnionRequestAPI.HTTPRequestFailedAtDestinationException)) { + if ((e is NonRetryableException || e.findCause() != null)) { val now = Instant.now() if (fileExpiry?.isBefore(now) == true || (lastUpdated?.isBefore(now.minus(Duration.ofDays(12)))) == true) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt index a20b473e74..0222ab4641 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt @@ -1,30 +1,30 @@ package org.thoughtcrime.securesms.attachments import android.app.Application -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.libsession_util.encrypt.Attachments import network.loki.messenger.libsession_util.util.Bytes -import org.session.libsession.messaging.file_server.FileServerApi +import org.session.libsession.messaging.file_server.FileServerApis +import org.session.libsession.messaging.file_server.FileUploadApi import org.session.libsession.utilities.AESGCM import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.Util import org.session.libsession.utilities.recipients.RemoteFile import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemoteFile +import org.session.libsession.utilities.withMutableUserConfigs import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.auth.LoginStateRepository +import org.thoughtcrime.securesms.api.server.ServerApiExecutor +import org.thoughtcrime.securesms.api.server.ServerApiRequest +import org.thoughtcrime.securesms.api.server.execute +import org.thoughtcrime.securesms.auth.AuthAwareComponent +import org.thoughtcrime.securesms.auth.LoggedInState import org.thoughtcrime.securesms.debugmenu.DebugLogGroup -import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import org.thoughtcrime.securesms.util.AnimatedImageUtils import org.thoughtcrime.securesms.util.castAwayType import javax.inject.Inject import javax.inject.Singleton @@ -40,33 +40,23 @@ class AvatarUploadManager @Inject constructor( private val application: Application, private val configFactory: ConfigFactoryProtocol, private val prefs: TextSecurePreferences, - @ManagerScope scope: CoroutineScope, private val localEncryptedFileOutputStreamFactory: LocalEncryptedFileOutputStream.Factory, - private val fileServerApi: FileServerApi, private val attachmentProcessor: AttachmentProcessor, - loginStateRepository: LoginStateRepository, -) : OnAppStartupComponent { - init { - // Manage scheduling/cancellation of the AvatarReuploadWorker based on login state - scope.launch { - combine( - loginStateRepository.loggedInState - .map { it != null } - .distinctUntilChanged(), - TextSecurePreferences._events.filter { it == TextSecurePreferences.DEBUG_AVATAR_REUPLOAD } - .castAwayType() - .onStart { emit(Unit) } - ) { loggedIn, _ -> loggedIn } - .collectLatest { loggedIn -> - if (loggedIn) { - AvatarReuploadWorker.schedule(application, prefs) - } else { - AvatarReuploadWorker.cancel(application) - } - } - } + private val serverApiExecutor: ServerApiExecutor, + private val fileUploadApiFactory: FileUploadApi.Factory, +) : AuthAwareComponent { + override suspend fun doWhileLoggedIn(loggedInState: LoggedInState) { + TextSecurePreferences._events.filter { it == TextSecurePreferences.DEBUG_AVATAR_REUPLOAD } + .castAwayType() + .onStart { emit(Unit) } + .collectLatest { + AvatarReuploadWorker.schedule(application, prefs) + } } + override fun onLoggedOut() { + AvatarReuploadWorker.cancel(application) + } /** * Uploads the given avatar image data to the file server, updates the user profile to point to @@ -95,11 +85,17 @@ class AvatarUploadManager @Inject constructor( AttachmentProcessor.EncryptResult(ciphertext = ciphertext, key = key) } - val uploadResult = fileServerApi.upload( - file = result.ciphertext, - fileServer = prefs.alternativeFileServer ?: FileServerApi.DEFAULT_FILE_SERVER, - usedDeterministicEncryption = usesDeterministicEncryption, - customExpiresDuration = DEBUG_AVATAR_TTL.takeIf { prefs.forcedShortTTL() } + val fileServer = prefs.alternativeFileServer ?: FileServerApis.DEFAULT_FILE_SERVER + val uploadResult = serverApiExecutor.execute( + ServerApiRequest( + fileServer = fileServer, + api = fileUploadApiFactory.create( + fileServer = fileServer, + data = result.ciphertext, + usedDeterministicEncryption = usesDeterministicEncryption, + customExpiresSeconds = DEBUG_AVATAR_TTL.takeIf { prefs.forcedShortTTL() }?.inWholeSeconds + ) + ) ) Log.d(DebugLogGroup.AVATAR.label, "Avatar upload finished with $uploadResult") @@ -114,18 +110,21 @@ class AvatarUploadManager @Inject constructor( it.write(pictureData) } + val isAnimated = AnimatedImageUtils.isAnimated(pictureData) + Log.d(DebugLogGroup.AVATAR.label, "Avatar file written to local storage") // Now that we have the file both locally and remotely, we can update the user profile - val oldPic = configFactory.withMutableUserConfigs { - val result = it.userProfile.getPic() + val oldPic = configFactory.withMutableUserConfigs { configs -> + val result = configs.userProfile.getPic() val userPic = remoteFile.toUserPic() if (isReupload) { - it.userProfile.setReuploadedPic(userPic) + configs.userProfile.setReuploadedPic(userPic) } else { - it.userProfile.setPic(userPic) + configs.userProfile.setPic(userPic) } + configs.userProfile.setAnimatedAvatar(isAnimated) result.toRemoteFile() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/auth/AuthAwareComponent.kt b/app/src/main/java/org/thoughtcrime/securesms/auth/AuthAwareComponent.kt new file mode 100644 index 0000000000..843e515267 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/auth/AuthAwareComponent.kt @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.auth + +/** + * A component that is aware of authentication state changes. + * + * `doWhileLoggedIn` is called when the user is logged in, and provides the current [LoggedInState], + * when the user logs out, the suspended function is cancelled and `onLoggedOut` will be called. + * + * The component is very likely to be lazily initialized as well. + */ +interface AuthAwareComponent { + suspend fun doWhileLoggedIn(loggedInState: LoggedInState) + + fun onLoggedOut() { + + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/auth/AuthAwareComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/auth/AuthAwareComponents.kt new file mode 100644 index 0000000000..f273de03eb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/auth/AuthAwareComponents.kt @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.auth + +import dagger.Lazy +import org.session.libsession.messaging.sending_receiving.pollers.PollerManager +import org.thoughtcrime.securesms.attachments.AvatarUploadManager +import org.thoughtcrime.securesms.configs.ConfigToDatabaseSync +import org.thoughtcrime.securesms.configs.ConfigUploader +import org.thoughtcrime.securesms.groups.handler.AdminStateSync +import org.thoughtcrime.securesms.groups.handler.CleanupInvitationHandler +import org.thoughtcrime.securesms.groups.handler.DestroyedGroupSync +import org.thoughtcrime.securesms.groups.handler.RemoveGroupMemberHandler +import org.thoughtcrime.securesms.notifications.BackgroundPollManager +import org.thoughtcrime.securesms.notifications.PushRegistrationHandler +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.service.ExpiringMessageManager +import org.thoughtcrime.securesms.webrtc.CallMessageProcessor +import javax.inject.Inject + +/** + * A collection of all [AuthAwareComponent]s in the application. + * + * This class is primarily used to inject all [AuthAwareComponent]s into a single location, + * so that they can be started and stopped based on authentication state changes. + */ +class AuthAwareComponents( + val components: List>, +) { + + @Inject + constructor( + expiringMessageManager: Lazy, + adminStateSync: Lazy, + configUploader: Lazy, + avatarUploadManager: Lazy, + callMessageProcessor: Lazy, + cleanupInvitationHandler: Lazy, + removeGroupMemberHandler: Lazy, + destroyedGroupSync: Lazy, + pushRegistrationHandler: Lazy, + configToDatabaseSync: Lazy, + proStatusManager: Lazy, + pollerManager: Lazy, + backgroundPollManager: Lazy, + ): this( + components = listOf>( + expiringMessageManager, + adminStateSync, + configUploader, + avatarUploadManager, + callMessageProcessor, + cleanupInvitationHandler, + removeGroupMemberHandler, + destroyedGroupSync, + pushRegistrationHandler, + configToDatabaseSync, + proStatusManager, + pollerManager, + backgroundPollManager, + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/auth/AuthAwareComponentsHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/auth/AuthAwareComponentsHandler.kt new file mode 100644 index 0000000000..940b99dc9a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/auth/AuthAwareComponentsHandler.kt @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.auth + +import dagger.Lazy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.cancellation.CancellationException + +/** + * A handler that starts on app startup and listens for authentication state changes. + * + * * When the user logs in, it will invoke `doWhileLoggedIn` on all registered [AuthAwareComponent]s. + * * When the user logs out, it will invoke `onLoggedOut` on all registered [AuthAwareComponent]s. + */ +@Singleton +class AuthAwareComponentsHandler @Inject constructor( + private val components: Lazy, + private val loginStateRepository: LoginStateRepository, + @param:ManagerScope private val scope: CoroutineScope, +) : OnAppStartupComponent { + override fun onPostAppStarted() { + scope.launch { + loginStateRepository + .loggedInState + .scan(Pair(null, null)) { (_, oldState), newState -> + oldState to newState + } + .collectLatest { (oldState, newState) -> + if (newState != null) { + supervisorScope { + for (comp in components.get().components) { + launch { + delay((Math.random() * 1000).toLong()) // Stagger startups to avoid jank + + var component: AuthAwareComponent? = null + + try { + component = comp.get() + Log.d(TAG, "Processing component: ${component.javaClass.simpleName}") + component.doWhileLoggedIn(newState) + } catch (e: Exception) { + if (e is CancellationException) throw e + + Log.e(TAG, "Error processing component: ${component?.javaClass?.simpleName}", e) + } + } + } + } + + } else if (oldState != null) { + components.get().components.forEach { + it.get().onLoggedOut() + } + } + } + } + } + + companion object { + private const val TAG = "AuthAwareComponentsHandler" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/auth/LoggedInState.kt b/app/src/main/java/org/thoughtcrime/securesms/auth/LoggedInState.kt index 79c332d4cb..d05db9be62 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/auth/LoggedInState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/auth/LoggedInState.kt @@ -1,14 +1,20 @@ package org.thoughtcrime.securesms.auth import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import network.loki.messenger.libsession_util.Curve25519 import network.loki.messenger.libsession_util.ED25519 +import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.Bytes import network.loki.messenger.libsession_util.util.KeyPair import org.session.libsession.utilities.serializable.BytesAsBase64Serializer import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import java.security.SecureRandom +import java.util.concurrent.ConcurrentHashMap + +typealias CommunityServerUrl = String @Serializable data class LoggedInState( @@ -27,6 +33,23 @@ data class LoggedInState( val accountX25519KeyPair: KeyPair get() = seeded.accountX25519KeyPair val accountId: AccountId get() = seeded.accountId + @Transient + private val blindedKeys: ConcurrentHashMap = ConcurrentHashMap() + + /** + * Returns a blinded key pair for the given server URL and server public key. + * + * The result is cached for future calls. + */ + fun getBlindedKeyPair(serverUrl: CommunityServerUrl, serverPubKeyHex: String): KeyPair { + return blindedKeys.getOrPut(serverUrl) { + BlindKeyAPI.blind15KeyPair( + ed25519SecretKey = accountEd25519KeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(serverPubKeyHex) + ) + } + } + /** * Holds the account seed. Almost all account related keys are derived from this seed. diff --git a/app/src/main/java/org/thoughtcrime/securesms/coil/PermanentErrorCacheInterceptor.kt b/app/src/main/java/org/thoughtcrime/securesms/coil/PermanentErrorCacheInterceptor.kt index 7fee253c6c..8a0d39009a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/coil/PermanentErrorCacheInterceptor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/coil/PermanentErrorCacheInterceptor.kt @@ -7,7 +7,7 @@ import coil3.request.ImageResult import org.session.libsession.utilities.recipients.RemoteFile import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.util.getRootCause +import org.thoughtcrime.securesms.util.findCause import javax.inject.Inject /** @@ -41,7 +41,7 @@ class PermanentErrorCacheInterceptor @Inject constructor() : Interceptor { val result = runCatching { chain.proceed() } val error = result.exceptionOrNull() ?: (result.getOrNull() as? ErrorResult)?.throwable - val rootCause = error?.getRootCause() + val rootCause = error?.findCause() if (rootCause != null) { // Cache the permanent error for this RemoteFile. permanentErrors.put(data, rootCause) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java index d045e82b14..556c192b68 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java @@ -77,7 +77,7 @@ public void setText(Recipient recipient, boolean read) { setText(builder); - if (recipient.getBlocked()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_user_round_x, 0, 0, 0); + if (recipient.getBlocked()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_user_round_block, 0, 0, 0); else if (recipient.isMuted()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_volume_off, 0, 0, 0); else setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt index bec825c725..388f9cf16f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt @@ -18,24 +18,30 @@ import network.loki.messenger.libsession_util.util.UserPic import org.session.libsession.avatars.AvatarCacheCleaner import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier -import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 +import org.session.libsession.network.SnodeClock import org.session.libsession.snode.OwnedSwarmAuth -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.allConfigAddresses import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.userConfigsChanged +import org.session.libsession.utilities.withGroupConfigs +import org.session.libsession.utilities.withMutableUserConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.auth.LoginStateRepository +import org.thoughtcrime.securesms.api.snode.DeleteMessageApi +import org.thoughtcrime.securesms.api.swarm.SwarmApiExecutor +import org.thoughtcrime.securesms.api.swarm.SwarmApiRequest +import org.thoughtcrime.securesms.api.swarm.execute +import org.thoughtcrime.securesms.auth.AuthAwareComponent +import org.thoughtcrime.securesms.auth.LoggedInState import org.thoughtcrime.securesms.database.CommunityDatabase import org.thoughtcrime.securesms.database.DraftDatabase import org.thoughtcrime.securesms.database.GroupDatabase @@ -50,7 +56,6 @@ import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.util.SessionMetaProtocol import org.thoughtcrime.securesms.util.castAwayType @@ -86,35 +91,30 @@ class ConfigToDatabaseSync @Inject constructor( private val messageNotifier: MessageNotifier, private val recipientSettingsDatabase: RecipientSettingsDatabase, private val avatarCacheCleaner: AvatarCacheCleaner, - private val loginStateRepository: LoginStateRepository, + private val swarmApiExecutor: SwarmApiExecutor, + private val deleteMessageApiFactory: DeleteMessageApi.Factory, @param:ManagerScope private val scope: CoroutineScope, -) : OnAppStartupComponent { - init { - // Sync conversations from config -> database - scope.launch { - loginStateRepository.flowWithLoggedInState { - combine( - conversationRepository.conversationListAddressesFlow, - configFactory.userConfigsChanged(EnumSet.of(UserConfigType.CONVO_INFO_VOLATILE)) - .castAwayType() - .onStart { emit(Unit) } - .map { _ -> configFactory.withUserConfigs { it.convoInfoVolatile.all() } }, - ::Pair - ) - } - .distinctUntilChanged() - .collectLatest { (conversations, convoInfo) -> - try { - ensureConversations(conversations) - updateConvoVolatile(convoInfo) - } catch (e: Exception) { - Log.e(TAG, "Error updating conversations from config", e) - } +) : AuthAwareComponent { + override suspend fun doWhileLoggedIn(loggedInState: LoggedInState) { + combine( + conversationRepository.conversationListAddressesFlow, + configFactory.userConfigsChanged(EnumSet.of(UserConfigType.CONVO_INFO_VOLATILE)) + .castAwayType() + .onStart { emit(Unit) } + .map { _ -> configFactory.withUserConfigs { it.convoInfoVolatile.all() } }, + ::Pair + ).distinctUntilChanged() + .collectLatest { (conversations, convoInfo) -> + try { + ensureConversations(conversations, loggedInState.accountId) + updateConvoVolatile(convoInfo) + } catch (e: Exception) { + Log.e(TAG, "Error updating conversations from config", e) } - } + } } - private fun ensureConversations(addresses: Set) { + private fun ensureConversations(addresses: Set, myAccountId: AccountId) { val result = threadDatabase.ensureThreads(addresses) if (result.deletedThreads.isNotEmpty()) { @@ -139,7 +139,7 @@ class ConfigToDatabaseSync @Inject constructor( when (address) { is Address.Community -> deleteCommunityData(address, threadId) - is Address.LegacyGroup -> deleteLegacyGroupData(address) + is Address.LegacyGroup -> deleteLegacyGroupData(address, myAccountId) is Address.Group -> deleteGroupData(address) is Address.Blinded, is Address.CommunityBlindedId, @@ -160,7 +160,7 @@ class ConfigToDatabaseSync @Inject constructor( when (address) { is Address.Community -> onCommunityAdded(address, threadId) is Address.Group -> onGroupAdded(address, threadId) - is Address.LegacyGroup -> onLegacyGroupAdded(address, threadId) + is Address.LegacyGroup -> onLegacyGroupAdded(address, threadId, myAccountId) is Address.Blinded, is Address.CommunityBlindedId, is Address.Standard, @@ -192,7 +192,8 @@ class ConfigToDatabaseSync @Inject constructor( private fun onLegacyGroupAdded( address: Address.LegacyGroup, - threadId: Long + threadId: Long, + myAccountId: AccountId, ) { val group = configFactory.withUserConfigs { it.userGroups.getLegacyGroupInfo(address.groupPublicKeyHex) } ?: return @@ -206,9 +207,7 @@ class ConfigToDatabaseSync @Inject constructor( storage.addClosedGroupPublicKey(group.accountId) // Store the encryption key pair val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey.data), DjbECPrivateKey(group.encSecKey.data)) - storage.addClosedGroupEncryptionKeyPair(keyPair, group.accountId, clock.currentTimeMills()) - // Notify the PN server - PushRegistryV1.subscribeGroup(group.accountId, publicKey = loginStateRepository.requireLocalNumber()) + storage.addClosedGroupEncryptionKeyPair(keyPair, group.accountId, clock.currentTimeMillis()) threadDatabase.setCreationDate(threadId, formationTimestamp) } @@ -251,17 +250,13 @@ class ConfigToDatabaseSync @Inject constructor( communityDatabase.deleteRoomInfo(address) } - private fun deleteLegacyGroupData(address: Address.LegacyGroup) { - val myAddress = loginStateRepository.requireLocalNumber() - + private fun deleteLegacyGroupData(address: Address.LegacyGroup, myAccountId: AccountId) { // Mark the group as inactive storage.setActive(address.address, false) storage.removeClosedGroupPublicKey(address.groupPublicKeyHex) // Remove the key pairs storage.removeAllClosedGroupEncryptionKeyPairs(address.groupPublicKeyHex) - storage.removeMember(address.address, Address.fromSerialized(myAddress)) - // Notify the PN server - PushRegistryV1.unsubscribeGroup(closedGroupPublicKey = address.groupPublicKeyHex, publicKey = myAddress) + storage.removeMember(address.address, myAccountId.toAddress()) messageNotifier.updateNotification(context) } @@ -319,15 +314,20 @@ class ConfigToDatabaseSync @Inject constructor( OwnedSwarmAuth.ofClosedGroup(groupInfoConfig.id, it) } ?: return - // remove messages from swarm SnodeAPI.deleteMessage + // remove messages from swarm deleteMessage scope.launch(Dispatchers.Default) { val cleanedHashes: List = messages.asSequence().map { it.second }.filter { !it.isNullOrEmpty() }.filterNotNull().toList() - if (cleanedHashes.isNotEmpty()) SnodeAPI.deleteMessage( - groupInfoConfig.id.hexString, - groupAdminAuth, - cleanedHashes - ) + if (cleanedHashes.isNotEmpty()) { + val deleteMessageApi = deleteMessageApiFactory.create( + messageHashes = cleanedHashes, + swarmAuth = groupAdminAuth + ) + swarmApiExecutor.execute(SwarmApiRequest( + swarmPubKeyHex = groupInfoConfig.id.hexString, + api = deleteMessageApi + )) + } } } groupInfoConfig.deleteAttachmentsBefore?.let { removeAttachmentsBefore -> @@ -356,9 +356,9 @@ class ConfigToDatabaseSync @Inject constructor( } } - val threadId = threadDatabase.getThreadIdIfExistsFor(address) + val threadId = storage.getThreadId(address) - if (threadId != -1L) { + if (threadId != null) { if (conversation.lastRead > storage.getLastSeen(threadId)) { storage.markConversationAsRead( threadId, diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt index 9ab0adfbae..4a051ee671 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt @@ -1,17 +1,13 @@ package org.thoughtcrime.securesms.configs -import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance @@ -24,29 +20,36 @@ import network.loki.messenger.libsession_util.Namespace import network.loki.messenger.libsession_util.util.ConfigPush import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth -import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.network.SnodeClock +import org.session.libsession.network.model.PathStatus +import org.session.libsession.network.onion.PathManager +import org.session.libsession.network.snode.SwarmDirectory import org.session.libsession.snode.OwnedSwarmAuth -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.SwarmAuth import org.session.libsession.snode.model.StoreMessageResponse -import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigPushResult import org.session.libsession.utilities.ConfigUpdateNotification import org.session.libsession.utilities.MutableGroupConfigs -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.userConfigsChanged +import org.session.libsession.utilities.withMutableGroupConfigs +import org.session.libsession.utilities.withMutableUserConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.retryWithUniformInterval -import org.thoughtcrime.securesms.auth.LoginStateRepository -import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import org.thoughtcrime.securesms.api.snode.DeleteMessageApi +import org.thoughtcrime.securesms.api.snode.SnodeApiExecutor +import org.thoughtcrime.securesms.api.snode.SnodeApiRequest +import org.thoughtcrime.securesms.api.snode.StoreMessageApi +import org.thoughtcrime.securesms.api.snode.execute +import org.thoughtcrime.securesms.auth.AuthAwareComponent +import org.thoughtcrime.securesms.auth.LoggedInState import org.thoughtcrime.securesms.util.NetworkConnectivity import javax.inject.Inject @@ -68,10 +71,12 @@ class ConfigUploader @Inject constructor( private val storageProtocol: StorageProtocol, private val clock: SnodeClock, private val networkConnectivity: NetworkConnectivity, - private val loginStateRepository: LoginStateRepository, -) : OnAppStartupComponent { - private var job: Job? = null - + private val swarmDirectory: SwarmDirectory, + private val pathManager: PathManager, + private val snodeApiExecutor: SnodeApiExecutor, + private val storeMessageApiFactory: StoreMessageApi.Factory, + private val deleteMessageApiFactory: DeleteMessageApi.Factory, +) : AuthAwareComponent { /** * A flow that only emits when * 1. There's internet connection AND, @@ -83,91 +88,63 @@ class ConfigUploader @Inject constructor( private fun pathBecomesAvailable(): Flow<*> = networkConnectivity.networkAvailable .flatMapLatest { hasNetwork -> if (hasNetwork) { - OnionRequestAPI.hasPath.filter { it } + pathManager.status.filter { it == PathStatus.READY } } else { emptyFlow() } } - // A flow that emits true when there's a logged in user - private fun hasLoggedInUser(): Flow = loginStateRepository.loggedInState - .map { it != null } - .distinctUntilChanged() - - - @OptIn(DelicateCoroutinesApi::class, FlowPreview::class, ExperimentalCoroutinesApi::class) - override fun onPostAppStarted() { - require(job == null) { "Already started" } - - job = GlobalScope.launch { - supervisorScope { + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + override suspend fun doWhileLoggedIn(loggedInState: LoggedInState) { + supervisorScope { + launch { // For any of these events, we need to push the user configs: // - The onion path has just become available to use // - The user configs have been modified // Also, these events are only relevant when there's a logged in user - val job1 = launch { - hasLoggedInUser() - .flatMapLatest { loggedIn -> - if (loggedIn) { - merge( - pathBecomesAvailable(), - configFactory.userConfigsChanged() - .filter { !it.fromMerge } - .debounce(1000L) - ) - } else { - emptyFlow() - } - } - .collect { - try { - retryWithUniformInterval { - pushUserConfigChangesIfNeeded() - } - } catch (e: Exception) { - Log.e(TAG, "Failed to push user configs", e) - } + + merge( + pathBecomesAvailable(), + configFactory.userConfigsChanged() + .filter { !it.fromMerge } + .debounce(1000L) + ).collect { + try { + retryWithUniformInterval { + pushUserConfigChangesIfNeeded() } + } catch (e: Exception) { + Log.e(TAG, "Failed to push user configs", e) + } } + } - val job2 = launch { - hasLoggedInUser() - .flatMapLatest { loggedIn -> - if (loggedIn) { - merge( - // When the onion request path changes, we need to examine all the groups - // and push the pending configs for them - pathBecomesAvailable().flatMapLatest { - configFactory.withUserConfigs { configs -> configs.userGroups.allClosedGroupInfo() } - .asSequence() - .filter { !it.destroyed && !it.kicked } - .map { AccountId(it.groupAccountId) } - .asFlow() - }, - - // Or, when a group config is updated, we need to push the changes for that group - configFactory.configUpdateNotifications - .filterIsInstance() - .map { it.groupId } - .debounce(1000L) - ) - } else { - emptyFlow() - } - } - .collect { groupId -> - try { - retryWithUniformInterval { - pushGroupConfigsChangesIfNeeded(groupId) - } - } catch (e: Exception) { - Log.e(TAG, "Failed to push group configs", e) + launch { + merge( + // When the onion request path changes, we need to examine all the groups + // and push the pending configs for them + pathBecomesAvailable().flatMapLatest { + configFactory.withUserConfigs { configs -> configs.userGroups.allClosedGroupInfo() } + .asSequence() + .filter { !it.destroyed && !it.kicked } + .map { AccountId(it.groupAccountId) } + .asFlow() + }, + + // Or, when a group config is updated, we need to push the changes for that group + configFactory.configUpdateNotifications + .filterIsInstance() + .map { it.groupId } + .debounce(1000L) + ).collect { groupId -> + try { + retryWithUniformInterval { + pushGroupConfigsChangesIfNeeded(groupId) } + } catch (e: Exception) { + Log.e(TAG, "Failed to push group configs", e) } } - - job1.join() - job2.join() } } } @@ -232,26 +209,26 @@ class ConfigUploader @Inject constructor( Log.d(TAG, "Pushing group configs") - val snode = SnodeAPI.getSingleTargetSnode(groupId.hexString).await() + val snode = swarmDirectory.getSingleTargetSnode(groupId.hexString) val auth = OwnedSwarmAuth.ofClosedGroup(groupId, adminKey) // Keys push is different: it doesn't have the delete call so we don't call pushConfig. // Keys must be pushed first because the other configs depend on it. val keysPushResult = keysPush?.let { push -> - SnodeAPI.sendBatchRequest( - snode = snode, - publicKey = auth.accountId.hexString, - request = SnodeAPI.buildAuthenticatedStoreBatchInfo( - Namespace.GROUP_KEYS(), - SnodeMessage( - auth.accountId.hexString, - Base64.encodeBytes(push), - SnodeMessage.CONFIG_TTL, - clock.currentTimeMills(), - ), - auth - ), - responseType = StoreMessageResponse.serializer() + snodeApiExecutor.execute( + SnodeApiRequest( + snode = snode, + api = storeMessageApiFactory.create( + namespace = Namespace.GROUP_KEYS(), + message = SnodeMessage( + auth.accountId.hexString, + Base64.encodeBytes(push), + SnodeMessage.CONFIG_TTL, + clock.currentTimeMillis(), + ), + auth = auth + ) + ) ).let(::listOf).toConfigPushResult() } @@ -310,27 +287,27 @@ class ConfigUploader @Inject constructor( // process will be cancelled. This is the requirement of pushing config: all messages have // to be sent successfully for us to consider this process as success val responses = coroutineScope { - val timestamp = clock.currentTimeMills() + val timestamp = clock.currentTimeMillis() Log.d(TAG, "Pushing ${push.messages.size} config messages") push.messages .map { message -> async { - SnodeAPI.sendBatchRequest( - snode = snode, - publicKey = auth.accountId.hexString, - request = SnodeAPI.buildAuthenticatedStoreBatchInfo( - namespace, - SnodeMessage( - auth.accountId.hexString, - Base64.encodeBytes(message.data), - SnodeMessage.CONFIG_TTL, - timestamp, - ), - auth, - ), - responseType = StoreMessageResponse.serializer() + snodeApiExecutor.execute( + SnodeApiRequest( + snode = snode, + api = storeMessageApiFactory.create( + namespace = namespace, + message = SnodeMessage( + auth.accountId.hexString, + Base64.encodeBytes(message.data), + SnodeMessage.CONFIG_TTL, + timestamp, + ), + auth = auth + ) + ) ) } } @@ -338,10 +315,14 @@ class ConfigUploader @Inject constructor( } if (push.obsoleteHashes.isNotEmpty()) { - SnodeAPI.sendBatchRequest( - snode = snode, - publicKey = auth.accountId.hexString, - request = SnodeAPI.buildAuthenticatedDeleteBatchInfo(auth, push.obsoleteHashes) + snodeApiExecutor.execute( + SnodeApiRequest( + snode = snode, + api = deleteMessageApiFactory.create( + swarmAuth = auth, + messageHashes = push.obsoleteHashes + ) + ) ) } @@ -377,7 +358,7 @@ class ConfigUploader @Inject constructor( Log.d(TAG, "Pushing ${pushes.size} user configs") - val snode = SnodeAPI.getSingleTargetSnode(userAuth.accountId.hexString).await() + val snode = swarmDirectory.getSingleTargetSnode(userAuth.accountId.hexString) val pushTasks = pushes.map { (configType, configPush) -> async { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt index ffdea7cb52..009ca70af0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt @@ -8,13 +8,12 @@ import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.ExpirationUtil import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol import org.session.libsession.utilities.StringSubstitutionConstants.DISAPPEARING_MESSAGES_TYPE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.isGroupV2 import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.AccountId @@ -43,7 +42,7 @@ class DisappearingMessages @Inject constructor( sender = loginStateRepository.getLocalNumber() isSenderSelf = true recipient = address.toString() - sentTimestamp = clock.currentTimeMills() + sentTimestamp = clock.currentTimeMillis() } messageExpirationManager.insertExpirationTimerMessage(message) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index ede508c241..b9946d848f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -45,11 +45,13 @@ import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.fragment.app.DialogFragment import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.viewModelScope import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager @@ -76,6 +78,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -92,13 +95,17 @@ import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.visible.Reaction import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.messaging.open_groups.api.AddReactionApi +import org.session.libsession.messaging.open_groups.api.CommunityApiExecutor +import org.session.libsession.messaging.open_groups.api.CommunityApiRequest +import org.session.libsession.messaging.open_groups.api.DeleteReactionApi +import org.session.libsession.messaging.open_groups.api.execute import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.MediaTypes @@ -114,6 +121,7 @@ import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.isBlinded import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.displayName +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.crypto.MnemonicCodec import org.session.libsignal.utilities.ListenableFuture import org.session.libsignal.utilities.Log @@ -189,6 +197,7 @@ import org.thoughtcrime.securesms.mms.SlideDeck import org.thoughtcrime.securesms.mms.VideoSlide import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity +import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment import org.thoughtcrime.securesms.showSessionDialog @@ -238,7 +247,7 @@ private const val TAG_REACTION_FRAGMENT = "ReactionsDialog" class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher, ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, - SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks, + SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks, OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback { private lateinit var binding: ActivityConversationV2Binding @@ -262,12 +271,26 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, @Inject lateinit var messageSender: MessageSender @Inject lateinit var resendMessageUtilities: ResendMessageUtilities @Inject lateinit var messageNotifier: MessageNotifier + @Inject lateinit var proStatusManager: ProStatusManager + @Inject lateinit var snodeClock: SnodeClock @Inject @ManagerScope lateinit var scope: CoroutineScope @Inject lateinit var loginStateRepository: LoginStateRepository + @Inject + lateinit var conversationLoaderFactory: ConversationLoader.Factory + + @Inject + lateinit var communityApiExecutor: CommunityApiExecutor + + @Inject + lateinit var addReactionApiFactory: AddReactionApi.Factory + + @Inject + lateinit var deleteReactionApiFactory: DeleteReactionApi.Factory + override val applyDefaultWindowInsets: Boolean get() = false @@ -372,7 +395,8 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, private val adapter by lazy { val adapter = ConversationAdapter( this, - storage.getLastSeen(viewModel.threadId), + originalLastSeen = viewModel.threadId + ?.let { storage.getLastSeen(it) }, false, onItemPress = { message, position, view, event -> handlePress(message, position, view, event) @@ -473,7 +497,9 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } private val isScrolledToBottom: Boolean - get() = binding.conversationRecyclerView.isNearBottom + get() = with(binding.conversationRecyclerView){ + !canScrollVertically(1) || isNearBottom + } // When the user clicks on the original message in a reply then we scroll to and highlight that original // message. To do this we keep track of the replied-to message's location in the recycler view. @@ -544,12 +570,12 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, // Check if address is null before proceeding with initialization if ( - IntentCompat.getParcelableExtra( - intent, - ADDRESS, - Address.Conversable::class.java - ) == null && - intent.data?.getQueryParameter(ADDRESS).isNullOrEmpty() + IntentCompat.getParcelableExtra( + intent, + ADDRESS, + Address.Conversable::class.java + ) == null && + intent.data?.getQueryParameter(ADDRESS).isNullOrEmpty() ) { Log.w(TAG, "ConversationActivityV2 launched without ADDRESS extra - Returning home") val intent = Intent(this, HomeActivity::class.java).apply { @@ -630,8 +656,10 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, try { when (it) { is Long -> { - if (storage.getLastSeen(viewModel.threadId) < it) { - storage.markConversationAsRead(viewModel.threadId, it) + viewModel.threadId?.let { threadId -> + if (storage.getLastSeen(threadId) < it) { + storage.markConversationAsRead(threadId, it) + } } } @@ -649,17 +677,24 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } lifecycleScope.launch { - viewModel.conversationReloadNotification - .collect { - LoaderManager.getInstance(this@ConversationActivityV2) - .restartLoader(0, null, this@ConversationActivityV2) - } + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.conversationReloadNotification + .collect { + if (!firstLoad.get()) { + restartConversationLoader() + } + } + } } setupMentionView() setupUiEventsObserver() } + private fun restartConversationLoader() { + LoaderManager.getInstance(this).restartLoader(0, null, this) + } + private fun startConversationLoaderWithDelay() { conversationLoadAnimationJob = lifecycleScope.launch { delay(700) @@ -770,7 +805,9 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, override fun onResume() { super.onResume() - messageNotifier.setVisibleThread(viewModel.threadId) + viewModel.threadId?.let { threadId -> + messageNotifier.setVisibleThread(threadId) + } } override fun onPause() { @@ -790,16 +827,15 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, dialogFragment.show(supportFragmentManager, tag) } - override fun onCreateLoader(id: Int, bundle: Bundle?): Loader { - return ConversationLoader( + override fun onCreateLoader(id: Int, bundle: Bundle?): Loader { + return conversationLoaderFactory.create( threadID = viewModel.threadId, reverse = false, - context = this@ConversationActivityV2, - mmsSmsDatabase = mmsSmsDb ) } - override fun onLoadFinished(loader: Loader, cursor: Cursor?) { + override fun onLoadFinished(loader: Loader, data: ConversationLoader.Data?) { + val cursor = data?.messageCursor val oldCount = adapter.itemCount val newCount = cursor?.count ?: 0 adapter.changeCursor(cursor) @@ -807,14 +843,14 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, if (cursor != null) { val messageTimestamp = messageToScrollTimestamp.getAndSet(-1) val author = messageToScrollAuthor.getAndSet(null) - val initialUnreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId) + val newUnreadCount = data.threadUnreadCount // Update the unreadCount value to be loaded from the database since we got a new message - if (firstLoad.get() || oldCount != newCount || initialUnreadCount != unreadCount) { + if (firstLoad.get() || oldCount != newCount || newUnreadCount != unreadCount) { // Update the unreadCount value to be loaded from the database since we got a new // message (we need to store it in a local variable as it can get overwritten on // another thread before the 'firstLoad.getAndSet(false)' case below) - unreadCount = initialUnreadCount + unreadCount = newUnreadCount updateUnreadCountIndicator() } @@ -833,11 +869,13 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, ) } - if (isUnread) { - storage.markConversationAsRead( - viewModel.threadId, - clock.currentTimeMills() - ) + viewModel.threadId?.let { threadId -> + if (isUnread) { + storage.markConversationAsRead( + threadId, + clock.currentTimeMillis() + ) + } } } } @@ -863,7 +901,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } - override fun onLoaderReset(cursor: Loader) = adapter.changeCursor(null) + override fun onLoaderReset(cursor: Loader) = adapter.changeCursor(null) // called from onCreate private fun setUpRecyclerView() { @@ -871,7 +909,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) binding.conversationRecyclerView.layoutManager = layoutManager // Workaround for the fact that CursorRecyclerViewAdapter doesn't auto-update automatically (even though it says it will) - LoaderManager.getInstance(this).restartLoader(0, null, this) + restartConversationLoader() binding.conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { @@ -908,22 +946,22 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, // called from onCreate private fun setUpToolBar() { binding.conversationAppBar.setThemedContent { - val data by viewModel.appBarData.collectAsState() - val query by searchViewModel.searchQuery.collectAsState() - - ConversationAppBar( - data = data, - onBackPressed = ::finish, - onCallPressed = ::callRecipient, - searchQuery = query ?: "", - onSearchQueryChanged = ::onSearchQueryUpdated, - onSearchQueryClear = { onSearchQueryUpdated("") }, - onSearchCanceled = ::onSearchClosed, - onAvatarPressed = { - val intent = ConversationSettingsActivity.createIntent(this, address) - settingsLauncher.launch(intent) - } - ) + val data by viewModel.appBarData.collectAsState() + val query by searchViewModel.searchQuery.collectAsState() + + ConversationAppBar( + data = data, + onBackPressed = ::finish, + onCallPressed = ::callRecipient, + searchQuery = query ?: "", + onSearchQueryChanged = ::onSearchQueryUpdated, + onSearchQueryClear = { onSearchQueryUpdated("") }, + onSearchCanceled = ::onSearchClosed, + onAvatarPressed = { + val intent = ConversationSettingsActivity.createIntent(this, address) + settingsLauncher.launch(intent) + } + ) } } @@ -947,6 +985,30 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, // called from onCreate private fun restoreDraftIfNeeded() { + // Handle Multiple Streams (ACTION_SEND_MULTIPLE) + if (intent.action == Intent.ACTION_SEND_MULTIPLE && intent.hasExtra(Intent.EXTRA_STREAM)) { + val uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + if (!uris.isNullOrEmpty()) { + val mediaList = uris.mapNotNull { uri -> + val mime = MediaUtil.getMimeType(this, uri) + if (mime != null) { + val filename = FilenameUtils.getFilenameFromUri(this, uri) + Media(uri, filename, mime, 0, 0, 0, 0, null, null) + } else null + } + + if (mediaList.isNotEmpty()) { + startActivityForResult(MediaSendActivity.buildEditorIntent( + this, + mediaList, + viewModel.recipient.address, + getMessageBody() + ), PICK_FROM_LIBRARY) + return + } + } + } + val mediaURI = intent.data val mediaType = AttachmentManager.MediaType.from(intent.type) val mimeType = MediaUtil.getMimeType(this, mediaURI) @@ -955,7 +1017,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, val filename = FilenameUtils.getFilenameFromUri(this, mediaURI) if (mimeType != null && - (AttachmentManager.MediaType.IMAGE == mediaType || + (AttachmentManager.MediaType.IMAGE == mediaType || AttachmentManager.MediaType.GIF == mediaType || AttachmentManager.MediaType.VIDEO == mediaType) ) { @@ -991,19 +1053,45 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } // called from onCreate + private var typistsLiveData: LiveData? = null private fun setUpTypingObserver() { - typingStatusRepository.getTypists(viewModel.threadId).observe(this) { state -> - val recipients = if (state != null) state.typists else listOf() - // FIXME: Also checking isScrolledToBottom is a quick fix for an issue where the - // typing indicator overlays the recycler view when scrolled up - val viewContainer = binding.typingIndicatorViewContainer - viewContainer.isVisible = recipients.isNotEmpty() && isScrolledToBottom - viewContainer.setTypists(recipients) + // Observe typists only when we have a real threadId, + // and swap if it changes, example: message request was created then accepted + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.threadIdFlow + .collect { threadId -> + // Detach any previous observer + typistsLiveData?.removeObservers(this@ConversationActivityV2) + typistsLiveData = null + + if (threadId == null) { + binding.typingIndicatorViewContainer.isVisible = false + return@collect + } + + // Attach observer for the real threadId + typistsLiveData = + typingStatusRepository.getTypists(threadId).also { liveData -> + liveData.observe(this@ConversationActivityV2) { state -> + val recipients = state?.typists ?: emptyList() + + // Quick-fix behavior kept as-is + val viewContainer = binding.typingIndicatorViewContainer + viewContainer.isVisible = + recipients.isNotEmpty() && isScrolledToBottom + viewContainer.setTypists(recipients) + } + } + } + } } if (textSecurePreferences.isTypingIndicatorsEnabled()) { binding.inputBar.addTextChangedListener { - if(it.isNotEmpty()) { - typingStatusSender.onTypingStarted(viewModel.threadId) + if (it.isNotEmpty()) { + viewModel.threadId?.let { threadId -> + typingStatusSender.onTypingStarted(threadId) + } } } } @@ -1142,17 +1230,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } - // React to input bar state changes - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.inputBarState - .map { it.charLimitState } - .distinctUntilChanged() - .collectLatest(binding.inputBar::setCharLimitState) - } - } - - // React to loader visibility changes lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -1217,7 +1294,10 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, return } - val lastSeenTimestamp = threadDb.getLastSeenAndHasSent(viewModel.threadId).first() + val threadId = viewModel.threadId + if (threadId == null) return // Maybe don't scroll + + val lastSeenTimestamp = threadDb.getLastSeenAndHasSent(threadId).first() val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return binding.conversationRecyclerView.runWhenLaidOut { @@ -1226,7 +1306,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, ((layoutManager?.height ?: 0) / 2) ) } - } private fun highlightViewAtPosition(position: Int) { @@ -1295,8 +1374,16 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, if (textSecurePreferences.isLinkPreviewsEnabled()) { linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0) } + + // use the normalised version of the text's body to get the characters amount with the + // mentions as their account id + viewModel.onTextChanged(mentionViewModel.deconstructMessageMentions()) + } + + override fun onInputBarEditTextPasted() { + val inputBarText = binding.inputBar.text if ( !textSecurePreferences.isLinkPreviewsEnabled() && !textSecurePreferences.hasSeenLinkPreviewSuggestionDialog() - && LinkPreviewUtil.findWhitelistedUrls(newContent.toString()).isNotEmpty()) { + && LinkPreviewUtil.findWhitelistedUrls(inputBarText).isNotEmpty()) { viewModel.showLinkDownloadDialog { textSecurePreferences.setLinkPreviewsEnabled(true) setUpLinkPreviewObserver() @@ -1305,10 +1392,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } textSecurePreferences.setHasSeenLinkPreviewSuggestionDialog() } - - // use the normalised version of the text's body to get the characters amount with the - // mentions as their account id - viewModel.onTextChanged(mentionViewModel.deconstructMessageMentions()) } override fun toggleAttachmentOptions() { @@ -1571,8 +1654,8 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, title(R.string.block) text( Phrase.from(context, R.string.blockDescription) - .put(NAME_KEY, name) - .format() + .put(NAME_KEY, name) + .format() ) dangerButton(R.string.block, R.string.AccessibilityId_blockConfirm) { viewModel.block() @@ -1760,7 +1843,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, // Create the message val recipient = viewModel.recipient val reactionMessage = VisibleMessage() - val emojiTimestamp = SnodeAPI.nowWithOffset + val emojiTimestamp = snodeClock.currentTimeMillis() reactionMessage.sentTimestamp = emojiTimestamp val author = loginStateRepository.getLocalNumber() @@ -1798,7 +1881,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, // Send it reactionMessage.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.toString(), emoji, true) if (recipient.address is Address.Community) { - // Increment the reaction count locally immediately. This // has to apply on all the ReactionRecords with the same messageId/emoji per design. reactionDb.updateAllCountFor(messageId, emoji, 1) @@ -1808,19 +1890,25 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, scope.launch { runCatching { - OpenGroupApi.addReaction( - room = recipient.address.room, - server = recipient.address.serverUrl, - messageId = messageServerId, - emoji = emoji + communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = recipient.address.serverUrl, + api = addReactionApiFactory.create( + room = recipient.address.room, + messageId = messageServerId, + emoji = emoji + ) + ) ) + }.onFailure { + Log.e(TAG, "Failed to send emoji reaction to community message", it) } } } else { messageSender.send(reactionMessage, recipient.address) } - LoaderManager.getInstance(this).restartLoader(0, null, this) + restartConversationLoader() } } @@ -1829,7 +1917,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, private fun sendEmojiRemoval(emoji: String, originalMessage: MessageRecord) { val recipient = viewModel.recipient val message = VisibleMessage() - val emojiTimestamp = SnodeAPI.nowWithOffset + val emojiTimestamp = snodeClock.currentTimeMillis() message.sentTimestamp = emojiTimestamp val author = loginStateRepository.getLocalNumber() @@ -1861,22 +1949,27 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, val messageServerId = lokiMessageDb.getServerID(originalMessage.messageId) ?: - return Log.w(TAG, "Failed to find message server ID when removing emoji reaction") + return Log.w(TAG, "Failed to find message server ID when removing emoji reaction") scope.launch { runCatching { - OpenGroupApi.deleteReaction( - recipient.address.room, - recipient.address.serverUrl, - messageServerId, - emoji + communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = recipient.address.serverUrl, + api = deleteReactionApiFactory.create( + room = recipient.address.room, + messageId = messageServerId, + emoji = emoji + ) + ) ) } } } else { messageSender.send(message, recipient.address) } - LoaderManager.getInstance(this).restartLoader(0, null, this) + + restartConversationLoader() } } @@ -1891,7 +1984,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, ReactWithAnyEmojiDialogFragment .createForMessageRecord(messageRecord, reactWithAnyEmojiStartPage) - .show(supportFragmentManager, "BOTTOM"); + .show(supportFragmentManager, "BOTTOM") } } @@ -2097,9 +2190,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, if (sentMessageInfo != null) { messageToScrollAuthor.set(sentMessageInfo.first) messageToScrollTimestamp.set(sentMessageInfo.second) - binding.conversationRecyclerView.postDelayed({ - binding.conversationRecyclerView.handleScrollToBottom() - }, 500L) } } @@ -2127,7 +2217,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false): Pair? { val recipient = viewModel.recipient - val sentTimestamp = SnodeAPI.nowWithOffset + val sentTimestamp = snodeClock.currentTimeMillis() viewModel.implicitlyApproveRecipient()?.let { conversationApprovalJob = it } val text = getMessageBody() val isNoteToSelf = recipient.isLocalNumber @@ -2146,6 +2236,8 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, val message = VisibleMessage().applyExpiryMode(viewModel.address) message.sentTimestamp = sentTimestamp message.text = text + // pro features + proStatusManager.addProFeatures(message) val expiresInMillis = viewModel.recipient.expiryMode.expiryMillis val outgoingTextMessage = OutgoingTextMessage( message = message, @@ -2171,8 +2263,8 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, waitForApprovalJobToBeSubmitted() messageSender.send(message, recipient.address) } - // Send a typing stopped message - typingStatusSender.onTypingStopped(viewModel.threadId) + + stopTyping() return Pair(recipient.address, sentTimestamp) } @@ -2184,7 +2276,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, deleteAttachmentFilesAfterSave: Boolean = false, ): Pair? { val recipient = viewModel.recipient - val sentTimestamp = SnodeAPI.nowWithOffset + val sentTimestamp = snodeClock.currentTimeMillis() viewModel.implicitlyApproveRecipient()?.let { conversationApprovalJob = it } // Create the message @@ -2208,6 +2300,8 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, val expireStartedAtMs = if (viewModel.recipient.expiryMode is ExpiryMode.AfterSend) { sentTimestamp } else 0 + // pro features + proStatusManager.addProFeatures(message) val outgoingTextMessage = OutgoingMediaMessage( message = message, recipient = recipient.address, @@ -2266,11 +2360,17 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } - // Send a typing stopped message - typingStatusSender.onTypingStopped(viewModel.threadId) + stopTyping() return Pair(recipient.address, sentTimestamp) } + private fun stopTyping(){ + // Send a typing stopped message + viewModel.threadId?.let { threadId -> + typingStatusSender.onTypingStopped(threadId) + } + } + private fun showGIFPicker() { val hasSeenGIFMetaDataWarning: Boolean = textSecurePreferences.hasSeenGIFMetaDataWarning() if (!hasSeenGIFMetaDataWarning) { @@ -2532,12 +2632,29 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, override fun banUser(messages: Set) { showSessionDialog { title(R.string.banUser) - text(R.string.communityBanDescription) + text( + Phrase.from(applicationContext, R.string.communityBanUserDescription) + .put(NAME_KEY, messages.first().individualRecipient.displayName()) + .format() + ) dangerButton(R.string.theContinue) { viewModel.banUser(messages.first().individualRecipient.address); endActionMode() } cancelButton(::endActionMode) } } + override fun unbanUser(messages: Set) { + showSessionDialog { + title(R.string.banUnbanUser) + text( + Phrase.from(applicationContext, R.string.communityUnbanUserDescription) + .put(NAME_KEY, messages.first().individualRecipient.displayName()) + .format() + ) + dangerButton(R.string.theContinue) { viewModel.unbanUser(messages.first().individualRecipient.address); endActionMode() } + cancelButton(::endActionMode) + } + } + override fun banAndDeleteAll(messages: Set) { showSessionDialog { title(R.string.banDeleteAll) @@ -2768,7 +2885,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, private fun sendMediaSavedNotification() { val recipient = viewModel.recipient if (recipient.isGroupOrCommunityRecipient) { return } - val timestamp = SnodeAPI.nowWithOffset + val timestamp = snodeClock.currentTimeMillis() val kind = DataExtractionNotification.Kind.MediaSaved(timestamp) val message = DataExtractionNotification(kind) messageSender.send(message, recipient.address) @@ -2821,9 +2938,11 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } fun onSearchQueryUpdated(query: String) { - binding.searchBottomBar.showLoading() - searchViewModel.onQueryUpdated(query, viewModel.threadId) - adapter.onSearchQueryUpdated(query.takeUnless { it.length < 2 }) + viewModel.threadId?.let { threadId -> + binding.searchBottomBar.showLoading() + searchViewModel.onQueryUpdated(query, threadId) + adapter.onSearchQueryUpdated(query.takeUnless { it.length < 2 }) + } } override fun onSearchMoveUpPressed() { @@ -2835,9 +2954,11 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } private fun jumpToMessage(author: Address, timestamp: Long, highlight: Boolean, onMessageNotFound: Runnable?) { - SimpleTask.run(lifecycle, { - mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, timestamp, author, false) - }) { p: Int -> moveToMessagePosition(p, highlight, onMessageNotFound) } + viewModel.threadId?.let { threadId -> + SimpleTask.run(lifecycle, { + mmsSmsDb.getMessagePositionInConversation(threadId, timestamp, author, false) + }) { p: Int -> moveToMessagePosition(p, highlight, onMessageNotFound) } + } } private fun moveToMessagePosition(position: Int, highlight: Boolean, onMessageNotFound: Runnable?) { @@ -2868,8 +2989,9 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, ConversationReactionOverlay.Action.DELETE -> deleteMessages(selectedItems) ConversationReactionOverlay.Action.BAN_AND_DELETE_ALL -> banAndDeleteAll(selectedItems) ConversationReactionOverlay.Action.BAN_USER -> banUser(selectedItems) + ConversationReactionOverlay.Action.UNBAN_USER -> unbanUser(selectedItems) ConversationReactionOverlay.Action.COPY_ACCOUNT_ID -> copyAccountID(selectedItems) } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt index 767fae1c67..aa1a739a6e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt @@ -19,11 +19,12 @@ import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference import kotlin.math.min class ConversationAdapter( context: Context, - originalLastSeen: Long, + originalLastSeen: Long?, private val isReversed: Boolean, private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit, private val onItemSwipeToReply: (MessageRecord, Int) -> Unit, @@ -42,7 +43,7 @@ class ConversationAdapter( private var searchQuery: String? = null var visibleMessageViewDelegate: VisibleMessageViewDelegate? = null - private val lastSeen = AtomicLong(originalLastSeen) + private val lastSeen : AtomicReference = AtomicReference(originalLastSeen) var lastSentMessageId: MessageId? = null set(value) { @@ -143,6 +144,7 @@ class ConversationAdapter( viewHolder.view.bind( message = message, previous = messageBefore, + threadRecipient = threadRecipientProvider(), longPress = { onItemLongPress(message, viewHolder.adapterPosition, viewHolder.view) } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt index c85daf6e23..06d5b82a68 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt @@ -1,18 +1,49 @@ package org.thoughtcrime.securesms.conversation.v2 -import android.content.Context +import android.app.Application +import android.database.ContentObserver import android.database.Cursor +import android.database.MatrixCursor +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.util.AbstractCursorLoader -class ConversationLoader( - private val threadID: Long, - private val reverse: Boolean, - context: Context, - val mmsSmsDatabase: MmsSmsDatabase -) : AbstractCursorLoader(context) { +class ConversationLoader @AssistedInject constructor( + @Assisted private val threadID: Long?, + @Assisted private val reverse: Boolean, + application: Application, + private val mmsSmsDatabase: MmsSmsDatabase, +) : AbstractCursorLoader(application) { - override fun getCursor(): Cursor { - return mmsSmsDatabase.getConversation(threadID, reverse) + override fun getData(): Data { + // Return an empty cursor + val id = threadID ?: return Data( + messageCursor = MatrixCursor(emptyArray()), + threadUnreadCount = 0 + ) + + return Data( + messageCursor = mmsSmsDatabase.getConversation(id, reverse), + threadUnreadCount = mmsSmsDatabase.getUnreadCount(id), + ) + } + + data class Data( + val messageCursor: Cursor, + val threadUnreadCount: Int, + ) : CursorLike { + override fun close() = messageCursor.close() + override fun isClosed() = messageCursor.isClosed + override fun getCount() = messageCursor.count + override fun registerContentObserver(observer: ContentObserver?) + = messageCursor.registerContentObserver(observer) + } + + @AssistedFactory + interface Factory { + fun create(threadID: Long?, reverse: Boolean): ConversationLoader } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index 5b483b41a2..1106c3cb76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -37,7 +37,7 @@ import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.LocalisedTimeUtil.toShortTwoPartString import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY import org.session.libsession.utilities.ThemeUtil @@ -53,7 +53,6 @@ import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord -import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.util.AnimationCompleteListener @@ -106,6 +105,7 @@ class ConversationReactionOverlay : FrameLayout { @Inject lateinit var threadDatabase: ThreadDatabase @Inject lateinit var deprecationManager: LegacyGroupDeprecationManager @Inject lateinit var openGroupManager: OpenGroupManager + @Inject lateinit var snodeClock: SnodeClock private var job: Job? = null @@ -593,14 +593,27 @@ class ConversationReactionOverlay : FrameLayout { // control messages and "marked as deleted" messages can only delete val isDeleteOnly = message.isDeleted || containsControlMessage - // Select message - if(!isDeleteOnly && !isDeprecatedLegacyGroup) { - items += ActionItem( - R.attr.menu_select_icon, - R.string.select, - { handleActionItemClicked(Action.SELECT) }, - R.string.AccessibilityId_select - ) + // Resend + if (message.isFailed && !isDeprecatedLegacyGroup) { + items += ActionItem(R.attr.menu_reply_icon, R.string.resend, { handleActionItemClicked(Action.RESEND) }) + } + + // Resync + if (message.isSyncFailed && !isDeprecatedLegacyGroup) { + items += ActionItem(R.attr.menu_reply_icon, R.string.resync, { handleActionItemClicked(Action.RESYNC) }) + } + + // Save media.. + if (message.isMms && !isDeleteOnly) { + // ..but only provide the save option if the there is a media attachment which has finished downloading. + val mmsMessage = message as MediaMmsMessageRecord + if (mmsMessage.containsMediaSlide() && !mmsMessage.isMediaPending) { + items += ActionItem(R.attr.menu_save_icon, + R.string.save, + { handleActionItemClicked(Action.DOWNLOAD) }, + R.string.AccessibilityId_saveAttachment + ) + } } // Reply @@ -609,14 +622,35 @@ class ConversationReactionOverlay : FrameLayout { && !isDeprecatedLegacyGroup) { items += ActionItem(R.attr.menu_reply_icon, R.string.reply, { handleActionItemClicked(Action.REPLY) }, R.string.AccessibilityId_reply) } + // Copy message text if (!containsControlMessage && hasText && !isDeleteOnly) { items += ActionItem(R.attr.menu_copy_icon, R.string.copy, { handleActionItemClicked(Action.COPY_MESSAGE) }) } + + // Message detail + if(!isDeleteOnly) { + items += ActionItem( + R.attr.menu_info_icon, + R.string.info, + { handleActionItemClicked(Action.VIEW_INFO) }) + } + + // Select message + if(!isDeleteOnly && !isDeprecatedLegacyGroup) { + items += ActionItem( + R.attr.menu_select_icon, + R.string.select, + { handleActionItemClicked(Action.SELECT) }, + R.string.AccessibilityId_select + ) + } + // Copy Account ID if (!recipient.isCommunity && message.isIncoming && !isDeleteOnly) { items += ActionItem(R.attr.menu_copy_icon, R.string.accountIDCopy, { handleActionItemClicked(Action.COPY_ACCOUNT_ID) }) } + // Delete message if (!isDeprecatedLegacyGroup) { items += ActionItem( @@ -624,7 +658,7 @@ class ConversationReactionOverlay : FrameLayout { R.string.delete, { handleActionItemClicked(Action.DELETE) }, R.string.AccessibilityId_deleteMessage, - message.subtitle, + message.subtitle(snodeClock.currentTimeMillis()), ThemeUtil.getThemedColor(context, R.attr.danger) ) } @@ -632,37 +666,8 @@ class ConversationReactionOverlay : FrameLayout { // Ban user if (userCanBanSelectedUsers(message) && !isDeleteOnly && !isDeprecatedLegacyGroup) { items += ActionItem(R.attr.menu_ban_icon, R.string.banUser, { handleActionItemClicked(Action.BAN_USER) }) - } - // Ban and delete all - if (userCanBanSelectedUsers(message) && !isDeleteOnly && !isDeprecatedLegacyGroup) { items += ActionItem(R.attr.menu_trash_icon, R.string.banDeleteAll, { handleActionItemClicked(Action.BAN_AND_DELETE_ALL) }) - } - // Message detail - if(!isDeleteOnly) { - items += ActionItem( - R.attr.menu_info_icon, - R.string.messageInfo, - { handleActionItemClicked(Action.VIEW_INFO) }) - } - // Resend - if (message.isFailed && !isDeprecatedLegacyGroup) { - items += ActionItem(R.attr.menu_reply_icon, R.string.resend, { handleActionItemClicked(Action.RESEND) }) - } - // Resync - if (message.isSyncFailed && !isDeprecatedLegacyGroup) { - items += ActionItem(R.attr.menu_reply_icon, R.string.resync, { handleActionItemClicked(Action.RESYNC) }) - } - // Save media.. - if (message.isMms && !isDeleteOnly) { - // ..but only provide the save option if the there is a media attachment which has finished downloading. - val mmsMessage = message as MediaMmsMessageRecord - if (mmsMessage.containsMediaSlide() && !mmsMessage.isMediaPending) { - items += ActionItem(R.attr.menu_save_icon, - R.string.save, - { handleActionItemClicked(Action.DOWNLOAD) }, - R.string.AccessibilityId_saveAttachment - ) - } + items += ActionItem(R.attr.menu_unban_icon, R.string.banUnbanUser, { handleActionItemClicked(Action.UNBAN_USER) }) } // deleted messages have no emoji reactions @@ -792,6 +797,7 @@ class ConversationReactionOverlay : FrameLayout { SELECT, DELETE, BAN_USER, + UNBAN_USER, BAN_AND_DELETE_ALL } @@ -801,11 +807,11 @@ class ConversationReactionOverlay : FrameLayout { } } -private val MessageRecord.subtitle: ((Context) -> CharSequence?)? - get() = if (expiresIn <= 0 || expireStarted <= 0) { +private fun MessageRecord.subtitle(timeMilli: Long): ((Context) -> CharSequence?)? { + return if (expiresIn <= 0 || expireStarted <= 0) { null } else { context -> - (expiresIn - (SnodeAPI.nowWithOffset - expireStarted)) + (expiresIn - (timeMilli - expireStarted)) .coerceAtLeast(0L) .milliseconds .toShortTwoPartString() @@ -814,4 +820,5 @@ private val MessageRecord.subtitle: ((Context) -> CharSequence?)? .put(TIME_LARGE_KEY, it) .format().toString() } - } \ No newline at end of file + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 33bdee03ba..689cc81e5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart @@ -47,7 +48,6 @@ import network.loki.messenger.R import network.loki.messenger.libsession_util.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.util.BitSet -import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.BlindedContact import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.UserPic @@ -58,6 +58,10 @@ import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.open_groups.GroupMemberRole import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.messaging.open_groups.api.CommunityApiExecutor +import org.session.libsession.messaging.open_groups.api.CommunityApiRequest +import org.session.libsession.messaging.open_groups.api.DeleteAllReactionsApi +import org.session.libsession.messaging.open_groups.api.execute import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.utilities.Address @@ -83,16 +87,17 @@ import org.session.libsession.utilities.recipients.repeatedWithEffectiveNotifyTy import org.session.libsession.utilities.toGroupString import org.session.libsession.utilities.upsertContact import org.session.libsession.utilities.userConfigsChanged +import org.session.libsession.utilities.withMutableUserConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.InputbarViewModel import org.thoughtcrime.securesms.audio.AudioSlidePlayer +import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.database.BlindMappingRepository import org.thoughtcrime.securesms.database.GroupDatabase -import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.ReactionDatabase import org.thoughtcrime.securesms.database.RecipientRepository @@ -142,7 +147,6 @@ class ConversationViewModel @AssistedInject constructor( private val threadDb: ThreadDatabase, private val reactionDb: ReactionDatabase, private val lokiMessageDb: LokiMessageDatabase, - private val lokiAPIDb: LokiAPIDatabase, private val configFactory: ConfigFactory, private val groupManagerV2: GroupManagerV2, private val callManager: CallManager, @@ -158,7 +162,10 @@ class ConversationViewModel @AssistedInject constructor( private val upmFactory: UserProfileUtils.UserProfileUtilsFactory, attachmentDownloadHandlerFactory: AttachmentDownloadHandler.Factory, private val openGroupManager: OpenGroupManager, - private val attachmentDownloadJobFactory: AttachmentDownloadJob.Factory + private val attachmentDownloadJobFactory: AttachmentDownloadJob.Factory, + private val communityApiExecutor: CommunityApiExecutor, + private val deleteAllReactionsApiFactory: DeleteAllReactionsApi.Factory, + private val loginStateRepository: LoginStateRepository, ) : InputbarViewModel( application = application, proStatusManager = proStatusManager, @@ -175,29 +182,18 @@ class ConversationViewModel @AssistedInject constructor( val dialogsState: StateFlow = _dialogsState val threadIdFlow: StateFlow = - threadDb.getThreadIdIfExistsFor(address).takeIf { it != -1L } + storage.getThreadId(address) ?.let { MutableStateFlow(it) } - ?: threadDb - .updateNotifications - .map { - withContext(Dispatchers.Default) { - threadDb.getThreadIdIfExistsFor(address) - } - } - .filter { it != -1L } + ?: threadDb.updateNotifications + .map { storage.getThreadId(address) } + .flowOn(Dispatchers.Default) + .filterNotNull() .take(1) - .stateIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = null - ) + .stateIn(viewModelScope, SharingStarted.Eagerly, null) - /** - * Current thread ID, or -1 if it doesn't exist yet. - * - */ + // Current thread ID, or null if it doesn't exist yet. @Deprecated("Use threadIdFlow instead") - val threadId: Long get() = threadIdFlow.value ?: -1L + val threadId: Long? get() = threadIdFlow.value val recipientFlow: StateFlow = recipientRepository.observeRecipient(address) .filterNotNull() @@ -211,12 +207,12 @@ class ConversationViewModel @AssistedInject constructor( val conversationReloadNotification: SharedFlow<*> = merge( threadIdFlow .filterNotNull() - .flatMapLatest { id -> threadDb.updateNotifications.filter { it == id } }, + .flatMapLatest { id -> threadDb.updateNotifications.filter { it == id } }, recipientSettingsDatabase.changeNotification.filter { it == address }, attachmentDatabase.changesNotification, reactionDb.changeNotification, ).debounce(200L) // debounce to avoid too many reloads - .shareIn(viewModelScope, SharingStarted.Eagerly) + .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) val showSendAfterApprovalText: Flow get() = recipientFlow.map { r -> @@ -271,7 +267,8 @@ class ConversationViewModel @AssistedInject constructor( get() { if (!recipient.isGroupV2Recipient) return null - return repository.getInvitingAdmin(threadId)?.let(recipientRepository::getRecipientSync) + if (threadId == null) return null + return repository.getInvitingAdmin(threadId!!)?.let(recipientRepository::getRecipientSync) } val groupV2ThreadState: Flow get() = when { @@ -299,10 +296,12 @@ class ConversationViewModel @AssistedInject constructor( val blindedPublicKey: String? get() = if (recipient.data !is RecipientData.Community || edKeyPair == null || !serverCapabilities.contains(OpenGroupApi.Capability.BLIND.name.lowercase())) null else { - BlindKeyAPI.blind15KeyPairOrNull( - ed25519SecretKey = edKeyPair!!.secretKey.data, - serverPubKey = Hex.fromStringCondensed((recipient.data as RecipientData.Community).serverPubKey), - )?.pubKey?.data + loginStateRepository.peekLoginState() + ?.getBlindedKeyPair( + serverUrl = (recipient.data as RecipientData.Community).serverUrl, + serverPubKeyHex = (recipient.data as RecipientData.Community).serverPubKey + ) + ?.pubKey?.data ?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString } @@ -686,16 +685,21 @@ class ConversationViewModel @AssistedInject constructor( } fun saveDraft(text: String) { - GlobalScope.launch(Dispatchers.IO) { - repository.saveDraft(threadId, text) + threadId?.let { threadID -> + GlobalScope.launch(Dispatchers.IO) { + repository.saveDraft(threadID, text) + } } + } fun getDraft(): String? { - val draft: String? = repository.getDraft(threadId) + val threadID = threadId ?: return null + + val draft = repository.getDraft(threadID) viewModelScope.launch(Dispatchers.IO) { - repository.clearDrafts(threadId) + repository.clearDrafts(threadID) } return draft @@ -1108,6 +1112,19 @@ class ConversationViewModel @AssistedInject constructor( } } + fun unbanUser(recipient: Address) = viewModelScope.launch { + repository.unbanUser( + community = address as Address.Community, + userId = (recipient as Address.WithAccountId).accountId + ) + .onSuccess { + showMessage(application.getString(R.string.banUnbanUserUnbanned)) + } + .onFailure { + showMessage(application.getString(R.string.banUnbanErrorFailed)) + } + } + fun banAndDeleteAll(messageRecord: MessageRecord) = viewModelScope.launch { repository.banAndDeleteAll( community = address as Address.Community, @@ -1117,7 +1134,9 @@ class ConversationViewModel @AssistedInject constructor( showMessage(application.getString(R.string.banUserBanned)) // ..so we can now delete all their messages in this thread from local storage & remove the views. - repository.deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord) + withContext(Dispatchers.IO) { + repository.deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord) + } } .onFailure { showMessage(application.getString(R.string.banErrorFailed)) @@ -1379,11 +1398,15 @@ class ConversationViewModel @AssistedInject constructor( (address as? Address.Community)?.let { openGroup -> lokiMessageDb.getServerID(messageId)?.let { serverId -> runCatching { - OpenGroupApi.deleteAllReactions( - openGroup.room, - openGroup.serverUrl, - serverId, - emoji + communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = openGroup.serverUrl, + api = deleteAllReactionsApiFactory.create( + room = openGroup.room, + messageId = serverId, + emoji = emoji + ) + ) ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt index d9997f960d..341abe63ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt @@ -77,6 +77,7 @@ import org.thoughtcrime.securesms.MediaPreviewActivity import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader +import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.ui.AnimatedProfilePicProCTA import org.thoughtcrime.securesms.ui.CTAFeature @@ -341,7 +342,7 @@ fun CellMetadata( .align(Alignment.CenterVertically), size = LocalDimensions.current.iconLarge, data = senderAvatarData, - badge = if (state.senderHasAdminCrown) { AvatarBadge.Admin } else AvatarBadge.None + badge = if (state.senderHasAdminCrown) { AvatarBadge.ResourceBadge.Admin } else AvatarBadge.None ) Spacer(modifier = Modifier.width(LocalDimensions.current.smallSpacing)) } @@ -720,7 +721,7 @@ fun MessageDetailDialogs( is ProBadgeCTA.AnimatedProfile -> AnimatedProfilePicProCTA( - proSubscription = state.proBadgeCTA.proSubscription, + expired = state.proBadgeCTA.proSubscription is ProStatus.Expired, onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)} ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt index c17a4bc114..169b478912 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt @@ -6,7 +6,7 @@ import android.util.AttributeSet import androidx.appcompat.widget.AppCompatImageView import androidx.core.content.ContextCompat import network.loki.messenger.R -import org.session.libsession.snode.SnodeAPI.nowWithOffset +import org.session.libsession.messaging.MessagingModuleConfiguration import kotlin.math.round class ExpirationTimerView @JvmOverloads constructor( @@ -46,7 +46,7 @@ class ExpirationTimerView @JvmOverloads constructor( return } - val elapsedTime = nowWithOffset - startedAt + val elapsedTime = MessagingModuleConfiguration.shared.snodeClock.currentTimeMillis() - startedAt val remainingTime = expiresIn - elapsedTime val remainingPercent = (remainingTime / expiresIn.toFloat()).coerceIn(0f, 1f) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index 6121dbc096..0e16def3d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -20,7 +20,6 @@ import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ViewInputBarBinding import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview -import org.session.libsession.utilities.Address import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.recipients.Recipient @@ -308,6 +307,10 @@ class InputBar @JvmOverloads constructor( requestLayout() } + override fun onPaste() { + delegate?.onInputBarEditTextPasted() + } + private fun showOrHideInputIfNeeded() { if (!showInput) { cancelQuoteDraft() @@ -368,9 +371,12 @@ class InputBar @JvmOverloads constructor( // handle buttons state allowAttachMultimediaButtons = state.enableAttachMediaControls + + // handle character limit + setCharLimitState(state.charLimitState) } - fun setCharLimitState(state: InputbarViewModel.InputBarCharLimitState?) { + private fun setCharLimitState(state: InputbarViewModel.InputBarCharLimitState?) { // handle char limit if(state != null){ binding.characterLimitText.text = state.countFormatted @@ -391,6 +397,7 @@ class InputBar @JvmOverloads constructor( interface InputBarDelegate { fun inputBarEditTextContentChanged(newContent: CharSequence) + fun onInputBarEditTextPasted() {} // no-op by default fun toggleAttachmentOptions() fun showVoiceMessageUI() fun startRecordingVoiceMessage() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarEditText.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarEditText.kt index f70dab9f55..3995c32ce1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarEditText.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarEditText.kt @@ -6,14 +6,10 @@ import android.net.Uri import android.util.AttributeSet import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection -import android.widget.RelativeLayout import androidx.appcompat.widget.AppCompatEditText import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.InputConnectionCompat -import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities import org.thoughtcrime.securesms.util.toPx -import kotlin.math.max -import kotlin.math.min import kotlin.math.roundToInt class InputBarEditText : AppCompatEditText { @@ -22,7 +18,6 @@ class InputBarEditText : AppCompatEditText { var allowMultimediaInput: Boolean = true - constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet) : super(context, attrs) constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) @@ -30,6 +25,22 @@ class InputBarEditText : AppCompatEditText { override fun onTextChanged(text: CharSequence, start: Int, lengthBefore: Int, lengthAfter: Int) { super.onTextChanged(text, start, lengthBefore, lengthAfter) delegate?.inputBarEditTextContentChanged(text) + + // - A "chunk" got inserted (lengthAfter >= 3) + // - If it is large enough, treat it as paste. We do this because some IME's clipboard history + // will not be treated as a "paste" but instead just a normal text insertion + if (lengthAfter >= 3) { // catch most real paste + val inserted = safeSubSequence(text, start, start + lengthAfter) + if (!inserted.isNullOrEmpty()) { + // Bulk insert will mostly come from IME that supports clipboard history + val isBulkInsert = inserted.length >= 5 // small enough to catch URIs + + if (isBulkInsert) { + delegate?.onPaste() + } + } + } + // Calculate the width manually to get it right even before layout has happened (i.e. // when restoring a draft). The 64 DP is the horizontal margin around the input bar // edit text. @@ -37,6 +48,12 @@ class InputBarEditText : AppCompatEditText { if (width < 0) { return } // screenWidth initially evaluates to 0 } + // Small helper to avoid IndexOutOfBounds on weird IME behavior + private fun safeSubSequence(text: CharSequence, start: Int, end: Int): String? { + if (start < 0 || end > text.length || start >= end) return null + return text.subSequence(start, end).toString() + } + override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection? { val ic = super.onCreateInputConnection(editorInfo) ?: return null EditorInfoCompat.setContentMimeTypes(editorInfo, @@ -62,12 +79,15 @@ class InputBarEditText : AppCompatEditText { true // return true if succeeded } + + + return InputConnectionCompat.createWrapper(ic, editorInfo, callback) } - } interface InputBarEditTextDelegate { fun inputBarEditTextContentChanged(text: CharSequence) fun commitInputContent(contentUri: Uri) + fun onPaste() } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt index 46101962f9..7252b437e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt @@ -13,7 +13,7 @@ fun ViewMentionCandidateV2Binding.update(candidate: MentionViewModel.Candidate) Avatar( size = LocalDimensions.current.iconMediumAvatar, data = candidate.member.avatarData, - badge = if (candidate.member.showAdminCrown) AvatarBadge.Admin else AvatarBadge.None + badge = if (candidate.member.showAdminCrown) AvatarBadge.ResourceBadge.Admin else AvatarBadge.None ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt index 86cec9f1d0..c2465449e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt @@ -86,7 +86,7 @@ class MentionViewModel @AssistedInject constructor( recipientRepository.observeRecipient(address) .mapLatest { recipient -> val threadID = withContext(Dispatchers.Default) { - threadDatabase.getThreadIdIfExistsFor(address) + storage.getThreadId(address) } val memberIDs = when { @@ -94,14 +94,18 @@ class MentionViewModel @AssistedInject constructor( groupDatabase.getGroupMemberAddresses(address.toGroupString(), false) .map { it.toString() } } + address.isGroupV2 -> { storage.getMembers(address.toString()).map { it.accountId() } } - address.isCommunity -> mmsSmsDatabase.getRecentChatMemberAddresses( - threadID, - 300 - ) + address.isCommunity -> threadID?.let { + mmsSmsDatabase.getRecentChatMemberAddresses( + it, + 300 + ) + } ?: emptyList() + else -> listOf(address.address) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt index b1546c3d1c..c8cc8d56e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt @@ -68,7 +68,9 @@ class ConversationActionModeCallback( // Delete message menu.findItem(R.id.menu_context_delete_message).isVisible = !isDeprecatedLegacyGroup // can always delete since delete logic will be handled by the VM // Ban user - menu.findItem(R.id.menu_context_ban_user).isVisible = userCanBanSelectedUsers() && !isDeprecatedLegacyGroup + val canBan = userCanBanSelectedUsers() && !isDeprecatedLegacyGroup + menu.findItem(R.id.menu_context_ban_user).isVisible = canBan + menu.findItem(R.id.menu_context_unban_user).isVisible = canBan // Ban and delete all menu.findItem(R.id.menu_context_ban_and_delete_all).isVisible = userCanBanSelectedUsers() && !isDeprecatedLegacyGroup // Copy message text @@ -99,6 +101,7 @@ class ConversationActionModeCallback( when (item.itemId) { R.id.menu_context_delete_message -> delegate?.deleteMessages(selectedItems) R.id.menu_context_ban_user -> delegate?.banUser(selectedItems) + R.id.menu_context_unban_user -> delegate?.unbanUser(selectedItems) R.id.menu_context_ban_and_delete_all -> delegate?.banAndDeleteAll(selectedItems) R.id.menu_context_copy -> delegate?.copyMessages(selectedItems) R.id.menu_context_resync -> delegate?.resyncMessage(selectedItems) @@ -122,6 +125,7 @@ interface ConversationActionModeCallbackDelegate { fun selectMessages(messages: Set) fun deleteMessages(messages: Set) fun banUser(messages: Set) + fun unbanUser(messages: Set) fun banAndDeleteAll(messages: Set) fun copyMessages(messages: Set) fun resyncMessage(messages: Set) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt index 5e4f71b93b..4cc0276c5e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt @@ -29,6 +29,7 @@ import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.isGroup import org.session.libsession.utilities.isGroupOrCommunity +import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.displayName import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages @@ -76,6 +77,7 @@ class ControlMessageView : LinearLayout { @Inject lateinit var recipientRepository: RecipientRepository @Inject lateinit var loginStateRepository: LoginStateRepository @Inject lateinit var threadDatabase: ThreadDatabase + @Inject lateinit var messageFormatter: MessageFormatter val controlContentView: View get() = binding.controlContentView @@ -83,13 +85,20 @@ class ControlMessageView : LinearLayout { layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) } - fun bind(message: MessageRecord, previous: MessageRecord?, longPress: (() -> Unit)? = null) { + fun bind(message: MessageRecord, + threadRecipient: Recipient, + previous: MessageRecord?, + longPress: (() -> Unit)? = null) { binding.dateBreakTextView.showDateBreak(message, previous, dateUtils) binding.iconImageView.isGone = true binding.expirationTimerView.isGone = true binding.followSetting.isGone = true - var messageBody: CharSequence = message.getDisplayBody(context) + val messageBody = messageFormatter.formatMessageBody( + context = context, + message = message, + threadRecipient = threadRecipient, + ) binding.root.contentDescription = null binding.textView.text = messageBody @@ -99,9 +108,7 @@ class ControlMessageView : LinearLayout { binding.apply { expirationTimerView.isVisible = true - val threadRecipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(message.threadId) - - if (threadRecipient?.isGroup == true) { + if (threadRecipient.isGroupRecipient) { expirationTimerView.setTimerIcon() } else { expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) @@ -110,7 +117,7 @@ class ControlMessageView : LinearLayout { followSetting.isVisible = ExpirationConfiguration.isNewConfigEnabled && !message.isOutgoing && messageContent.expiryMode != (message.individualRecipient?.expiryMode ?: ExpiryMode.NONE) - && threadRecipient?.isGroupOrCommunity != true + && !threadRecipient.isGroupOrCommunityRecipient if (followSetting.isVisible) { binding.controlContentView.setOnClickListener { @@ -138,20 +145,6 @@ class ControlMessageView : LinearLayout { } } message.isMessageRequestResponse -> { - val msgRecipient = message.recipient.address.toString() - val me = loginStateRepository.getLocalNumber() - binding.textView.text = if (me == msgRecipient) { // you accepted the user's request - threadDatabase.getRecipientForThreadId(message.threadId) - ?.let { recipientRepository.getRecipientSync(it) } - ?.let { recipient -> context.getSubbedCharSequence( - R.string.messageRequestYouHaveAccepted, - NAME_KEY to recipient.displayName() - ) - } - } else { // they accepted your request - context.getString(R.string.messageRequestsAccepted) - } - binding.root.contentDescription = context.getString(R.string.AccessibilityId_message_request_config_message) } message.isCallLog -> { diff --git a/app/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/MessageFormatter.kt similarity index 64% rename from app/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/MessageFormatter.kt index 5a1231151e..fd818fd977 100644 --- a/app/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/MessageFormatter.kt @@ -1,50 +1,242 @@ -package org.session.libsession.messaging.utilities +package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context +import android.text.Spannable +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.style.ForegroundColorSpan import com.squareup.phrase.Phrase import network.loki.messenger.R import network.loki.messenger.libsession_util.util.ExpiryMode -import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.calls.CallMessageType -import org.session.libsession.messaging.calls.CallMessageType.CALL_FIRST_MISSED -import org.session.libsession.messaging.calls.CallMessageType.CALL_INCOMING -import org.session.libsession.messaging.calls.CallMessageType.CALL_MISSED -import org.session.libsession.messaging.calls.CallMessageType.CALL_OUTGOING import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage -import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED -import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage.Kind.SCREENSHOT +import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ExpirationUtil +import org.session.libsession.utilities.StringSubstitutionConstants.AUTHOR_KEY import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DISAPPEARING_MESSAGES_TYPE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.MESSAGE_SNIPPET_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY +import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.getGroup +import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.displayName import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.auth.LoginStateRepository +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.database.model.GroupThreadStatus +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate +import org.thoughtcrime.securesms.ui.getSubbedCharSequence +import javax.inject.Inject -object UpdateMessageBuilder { - const val TAG = "UpdateMessageBuilder" +class MessageFormatter @Inject constructor( + private val configFactory: ConfigFactoryProtocol, + private val recipientRepository: RecipientRepository, + private val loginStateRepository: LoginStateRepository, +) { - val storage = MessagingModuleConfiguration.shared.storage - val recipientRepository = MessagingModuleConfiguration.shared.recipientRepository + fun formatMessageBody( + context: Context, + message: MessageRecord, + threadRecipient: Recipient + ): CharSequence { + when { + message.isGroupUpdateMessage -> { + val updateMessageData: UpdateMessageData = message.getGroupUpdateMessage() ?: return "" - private fun getGroupMemberName(senderAddress: String): String { - return recipientRepository.getRecipientSync(Address.fromSerialized(senderAddress)) - .displayName() + val text = SpannableString( + buildGroupUpdateMessage( + context = context, + updateMessageData = updateMessageData, + isOutgoing = message.isOutgoing, + messageTimestamp = message.timestamp, + expireStarted = message.expireStarted + ) + ) + + if (updateMessageData.isGroupErrorQuitKind()) { + text.setSpan( + ForegroundColorSpan(ThemeUtil.getThemedColor(context, R.attr.danger)), + 0, + text.length, + Spannable.SPAN_INCLUSIVE_EXCLUSIVE + ) + } else if (updateMessageData.isGroupLeavingKind()) { + text.setSpan( + ForegroundColorSpan( + ThemeUtil.getThemedColor( + context, + android.R.attr.textColorTertiary + ) + ), 0, text.length, Spannable.SPAN_INCLUSIVE_EXCLUSIVE + ) + } + + return text + } + message.messageContent is DisappearingMessageUpdate -> { + val isGroup = threadRecipient.isGroupOrCommunityRecipient + return buildExpirationTimerMessage( + context, + (message.messageContent as DisappearingMessageUpdate).expiryMode, + isGroup, + message.individualRecipient, + message.isOutgoing + ) + } + message.isDataExtractionNotification -> { + if (message.isScreenshotNotification) return SpannableString( + buildDataExtractionMessage( + context = context, + kind = DataExtractionNotificationInfoMessage.Kind.SCREENSHOT, + sender = message.individualRecipient + ) + ) + else if (message.isMediaSavedNotification) return SpannableString( + buildDataExtractionMessage( + context = context, + kind = DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED, + sender = message.individualRecipient + ) + ) + } + message.isCallLog -> { + val callType = if (message.isIncomingCall) { + CallMessageType.CALL_INCOMING + } else if (message.isOutgoingCall) { + CallMessageType.CALL_OUTGOING + } else if (message.isMissedCall) { + CallMessageType.CALL_MISSED + } else { + CallMessageType.CALL_FIRST_MISSED + } + + return SpannableString( + buildCallMessage( + context = context, + type = callType, + sender = message.individualRecipient + ) + ) + } + message.isMessageRequestResponse -> { + return if (message.recipient.isSelf) { + // you accepted the user's request + context.getSubbedCharSequence( + R.string.messageRequestYouHaveAccepted, + NAME_KEY to threadRecipient.displayName() + ) + } else { + // the user accepted your request + context.getString(R.string.messageRequestsAccepted) + } + } + } + + return SpannableString(message.body) + } + + fun formatThreadSnippet( + context: Context, + thread: ThreadRecord + ): CharSequence { + val lastMessage = thread.lastMessage + + return when { + thread.groupThreadStatus == GroupThreadStatus.Kicked -> { + Phrase.from(context, R.string.groupRemovedYou) + .put(GROUP_NAME_KEY, thread.recipient.displayName()) + .format() + .toString() + } + + thread.groupThreadStatus == GroupThreadStatus.Destroyed -> { + Phrase.from(context, R.string.groupDeletedMemberDescription) + .put(GROUP_NAME_KEY, thread.recipient.displayName()) + .format() + .toString() + } + + lastMessage == null -> { + // no need to display anything if there are no messages + "" + } + + // We will show different text for community invitation on the thread list + lastMessage.isOpenGroupInvitation -> { + context.getString(R.string.communityInvitation) + } + + else -> { + val bodyText = formatMessageBody( + context = context, + message = lastMessage, + threadRecipient = thread.recipient + ) + + // This is used to show a placeholder text for MMS messages in the snippet, + // for example, " Attachment" + val mmsPlaceholderBody = (lastMessage as? MmsMessageRecord)?.slideDeck?.body + + val text = when { + // If both body and placeholder are blank, return empty string + bodyText.isBlank() && mmsPlaceholderBody.isNullOrBlank() -> "" + + // If both body and placeholder are non-blank, combine them + bodyText.isNotBlank() && !mmsPlaceholderBody.isNullOrBlank() -> + SpannableStringBuilder(mmsPlaceholderBody) + .append(": ") + .append(bodyText) + + // If only placeholder is non-blank, use it + !mmsPlaceholderBody.isNullOrBlank() -> mmsPlaceholderBody + + // Otherwise, use the body text + else -> bodyText + } + + when { + // There are certain messages that we want to keep their formatting + lastMessage.groupUpdateMessage?.isGroupLeavingKind() == true || + lastMessage.groupUpdateMessage?.isGroupErrorQuitKind() == true -> { + text + } + + // For group/community threads, we want to prefix the snippet with the author's name + thread.recipient.isGroupOrCommunityRecipient -> { + val prefix = if (lastMessage.isOutgoing) { + context.getString(R.string.you) + } else { + lastMessage.individualRecipient.displayName() + } + + Phrase.from(context.getString(R.string.messageSnippetGroup)) + .put(AUTHOR_KEY, prefix) + .put(MESSAGE_SNIPPET_KEY, text.toString()) + .format() + } + + // For all other messages, convert to plain string to avoid weird snippet appearances + else -> text.toString() + } + } + } } - @JvmStatic - fun buildGroupUpdateMessage( + private fun buildGroupUpdateMessage( context: Context, - groupV2Id: AccountId?, // null for legacy groups updateMessageData: UpdateMessageData, - configFactory: ConfigFactoryProtocol, isOutgoing: Boolean, messageTimestamp: Long, expireStarted: Long, @@ -103,14 +295,14 @@ object UpdateMessageBuilder { // --- Group member(s) removed --- is UpdateMessageData.Kind.GroupMemberRemoved -> { - val userPublicKey = storage.getUserPublicKey()!! + val userPublicKey = loginStateRepository.requireLocalNumber() // 1st case: you are part of the removed members return if (userPublicKey in updateData.updatedMembers) { if (isOutgoing) context.getText(R.string.groupMemberYouLeft) // You chose to leave else Phrase.from(context, R.string.groupRemovedYou) // You were forced to leave - .put(GROUP_NAME_KEY, updateData.groupName) - .format() + .put(GROUP_NAME_KEY, updateData.groupName) + .format() } else // 2nd case: you are not part of the removed members { @@ -120,7 +312,7 @@ object UpdateMessageBuilder { 0 -> { Log.w(TAG, "Somehow you asked to remove zero members.") "" // Return an empty string - we don't want to show the error in the conversation - } + } 1 -> Phrase.from(context, R.string.groupRemoved) .put(NAME_KEY, getGroupMemberName(updateData.updatedMembers.elementAt(0))) .format() @@ -129,9 +321,9 @@ object UpdateMessageBuilder { .put(OTHER_NAME_KEY, getGroupMemberName(updateData.updatedMembers.elementAt(1))) .format() else -> Phrase.from(context, R.string.groupRemovedMultiple) - .put(NAME_KEY, getGroupMemberName(updateData.updatedMembers.elementAt(0))) - .put(COUNT_KEY, updateData.updatedMembers.size - 1) - .format() + .put(NAME_KEY, getGroupMemberName(updateData.updatedMembers.elementAt(0))) + .put(COUNT_KEY, updateData.updatedMembers.size - 1) + .format() } } else // b.) Someone else is the person doing the removing of one or more members @@ -184,15 +376,18 @@ object UpdateMessageBuilder { } is UpdateMessageData.Kind.GroupAvatarUpdated -> context.getString(R.string.groupDisplayPictureUpdated) is UpdateMessageData.Kind.GroupExpirationUpdated -> { - buildExpirationTimerMessage(context, updateData.updatedExpiration, isGroup = true, - senderId = updateData.updatingAdmin, + buildExpirationTimerMessage( + context = context, + duration = updateData.updatedExpiration, + isGroup = true, + sender = recipientRepository.getRecipientSync(updateData.updatingAdmin.toAddress()), isOutgoing = isOutgoing, timestamp = messageTimestamp, expireStarted = expireStarted ) } is UpdateMessageData.Kind.GroupMemberUpdated -> { - val userPublicKey = storage.getUserPublicKey()!! + val userPublicKey = loginStateRepository.requireLocalNumber() val number = updateData.sessionIds.size val containsUser = updateData.sessionIds.contains(userPublicKey) val historyShared = updateData.historyShared @@ -203,15 +398,15 @@ object UpdateMessageBuilder { if (historyShared) R.string.groupInviteYouHistory else R.string.groupInviteYou) .format() number == 1 -> Phrase.from(context, - if (historyShared) R.string.groupMemberNewHistory else R.string.groupMemberNew) + if (historyShared) R.string.groupMemberInvitedHistory else R.string.groupMemberNew) .put(NAME_KEY, getGroupMemberName(updateData.sessionIds.first())) .format() number == 2 && containsUser -> Phrase.from(context, - if (historyShared) R.string.groupMemberNewYouHistoryTwo else R.string.groupInviteYouAndOtherNew) + if (historyShared) R.string.groupMemberNewYouHistoryTwo else R.string.groupInviteYouAndOtherNew) .put(OTHER_NAME_KEY, getGroupMemberName(updateData.sessionIds.first { it != userPublicKey })) .format() number == 2 -> Phrase.from(context, - if (historyShared) R.string.groupMemberNewHistoryTwo else R.string.groupMemberNewTwo) + if (historyShared) R.string.groupMemberInvitedHistoryTwo else R.string.groupMemberNewTwo) .put(NAME_KEY, getGroupMemberName(updateData.sessionIds.first())) .put(OTHER_NAME_KEY, getGroupMemberName(updateData.sessionIds.last())) .format() @@ -220,7 +415,7 @@ object UpdateMessageBuilder { .put(COUNT_KEY, updateData.sessionIds.size - 1) .format() number > 0 -> Phrase.from(context, - if (historyShared) R.string.groupMemberNewHistoryMultiple else R.string.groupMemberNewMultiple) + if (historyShared) R.string.groupMemberInvitedHistoryMultiple else R.string.groupMemberNewMultiple) .put(NAME_KEY, getGroupMemberName(updateData.sessionIds.first())) .put(COUNT_KEY, updateData.sessionIds.size - 1) .format() @@ -319,21 +514,19 @@ object UpdateMessageBuilder { } } + private fun getGroupMemberName(senderAddress: String): String { + return recipientRepository.getRecipientSync(Address.fromSerialized(senderAddress)) + .displayName() + } + fun buildExpirationTimerMessage( context: Context, mode: ExpiryMode, isGroup: Boolean, // Note: isGroup should cover both closed groups AND communities - senderId: String?, + sender: Recipient, isOutgoing: Boolean, ): CharSequence { - if (!isOutgoing && senderId == null) { - Log.w(TAG, "buildExpirationTimerMessage: Cannot build for outgoing message when senderId is null.") - return "" - } - - val senderName = if (isOutgoing) context.getString(R.string.you) else getGroupMemberName( - senderId!! - ) + val senderName = if (isOutgoing) context.getString(R.string.you) else sender.displayName() // Case 1.) Disappearing messages have been turned off.. if (mode == ExpiryMode.NONE) { @@ -390,13 +583,40 @@ object UpdateMessageBuilder { } } + fun buildDataExtractionMessage(context: Context, + kind: DataExtractionNotificationInfoMessage.Kind, + sender: Recipient): CharSequence { + + return when (kind) { + DataExtractionNotificationInfoMessage.Kind.SCREENSHOT -> Phrase.from(context, R.string.screenshotTaken) + .put(NAME_KEY, sender.displayName()) + .format() + + DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED -> Phrase.from(context, R.string.attachmentsMediaSaved) + .put(NAME_KEY, sender.displayName()) + .format() + } + } + + fun buildCallMessage(context: Context, type: CallMessageType, sender: Recipient): String { + return when (type) { + CallMessageType.CALL_INCOMING -> Phrase.from(context, R.string.callsCalledYou).put(NAME_KEY, sender.displayName()) + .format().toString() + + CallMessageType.CALL_OUTGOING -> Phrase.from(context, R.string.callsYouCalled).put(NAME_KEY, sender.displayName()) + .format().toString() + + CallMessageType.CALL_MISSED, CallMessageType.CALL_FIRST_MISSED -> Phrase.from(context, R.string.callsMissedCallFrom) + .put(NAME_KEY, sender.displayName()).format().toString() + } + } @Deprecated("Use the version with ExpiryMode instead. This will be removed in a future release.") fun buildExpirationTimerMessage( context: Context, duration: Long, isGroup: Boolean, // Note: isGroup should cover both closed groups AND communities - senderId: String? = null, + sender: Recipient, isOutgoing: Boolean = false, timestamp: Long, expireStarted: Long @@ -409,41 +629,12 @@ object UpdateMessageBuilder { else -> ExpiryMode.AfterRead(duration) }, isGroup = isGroup, - senderId = senderId, + sender = sender, isOutgoing = isOutgoing, ) } - fun buildDataExtractionMessage(context: Context, - kind: DataExtractionNotificationInfoMessage.Kind, - senderId: String? = null): CharSequence { - - val senderName = if (senderId != null) getGroupMemberName(senderId) else context.getString(R.string.unknown) - - return when (kind) { - SCREENSHOT -> Phrase.from(context, R.string.screenshotTaken) - .put(NAME_KEY, senderName) - .format() - - MEDIA_SAVED -> Phrase.from(context, R.string.attachmentsMediaSaved) - .put(NAME_KEY, senderName) - .format() - } - } - - fun buildCallMessage(context: Context, type: CallMessageType, senderId: String): String { - val senderName = recipientRepository.getRecipientSync(Address.fromSerialized(senderId)) - .displayName() - - return when (type) { - CALL_INCOMING -> Phrase.from(context, R.string.callsCalledYou).put(NAME_KEY, senderName) - .format().toString() - - CALL_OUTGOING -> Phrase.from(context, R.string.callsYouCalled).put(NAME_KEY, senderName) - .format().toString() - - CALL_MISSED, CALL_FIRST_MISSED -> Phrase.from(context, R.string.callsMissedCallFrom) - .put(NAME_KEY, senderName).format().toString() - } + companion object { + private const val TAG = "MessageFormatter" } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 3fbdd1a794..1a7999eb6c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -159,7 +159,7 @@ class VisibleMessageView : FrameLayout { next: MessageRecord? = null, glide: RequestManager = Glide.with(this), searchQuery: String? = null, - lastSeen: Long, + lastSeen: Long?, lastSentMessageId: MessageId?, delegate: VisibleMessageViewDelegate? = null, downloadPendingAttachment: (DatabaseAttachment) -> Unit, @@ -211,7 +211,7 @@ class VisibleMessageView : FrameLayout { Avatar( size = LocalDimensions.current.iconMediumAvatar, data = avatarUtils.getUIDataFromRecipient(sender), - badge = if(showProBadge) AvatarBadge.Admin else AvatarBadge.None, + badge = if(showProBadge) AvatarBadge.ResourceBadge.Admin else AvatarBadge.None, modifier = Modifier.clickable { delegate?.showUserProfileModal(message.recipient) } @@ -254,7 +254,7 @@ class VisibleMessageView : FrameLayout { } // Unread marker - val shouldShowUnreadMarker = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing + val shouldShowUnreadMarker = lastSeen != null && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing if (shouldShowUnreadMarker) { markerContainerBinding.value.root.isVisible = true } else if (markerContainerBinding.isInitialized()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt index d058fb29f9..2492c99893 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt @@ -80,6 +80,8 @@ fun ConversationSettingsDialogs( buttons.add( DialogButtonData( text = GetString(dialogsState.showSimpleDialog.negativeText), + color = if (dialogsState.showSimpleDialog.negativeStyleDanger) LocalColors.current.danger + else LocalColors.current.text, qaTag = dialogsState.showSimpleDialog.negativeQaTag, onClick = dialogsState.showSimpleDialog.onNegative ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 771dfd29dd..3460c6f325 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -6,12 +6,15 @@ import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.dropUnlessResumed import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.NavBackStackEntry import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute @@ -26,17 +29,24 @@ import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.Disappear import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.* import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsScreen import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsViewModel -import org.thoughtcrime.securesms.groups.EditGroupViewModel +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel import org.thoughtcrime.securesms.groups.GroupMembersViewModel -import org.thoughtcrime.securesms.groups.SelectContactsViewModel -import org.thoughtcrime.securesms.groups.compose.EditGroupScreen +import org.thoughtcrime.securesms.groups.InviteMembersViewModel +import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel +import org.thoughtcrime.securesms.groups.compose.ManageGroupMembersScreen import org.thoughtcrime.securesms.groups.compose.GroupMembersScreen -import org.thoughtcrime.securesms.groups.compose.GroupMinimumVersionBanner +import org.thoughtcrime.securesms.groups.compose.InviteAccountIdScreen import org.thoughtcrime.securesms.groups.compose.InviteContactsScreen +import org.thoughtcrime.securesms.groups.compose.ManageGroupAdminsScreen +import org.thoughtcrime.securesms.groups.compose.PromoteMembersScreen +import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessageViewModel +import org.thoughtcrime.securesms.home.startconversation.newmessage.State import org.thoughtcrime.securesms.media.MediaOverviewScreen import org.thoughtcrime.securesms.media.MediaOverviewViewModel import org.thoughtcrime.securesms.ui.NavigationAction import org.thoughtcrime.securesms.ui.ObserveAsEvents +import org.thoughtcrime.securesms.ui.OpenURLAlertDialog import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.ui.handleIntent import org.thoughtcrime.securesms.ui.horizontalSlideComposable @@ -67,6 +77,30 @@ sealed interface ConversationSettingsDestination: Parcelable { val groupAddress: Address.Group get() = Address.Group(AccountId(address)) } + @Serializable + @Parcelize + data class RouteManageAdmins private constructor( + private val address: String, + val navigateToPromoteMembers: Boolean = false + ) : ConversationSettingsDestination { + constructor(groupAddress: Address.Group, navigateToPromoteMembers: Boolean = false) : this( + groupAddress.address, + navigateToPromoteMembers + ) + + val groupAddress: Address.Group get() = Address.Group(AccountId(address)) + } + + @Serializable + @Parcelize + data class RoutePromoteMembers( + private val address: String + ): ConversationSettingsDestination { + constructor(groupAddress: Address.Group): this(groupAddress.address) + + val groupAddress: Address.Group get() = Address.Group(AccountId(address)) + } + @Serializable @Parcelize data class RouteInviteToGroup private constructor( @@ -96,6 +130,18 @@ sealed interface ConversationSettingsDestination: Parcelable { data class RouteInviteToCommunity( val communityUrl: String ): ConversationSettingsDestination + + @Serializable + @Parcelize + data class RouteInviteAccountIdToGroup private constructor( + private val address: String, + val excludingAccountIDs: List + ): ConversationSettingsDestination { + constructor(groupAddress: Address.Group, excludingAccountIDs: List) + : this(groupAddress.address, excludingAccountIDs) + + val groupAddress: Address.Group get() = Address.Group(AccountId(address)) + } } @SuppressLint("RestrictedApi") @@ -185,20 +231,29 @@ fun ConversationSettingsNavHost( val data: RouteManageMembers = backStackEntry.toRoute() val viewModel = - hiltViewModel { factory -> - factory.create(data.groupAddress) + hiltViewModel { factory -> + factory.create(data.groupAddress, navigator) } - EditGroupScreen( + ManageGroupMembersScreen( viewModel = viewModel, - navigateToInviteContact = { - navController.navigate( - RouteInviteToGroup( - groupAddress = data.groupAddress, - excludingAccountIDs = viewModel.excludingAccountIDsFromContactSelection.toList() - ) - ) + onBack = dropUnlessResumed { + handleBack() }, + ) + } + + // Manage group Admins + horizontalSlideComposable { backStackEntry -> + val data: RouteManageAdmins = backStackEntry.toRoute() + + val viewModel = + hiltViewModel { factory -> + factory.create(data.groupAddress, navigator, data.navigateToPromoteMembers) + } + + ManageGroupAdminsScreen( + viewModel = viewModel, onBack = dropUnlessResumed { handleBack() }, @@ -210,8 +265,9 @@ fun ConversationSettingsNavHost( val data: RouteInviteToGroup = backStackEntry.toRoute() val viewModel = - hiltViewModel { factory -> + hiltViewModel { factory -> factory.create( + groupAddress = data.groupAddress, excludingAccountIDs = data.excludingAccountIDs.map(Address::fromSerialized).toSet() ) } @@ -222,29 +278,26 @@ fun ConversationSettingsNavHost( RouteManageMembers(data.groupAddress) ) } - val editGroupViewModel: EditGroupViewModel = hiltViewModel(parentEntry) + val manageGroupMembersViewModel: ManageGroupMembersViewModel = hiltViewModel(parentEntry) InviteContactsScreen( viewModel = viewModel, - onDoneClicked = dropUnlessResumed { + onDoneClicked = { shareHistory -> //send invites from the manage group screen - editGroupViewModel.onContactSelected(viewModel.currentSelected) - + manageGroupMembersViewModel.onSendInviteClicked(viewModel.currentSelected, shareHistory) handleBack() }, onBack = dropUnlessResumed { handleBack() }, - banner = { - GroupMinimumVersionBanner() - } + forCommunity = false ) } // Invite Contacts to community horizontalSlideComposable { backStackEntry -> val viewModel = - hiltViewModel { factory -> + hiltViewModel { factory -> factory.create() } @@ -264,10 +317,94 @@ fun ConversationSettingsNavHost( // clear selected contacts viewModel.clearSelection() + handleBack() + }, + onBack = dropUnlessResumed { + handleBack() + }, + forCommunity = true + ) + } + + // Invite contacts using Account ID + horizontalSlideComposable { backStackEntry -> + val data: RouteInviteAccountIdToGroup = backStackEntry.toRoute() + + val viewModel = + hiltViewModel { factory -> + factory.create( + groupAddress = data.groupAddress, + excludingAccountIDs = data.excludingAccountIDs.map(Address::fromSerialized).toSet() + ) + } + + val newMessageViewModel = hiltViewModel() + val uiState by newMessageViewModel.state.collectAsState(State()) + + // grab a hold of manage group's VM + val parentEntry = remember(backStackEntry) { + navController.getBackStackEntry( + RouteManageMembers(data.groupAddress) + ) + } + + val manageGroupMembersViewModel: ManageGroupMembersViewModel = hiltViewModel(parentEntry) + + LaunchedEffect(Unit) { + newMessageViewModel.success.collect { success -> + viewModel.sendCommand( + InviteMembersViewModel.Commands.HandleAccountId( + address = success.address + ) + ) + } + } + + InviteAccountIdScreen( + viewModel = viewModel, + state = uiState, + qrErrors = newMessageViewModel.qrErrors, + callbacks = newMessageViewModel, + onBack = { handleBack() }, + onHelp = { newMessageViewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) }, + onDismissHelpDialog = { + newMessageViewModel.onCommand(NewMessageViewModel.Commands.DismissUrlDialog) }, + onSendInvite = { shareHistory -> + manageGroupMembersViewModel.onCommand( + ManageGroupMembersViewModel.Commands.SendInvites( + address = viewModel.currentSelected, + shareHistory = shareHistory + ) + ) + handleBack() + }, + ) + } + + // Promote Members to group Admin + horizontalSlideComposable { backStackEntry -> + val data: RoutePromoteMembers = backStackEntry.toRoute() + + val viewModel = + hiltViewModel { factory -> + factory.create(groupAddress = data.groupAddress) + } + + val parentEntry = remember(backStackEntry) { + navController.previousBackStackEntry ?: error("RouteManageAdmin not in backstack") + } + val manageGroupAdminsViewModel: ManageGroupAdminsViewModel = hiltViewModel(parentEntry) + + PromoteMembersScreen( + viewModel = viewModel, onBack = dropUnlessResumed { handleBack() }, + onPromoteClicked = { selectedMembers -> + manageGroupAdminsViewModel.onSendPromotionsClicked(selectedMembers) + handleBack() + } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index b54cba5abc..64b504f2da 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -11,7 +11,6 @@ import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity.CLIPBOARD_SERVICE import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.compose.viewModel import com.squareup.phrase.Phrase import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -21,6 +20,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -41,12 +41,15 @@ import org.session.libsession.utilities.StringSubstitutionConstants.COMMUNITY_NA import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY +import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.isGroupOrCommunity import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientData import org.session.libsession.utilities.recipients.displayName import org.session.libsession.utilities.updateContact import org.session.libsession.utilities.upsertContact +import org.session.libsession.utilities.withMutableUserConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 @@ -180,7 +183,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( private val optionBlock: OptionsItem by lazy{ OptionsItem( name = context.getString(R.string.block), - icon = R.drawable.ic_user_round_x, + icon = R.drawable.ic_user_round_block, qaTag = R.string.qa_conversation_settings_block, onClick = ::confirmBlockUser ) @@ -280,21 +283,38 @@ class ConversationSettingsViewModel @AssistedInject constructor( ) } - private val optionLeaveGroup: OptionsItem by lazy{ + private val optionManageAdmins: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.manageAdmins), + icon = R.drawable.ic_add_admin_custom, + qaTag = R.string.qa_conversation_settings_manage_admins, + onClick = { + (address as? Address.Group)?.let { + navigateTo(ConversationSettingsDestination.RouteManageAdmins(it)) + } + } + ) + } + + private val optionLeaveGroup: OptionsItem by lazy { OptionsItem( name = context.getString(R.string.groupLeave), icon = R.drawable.ic_log_out, qaTag = R.string.qa_conversation_settings_leave_group, - onClick = ::confirmLeaveGroup + onClick = ::handleLeaveOptionClick ) } - private val optionDeleteGroup: OptionsItem by lazy{ + // Delete group: + // - Admins can delete a group, even if other admins are still in the group + // - Non admins can sometimes see this option if they were kicked out of a group + // In that case "delete" group is a fake delete, it's really only there to remove the "broken" group + private val optionDeleteGroup: OptionsItem by lazy { OptionsItem( name = context.getString(R.string.groupDelete), icon = R.drawable.ic_trash_2, qaTag = R.string.qa_conversation_settings_delete_group, - onClick = ::confirmLeaveGroup + onClick = ::confirmDeleteGroup ) } @@ -567,6 +587,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( dangerOptions.addAll( listOf( optionClearMessages, + optionLeaveGroup, optionDeleteGroup ) ) @@ -575,6 +596,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( adminOptions.addAll( listOf( optionManageMembers, + optionManageAdmins, optionDisappearingMessage(disappearingSubtitle) ) ) @@ -1011,8 +1033,41 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } - private fun confirmLeaveGroup(){ + /** + * Entry point for the "Leave group" menu item. + * + * - For admins, branches to an admin-specific flow (only admin vs multiple admins). + * - For non-admins, just shows a standard leave confirmation and leaves the group. + */ + private fun handleLeaveOptionClick(){ + val groupV2Id = (address as? Address.Group)?.accountId ?: return + val groupInfo = configFactory.getGroup(groupV2Id)?: return + + if(groupInfo.hasAdminKey()){ + confirmAdminLeaveGroup() + }else{ + confirmLeaveGroup() + } + } + + /** + * Admin-specific "Leave group" confirmation. + * + * @param isUserLastAdmin Whether the current user is the only admin. + * + * Behavior: + * - If there is only one admin: + * - Primary action: go to Manage Admins (so they can promote others). + * - Secondary action: open a second confirmation to delete/leave the group. + * + * - If there are multiple admins: + * - Primary action: leave the group without deleting it. + * - Secondary action: do nothing. + */ + private fun confirmAdminLeaveGroup(){ val groupV2Id = (address as? Address.Group)?.accountId ?: return + val isUserLastAdmin = groupManager.isCurrentUserLastAdmin(groupV2Id) + _dialogState.update { state -> val dialogData = groupManager.getLeaveGroupConfirmationDialogData( groupV2Id, @@ -1025,27 +1080,121 @@ class ConversationSettingsViewModel @AssistedInject constructor( message = dialogData.message, positiveText = context.getString(dialogData.positiveText), negativeText = context.getString(dialogData.negativeText), - positiveQaTag = dialogData.positiveQaTag?.let{ context.getString(it) }, - negativeQaTag = dialogData.negativeQaTag?.let{ context.getString(it) }, - onPositive = ::leaveGroup, - onNegative = {} + positiveQaTag = dialogData.positiveQaTag?.let { context.getString(it) }, + negativeQaTag = dialogData.negativeQaTag?.let { context.getString(it) }, + onPositive = { + if (isUserLastAdmin){// option to add new admin(s) + // Calling this to have the ManageAdminScreen in the backstack so we can + // get its VM and PromoteMembersScreen can navigate back to it after sending promotions + navigateTo( + ConversationSettingsDestination.RouteManageAdmins( + groupAddress = address, + navigateToPromoteMembers = true + ) + ) + }else{ + // there are other admins so admin can leave without deleting + leaveGroup() + } + }, + positiveStyleDanger = !isUserLastAdmin, + onNegative = { + // Show confirmation dialog to delete or leave the group + // put True here since this option is to "Delete Group" + if (isUserLastAdmin){ + // with how we handle dialog dismissal on option click, showing the simpleDialog + // from another simpleDialog without delay will cause it to become null and not display + viewModelScope.launch { + delay(200) + confirmDeleteGroup() + } + } + }, + showXIcon = dialogData.showCloseButton, + negativeStyleDanger = isUserLastAdmin // red color on the right + ) + ) + } + } + + private fun confirmDeleteGroup() { + val groupV2Id = (address as? Address.Group)?.accountId ?: return + val groupInfo = configFactory.getGroup(groupV2Id) ?: return + _dialogState.update { state -> + val dialogData = groupManager.getDeleteGroupConfirmationDialogData( + groupV2Id, + _uiState.value.name + ) ?: return + + state.copy( + showSimpleDialog = SimpleDialogData( + title = dialogData.title, + message = dialogData.message, + positiveText = context.getString(dialogData.positiveText), + negativeText = context.getString(dialogData.negativeText), + positiveQaTag = dialogData.positiveQaTag?.let { context.getString(it) }, + negativeQaTag = dialogData.negativeQaTag?.let { context.getString(it) }, + onPositive = { leaveGroup(deleteGroup = groupInfo.hasAdminKey()) }, + showXIcon = dialogData.showCloseButton + ) + ) + } + } + + /** + * Show the confirmation dialog for leaving the group. + * + * This is used for: + * - Non-admins leaving the group + * - Admins confirming "Delete group" + * - Users cleaning up a kicked/destroyed group + * + * @param deleteGroup this will be passed on to [leaveGroup] to determine if + * we want to Delete the group or simply Leave. + */ + private fun confirmLeaveGroup() { + val groupV2Id = (address as? Address.Group)?.accountId ?: return + _dialogState.update { state -> + val dialogData = groupManager.getLeaveGroupConfirmationDialogData( + groupV2Id, + _uiState.value.name + ) ?: return + + state.copy( + showSimpleDialog = SimpleDialogData( + title = dialogData.title, + message = dialogData.message, + positiveText = context.getString(dialogData.positiveText), + negativeText = context.getString(dialogData.negativeText), + positiveQaTag = dialogData.positiveQaTag?.let { context.getString(it) }, + negativeQaTag = dialogData.negativeQaTag?.let { context.getString(it) }, + onPositive = { leaveGroup() }, + showXIcon = dialogData.showCloseButton ) ) } } - private fun leaveGroup() { + /** + * @param deleteGroup will determine if we want to Delete the group or simply leave. + * + * Note that the worker will always delete the group if the only admin tries to leave. + */ + private fun leaveGroup(deleteGroup: Boolean = false) { val conversation = recipient ?: return viewModelScope.launch { showLoading() try { withContext(Dispatchers.Default) { - groupManagerV2.leaveGroup(AccountId(conversation.address.toString())) + groupManagerV2.leaveGroup( + groupId = AccountId(conversation.address.toString()), + deleteGroup = deleteGroup + ) } hideLoading() goBackHome() - } catch (e: Exception){ + } catch (e: Exception) { hideLoading() val txt = Phrase.from(context, R.string.groupLeaveErrorFailed) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt index 5b6fe0ff06..5a037e060f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt @@ -4,27 +4,17 @@ import android.content.Context import android.graphics.Typeface import android.text.Spannable import android.text.SpannableString -import android.text.SpannableStringBuilder import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan import android.util.Range import network.loki.messenger.R -import network.loki.messenger.libsession_util.util.BlindKeyAPI -import nl.komponents.kovenant.combine.Tuple2 -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.open_groups.OpenGroup -import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.getColorFromAttr -import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.displayName -import org.session.libsession.utilities.truncateIdForDisplay import org.thoughtcrime.securesms.conversation.v2.mention.MentionEditable import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel import org.thoughtcrime.securesms.database.RecipientRepository -import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.RoundedBackgroundSpan import org.thoughtcrime.securesms.util.getAccentColor import java.util.regex.Pattern @@ -84,7 +74,7 @@ object MentionUtilities { @Suppress("NAME_SHADOWING") var text = text var matcher = pattern.matcher(text) - val mentions = mutableListOf, String>>() + val mentions = mutableListOf, String>>() var startIndex = 0 // Format the mention text @@ -103,7 +93,7 @@ object MentionUtilities { text = text.subSequence(0, matcher.start()).toString() + mention + text.subSequence(matcher.end(), text.length) val endIndex = matcher.start() + 1 + userDisplayName.length startIndex = endIndex - mentions.add(Tuple2(Range.create(matcher.start(), endIndex), publicKey)) + mentions.add(Pair(Range.create(matcher.start(), endIndex), publicKey)) matcher = pattern.matcher(text) if (!matcher.find(startIndex)) { break } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt index 1e3fb60b8c..baeffda3f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt @@ -35,6 +35,7 @@ class ResendMessageUtilities @Inject constructor( message.openGroupInvitation = openGroupInvitation } else { message.text = messageRecord.body + message.proFeatures = messageRecord.proFeatures } message.sentTimestamp = messageRecord.timestamp if (recipient.isGroupOrCommunity) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/BlindMappingRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/database/BlindMappingRepository.kt index 75c7f53cf3..eca80d52bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/BlindMappingRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/BlindMappingRepository.kt @@ -11,9 +11,9 @@ import kotlinx.coroutines.flow.stateIn import network.loki.messenger.libsession_util.util.BaseCommunityInfo import network.loki.messenger.libsession_util.util.BlindKeyAPI import org.session.libsession.utilities.Address -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.userConfigsChanged +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.auth.LoginStateRepository diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt index 6f2e3ec679..e756cf542c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt @@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context +import androidx.collection.arrayMapOf +import androidx.sqlite.db.SupportSQLiteDatabase import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPublicKey @@ -9,13 +11,12 @@ import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.session.libsignal.utilities.ForkInfo import org.session.libsignal.utilities.Hex -import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.PublicKeyValidation import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.removingIdPrefixIfNeeded import org.session.libsignal.utilities.toHexString -import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.util.asSequence import java.util.Date import javax.inject.Provider @@ -27,15 +28,18 @@ class LokiAPIDatabase(context: Context, helper: Provider) : private const val timestamp = "timestamp" private const val snode = "snode" // Snode pool - public val snodePoolTable = "loki_snode_pool_cache" + @Deprecated("Only available for migration purposes. The table is already deleted") + val snodePoolTable = "loki_snode_pool_cache" private val dummyKey = "dummy_key" private val snodePool = "snode_pool_key" @JvmStatic val createSnodePoolTableCommand = "CREATE TABLE $snodePoolTable ($dummyKey TEXT PRIMARY KEY, $snodePool TEXT);" + @Deprecated("Only available for migration purposes. The table is already deleted") // Onion request paths - private val onionRequestPathTable = "loki_path_cache" + val onionRequestPathTable = "loki_path_cache" private val indexPath = "index_path" @JvmStatic val createOnionRequestPathTableCommand = "CREATE TABLE $onionRequestPathTable ($indexPath TEXT PRIMARY KEY, $snode TEXT);" // Swarms + @Deprecated("Only available for migration purposes. The table is already deleted") public val swarmTable = "loki_api_swarm_cache" private val swarmPublicKey = "hex_encoded_public_key" private val swarm = "swarm" @@ -172,112 +176,66 @@ class LokiAPIDatabase(context: Context, helper: Provider) : const val RESET_SEQ_NO = "UPDATE $lastMessageServerIDTable SET $lastMessageServerID = 0;" // endregion - } - - override fun getSnodePool(): Set { - val database = readableDatabase - return database.get(snodePoolTable, "${Companion.dummyKey} = ?", wrap("dummy_key")) { cursor -> - val snodePoolAsString = cursor.getString(cursor.getColumnIndexOrThrow(snodePool)) - snodePoolAsString.split(", ").mapNotNull(::Snode) - }?.toSet() ?: setOf() - } - override fun setSnodePool(newValue: Set) { - val database = writableDatabase - val snodePoolAsString = newValue.joinToString(", ") { snode -> - var string = "${snode.address}-${snode.port}" - val keySet = snode.publicKeySet - if (keySet != null) { - string += "-${keySet.ed25519Key}-${keySet.x25519Key}" - } - string += "-${snode.version}" - string + @Deprecated("Only available for migration purposes") + fun getSnodePool(database: SupportSQLiteDatabase): List { + return database.query("SELECT * FROM $snodePoolTable WHERE ${dummyKey} = ?", wrap("dummy_key")).use { cursor -> + if (cursor.moveToNext()) { + val snodePoolAsString = + cursor.getString(cursor.getColumnIndexOrThrow(snodePool)) + snodePoolAsString.split(", ").mapNotNull(::Snode) + } else { + null + } + }?.toList().orEmpty() } - val row = wrap(mapOf( Companion.dummyKey to "dummy_key", snodePool to snodePoolAsString )) - database.insertOrUpdate(snodePoolTable, row, "${Companion.dummyKey} = ?", wrap("dummy_key")) - } - override fun setOnionRequestPaths(newValue: List>) { - // FIXME: This approach assumes either 1 or 2 paths of length 3 each. We should do better than this. - val database = writableDatabase - fun set(indexPath: String, snode: Snode) { - var snodeAsString = "${snode.address}-${snode.port}" - val keySet = snode.publicKeySet - if (keySet != null) { - snodeAsString += "-${keySet.ed25519Key}-${keySet.x25519Key}" + @Deprecated("Only available for migration purposes") + fun getOnionRequestPaths(database: SupportSQLiteDatabase): List> { + fun get(indexPath: String): Snode? { + return database.query("SELECT * FROM $onionRequestPathTable WHERE ${Companion.indexPath} = ?", wrap(indexPath)).use { cursor -> + if (cursor.moveToNext()) { + Snode(cursor.getString(cursor.getColumnIndexOrThrow(snode))) + } else { + null + } + } } - snodeAsString += "-${snode.version}" - val row = wrap(mapOf( Companion.indexPath to indexPath, Companion.snode to snodeAsString )) - database.insertOrUpdate(onionRequestPathTable, row, "${Companion.indexPath} = ?", wrap(indexPath)) - } - Log.d("Loki", "Persisting onion request paths to database.") - clearOnionRequestPaths() - if (newValue.count() < 1) { return } - val path0 = newValue[0] - if (path0.count() != 3) { return } - set("0-0", path0[0]); set("0-1", path0[1]); set("0-2", path0[2]) - if (newValue.count() < 2) { return } - val path1 = newValue[1] - if (path1.count() != 3) { return } - set("1-0", path1[0]); set("1-1", path1[1]); set("1-2", path1[2]) - } - - override fun getOnionRequestPaths(): List> { - val database = readableDatabase - fun get(indexPath: String): Snode? { - return database.get(onionRequestPathTable, "${Companion.indexPath} = ?", wrap(indexPath)) { cursor -> - Snode(cursor.getString(cursor.getColumnIndexOrThrow(snode))) + val result = mutableListOf>() + val path0Snode0 = get("0-0"); val path0Snode1 = get("0-1"); val path0Snode2 = get("0-2") + if (path0Snode0 != null && path0Snode1 != null && path0Snode2 != null) { + result.add(listOf( path0Snode0, path0Snode1, path0Snode2 )) } + val path1Snode0 = get("1-0"); val path1Snode1 = get("1-1"); val path1Snode2 = get("1-2") + if (path1Snode0 != null && path1Snode1 != null && path1Snode2 != null) { + result.add(listOf( path1Snode0, path1Snode1, path1Snode2 )) + } + return result } - val result = mutableListOf>() - val path0Snode0 = get("0-0"); val path0Snode1 = get("0-1"); val path0Snode2 = get("0-2") - if (path0Snode0 != null && path0Snode1 != null && path0Snode2 != null) { - result.add(listOf( path0Snode0, path0Snode1, path0Snode2 )) - } - val path1Snode0 = get("1-0"); val path1Snode1 = get("1-1"); val path1Snode2 = get("1-2") - if (path1Snode0 != null && path1Snode1 != null && path1Snode2 != null) { - result.add(listOf( path1Snode0, path1Snode1, path1Snode2 )) - } - return result - } - override fun clearSnodePool() { - val database = writableDatabase - database.delete(snodePoolTable, null, null) - } - override fun clearOnionRequestPaths() { - val database = writableDatabase - fun delete(indexPath: String) { - database.delete(onionRequestPathTable, "${Companion.indexPath} = ?", wrap(indexPath)) - } - delete("0-0"); delete("0-1") - delete("0-2"); delete("1-0") - delete("1-1"); delete("1-2") - } + @Deprecated("Only available for migration purposes") + fun getSwarms(database: SupportSQLiteDatabase): Map> { + return database.query("SELECT * FROM $swarmTable").use { cursor -> + val swarmIndex = cursor.getColumnIndexOrThrow(swarm) + val pubKeyIndex = cursor.getColumnIndexOrThrow(swarmPublicKey) - override fun getSwarm(publicKey: String): Set? { - val database = readableDatabase - return database.get(swarmTable, "${Companion.swarmPublicKey} = ?", wrap(publicKey)) { cursor -> - val swarmAsString = cursor.getString(cursor.getColumnIndexOrThrow(swarm)) - swarmAsString.split(", ").mapNotNull(::Snode) - }?.toSet() - } + cursor.asSequence() + .associate { + val pubKey = cursor.getString(pubKeyIndex) + val swarmNodes = + cursor.getString(swarmIndex) + .splitToSequence(", ") + .mapNotNull(::Snode) + .toList() - override fun setSwarm(publicKey: String, newValue: Set) { - val database = writableDatabase - val swarmAsString = newValue.joinToString(", ") { target -> - var string = "${target.address}-${target.port}" - val keySet = target.publicKeySet - if (keySet != null) { - string += "-${keySet.ed25519Key}-${keySet.x25519Key}" + pubKey to swarmNodes + } } - string } - val row = wrap(mapOf( Companion.swarmPublicKey to publicKey, swarm to swarmAsString )) - database.insertOrUpdate(swarmTable, row, "${Companion.swarmPublicKey} = ?", wrap(publicKey)) } + override fun getLastMessageHashValue(snode: Snode, publicKey: String, namespace: Int): String? { val database = readableDatabase val query = "${Companion.snode} = ? AND ${Companion.publicKey} = ? AND $lastMessageHashNamespace = ?" @@ -436,16 +394,6 @@ class LokiAPIDatabase(context: Context, helper: Provider) : database.insertOrUpdate(openGroupPublicKeyTable, row, "${LokiAPIDatabase.server} = ?", wrap(server)) } - override fun getLastSnodePoolRefreshDate(): Date? { - val time = TextSecurePreferences.getLastSnodePoolRefreshDate(context) - if (time <= 0) { return null } - return Date(time) - } - - override fun setLastSnodePoolRefreshDate(date: Date) { - TextSecurePreferences.setLastSnodePoolRefreshDate(context, date) - } - fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) { val database = writableDatabase val index = "$groupPublicKey-$timestamp" @@ -513,6 +461,10 @@ class LokiAPIDatabase(context: Context, helper: Provider) : }?.split(",") } + fun clearServerCapabilities(serverName: String) { + writableDatabase.delete(serverCapabilitiesTable, "$server = ?", wrap(serverName)) + } + fun setLastInboxMessageId(serverName: String, newValue: Long) { val database = writableDatabase val row = wrap(mapOf(server to serverName, lastInboxMessageServerId to newValue.toString())) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt index 2283612690..3454dec087 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt @@ -208,7 +208,7 @@ class LokiMessageDatabase(context: Context, helper: Provider(messageID.id.toString(), messageID.asMessageType)) } fun deleteThread(threadId: Long) { @@ -263,7 +263,7 @@ class LokiMessageDatabase(context: Context, helper: Provider(threadId, JSONArray(hashes).toString())) .use { cursor -> cursor.asSequence() .map { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index 70a4e50947..047af1b67c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -34,7 +34,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled @@ -79,6 +79,7 @@ class MmsDatabase @Inject constructor( private val reactionDatabase: ReactionDatabase, private val mmsSmsDatabase: Lazy, private val groupDatabase: GroupDatabase, + private val snodeClock: SnodeClock ) : MessagingDatabase(context, databaseHelper) { private val earlyDeliveryReceiptCache = EarlyReceiptCache() private val earlyReadReceiptCache = EarlyReceiptCache() @@ -585,7 +586,7 @@ class MmsDatabase @Inject constructor( // In open groups messages should be sorted by their server timestamp var receivedTimestamp = serverTimestamp if (serverTimestamp == 0L) { - receivedTimestamp = SnodeAPI.nowWithOffset + receivedTimestamp = snodeClock.currentTimeMillis() } contentValues.put(DATE_RECEIVED, receivedTimestamp) contentValues.put(EXPIRES_IN, message.expiresInMillis) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index 1aa7363678..ac545510fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -187,7 +187,7 @@ public MessageId getLastSentMessageID(long threadId) { public Cursor getConversation(long threadId, boolean reverse, long offset, long limit) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + (reverse ? " DESC" : " ASC"); - String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; + String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + THREAD_ID + " != " + -1L; String limitStr = limit > 0 || offset > 0 ? offset + ", " + limit : null; return queryTables(PROJECTION_ALL, selection, true, null, order, limitStr); @@ -397,7 +397,7 @@ private String buildOutgoingTypesList() { } public int getUnreadCount(long threadId) { - String selection = READ + " = 0 AND " + NOTIFIED + " = 0 AND " + MmsSmsColumns.THREAD_ID + " = " + threadId; + String selection = READ + " = 0 AND " + NOTIFIED + " = 0 AND " + MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.THREAD_ID + " != " + -1L; try (Cursor cursor = queryTables(ID, selection, true, null, null, null)) { return cursor != null ? cursor.getCount() : 0; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt index b0846c1d69..032bdf2153 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt @@ -33,7 +33,7 @@ import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.GroupInfo import org.session.libsession.messaging.open_groups.GroupMemberRole import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol @@ -48,6 +48,8 @@ import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemote import org.session.libsession.utilities.toBlinded import org.session.libsession.utilities.toGroupString import org.session.libsession.utilities.userConfigsChanged +import org.session.libsession.utilities.withGroupConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.auth.LoginStateRepository @@ -200,11 +202,11 @@ class RecipientRepository @Inject constructor( ) val changeSources: MutableList>? - val value: Recipient + val recipient: Recipient when (configData) { is RecipientData.Self -> { - value = createLocalRecipient(address, configData) + recipient = createLocalRecipient(address, configData) changeSources = if (needFlow) { arrayListOf( configFactory.userConfigsChanged(onlyConfigTypes = EnumSet.of(UserConfigType.USER_PROFILE)), @@ -219,7 +221,7 @@ class RecipientRepository @Inject constructor( } is RecipientData.BlindedContact -> { - value = Recipient(address = address, data = configData) + recipient = Recipient(address = address, data = configData) changeSources = if (needFlow) { arrayListOf( @@ -233,7 +235,7 @@ class RecipientRepository @Inject constructor( } is RecipientData.Contact -> { - value = createContactRecipient( + recipient = createContactRecipient( address = address, configData = configData, fallbackSettings = settingsFetcher(address) @@ -252,7 +254,7 @@ class RecipientRepository @Inject constructor( } is RecipientData.Group -> { - value = createGroupV2Recipient( + recipient = createGroupV2Recipient( address = address, proDataContext = proDataContext, configData = configData, @@ -319,9 +321,8 @@ class RecipientRepository @Inject constructor( } else { null } - value = group?.let { + recipient = group?.let { createLegacyGroupRecipient( - proDataContext = proDataContext, address = address, config = groupConfig, group = it, @@ -336,7 +337,7 @@ class RecipientRepository @Inject constructor( } is Address.Community -> { - value = configFactory.withUserConfigs { + recipient = configFactory.withUserConfigs { it.userGroups.getCommunityInfo(address.serverUrl, address.room) }?.let { groupConfig -> createCommunityRecipient( @@ -372,7 +373,7 @@ class RecipientRepository @Inject constructor( // members: val allGroups = configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() } - value = allGroups + recipient = allGroups .asSequence() .mapNotNull { groupInfo -> configFactory.withGroupConfigs(AccountId(groupInfo.groupAccountId)) { @@ -382,7 +383,7 @@ class RecipientRepository @Inject constructor( .firstOrNull() ?.let { groupMember -> fetchGroupMember( - proDataContext = proDataContext, + groupProDataContext = proDataContext, member = groupMember, settingsFetcher = settingsFetcher ) @@ -408,7 +409,7 @@ class RecipientRepository @Inject constructor( } else -> { - value = createGenericRecipient( + recipient = createGenericRecipient( address = address, proDataContext = proDataContext, settings = settings @@ -429,52 +430,72 @@ class RecipientRepository @Inject constructor( } // Calculate the ProData for this recipient - val proDataList = proDataContext?.proDataList - var proData = if (!proDataList.isNullOrEmpty()) { - proDataList.removeAll { - it.isExpired(now) || proDatabase.isRevoked(it.genIndexHash) - } + val updatedValue = resolveProStatus(recipient, proDataContext) - // The pro data that goes into the recipient, should show the one with the most information - proDataList.firstOrNull { it.showProBadge }?.let { - RecipientData.ProData(it.showProBadge) - } + // FLOW MANAGEMENT + // We still need to access the proDataList to schedule flow updates (timers) + // Since resolveProStatus filtered the list inside the context/recipient logic, + // we should look at the proDataContext list again (which might need re-filtering here or + // relied upon if resolveProStatus modified the list in place) + + // Safety: Let's filter again for the flow logic to be 100% sure we are only setting timers for valid proofs + val validProDataList = proDataContext?.proDataList?.filter { + !it.isExpired(now) && !proDatabase.isRevoked(it.genIndexHash) + } + + if (changeSources != null && !validProDataList.isNullOrEmpty()) { + val earliestProExpiry = validProDataList.minOf { it.expiry } + val delayMills = Duration.between(now, earliestProExpiry).toMillis() + changeSources.add(flowOf("Pro proof expires").onStart { delay(delayMills) }) + } + + changeSources?.add(proStatusManager.get().postProLaunchStatus.drop(1)) + + return updatedValue to changeSources?.let { merge(*it.toTypedArray()) } + } + + /** + * Resolves the final Pro status for a recipient. + * 1. Filters expired/revoked proofs. + * 2. Checks Debug preferences overrides. + * 3. Updates the recipient data with the final result. + */ + private fun resolveProStatus( + recipient: Recipient, + context: ProDataContext? + ): Recipient { + val now = snodeClock.get().currentTime() + val proDataList = context?.proDataList + + // 1. Filter invalid proofs + proDataList?.removeAll { + it.isExpired(now) || proDatabase.isRevoked(it.genIndexHash) + } + + // 2. Determine base Pro Data from valid proofs + var proData = if (!proDataList.isNullOrEmpty()) { + RecipientData.ProData(showProBadge = proDataList.any { it.showProBadge }) } else { null } - // Debug overrides from preferences - if (value.isSelf && proData == null && prefs.forceCurrentUserAsPro()) { + // 3. Apply Debug Overrides + if (recipient.isSelf && proData == null && prefs.forceCurrentUserAsPro()) { proData = RecipientData.ProData(showProBadge = true) - } else if (!value.isSelf - && (value.address is Address.Standard || value.address is Address.Group) + } else if (!recipient.isSelf + && (recipient.address is Address.Standard) && proData == null - && prefs.forceOtherUsersAsPro()) { + && prefs.forceOtherUsersAsPro() + ) { proData = RecipientData.ProData(showProBadge = true) } - val updatedValue = if (value.data.proData != proData && proData != null) { - value.copy(data = value.data.setProData(proData)) + // 4. Update Recipient if data changed + return if (recipient.data.proData != proData && proData != null) { + recipient.copy(data = recipient.data.setProData(proData)) } else { - value + recipient } - - if (changeSources != null && !proDataList.isNullOrEmpty()) { - // If we have valid pro data, we need to add a flow to trigger a re-fetch when - // the earliest pro proof expires. - val earliestProExpiry = proDataList.minOf { it.expiry } - - val delayMills = Duration.between(now, earliestProExpiry).toMillis() - // Add a flow that triggers when the pro proof expires - changeSources.add(flowOf("Pro proof expires") - .onStart { delay(delayMills) }) - } - - // Add post-pro status as a change source to ensure recipients are updated after - // post-pro launch flag is toggled. - changeSources?.add(proStatusManager.get().postProLaunchStatus.drop(1)) - - return updatedValue to changeSources?.let { merge(*it.toTypedArray()) } } /** @@ -482,15 +503,22 @@ class RecipientRepository @Inject constructor( * for a group member purpose. */ private inline fun fetchGroupMember( - proDataContext: ProDataContext?, + groupProDataContext: ProDataContext?, // The GROUP'S context member: RecipientData.GroupMemberInfo, settingsFetcher: (address: Address) -> RecipientSettings ): Recipient { - return when (val configData = getDataFromConfig(member.address, proDataContext)) { + // 1. Create a local context specifically for this member + val memberProDataContext = if (proStatusManager.get().postProLaunchStatus.value) { + ProDataContext() + } else { + null + } + + // 2. Fetch the basic recipient data + val rawRecipient = when (val configData = getDataFromConfig(member.address, memberProDataContext)) { is RecipientData.Self -> { createLocalRecipient(member.address, configData) } - is RecipientData.Contact -> { createContactRecipient( address = member.address, @@ -498,26 +526,45 @@ class RecipientRepository @Inject constructor( fallbackSettings = settingsFetcher(member.address) ) } - else -> { // If we don't have the right config data, we can still create a generic recipient // with the settings fetched from the database. createGenericRecipient( address = member.address, - proDataContext = proDataContext, + proDataContext = memberProDataContext, settings = settingsFetcher(member.address), groupMemberInfo = member ) } } + + // 3. Resolve the MEMBER'S pro status + val resolvedMember = resolveProStatus(rawRecipient, memberProDataContext) + + // 4. Logic: If Member is Admin, their proofs contribute to the Group's Pro Status. + // We copy the data from the member's context to the parent (Group) context. + if (member.isAdmin && groupProDataContext != null && memberProDataContext?.proDataList != null) { + memberProDataContext.proDataList?.forEach { + groupProDataContext.addProData(it) + } + } + + return resolvedMember } private inline fun fetchLegacyGroupMember( address: Address.Standard, - proDataContext: ProDataContext?, settingsFetcher: (address: Address) -> RecipientSettings, ): Recipient { - return when (val configData = getDataFromConfig(address, proDataContext)) { + // 1. Create Local Context + val memberProDataContext = if (proStatusManager.get().postProLaunchStatus.value) { + ProDataContext() + } else { + null + } + + // 2. Fetch Data + val rawRecipient = when (val configData = getDataFromConfig(address, memberProDataContext)) { is RecipientData.Self -> { createLocalRecipient(address, configData) } @@ -535,12 +582,16 @@ class RecipientRepository @Inject constructor( // with the settings fetched from the database. createGenericRecipient( address = address, - proDataContext = proDataContext, + proDataContext = memberProDataContext, settings = settingsFetcher(address), ) } } + // 3. Resolve Member Status + val resolvedMember = resolveProStatus(rawRecipient, memberProDataContext) + + return resolvedMember } suspend fun getRecipient(address: Address): Recipient { @@ -583,7 +634,17 @@ class RecipientRepository @Inject constructor( configFactory.withUserConfigs { configs -> val pro = configs.userProfile.getProConfig() - if (pro != null) { + if (prefs.forceCurrentUserAsPro()) { + proDataContext?.addProData( + RecipientSettings.ProData( + showProBadge = configs.userProfile.getProFeatures().contains( + ProProfileFeature.PRO_BADGE + ), + expiry = Instant.now().plusSeconds(3600), + genIndexHash = "a1b2c3d4", + ) + ) + } else if (pro != null) { proDataContext?.addProData( RecipientSettings.ProData( showProBadge = configs.userProfile.getProFeatures().contains( @@ -650,7 +711,8 @@ class RecipientRepository @Inject constructor( avatar = configs.groupInfo.getProfilePic().toRemoteFile(), expiryMode = configs.groupInfo.expiryMode, name = configs.groupInfo.getName() ?: groupInfo.name, - proData = null, // final ProData will be calculated later + //todo LARGE GROUP hiding group pro status until we enable large groups + //proData = null, // final ProData will be calculated later description = configs.groupInfo.getDescription(), members = configs.groupMembers.all() .asSequence() @@ -753,10 +815,10 @@ class RecipientRepository @Inject constructor( address = address, data = configData.copy( firstMember = configData.members.firstOrNull()?.let { member -> - fetchGroupMember(proDataContext?.takeIf { member.isAdmin }, member, settingsFetcher) + fetchGroupMember(proDataContext, member, settingsFetcher) } ?: getSelf(), // Fallback to have self as first member if no members are present secondMember = configData.members.getOrNull(1)?.let { member -> - fetchGroupMember(proDataContext?.takeIf { member.isAdmin }, member, settingsFetcher) + fetchGroupMember(proDataContext, member, settingsFetcher) }, ), mutedUntil = settings?.muteUntil, @@ -766,7 +828,6 @@ class RecipientRepository @Inject constructor( } private inline fun createLegacyGroupRecipient( - proDataContext: ProDataContext?, address: Address, config: GroupInfo.LegacyGroupInfo?, group: GroupRecord, // Local db data @@ -800,10 +861,10 @@ class RecipientRepository @Inject constructor( } }, firstMember = memberAddresses.firstOrNull() - ?.let { fetchLegacyGroupMember(it, proDataContext, settingsFetcher) } + ?.let { fetchLegacyGroupMember(it, settingsFetcher) } ?: getSelf(), // Fallback to have self as first member if no members are present secondMember = memberAddresses.getOrNull(1) - ?.let { fetchLegacyGroupMember(it, proDataContext, settingsFetcher) }, + ?.let { fetchLegacyGroupMember(it, settingsFetcher) }, isCurrentUserAdmin = Address.Standard(myAccountId) in group.admins ), mutedUntil = settings?.muteUntil, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 7a6204dfe6..48159a509a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -36,7 +36,7 @@ import org.session.libsession.messaging.calls.CallMessageType; import org.session.libsession.messaging.messages.signal.IncomingTextMessage; import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; -import org.session.libsession.snode.SnodeAPI; +import org.session.libsession.network.SnodeClock; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.recipients.Recipient; @@ -55,7 +55,6 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; -import java.util.Set; import javax.inject.Inject; import javax.inject.Provider; @@ -64,9 +63,6 @@ import dagger.Lazy; import dagger.hilt.android.qualifiers.ApplicationContext; import network.loki.messenger.libsession_util.protocol.ProFeature; -import network.loki.messenger.libsession_util.protocol.ProMessageFeature; -import network.loki.messenger.libsession_util.protocol.ProProfileFeature; -import network.loki.messenger.libsession_util.util.BitSet; /** * Database for storage of SMS messages. @@ -146,6 +142,7 @@ public static void addProFeatureColumns(SupportSQLiteDatabase db) { private static final EarlyReceiptCache earlyReadReceiptCache = new EarlyReceiptCache(); private final RecipientRepository recipientRepository; + private final SnodeClock snodeClock; private final Lazy<@NonNull ThreadDatabase> threadDatabase; private final Lazy<@NonNull ReactionDatabase> reactionDatabase; @@ -153,10 +150,12 @@ public static void addProFeatureColumns(SupportSQLiteDatabase db) { public SmsDatabase(@ApplicationContext Context context, Provider databaseHelper, RecipientRepository recipientRepository, + SnodeClock snodeClock, Lazy<@NonNull ThreadDatabase> threadDatabase, Lazy<@NonNull ReactionDatabase> reactionDatabase) { super(context, databaseHelper); this.recipientRepository = recipientRepository; + this.snodeClock = snodeClock; this.threadDatabase = threadDatabase; this.reactionDatabase = reactionDatabase; } @@ -549,7 +548,7 @@ public long insertMessageOutbox(long threadId, OutgoingTextMessage message, contentValues.put(ADDRESS, address.toString()); contentValues.put(THREAD_ID, threadId); contentValues.put(BODY, message.getMessage()); - contentValues.put(DATE_RECEIVED, SnodeAPI.getNowWithOffset()); + contentValues.put(DATE_RECEIVED, snodeClock.currentTimeMillis()); contentValues.put(DATE_SENT, message.getSentTimestampMillis()); contentValues.put(READ, 1); contentValues.put(TYPE, type); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SnodeDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SnodeDatabase.kt new file mode 100644 index 0000000000..e1e7673558 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SnodeDatabase.kt @@ -0,0 +1,686 @@ +package org.thoughtcrime.securesms.database + +import android.database.Cursor +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.transaction +import kotlinx.serialization.json.Json +import org.session.libsession.network.model.Path +import org.session.libsession.network.snode.SnodePathStorage +import org.session.libsession.network.snode.SnodePoolStorage +import org.session.libsession.network.snode.SwarmStorage +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.util.asSequence +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicReference +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton +import kotlin.math.min + +/** + * A database interface for storing snode related data: the snode pool, swarms and onion request + * paths. It consists of 3 tables: + * - snodes: stores all known snodes, including those in the pool, swarms and paths. + * Each snode has a strike count, which can be used to track misbehaving snodes and remove + * them from the pool if necessary. + * + * - swarm_snodes: a mapping table between swarms (identified by pubkey) and snodes (identified by id). + * This table references snodes with a foreign key, so if a snode is removed from the snodes + * table, it will be automatically removed from all swarms as well. + * + * - onion_paths: stores onion request paths. Each path has a strike count as well, which can be used + * to track bad paths and remove them if necessary. + * + * - onion_path_snodes: a mapping table between onion paths (identified by path_id) and snodes (identified by id). + * This table references snodes with a foreign key, but with RESTRICT delete behavior, meaning + * that a snode that is part of an onion path cannot be deleted from the snodes table until + * it is removed from the path. + * This is to prevent accidentally deleting snodes that are still in use in paths, as that could + * lead to loss of all paths that contain that snode. + * + */ +@Singleton +class SnodeDatabase @Inject constructor( + private val helper: Provider, + private val json: Json, +) : SwarmStorage, SnodePathStorage, SnodePoolStorage { + + private val swarmCache = ConcurrentHashMap>() + private val onionPathsCache = AtomicReference>(null) + private val poolCache = AtomicReference>(null) + + private val readableDatabase: SupportSQLiteDatabase get() = helper.get().readableDatabase + private val writableDatabase: SupportSQLiteDatabase get() = helper.get().writableDatabase + + override fun getSwarm(publicKey: String): List { + swarmCache.get(publicKey)?.let { + return it + } + + //language=roomsql + return readableDatabase.query( + """ + SELECT snodes.* + FROM snodes + WHERE snodes.id IN ( + SELECT snode_id + FROM swarm_snodes + WHERE pubkey = ? + ) + """, arrayOf(publicKey) + ).use { cursor -> + val indices = SnodeColumnIndices(cursor) + + cursor.asSequence() + .mapTo(arrayListOf()) { it.toSnode(indices) } + }.also { + swarmCache[publicKey] = it + } + } + + override fun setSwarm( + publicKey: String, + swarm: Collection + ) { + writableDatabase.transaction { + // First delete existing entries for this swarm + execSQL("DELETE FROM swarm_snodes WHERE pubkey = ?", arrayOf(publicKey)) + + // Insert the swarm mapping but only for snodes that exist in the snodes table + //language=roomsql + compileStatement( + """ + INSERT OR REPLACE INTO swarm_snodes (pubkey, snode_id) + SELECT ?1, id + FROM snodes + WHERE ed25519_pub_key = ?2 + """ + ).use { stmt -> + swarm.forEach { snode -> + stmt.clearBindings() + stmt.bindString(1, publicKey) + stmt.bindString(2, snode.ed25519Key) + stmt.execute() + } + } + } + + swarmCache.remove(publicKey) + } + + override fun dropSnodeFromSwarm(publicKey: String, snodeEd25519PubKey: String) { + swarmCache.remove(publicKey) + + //language=roomsql + writableDatabase.execSQL( + """ + DELETE FROM swarm_snodes + WHERE pubkey = ?1 AND snode_id = ( + SELECT id FROM snodes WHERE ed25519_pub_key = ?2 + )""", arrayOf(publicKey, snodeEd25519PubKey) + ) + } + + override fun getOnionRequestPaths(): List { + onionPathsCache.get()?.let { return it } + + class FoldState { + val paths = arrayListOf>() + var lastPathId: Int? = null + } + + //language=roomsql + return readableDatabase.query( + """ + SELECT onion_path_snodes.path_id, snodes.* + FROM snodes + INNER JOIN onion_path_snodes ON onion_path_snodes.snode_id = snodes.id + ORDER BY path_id, onion_path_snodes.position + """, emptyArray() + ).use { cursor -> + val indices = SnodeColumnIndices(cursor) + cursor + .asSequence() + .map { + val pathId = cursor.getInt(0) + pathId to cursor.toSnode(indices) + } + .fold(FoldState()) { state, (pathId, snode) -> + if (state.lastPathId == pathId) { + state.paths.last() += snode + } else { + state.paths += mutableListOf(snode) + } + + state.lastPathId = pathId + state + } + .paths + }.also(onionPathsCache::set) + } + + override fun setOnionRequestPaths(paths: List) { + onionPathsCache.set(null) + writableDatabase.transaction { + class PathInfo( + val strike: Int, + val createdAtMs: Long, + ) + + //language=roomsql + val existingPathInfoByPathKey = query(""" + WITH path_keys AS ($PATH_KEYS_CTE_SQL) + SELECT path_key, strikes, created_at_ms FROM onion_paths + INNER JOIN path_keys ON onion_paths.id = path_keys.path_id + """).use { cursor -> + cursor.asSequence() + .associate { + val pathKey = cursor.getString(0) + pathKey to PathInfo( + strike = cursor.getInt(1), + createdAtMs = cursor.getLong(2), + ) + } + } + + // Clear all paths + //language=roomsql + execSQL("DELETE FROM onion_paths WHERE 1") + + // Insert all paths, retaining path info where applicable + //language=roomsql + compileStatement("INSERT INTO onion_paths (id, created_at_ms, strikes) VALUES (?1, ?2, ?3)") + .use { pathInsertStmt -> + compileStatement( + """ + INSERT OR ABORT INTO onion_path_snodes (path_id, snode_id, position) + SELECT ?1, (SELECT id FROM snodes WHERE ed25519_pub_key = ?2), ?3 + """ + ).use { pathSnodeInsertStmt -> + paths.forEachIndexed { pathIdx, path -> + val pathKey = path.pathKey() + val existingInfo = existingPathInfoByPathKey[pathKey] + + pathInsertStmt.clearBindings() + pathInsertStmt.bindLong(1, pathIdx.toLong()) + pathInsertStmt.bindLong(2, existingInfo?.createdAtMs ?: System.currentTimeMillis()) + pathInsertStmt.bindLong(3, existingInfo?.strike?.toLong() ?: 0L) + pathInsertStmt.execute() + + path.forEachIndexed { snodeIdx, snode -> + pathSnodeInsertStmt.clearBindings() + pathSnodeInsertStmt.bindLong(1, pathIdx.toLong()) + pathSnodeInsertStmt.bindString(2, snode.ed25519Key) + pathSnodeInsertStmt.bindLong(3, snodeIdx.toLong()) + pathSnodeInsertStmt.execute() + } + } + } + } + } + } + + override fun replaceOnionRequestPath( + oldPath: Path, + newPath: Path + ) { + if (oldPath == newPath) { + return + } + + onionPathsCache.set(null) + writableDatabase.transaction { + //language=roomsql + val pathId = query( + """ + WITH path_keys AS ($PATH_KEYS_CTE_SQL) + SELECT path_id FROM path_keys WHERE path_key = ?1 + """, arrayOf(oldPath.pathKey()) + ).use { cursor -> + check(cursor.moveToNext()) { "Old path does not exist" } + + cursor.getInt(0) + } + + // Delete all existing snodes for the path + //language=roomsql + execSQL( + "DELETE FROM onion_path_snodes WHERE path_id = ?1", + arrayOf(pathId) + ) + + // Insert new snodes for the path + //language=roomsql + compileStatement(""" + INSERT OR ABORT INTO onion_path_snodes (path_id, snode_id, position) + SELECT ?1, (SELECT id FROM snodes WHERE ed25519_pub_key = ?2), ?3 + """).use { stmt -> + newPath.forEachIndexed { snodeIdx, snode -> + stmt.clearBindings() + stmt.bindLong(1, pathId.toLong()) + stmt.bindString(2, snode.ed25519Key) + stmt.bindLong(3, snodeIdx.toLong()) + stmt.execute() + } + } + } + } + + override fun removePath(path: Path) { + onionPathsCache.set(null) + + //language=roomsql + writableDatabase.execSQL( + """ + WITH path_keys AS ($PATH_KEYS_CTE_SQL) + DELETE FROM onion_paths + WHERE id = ( + SELECT path_id + FROM path_keys + WHERE path_key = ?1 + ) + """, arrayOf(path.pathKey()) + ) + } + + override fun clearOnionRequestPaths() { + onionPathsCache.set(null) + //language=roomsql + writableDatabase.execSQL("DELETE FROM onion_path_snodes WHERE 1") + } + + override fun increaseOnionRequestPathStrike( + path: Path, + increment: Int + ): Int? { + //language=roomsql + return writableDatabase.query( + """ + WITH path_keys AS ($PATH_KEYS_CTE_SQL) + UPDATE onion_paths + SET strikes = max(0, strikes + ?1) + WHERE id = ( + SELECT path_id + FROM path_keys + WHERE path_key = ?2 + ) + RETURNING strikes + """, arrayOf(increment, path.pathKey()) + ).use { cursor -> + cursor.asSequence() + .map { it.getInt(0) } + .firstOrNull() + } + } + + override fun increaseOnionRequestPathStrikeContainingSnode( + snodeEd25519PubKey: String, + increment: Int + ): Pair? { + return writableDatabase.transaction { + //language=roomsql + val (pathId, newStrikes) = query( + """ + UPDATE onion_paths + SET strikes = max(0, strikes + ?1) + WHERE id IN ( + SELECT ops.path_id + FROM onion_path_snodes AS ops + INNER JOIN snodes ON ops.snode_id = snodes.id + WHERE snodes.ed25519_pub_key = ?2 + ) + RETURNING id, strikes + """, arrayOf(increment, snodeEd25519PubKey) + ).use { cursor -> + if (!cursor.moveToNext()) return null + + cursor.getInt(0) to cursor.getInt(1) + } + + //language=roomsql + query( + """ + SELECT snodes.* + FROM snodes + INNER JOIN onion_path_snodes ON onion_path_snodes.snode_id = snodes.id + WHERE onion_path_snodes.path_id = ?1 + ORDER BY onion_path_snodes.position + """, arrayOf(pathId) + ).use { cursor -> + cursor.toSnodeList() to newStrikes + } + } + } + + override fun findRandomUnusedSnodesForNewPath(n: Int): List { + require(n > 0) { + "n(number of snodes) must be positive" + } + + //language=roomsql + return readableDatabase.query( + """ + SELECT * FROM snodes + WHERE id NOT IN (SELECT snode_id FROM onion_path_snodes) + """ + ).use { cursor -> + val totalAvailable = cursor.count + generateSequence { (0 until totalAvailable).random() } + .distinct() + .take( + min( + totalAvailable, + n + ) + ) // In case there are less available snodes than requested + .mapTo(ArrayList(2)) { randomIndex -> + cursor.moveToPosition(randomIndex) + cursor.toSnode() + } + } + } + + override fun getSnodePool(): List { + poolCache.get()?.let { return it } + + return readableDatabase.query("SELECT * FROM snodes").use { cursor -> + cursor.toSnodeList() + }.also(poolCache::set) + } + + override fun removeSnode(ed25519PubKey: String): Snode? { + poolCache.set(null) + swarmCache.clear() // Removing a snode may affect multiple swarms + + //language=roomsql + return writableDatabase.query( + """ + DELETE FROM snodes + WHERE ed25519_pub_key = ?1 + RETURNING * + """, arrayOf(ed25519PubKey) + ).use { cursor -> + cursor + .asSequence() + .map { it.toSnode() } + .firstOrNull() + } + } + + override fun setSnodePool(newValue: Collection) { + poolCache.set(null) + onionPathsCache.set(null) + swarmCache.clear() + + writableDatabase.transaction { + // Create temp table to hold the new snode pub keys, as the amount of data may be large + //language=roomsql + execSQL("CREATE TEMPORARY TABLE temp_snode_keys(ed25519_pub_key TEXT PRIMARY KEY)") + + // Insert new snode pub keys into temp table + //language=roomsql + compileStatement("INSERT INTO temp_snode_keys(ed25519_pub_key) VALUES (?)").use { stmt -> + newValue.forEach { snode -> + stmt.clearBindings() + stmt.bindString(1, snode.ed25519Key) + stmt.execute() + } + } + + // Delete paths that reference snodes not in the new pool + //language=roomsql + execSQL(""" + DELETE FROM onion_paths + WHERE id IN ( + SELECT ops.path_id + FROM onion_path_snodes AS ops + INNER JOIN snodes ON ops.snode_id = snodes.id + WHERE snodes.ed25519_pub_key NOT IN (SELECT ed25519_pub_key FROM temp_snode_keys) + ) + """) + + // Remove non-existing snodes + //language=roomsql + compileStatement( + """ + DELETE FROM snodes + WHERE ed25519_pub_key NOT IN (SELECT ed25519_pub_key FROM temp_snode_keys) + """ + ) + + // Actually inserting the new snodes, or updating the ip if they already exist + //language=roomsql + compileStatement( + """ + INSERT INTO snodes (ed25519_pub_key, x25519_pub_key, ip, https_port) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(ed25519_pub_key) DO UPDATE SET + ip = excluded.ip, + https_port = excluded.https_port, + strikes = 0 + WHERE snodes.ip != excluded.ip OR snodes.https_port != excluded.https_port OR snodes.strikes != 0 + """ + ).use { stmt -> + newValue.forEach { snode -> + stmt.clearBindings() + stmt.bindString(1, snode.ed25519Key) + stmt.bindString(2, snode.x25519Key) + stmt.bindString(3, snode.ip) + stmt.bindLong(4, snode.port.toLong()) + stmt.execute() + } + } + + // Drop the temp table + execSQL("DROP TABLE temp_snode_keys") + } + } + + override fun removeSnodesWithStrikesGreaterThan(n: Int): Int { + return writableDatabase.transaction { + //language=roomsql + val numDeleted = compileStatement("DELETE FROM snodes WHERE strikes > ?1").use { stmt -> + stmt.bindLong(1, n.toLong()) + stmt.executeUpdateDelete() + } + + if (numDeleted > 0) { + poolCache.set(null) + swarmCache.clear() // Removing snodes may affect multiple swarms + } + + numDeleted + } + } + + override fun increaseSnodeStrike( + snode: Snode, + increment: Int + ): Int? { + //language=roomsql + return writableDatabase.query( + """ + UPDATE snodes + SET strikes = max(0, strikes + ?1) + WHERE ed25519_pub_key = ?2 + RETURNING strikes + """, arrayOf(increment, snode.ed25519Key) + ).use { cursor -> + cursor.asSequence() + .map { it.getInt(0) } + .firstOrNull() + } + } + + private fun Path.pathKey(): String { + return joinToString(separator = ",", transform = { it.ed25519Key }) + } + + private class SnodeColumnIndices( + val ed25519Index: Int, + val x25519Index: Int, + val ipIndex: Int, + val httpsPortIndex: Int + ) { + constructor(cursor: Cursor) : + this( + ed25519Index = cursor.getColumnIndexOrThrow("ed25519_pub_key"), + x25519Index = cursor.getColumnIndexOrThrow("x25519_pub_key"), + ipIndex = cursor.getColumnIndexOrThrow("ip"), + httpsPortIndex = cursor.getColumnIndexOrThrow("https_port") + ) + } + + private fun Cursor.toSnode(indices: SnodeColumnIndices = SnodeDatabase.SnodeColumnIndices(this)): Snode { + return Snode( + address = "https://${getString(indices.ipIndex)}", + port = getInt(indices.httpsPortIndex), + publicKeySet = Snode.KeySet( + ed25519Key = getString(indices.ed25519Index), + x25519Key = getString(indices.x25519Index) + ), + ) + } + + private fun Cursor.toSnodeList(): List { + val indices = SnodeColumnIndices(this) + return asSequence() + .mapTo(ArrayList(count)) { toSnode(indices) } + } + + + companion object { + + // Common table expression for getting a deterministic representation of path in a form + // of comma separated list of each snode's ed25519 pubkey in order of their position in the path. + // Column: path_id, path_key + //language=roomsql + private const val PATH_KEYS_CTE_SQL = """ + SELECT ops.path_id, group_concat(snodes.ed25519_pub_key ORDER BY ops.position) AS path_key + FROM onion_path_snodes AS ops + INNER JOIN snodes ON ops.snode_id = snodes.id + GROUP BY ops.path_id + """ + + @Suppress("DEPRECATION") + fun createTableAndMigrateData( + db: SupportSQLiteDatabase, + migrateOldData: Boolean = true, // Only set to false for tests + ) { + //language=roomsql + arrayOf( + """ + CREATE TABLE snodes( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + ed25519_pub_key TEXT NOT NULL, + x25519_pub_key TEXT NOT NULL, + ip TEXT NOT NULL, + https_port INTEGER NOT NULL, + strikes INTEGER NOT NULL DEFAULT 0 + ) + """, + "CREATE UNIQUE INDEX index_snodes_on_ed25519_pub_key ON snodes(ed25519_pub_key)", + "CREATE UNIQUE INDEX index_snodes_on_x25519_pub_key ON snodes(x25519_pub_key)", + + """ + CREATE TABLE swarm_snodes( + pubkey TEXT NOT NULL, + snode_id INTEGER NOT NULL REFERENCES snodes(id) ON DELETE CASCADE, + PRIMARY KEY(pubkey, snode_id) + ) + """, + + """ + CREATE TABLE onion_paths( + id INTEGER NOT NULL PRIMARY KEY, + created_at_ms INTEGER NOT NULL, + strikes INTEGER NOT NULL DEFAULT 0 + ) + """, + + """ + CREATE TABLE onion_path_snodes( + path_id INTEGER NOT NULL REFERENCES onion_paths(id) ON DELETE CASCADE, + snode_id INTEGER NOT NULL REFERENCES snodes(id) ON DELETE RESTRICT, + position INTEGER NOT NULL, + PRIMARY KEY(path_id, snode_id, position) + ) + """, + + "CREATE INDEX path_snodes_idx_path_id ON onion_path_snodes(path_id)", + "CREATE UNIQUE INDEX path_snodes_idx_unique_snode ON onion_path_snodes(path_id, snode_id)", + "CREATE UNIQUE INDEX path_snodes_idx_disjoint ON onion_path_snodes(snode_id)" + ).forEach { sql -> + db.execSQL(sql) + } + + if (!migrateOldData) { + return + } + + // Migrate existing data: + // Note that the new db structure implies that snode pool contains ALL snodes used in + // swarms and paths. No such guarantee existed before, so we will merge the data from + // swarms and paths into the snode pool here, to avoid losing any snodes. + val oldSnodePool = LokiAPIDatabase.getSnodePool(db) + val oldSwarms = LokiAPIDatabase.getSwarms(db) + val oldPaths = LokiAPIDatabase.getOnionRequestPaths(db) + + oldSnodePool.asSequence() + .plus(oldSwarms.asSequence().flatMap { it.value.asSequence() }) + .plus(oldPaths.asSequence().flatMap { it.asSequence() }) + .forEach { snode -> + //language=roomsql + db.execSQL( + "INSERT OR IGNORE INTO snodes (ed25519_pub_key, x25519_pub_key, ip, https_port) VALUES (?, ?, ?, ?)", + arrayOf(snode.ed25519Key, snode.x25519Key, snode.ip, snode.port) + ) + } + + // Migrate swarms + oldSwarms.forEach { (pubkey, swarm) -> + swarm.forEach { snode -> + //language=roomsql + db.execSQL( + """ + INSERT OR IGNORE INTO swarm_snodes (pubkey, snode_id) + SELECT ?1, id FROM snodes WHERE ed25519_pub_key = ?2 + """, arrayOf( + pubkey, + snode.ed25519Key + ) + ) + } + } + + // Migrate paths + oldPaths.forEachIndexed { pathIndex, path -> + //language=roomsql + db.execSQL( + "INSERT INTO onion_paths (id, created_at_ms) VALUES (?1, ?2)", + arrayOf(pathIndex, System.currentTimeMillis()) + ) + + path.forEachIndexed { snodeIndex, snode -> + //language=roomsql + db.execSQL( + """ + INSERT OR IGNORE INTO onion_path_snodes (path_id, snode_id, position) + SELECT ?1, id, ?2 FROM snodes WHERE ed25519_pub_key = ?3 + """, arrayOf( + pathIndex, + snodeIndex, + snode.ed25519Key + ) + ) + } + } + + // Removing old tables + db.execSQL("DROP TABLE ${LokiAPIDatabase.swarmTable}") + db.execSQL("DROP TABLE ${LokiAPIDatabase.snodePoolTable}") + db.execSQL("DROP TABLE ${LokiAPIDatabase.onionRequestPathTable}") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index c0c8576408..a8148a7490 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -43,8 +43,7 @@ import org.session.libsession.messaging.sending_receiving.link_preview.LinkPrevi import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel import org.session.libsession.messaging.utilities.UpdateMessageData -import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.toAddress @@ -57,12 +56,17 @@ import org.session.libsession.utilities.isCommunityInbox import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientData import org.session.libsession.utilities.upsertContact +import org.session.libsession.utilities.withGroupConfigs +import org.session.libsession.utilities.withMutableGroupConfigs +import org.session.libsession.utilities.withMutableUserConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceAttachmentPointer import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.api.error.UnhandledStatusCodeException import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.model.MessageId @@ -73,6 +77,7 @@ import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.util.FilenameUtils import org.thoughtcrime.securesms.util.SessionMetaProtocol +import org.thoughtcrime.securesms.util.findCause import java.time.Instant import java.time.ZoneId import javax.inject.Inject @@ -369,7 +374,8 @@ open class Storage @Inject constructor( recipient = targetAddress, sentTimestampMillis = message.sentTimestamp!!, expiresInMillis = expiresInMillis, - expireStartedAtMillis = expireStartedAt + expireStartedAtMillis = expireStartedAt, + proFeatures = message.proFeatures )!! else OutgoingTextMessage( message = message, @@ -584,7 +590,7 @@ open class Storage @Inject constructor( } if (error.localizedMessage != null) { val message: String - if (error is OnionRequestAPI.HTTPRequestFailedAtDestinationException && error.statusCode == 429) { + if (error.findCause()?.code == 429) { message = "429: Rate limited." } else { message = error.localizedMessage!! @@ -600,7 +606,7 @@ open class Storage @Inject constructor( if (error.localizedMessage != null) { val message: String - if (error is OnionRequestAPI.HTTPRequestFailedAtDestinationException && error.statusCode == 429) { + if (error.findCause()?.code == 429) { message = "429: Rate limited." } else { message = error.localizedMessage!! @@ -745,7 +751,7 @@ open class Storage @Inject constructor( } override fun insertGroupInfoChange(message: GroupUpdated, closedGroup: AccountId) { - val sentTimestamp = message.sentTimestamp ?: clock.currentTimeMills() + val sentTimestamp = message.sentTimestamp ?: clock.currentTimeMillis() val senderPublicKey = message.sender val groupName = configFactory.withGroupConfigs(closedGroup) { it.groupInfo.getName() } ?: configFactory.getGroup(closedGroup)?.name @@ -756,7 +762,7 @@ open class Storage @Inject constructor( } override fun insertGroupInfoLeaving(closedGroup: AccountId) { - val sentTimestamp = clock.currentTimeMills() + val sentTimestamp = clock.currentTimeMillis() val senderPublicKey = getUserPublicKey() ?: return val updateData = UpdateMessageData.buildGroupLeaveUpdate(UpdateMessageData.Kind.GroupLeaving) @@ -764,7 +770,7 @@ open class Storage @Inject constructor( } override fun insertGroupInfoErrorQuit(closedGroup: AccountId) { - val sentTimestamp = clock.currentTimeMills() + val sentTimestamp = clock.currentTimeMillis() val senderPublicKey = getUserPublicKey() ?: return val groupName = configFactory.withGroupConfigs(closedGroup) { it.groupInfo.getName() } ?: configFactory.getGroup(closedGroup)?.name @@ -792,13 +798,12 @@ open class Storage @Inject constructor( val address = Address.Group(closedGroup) val recipient = recipientRepository.getRecipientSync(address) val threadDb = threadDatabase - val threadID = threadDb.getThreadIdIfExistsFor(address) + val threadID = threadDb.getOrCreateThreadIdFor(address) val expiryMode = recipient.expiryMode val expiresInMillis = expiryMode.expiryMillis val expireStartedAt = if (expiryMode is ExpiryMode.AfterSend) sentTimestamp else 0 val inviteJson = updateData.toJSON() - if (senderPublicKey == null || senderPublicKey == userPublicKey) { val infoMessage = OutgoingMediaMessage( recipient = address, @@ -859,6 +864,10 @@ open class Storage @Inject constructor( return lokiAPIDatabase.getServerCapabilities(server) } + override fun clearServerCapabilities(server: String) { + lokiAPIDatabase.clearServerCapabilities(server) + } + override fun getAllGroups(includeInactive: Boolean): List { return groupDatabase.getAllGroups(includeInactive) } @@ -872,7 +881,7 @@ open class Storage @Inject constructor( } override fun getThreadId(address: Address): Long? { - val threadID = threadDatabase.getThreadIdIfExistsFor(address) + val threadID = threadDatabase.getThreadIdIfExistsFor(address.address) return if (threadID < 0) null else threadID } @@ -1091,7 +1100,7 @@ open class Storage @Inject constructor( val message = IncomingMediaMessage( from = fromSerialized(userPublicKey), - sentTimeMillis = clock.currentTimeMills(), + sentTimeMillis = clock.currentTimeMillis(), expiresIn = 0, expireStartedAt = 0, isMessageRequestResponse = true, @@ -1113,7 +1122,7 @@ open class Storage @Inject constructor( val recipient = recipientRepository.getRecipientSync(address) val expiryMode = recipient.expiryMode.coerceSendToRead() val expiresInMillis = expiryMode.expiryMillis - val expireStartedAt = if (expiryMode != ExpiryMode.NONE) clock.currentTimeMills() else 0 + val expireStartedAt = if (expiryMode != ExpiryMode.NONE) clock.currentTimeMillis() else 0 val callMessage = IncomingTextMessage( callMessageType = callMessageType, sender = address, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 068ab51fbc..ef127b10a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -33,7 +33,7 @@ import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; -import org.session.libsession.snode.SnodeAPI; +import org.session.libsession.network.SnodeClock; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.AddressKt; import org.session.libsession.utilities.ConfigFactoryProtocol; @@ -231,6 +231,7 @@ public static void migrateLegacyCommunityAddresses(final SQLiteDatabase db) { private final MutableSharedFlow updateNotifications = SharedFlowKt.MutableSharedFlow(0, 256, BufferOverflow.DROP_OLDEST); private final Json json; private final TextSecurePreferences prefs; + private final SnodeClock snodeClock; private final Lazy<@NonNull RecipientRepository> recipientRepository; private final Lazy<@NonNull MmsSmsDatabase> mmsSmsDatabase; @@ -251,6 +252,7 @@ public ThreadDatabase(@dagger.hilt.android.qualifiers.ApplicationContext Context Lazy<@NonNull SmsDatabase> smsDatabase, Lazy<@NonNull MarkReadProcessor> markReadProcessor, TextSecurePreferences prefs, + SnodeClock snodeClock, Json json) { super(context, databaseHelper); this.recipientRepository = recipientRepository; @@ -260,6 +262,7 @@ public ThreadDatabase(@dagger.hilt.android.qualifiers.ApplicationContext Context this.mmsDatabase = mmsDatabase; this.smsDatabase = smsDatabase; this.markReadProcessor = markReadProcessor; + this.snodeClock = snodeClock; this.json = json; this.prefs = prefs; @@ -443,7 +446,7 @@ public List setRead(long threadId, boolean lastSeen) { contentValues.put(UNREAD_MENTION_COUNT, 0); if (lastSeen) { - contentValues.put(LAST_SEEN, SnodeAPI.getNowWithOffset()); + contentValues.put(LAST_SEEN, snodeClock.currentTimeMillis()); } SQLiteDatabase db = getWritableDatabase(); @@ -552,7 +555,7 @@ public boolean setLastSeen(long threadId, long timestamp) { SQLiteDatabase db = getWritableDatabase(); ContentValues contentValues = new ContentValues(1); - long lastSeenTime = timestamp == -1 ? SnodeAPI.getNowWithOffset() : timestamp; + long lastSeenTime = timestamp == -1 ? snodeClock.currentTimeMillis() : timestamp; contentValues.put(LAST_SEEN, lastSeenTime); db.beginTransaction(); db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(threadId)}); @@ -650,10 +653,6 @@ public long getThreadIdIfExistsFor(String address) { } } - public long getThreadIdIfExistsFor(Address address) { - return getThreadIdIfExistsFor(address.getAddress()); - } - public long getOrCreateThreadIdFor(Address address) { boolean created = false; @@ -662,7 +661,7 @@ public long getOrCreateThreadIdFor(Address address) { long threadId = getWritableDatabase().insertWithOnConflict(TABLE_NAME, null, contentValues, SQLiteDatabase.CONFLICT_IGNORE); if (threadId < 0) { - threadId = getThreadIdIfExistsFor(address); + threadId = getThreadIdIfExistsFor(address.getAddress()); } else { created = true; } @@ -868,7 +867,7 @@ public ThreadRecord getCurrent() { } final boolean isUnread = address instanceof Address.Conversable && - configFactory.get().withUserConfigs(configs -> + ConfigFactoryProtocolKt.withUserConfigs(configFactory.get(), configs -> SharedConfigUtilsKt.getConversationUnread( configs.getConvoInfoVolatile(), (Address.Conversable) address)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index d82ba6b813..770b33cce1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -40,6 +40,7 @@ import org.thoughtcrime.securesms.database.SessionContactDatabase; import org.thoughtcrime.securesms.database.SessionJobDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.database.SnodeDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.pro.db.ProDatabase; import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities; @@ -106,9 +107,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV55 = 76; private static final int lokiV56 = 77; private static final int lokiV57 = 78; + private static final int lokiV58 = 79; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV57; + private static final int DATABASE_VERSION = lokiV58; private static final int MIN_DATABASE_VERSION = lokiV7; public static final String DATABASE_NAME = "session.db"; @@ -275,6 +277,8 @@ public void onCreate(SQLiteDatabase db) { MmsDatabase.Companion.addProFeatureColumns(db); SmsDatabase.addProFeatureColumns(db); RecipientSettingsDatabase.Companion.migrateProStatusToProData(db); + + SnodeDatabase.Companion.createTableAndMigrateData(db, true); } @Override @@ -282,6 +286,7 @@ public void onConfigure(SQLiteDatabase db) { super.onConfigure(db); db.execSQL("PRAGMA cache_size = 10000"); + db.setForeignKeyConstraintsEnabled(true); } @Override @@ -624,6 +629,10 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { RecipientSettingsDatabase.Companion.migrateProStatusToProData(db); } + if (oldVersion < lokiV58) { + SnodeDatabase.Companion.createTableAndMigrateData(db, true); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java index 286979289b..bbc4372315 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java @@ -68,7 +68,6 @@ public abstract class DisplayRecord { public @NonNull String getBody() { return body == null ? "" : body; } - public abstract CharSequence getDisplayBody(@NonNull Context context); public Recipient getRecipient() { return recipient; } public long getDateSent() { return dateSent; } public long getDateReceived() { return dateReceived; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index ce04203b84..577f6dad6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -16,33 +16,17 @@ */ package org.thoughtcrime.securesms.database.model; -import android.content.Context; -import android.text.Spannable; -import android.text.SpannableString; -import android.text.style.ForegroundColorSpan; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import org.session.libsession.messaging.MessagingModuleConfiguration; -import org.session.libsession.messaging.calls.CallMessageType; -import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage; -import org.session.libsession.messaging.utilities.UpdateMessageBuilder; import org.session.libsession.messaging.utilities.UpdateMessageData; -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.AddressKt; -import org.session.libsession.utilities.ThemeUtil; import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsignal.utilities.AccountId; -import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate; import org.thoughtcrime.securesms.database.model.content.MessageContent; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import java.util.List; import java.util.Objects; import java.util.Set; -import network.loki.messenger.R; import network.loki.messenger.libsession_util.protocol.ProFeature; /** @@ -128,59 +112,6 @@ public UpdateMessageData getGroupUpdateMessage() { return groupUpdateMessage; } - @Override - public CharSequence getDisplayBody(@NonNull Context context) { - if (isGroupUpdateMessage()) { - UpdateMessageData updateMessageData = getGroupUpdateMessage(); - Address groupRecipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(getThreadId()); - - if (updateMessageData == null || groupRecipient == null) { - return ""; - } - - SpannableString text = new SpannableString(UpdateMessageBuilder.buildGroupUpdateMessage( - context, - AddressKt.isGroupV2(groupRecipient) ? new AccountId(groupRecipient.toString()) : null, // accountId is only used for GroupsV2 - updateMessageData, - MessagingModuleConfiguration.getShared().getConfigFactory(), - isOutgoing(), - getTimestamp(), - getExpireStarted()) - ); - - if (updateMessageData.isGroupErrorQuitKind()) { - text.setSpan(new ForegroundColorSpan(ThemeUtil.getThemedColor(context, R.attr.danger)), 0, text.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE); - } else if (updateMessageData.isGroupLeavingKind()) { - text.setSpan(new ForegroundColorSpan(ThemeUtil.getThemedColor(context, android.R.attr.textColorTertiary)), 0, text.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE); - } - - return text; - } else if (getMessageContent() instanceof DisappearingMessageUpdate) { - Address rec = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(getThreadId()); - if(rec == null) return ""; - boolean isGroup = AddressKt.isGroupOrCommunity(rec); - return UpdateMessageBuilder.INSTANCE - .buildExpirationTimerMessage(context, ((DisappearingMessageUpdate) getMessageContent()).getExpiryMode(), isGroup, getIndividualRecipient().getAddress().toString(), isOutgoing()); - } else if (isDataExtractionNotification()) { - if (isScreenshotNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.SCREENSHOT, getIndividualRecipient().getAddress().toString()))); - else if (isMediaSavedNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED, getIndividualRecipient().getAddress().toString()))); - } else if (isCallLog()) { - CallMessageType callType; - if (isIncomingCall()) { - callType = CallMessageType.CALL_INCOMING; - } else if (isOutgoingCall()) { - callType = CallMessageType.CALL_OUTGOING; - } else if (isMissedCall()) { - callType = CallMessageType.CALL_MISSED; - } else { - callType = CallMessageType.CALL_FIRST_MISSED; - } - return new SpannableString(UpdateMessageBuilder.INSTANCE.buildCallMessage(context, callType, getIndividualRecipient().getAddress().toString())); - } - - return new SpannableString(getBody()); - } - public boolean isGroupExpirationTimerUpdate() { if (!isGroupUpdateMessage()) { return false; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java index f9aa2e2846..6cb467c35d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java @@ -17,10 +17,6 @@ package org.thoughtcrime.securesms.database.model; -import android.content.Context; - -import androidx.annotation.NonNull; - import org.session.libsession.utilities.recipients.Recipient; import java.util.List; @@ -56,10 +52,6 @@ public long getType() { return type; } - @Override - public CharSequence getDisplayBody(@NonNull Context context) { - return super.getDisplayBody(context); - } @Override public boolean isMms() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java index 5c7f2f073e..5ed38dc3ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java @@ -17,34 +17,21 @@ */ package org.thoughtcrime.securesms.database.model; -import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; import static org.session.libsession.utilities.StringSubstitutionConstants.AUTHOR_KEY; -import static org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY; import static org.session.libsession.utilities.StringSubstitutionConstants.MESSAGE_SNIPPET_KEY; -import static org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY; import android.content.Context; -import android.text.SpannableString; -import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.squareup.phrase.Phrase; -import org.session.libsession.messaging.utilities.UpdateMessageData; import org.session.libsession.utilities.AddressKt; -import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.recipients.RecipientNamesKt; -import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.database.MmsSmsColumns; -import org.thoughtcrime.securesms.database.SmsDatabase; -import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate; import org.thoughtcrime.securesms.database.model.content.MessageContent; -import org.thoughtcrime.securesms.ui.UtilKt; -import kotlin.Pair; import network.loki.messenger.R; /** @@ -54,173 +41,37 @@ * */ public class ThreadRecord extends DisplayRecord { - - public @Nullable final MessageRecord lastMessage; - private final long count; - private final int unreadCount; - private final int unreadMentionCount; - private final long lastSeen; - private final String invitingAdminId; - private final boolean isUnread; - - @NonNull - private final GroupThreadStatus groupThreadStatus; - - public ThreadRecord(@NonNull String body, - @Nullable MessageRecord lastMessage, @NonNull Recipient recipient, long date, long count, int unreadCount, - int unreadMentionCount, long threadId, int deliveryReceiptCount, int status, - long snippetType, - long lastSeen, int readReceiptCount, String invitingAdminId, - @NonNull GroupThreadStatus groupThreadStatus, - @Nullable MessageContent messageContent, - boolean isUnread) - { - super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount, messageContent); - this.lastMessage = lastMessage; - this.count = count; - this.unreadCount = unreadCount; - this.unreadMentionCount = unreadMentionCount; - this.lastSeen = lastSeen; - this.invitingAdminId = invitingAdminId; - this.groupThreadStatus = groupThreadStatus; - this.isUnread = isUnread; - } - - private String getName() { - return RecipientNamesKt.displayName(getRecipient()); - } - - @Override - public CharSequence getDisplayBody(@NonNull Context context) { - if (groupThreadStatus == GroupThreadStatus.Kicked) { - return Phrase.from(context, R.string.groupRemovedYou) - .put(GROUP_NAME_KEY, getName()) - .format() - .toString(); - } else if (groupThreadStatus == GroupThreadStatus.Destroyed) { - return Phrase.from(context, R.string.groupDeletedMemberDescription) - .put(GROUP_NAME_KEY, getName()) - .format() - .toString(); - } else if (lastMessage == null){ - // no need to display anything if there are no messages - return ""; - } - else if (isGroupUpdateMessage()) { - CharSequence body = lastMessage.getDisplayBody(context); - UpdateMessageData updatedMessage = lastMessage.getGroupUpdateMessage(); - - // For group leaving and error quit messages, we will leave the message as formatted - if (updatedMessage != null && (updatedMessage.isGroupLeavingKind() || updatedMessage.isGroupErrorQuitKind())) { - return body; - } - - // Otherwise we'll need to remove all the formatting and just display the text - return body.toString(); - } else if (isOpenGroupInvitation()) { - return context.getString(R.string.communityInvitation); - } else if (MmsSmsColumns.Types.isLegacyType(type)) { - return Phrase.from(context, R.string.messageErrorOld) - .put(APP_NAME_KEY, context.getString(R.string.app_name)) - .format().toString(); - } else if (MmsSmsColumns.Types.isDraftMessageType(type)) { - String draftText = context.getString(R.string.draft); - return draftText + " " + getBody(); - } else if (SmsDatabase.Types.isOutgoingCall(type)) { - return Phrase.from(context, R.string.callsYouCalled) - .put(NAME_KEY, getName()) - .format().toString(); - } else if (SmsDatabase.Types.isIncomingCall(type)) { - return Phrase.from(context, R.string.callsCalledYou) - .put(NAME_KEY, getName()) - .format().toString(); - } else if (SmsDatabase.Types.isMissedCall(type)) { - return Phrase.from(context, R.string.callsMissedCallFrom) - .put(NAME_KEY, getName()) - .format().toString(); - } else if (getMessageContent() instanceof DisappearingMessageUpdate) { - // Use the same message as we would for displaying on the conversation screen. - // lastMessage shouldn't be null here, but we'll check just in case. - if (lastMessage != null) { - return lastMessage.getDisplayBody(context).toString(); - } else { - return ""; - } - } - else if (MmsSmsColumns.Types.isMediaSavedExtraction(type)) { - return Phrase.from(context, R.string.attachmentsMediaSaved) - .put(NAME_KEY, getName()) - .format().toString(); - - } else if (MmsSmsColumns.Types.isScreenshotExtraction(type)) { - return Phrase.from(context, R.string.screenshotTaken) - .put(NAME_KEY, getName()) - .format().toString(); - - } else if (MmsSmsColumns.Types.isMessageRequestResponse(type)) { - try { - if (lastMessage.getRecipient().getAddress().toString().equals( - ((ApplicationContext) context.getApplicationContext()).getLoginStateRepository() - .get().getLocalNumber())) { - return UtilKt.getSubbedCharSequence( - context, - R.string.messageRequestYouHaveAccepted, - new Pair<>(NAME_KEY, getName()) - ); - } - } - catch (Exception e){} // the above can throw a null exception - - return context.getString(R.string.messageRequestsAccepted); - } else if (getCount() == 0) { - return new SpannableString(context.getString(R.string.messageEmpty)); - } else { - // This block hits when we receive a media message from an unaccepted contact - however, - // unaccepted contacts aren't allowed to send us media - so we'll return an empty string - // if it's JUST an image, or the body text that accompanied the image should any exist. - // We could return null here - but then we have to find all the usages of this - // `getDisplayBody` method and make sure it doesn't fall over if it has a null result. - if (TextUtils.isEmpty(getBody())) { - return new SpannableString(""); - // Old behaviour was: return new SpannableString(emphasisAdded(context.getString(R.string.mediaMessage))); - } else { - return getNonControlMessageDisplayBody(context); - } - } + public @Nullable final MessageRecord lastMessage; + private final long count; + private final int unreadCount; + private final int unreadMentionCount; + private final long lastSeen; + private final String invitingAdminId; + private final boolean isUnread; + + @NonNull + public final GroupThreadStatus groupThreadStatus; + + public ThreadRecord(@NonNull String body, + @Nullable MessageRecord lastMessage, @NonNull Recipient recipient, long date, long count, int unreadCount, + int unreadMentionCount, long threadId, int deliveryReceiptCount, int status, + long snippetType, + long lastSeen, int readReceiptCount, String invitingAdminId, + @NonNull GroupThreadStatus groupThreadStatus, + @Nullable MessageContent messageContent, + boolean isUnread) + { + super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount, messageContent); + this.lastMessage = lastMessage; + this.count = count; + this.unreadCount = unreadCount; + this.unreadMentionCount = unreadMentionCount; + this.lastSeen = lastSeen; + this.invitingAdminId = invitingAdminId; + this.groupThreadStatus = groupThreadStatus; + this.isUnread = isUnread; } - /** - * Logic to get the body for non control messages - */ - public CharSequence getNonControlMessageDisplayBody(@NonNull Context context) { - Recipient recipient = getRecipient(); - // The logic will differ depending on the type. - // 1-1, note to self and control messages (we shouldn't have any in here, but leaving the - // logic to be safe) do not need author details - if (recipient.isLocalNumber() || !AddressKt.isGroupOrCommunity(recipient.getAddress()) || - (lastMessage != null && lastMessage.isControlMessage()) - ) { - return getBody(); - } else { // for groups (new, legacy, communities) show either 'You' or the contact's name - String prefix = ""; - if (lastMessage != null && lastMessage.isOutgoing()) { - prefix = context.getString(R.string.you); - } - else if(lastMessage != null){ - prefix = RecipientNamesKt.displayName(lastMessage.getIndividualRecipient()); - } - - return Phrase.from(context.getString(R.string.messageSnippetGroup)) - .put(AUTHOR_KEY, prefix) - .put(MESSAGE_SNIPPET_KEY, getBody()) - .format().toString(); - } - } - - @Override - public boolean isGroupUpdateMessage() { - return lastMessage != null && lastMessage.isGroupUpdateMessage(); - } public long getCount() { return count; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/content/DisappearingMessageUpdate.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/content/DisappearingMessageUpdate.kt index ab42a31ab2..08e5fbdf4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/content/DisappearingMessageUpdate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/content/DisappearingMessageUpdate.kt @@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.database.model.content import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import network.loki.messenger.libsession_util.util.ExpiryMode -import org.session.libsignal.protos.SignalServiceProtos +import org.session.protos.SessionProtos import org.thoughtcrime.securesms.util.ProtobufEnumSerializer @@ -15,27 +15,27 @@ data class DisappearingMessageUpdate( @SerialName(KEY_EXPIRY_TYPE) @Serializable(with = ExpirationTypeSerializer::class) - val expiryType: SignalServiceProtos.Content.ExpirationType, + val expiryType: SessionProtos.Content.ExpirationType, ) : MessageContent { val expiryMode: ExpiryMode get() = when (expiryType) { - SignalServiceProtos.Content.ExpirationType.DELETE_AFTER_SEND -> ExpiryMode.AfterSend(expiryTimeSeconds) - SignalServiceProtos.Content.ExpirationType.DELETE_AFTER_READ -> ExpiryMode.AfterRead(expiryTimeSeconds) + SessionProtos.Content.ExpirationType.DELETE_AFTER_SEND -> ExpiryMode.AfterSend(expiryTimeSeconds) + SessionProtos.Content.ExpirationType.DELETE_AFTER_READ -> ExpiryMode.AfterRead(expiryTimeSeconds) else -> ExpiryMode.NONE } constructor(mode: ExpiryMode) : this( expiryTimeSeconds = mode.expirySeconds, expiryType = when (mode) { - is ExpiryMode.AfterSend -> SignalServiceProtos.Content.ExpirationType.DELETE_AFTER_SEND - is ExpiryMode.AfterRead -> SignalServiceProtos.Content.ExpirationType.DELETE_AFTER_READ - ExpiryMode.NONE -> SignalServiceProtos.Content.ExpirationType.UNKNOWN + is ExpiryMode.AfterSend -> SessionProtos.Content.ExpirationType.DELETE_AFTER_SEND + is ExpiryMode.AfterRead -> SessionProtos.Content.ExpirationType.DELETE_AFTER_READ + ExpiryMode.NONE -> SessionProtos.Content.ExpirationType.UNKNOWN } ) - class ExpirationTypeSerializer : ProtobufEnumSerializer() { - override fun fromNumber(number: Int): SignalServiceProtos.Content.ExpirationType - = SignalServiceProtos.Content.ExpirationType.forNumber(number) ?: SignalServiceProtos.Content.ExpirationType.UNKNOWN + class ExpirationTypeSerializer : ProtobufEnumSerializer() { + override fun fromNumber(number: Int): SessionProtos.Content.ExpirationType + = SessionProtos.Content.ExpirationType.forNumber(number) ?: SessionProtos.Content.ExpirationType.UNKNOWN } companion object { @@ -44,7 +44,7 @@ data class DisappearingMessageUpdate( const val KEY_EXPIRY_TIME_SECONDS = "expiry_time_seconds" const val KEY_EXPIRY_TYPE = "expiry_type" - // These constants map to SignalServiceProtos.Content.ExpirationType but given we want to use + // These constants map to SessionProtos.Content.ExpirationType but given we want to use // a constants it's impossible to use the enum directly. Luckily the values aren't supposed // to change so we can safely use these constants. const val EXPIRY_MODE_AFTER_SENT = 2 diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt index 88f57881db..cbf062a599 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -28,10 +28,9 @@ import network.loki.messenger.libsession_util.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.protocol.ProFeature import network.loki.messenger.libsession_util.util.BlindKeyAPI -import network.loki.messenger.libsession_util.util.toBitSet import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.file_server.FileServer -import org.session.libsession.messaging.file_server.FileServerApi +import org.session.libsession.messaging.file_server.FileServerApis import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.notifications.TokenFetcher import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState @@ -39,6 +38,8 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.Environment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.upsertContact +import org.session.libsession.utilities.withMutableUserConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log @@ -190,7 +191,7 @@ class DebugMenuViewModel @AssistedInject constructor( is Commands.Copy07PrefixedBlindedPublicKey -> { val secretKey = storage.getUserED25519KeyPair()?.secretKey?.data - ?: throw (FileServerApi.Error.NoEd25519KeyPair) + ?: throw (FileServerApis.Error.NoEd25519KeyPair) val userBlindedKeys = BlindKeyAPI.blindVersionKeyPair(secretKey) val clip = ClipData.newPlainText("07-prefixed Version Blinded Public Key", diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt index 9bf4e762ca..23a45531fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.dependencies +import androidx.collection.arrayMapOf +import androidx.collection.arraySetOf import dagger.Lazy import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow @@ -16,7 +18,7 @@ import network.loki.messenger.libsession_util.util.ConfigPush import network.loki.messenger.libsession_util.util.MultiEncrypt import org.session.libsession.database.StorageProtocol import org.session.libsession.snode.OwnedSwarmAuth -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.snode.SwarmAuth import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol @@ -29,6 +31,8 @@ import org.session.libsession.utilities.MutableUserConfigs import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.UserConfigs import org.session.libsession.utilities.getGroup +import org.session.libsession.utilities.withGroupConfigs +import org.session.libsession.utilities.withMutableUserConfigs import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.configs.ConfigToDatabaseSync @@ -38,7 +42,6 @@ import java.util.EnumSet import java.util.concurrent.locks.ReentrantReadWriteLock import javax.inject.Inject import javax.inject.Singleton -import kotlin.concurrent.read import kotlin.concurrent.write @@ -134,13 +137,115 @@ class ConfigFactory @Inject constructor( } } - override fun withUserConfigs(cb: (UserConfigs) -> T): T { + override fun dangerouslyAccessMutableUserConfigs(): Pair Unit> { val (lock, configs) = ensureUserConfigsInitialized() - return lock.read { - cb(configs) + lock.writeLock().lock() + return configs to { + val changed = arraySetOf() + var dumped: MutableMap? = null + + for (type in UserConfigType.entries) { + val config = configs.getConfig(type) + if (config.dirty()) { + changed.add(type) + } + + if (config.needsDump()) { + if (dumped == null) { + dumped = arrayMapOf() + } + + dumped[type] = config.dump() + } + } + + lock.writeLock().unlock() + + // Persist dumped configs + if (dumped != null) { + coroutineScope.launch { + val userAccountId = requiresCurrentUserAccountId() + val currentTimeMs = clock.currentTimeMillis() + + for ((type, data) in dumped) { + configDatabase.storeConfig( + variant = type.configVariant, + publicKey = userAccountId.hexString, + data = data, + timestamp = currentTimeMs + ) + } + } + } + + // Notify changes on a coroutine + if (changed.isNotEmpty()) { + coroutineScope.launch { + _configUpdateNotifications.emit( + ConfigUpdateNotification.UserConfigsUpdated(updatedTypes = changed, fromMerge = false) + ) + } + } + } + } + + override fun dangerouslyAccessMutableGroupConfigs(groupId: AccountId): Pair Unit> { + val (lock, configs) = ensureGroupConfigsInitialized(groupId) + lock.writeLock().lock() + return configs to { + val changed = configs.groupInfo.dirty() || + configs.groupMembers.dirty() || + configs.groupKeys.needsDump() || + configs.groupKeys.needsRekey() + + val dumped = if (configs.groupInfo.needsDump() || configs.groupMembers.needsDump() || + configs.groupKeys.needsDump()) { + Triple( + configs.groupKeys.dump(), + configs.groupInfo.dump(), + configs.groupMembers.dump() + ) + } else { + null + } + + lock.writeLock().unlock() + + if (dumped != null) { + coroutineScope.launch { + configDatabase.storeGroupConfigs( + publicKey = groupId.hexString, + keysConfig = dumped.first, + infoConfig = dumped.second, + memberConfig = dumped.third, + timestamp = clock.currentTimeMillis() + ) + } + } + + // Notify changes on a coroutine + if (changed) { + coroutineScope.launch { + _configUpdateNotifications.emit( + ConfigUpdateNotification.GroupConfigsUpdated(groupId, fromMerge = false) + ) + } + } } } + override fun dangerouslyAccessUserConfigs(): Pair Unit> { + val (lock, configs) = ensureUserConfigsInitialized() + lock.readLock().lock() + return configs to lock.readLock()::unlock + } + + override fun dangerouslyAccessGroupConfigs(groupId: AccountId): Pair Unit> { + val (lock, configs) = ensureGroupConfigsInitialized(groupId) + lock.readLock().lock() + return configs to lock.readLock()::unlock + } + /** * Perform an operation on the user configs, and notify listeners if the configs were changed. * @@ -204,29 +309,6 @@ class ConfigFactory @Inject constructor( } } - override fun withMutableUserConfigs(cb: (MutableUserConfigs) -> T): T { - return doWithMutableUserConfigs(fromMerge = false) { - val result = cb(it) - - val changed = buildSet { - if (it.userGroups.dirty()) add(UserConfigType.USER_GROUPS) - if (it.convoInfoVolatile.dirty()) add(UserConfigType.CONVO_INFO_VOLATILE) - if (it.userProfile.dirty()) add(UserConfigType.USER_PROFILE) - if (it.contacts.dirty()) add(UserConfigType.CONTACTS) - } - - result to changed - } - } - - override fun withGroupConfigs(groupId: AccountId, cb: (GroupConfigs) -> T): T { - val (lock, configs) = ensureGroupConfigsInitialized(groupId) - - return lock.read { - cb(configs) - } - } - override fun createGroupConfigs(groupId: AccountId, adminKey: ByteArray): MutableGroupConfigs { return GroupConfigsImpl( userEd25519SecKey = requiresCurrentUserED25519SecKey(), @@ -268,14 +350,6 @@ class ConfigFactory @Inject constructor( return result } - override fun withMutableGroupConfigs( - groupId: AccountId, - cb: (MutableGroupConfigs) -> T - ): T { - return doWithMutableGroupConfigs(groupId = groupId, fromMerge = false) { - cb(it) to it.dumpIfNeeded(clock) - } - } override fun removeContactOrBlindedContact(address: Address.WithAccountId) { withMutableUserConfigs { @@ -575,7 +649,7 @@ private class GroupConfigsImpl( keysConfig = groupKeys.dump(), infoConfig = groupInfo.dump(), memberConfig = groupMembers.dump(), - timestamp = clock.currentTimeMills() + timestamp = clock.currentTimeMillis() ) return true } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseBindings.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseBindings.kt index 0cd2ca471e..77016bc30e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseBindings.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseBindings.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.dependencies +import androidx.sqlite.db.SupportSQLiteOpenHelper import dagger.Binds import dagger.Module import dagger.Provides @@ -12,6 +13,7 @@ import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.service.ExpiringMessageManager import javax.inject.Singleton @@ -30,4 +32,7 @@ abstract class DatabaseBindings { @Binds abstract fun bindMessageProvider(provider: DatabaseAttachmentProvider): MessageDataProvider + + @Binds + abstract fun bindSupportOpenHelper(openHelper: SQLCipherOpenHelper): SupportSQLiteOpenHelper } \ No newline at end of file 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 e1730e784e..3081b4943b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt @@ -2,33 +2,21 @@ package org.thoughtcrime.securesms.dependencies import org.session.libsession.messaging.notifications.TokenFetcher import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerManager -import org.session.libsession.messaging.sending_receiving.pollers.PollerManager -import org.session.libsession.snode.SnodeClock -import org.thoughtcrime.securesms.attachments.AvatarUploadManager -import org.thoughtcrime.securesms.configs.ConfigToDatabaseSync -import org.thoughtcrime.securesms.configs.ConfigUploader +import org.session.libsession.network.SnodeClock +import org.thoughtcrime.securesms.auth.AuthAwareComponentsHandler import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.disguise.AppDisguiseManager import org.thoughtcrime.securesms.emoji.EmojiIndexLoader import org.thoughtcrime.securesms.groups.ExpiredGroupManager import org.thoughtcrime.securesms.groups.GroupPollerManager -import org.thoughtcrime.securesms.groups.handler.AdminStateSync -import org.thoughtcrime.securesms.groups.handler.CleanupInvitationHandler -import org.thoughtcrime.securesms.groups.handler.DestroyedGroupSync -import org.thoughtcrime.securesms.groups.handler.RemoveGroupMemberHandler import org.thoughtcrime.securesms.logging.PersistentLogger import org.thoughtcrime.securesms.migration.DatabaseMigrationManager -import org.thoughtcrime.securesms.notifications.BackgroundPollManager -import org.thoughtcrime.securesms.notifications.PushRegistrationHandler -import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.subscription.SubscriptionCoordinator import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager -import org.thoughtcrime.securesms.service.ExpiringMessageManager import org.thoughtcrime.securesms.tokenpage.TokenDataManager import org.thoughtcrime.securesms.util.AppVisibilityManager import org.thoughtcrime.securesms.util.CurrentActivityObserver import org.thoughtcrime.securesms.util.VersionDataFetcher -import org.thoughtcrime.securesms.webrtc.CallMessageProcessor import org.thoughtcrime.securesms.webrtc.WebRtcCallBridge import javax.inject.Inject @@ -40,67 +28,41 @@ class OnAppStartupComponents private constructor( } @Inject constructor( - configUploader: ConfigUploader, - snodeClock: SnodeClock, - backgroundPollManager: BackgroundPollManager, - appVisibilityManager: AppVisibilityManager, groupPollerManager: GroupPollerManager, expiredGroupManager: ExpiredGroupManager, openGroupPollerManager: OpenGroupPollerManager, databaseMigrationManager: DatabaseMigrationManager, tokenManager: TokenDataManager, - expiringMessageManager: ExpiringMessageManager, currentActivityObserver: CurrentActivityObserver, webRtcCallBridge: WebRtcCallBridge, - cleanupInvitationHandler: CleanupInvitationHandler, - pollerManager: PollerManager, - proStatusManager: ProStatusManager, persistentLogger: PersistentLogger, appDisguiseManager: AppDisguiseManager, - removeGroupMemberHandler: RemoveGroupMemberHandler, - destroyedGroupSync: DestroyedGroupSync, - adminStateSync: AdminStateSync, - callMessageProcessor: CallMessageProcessor, - pushRegistrationHandler: PushRegistrationHandler, tokenFetcher: TokenFetcher, versionDataFetcher: VersionDataFetcher, threadDatabase: ThreadDatabase, emojiIndexLoader: EmojiIndexLoader, subscriptionCoordinator: SubscriptionCoordinator, - avatarUploadManager: AvatarUploadManager, - configToDatabaseSync: ConfigToDatabaseSync, + authAwareHandler: AuthAwareComponentsHandler, + snodeClock: SnodeClock, subscriptionManagers: Set<@JvmSuppressWildcards SubscriptionManager>, ): this( components = listOf( - configUploader, - snodeClock, - backgroundPollManager, - appVisibilityManager, groupPollerManager, expiredGroupManager, openGroupPollerManager, databaseMigrationManager, tokenManager, - expiringMessageManager, currentActivityObserver, webRtcCallBridge, - cleanupInvitationHandler, - pollerManager, - proStatusManager, persistentLogger, appDisguiseManager, - removeGroupMemberHandler, - destroyedGroupSync, - adminStateSync, - callMessageProcessor, - pushRegistrationHandler, tokenFetcher, versionDataFetcher, threadDatabase, emojiIndexLoader, subscriptionCoordinator, - avatarUploadManager, - configToDatabaseSync, + authAwareHandler, + snodeClock ) + subscriptionManagers ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/RecipientAvatarDownloadManager.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/RecipientAvatarDownloadManager.kt index db88434446..614d194458 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/RecipientAvatarDownloadManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/RecipientAvatarDownloadManager.kt @@ -14,9 +14,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import network.loki.messenger.libsession_util.util.GroupInfo -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.RemoteFile import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemoteFile +import org.session.libsession.utilities.withGroupConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.attachments.AvatarDownloadManager @@ -30,7 +31,6 @@ import javax.inject.Singleton @OptIn(FlowPreview::class) @Singleton class RecipientAvatarDownloadManager @Inject constructor( - private val prefs: TextSecurePreferences, private val configFactory: ConfigFactory, @ManagerScope scope: CoroutineScope, private val avatarDownloadManager: AvatarDownloadManager, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt index fbdd608b25..13fc4a8914 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -1,11 +1,15 @@ package org.thoughtcrime.securesms.groups import android.content.Context +import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -16,6 +20,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.libsession_util.allWithStatus @@ -27,6 +32,7 @@ import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigUpdateNotification import org.session.libsession.utilities.GroupDisplayInfo import org.session.libsession.utilities.recipients.displayName +import org.session.libsession.utilities.withGroupConfigs import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.util.AvatarUIData @@ -39,7 +45,7 @@ abstract class BaseGroupMembersViewModel( private val storage: StorageProtocol, private val configFactory: ConfigFactoryProtocol, private val avatarUtils: AvatarUtils, - private val recipientRepository: RecipientRepository, + private val recipientRepository: RecipientRepository ) : ViewModel() { private val groupId = groupAddress.accountId @@ -74,7 +80,12 @@ abstract class BaseGroupMembersViewModel( displayInfo to sortMembers(memberState, currentUserId) } - }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + // Current group name (for header / text, if needed) + val groupName: StateFlow = groupInfo + .map { it?.first?.name.orEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") private val mutableSearchQuery = MutableStateFlow("") val searchQuery: StateFlow get() = mutableSearchQuery @@ -87,6 +98,38 @@ abstract class BaseGroupMembersViewModel( ::filterContacts ).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + // Output: List of only NON-ADMINS + val nonAdminMembers: StateFlow> = members + .map { list -> list.filter { !it.showAsAdmin } } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + // Output : List of active members that can be promoted + val activeMembers: StateFlow> = members + .map { list -> list.filter { !it.showAsAdmin && it.status == GroupMember.Status.INVITE_ACCEPTED } } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val hasActiveMembers: StateFlow = + groupInfo + .map { pair -> pair?.second.orEmpty().any { !it.showAsAdmin && it.status == GroupMember.Status.INVITE_ACCEPTED } } + .stateIn(viewModelScope, SharingStarted.Lazily, false) + + val hasNonAdminMembers: StateFlow = + groupInfo + .map { pair -> pair?.second.orEmpty().any { !it.showAsAdmin } } + .stateIn(viewModelScope, SharingStarted.Lazily, false) + + // Output: List of only ADMINS + val adminMembers: StateFlow> = members + .map { list -> + list.filter { it.showAsAdmin } + .sortedWith( + compareBy { adminOrder(it) } + .thenComparing(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + .thenBy { it.accountId } + ) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + fun onSearchQueryChanged(query: String) { mutableSearchQuery.value = query } @@ -139,6 +182,7 @@ abstract class BaseGroupMembersViewModel( avatarUIData = avatarUtils.getUIDataFromAccountId(memberAccountId.hexString), clickable = !isMyself, statusLabel = getMemberLabel(status, context, amIAdmin), + isSelf = isMyself ) } @@ -170,14 +214,86 @@ abstract class BaseGroupMembersViewModel( } } - // Refer to notion doc for the sorting logic + // Refer to manage members/admin PRD for the sorting logic private fun sortMembers(members: List, currentUserId: AccountId) = members.sortedWith( - compareBy{ it.accountId != currentUserId } // Current user comes first - .thenBy { !it.showAsAdmin } // Admins come first - .thenComparing(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) // Sort by name (case insensitive) - .thenBy { it.accountId } // Last resort: sort by account ID + compareBy { stateOrder(it.status) } + .thenBy { it.accountId != currentUserId } + .thenComparing(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + .thenBy { it.accountId } ) + + fun showToast(text: String) { + Toast.makeText( + context, text, Toast.LENGTH_SHORT + ).show() + } + + /** + * Perform a group operation, such as inviting a member, removing a member. + * + * This is a helper function that encapsulates the common error handling and progress tracking. + */ + protected fun performGroupOperationCore( + showLoading: Boolean = false, + setLoading: (Boolean) -> Unit = {}, + errorMessage: ((Throwable) -> String?)? = null, + operation: suspend () -> Unit + ) { + viewModelScope.launch { + if (showLoading) setLoading(true) + + // We need to use GlobalScope here because we don't want + // any group operation to be cancelled when the view model is cleared. + @Suppress("OPT_IN_USAGE") + val task = GlobalScope.async { + operation() + } + + try { + task.await() + }catch (e: CancellationException) { + // Normal lifecycle cancellation - do not show toast but rethrow the exception + throw e + } catch (e: Throwable) { + val msg = errorMessage?.invoke(e) ?: context.getString(R.string.errorUnknown) + showToast(msg) + } finally { + if (showLoading) setLoading(false) + } + } + } +} + +private fun stateOrder(status: GroupMember.Status?): Int = when (status) { + // 1. Invite failed + GroupMember.Status.INVITE_FAILED -> 0 + // 2. Invite not sent + GroupMember.Status.INVITE_NOT_SENT -> 1 + // 3. Sending invite + GroupMember.Status.INVITE_SENDING -> 2 + // 4. Invite sent + GroupMember.Status.INVITE_SENT -> 3 + // 5. Invite status unknown + GroupMember.Status.INVITE_UNKNOWN -> 4 + // 6. Pending removal + GroupMember.Status.REMOVED, + GroupMember.Status.REMOVED_UNKNOWN, + GroupMember.Status.REMOVED_INCLUDING_MESSAGES -> 5 + // 7. Member (everything else) + else -> 6 +} + +private fun adminOrder(state: GroupMemberState): Int { + if (state.isSelf) return 7 // "You" always last + return when (state.status) { + GroupMember.Status.PROMOTION_FAILED -> 1 + GroupMember.Status.PROMOTION_NOT_SENT -> 2 + GroupMember.Status.PROMOTION_UNKNOWN -> 3 + GroupMember.Status.PROMOTION_SENDING -> 4 + GroupMember.Status.PROMOTION_SENT -> 5 + else -> 6 + } } data class GroupMemberState( @@ -194,6 +310,7 @@ data class GroupMemberState( val canPromote: Boolean, val clickable: Boolean, val statusLabel: String, + val isSelf: Boolean ) { val canEdit: Boolean get() = canRemove || canPromote || canResendInvite || canResendPromotion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt deleted file mode 100644 index bacdf3ae37..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt +++ /dev/null @@ -1,190 +0,0 @@ -package org.thoughtcrime.securesms.groups - -import android.content.Context -import androidx.lifecycle.viewModelScope -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import network.loki.messenger.R -import network.loki.messenger.libsession_util.getOrNull -import org.session.libsession.database.StorageProtocol -import org.session.libsession.messaging.groups.GroupInviteException -import org.session.libsession.messaging.groups.GroupManagerV2 -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.ConfigFactoryProtocol -import org.session.libsignal.utilities.AccountId -import org.thoughtcrime.securesms.database.RecipientRepository -import org.thoughtcrime.securesms.util.AvatarUtils - - -@HiltViewModel(assistedFactory = EditGroupViewModel.Factory::class) -class EditGroupViewModel @AssistedInject constructor( - @Assisted private val groupAddress: Address.Group, - @param:ApplicationContext private val context: Context, - storage: StorageProtocol, - private val configFactory: ConfigFactoryProtocol, - private val groupManager: GroupManagerV2, - private val recipientRepository: RecipientRepository, - avatarUtils: AvatarUtils, -) : BaseGroupMembersViewModel(groupAddress, context, storage, configFactory, avatarUtils, recipientRepository) { - private val groupId = groupAddress.accountId - - // Output: The name of the group. This is the current name of the group, not the name being edited. - val groupName: StateFlow = groupInfo - .map { it?.first?.name.orEmpty() } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") - - // Output: whether we should show the "add members" button - val showAddMembers: StateFlow = groupInfo - .map { it?.first?.isUserAdmin == true } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) - - // Output: Intermediate states - private val mutableInProgress = MutableStateFlow(false) - val inProgress: StateFlow get() = mutableInProgress - - // show action bottom sheet - private val _clickedMember: MutableStateFlow = MutableStateFlow(null) - val clickedMember: StateFlow get() = _clickedMember - - // Output: errors - private val mutableError = MutableStateFlow(null) - val error: StateFlow get() = mutableError - - // Output: - val excludingAccountIDsFromContactSelection: Set - get() = groupInfo.value?.second?.mapTo(hashSetOf()) { it.accountId.hexString }.orEmpty() - - fun onContactSelected(contacts: Set
) { - performGroupOperation( - showLoading = false, - errorMessage = { err -> - if (err is GroupInviteException) { - err.format(context, recipientRepository).toString() - } else { - null - } - } - ) { - groupManager.inviteMembers( - groupId, - contacts.map { AccountId(it.toString()) }.toList(), - shareHistory = false, - isReinvite = false, - ) - } - } - - fun onResendInviteClicked(contactSessionId: AccountId) { - performGroupOperation( - showLoading = false, - errorMessage = { err -> - if (err is GroupInviteException) { - err.format(context, recipientRepository).toString() - } else { - null - } - } - ) { - val historyShared = configFactory.withGroupConfigs(groupId) { - it.groupMembers.getOrNull(contactSessionId.hexString) - }?.supplement == true - - groupManager.inviteMembers( - groupId, - listOf(contactSessionId), - shareHistory = historyShared, - isReinvite = true, - ) - } - } - - fun onPromoteContact(memberSessionId: AccountId) { - performGroupOperation(showLoading = false) { - groupManager.promoteMember(groupId, listOf(memberSessionId), isRepromote = false) - } - } - - fun onRemoveContact(contactSessionId: AccountId, removeMessages: Boolean) { - performGroupOperation(showLoading = false) { - groupManager.removeMembers( - groupAccountId = groupId, - removedMembers = listOf(contactSessionId), - removeMessages = removeMessages - ) - } - } - - fun onResendPromotionClicked(memberSessionId: AccountId) { - performGroupOperation(showLoading = false) { - groupManager.promoteMember(groupId, listOf(memberSessionId), isRepromote = true) - } - } - - fun onDismissError() { - mutableError.value = null - } - - /** - * Perform a group operation, such as inviting a member, removing a member. - * - * This is a helper function that encapsulates the common error handling and progress tracking. - */ - private fun performGroupOperation( - showLoading: Boolean = true, - errorMessage: ((Throwable) -> String?)? = null, - operation: suspend () -> Unit) { - viewModelScope.launch { - if (showLoading) { - mutableInProgress.value = true - } - - // We need to use GlobalScope here because we don't want - // any group operation to be cancelled when the view model is cleared. - @Suppress("OPT_IN_USAGE") - val task = GlobalScope.async { - operation() - } - - try { - task.await() - } catch (e: Exception) { - mutableError.value = errorMessage?.invoke(e) - ?: context.getString(R.string.errorUnknown) - } finally { - if (showLoading) { - mutableInProgress.value = false - } - } - } - } - - fun onMemberClicked(groupMember: GroupMemberState){ - // if the member is clickable (ie, not 'you') but is an admin with no possible actions, - // show a toast mentioning they can't be removed - if(!groupMember.canEdit && groupMember.showAsAdmin){ - mutableError.value = context.getString(R.string.adminCannotBeRemoved) - } else { // otherwise pass in the clicked member to display the action sheet - _clickedMember.value = groupMember - } - } - - fun hideActionBottomSheet(){ - _clickedMember.value = null - } - - @AssistedFactory - interface Factory { - fun create(groupAddress: Address.Group): EditGroupViewModel - } -} 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/GroupLeavingWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt index 476b70925d..127cd0d066 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt @@ -12,26 +12,29 @@ import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.launch import org.session.libsession.messaging.groups.GroupScope import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.messaging.notifications.TokenFetcher import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.messaging.sending_receiving.notifications.NotificationServer import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.utilities.Address import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.waitUntilGroupConfigsPushed +import org.session.libsession.utilities.withGroupConfigs +import org.session.libsession.utilities.withMutableGroupConfigs import org.session.libsignal.exceptions.NonRetryableException -import org.session.libsignal.protos.SignalServiceProtos.DataMessage -import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log +import org.session.protos.SessionProtos +import org.session.protos.SessionProtos.GroupUpdateMessage +import org.thoughtcrime.securesms.api.server.ServerApiExecutor +import org.thoughtcrime.securesms.api.server.ServerApiRequest +import org.thoughtcrime.securesms.api.server.execute import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.notifications.PushRegistryV2 +import org.thoughtcrime.securesms.notifications.PushUnregisterApi @HiltWorker class GroupLeavingWorker @AssistedInject constructor( @@ -41,7 +44,8 @@ class GroupLeavingWorker @AssistedInject constructor( private val configFactory: ConfigFactory, private val groupScope: GroupScope, private val tokenFetcher: TokenFetcher, - private val pushRegistryV2: PushRegistryV2, + private val serverApiExecutor: ServerApiExecutor, + private val pushUnregisterApiFactory: PushUnregisterApi.Factory, private val messageSender: MessageSender, ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { @@ -49,6 +53,9 @@ class GroupLeavingWorker @AssistedInject constructor( "Group ID must be provided" }.let(::AccountId) + // delete this group instead of leaving. + val deleteGroup = inputData.getBoolean(KEY_DELETE_GROUP, false) + Log.d(TAG, "Group leaving work started for $groupId") return groupScope.launchAndWait(groupId, "GroupLeavingWorker") { @@ -67,13 +74,17 @@ class GroupLeavingWorker @AssistedInject constructor( val groupAuth = configFactory.getGroupAuth(groupId) if (groupAuth != null) { - val resp = pushRegistryV2.unregister(listOf( - pushRegistryV2.buildUnregisterRequest(currentToken, groupAuth) - )).firstOrNull() + serverApiExecutor.execute( + ServerApiRequest( + serverBaseUrl = NotificationServer.LATEST.url, + serverX25519PubKeyHex = NotificationServer.LATEST.publicKey, + api = pushUnregisterApiFactory.create( + token = currentToken, + swarmAuth = groupAuth, + ) + ) + ) - check(resp?.success == true) { - "Unsubscription failed: code = ${resp?.error}, message = ${resp?.message}" - } Log.d(TAG, "Unsubscribed from group $groupId successfully") } @@ -102,7 +113,7 @@ class GroupLeavingWorker @AssistedInject constructor( messageSender.send( GroupUpdated( GroupUpdateMessage.newBuilder() - .setMemberLeftNotificationMessage(DataMessage.GroupUpdateMemberLeftNotificationMessage.getDefaultInstance()) + .setMemberLeftNotificationMessage(SessionProtos.GroupUpdateMemberLeftNotificationMessage.getDefaultInstance()) .build() ), address, @@ -114,7 +125,7 @@ class GroupLeavingWorker @AssistedInject constructor( messageSender.send( GroupUpdated( GroupUpdateMessage.newBuilder() - .setMemberLeftMessage(DataMessage.GroupUpdateMemberLeftMessage.getDefaultInstance()) + .setMemberLeftMessage(SessionProtos.GroupUpdateMemberLeftMessage.getDefaultInstance()) .build() ), address, @@ -127,8 +138,9 @@ class GroupLeavingWorker @AssistedInject constructor( } } - // If we are the only admin, leaving this group will destroy the group - if (weAreTheOnlyAdmin) { + // We now have an admin option to leave group so we need a way of Deleting the group + // even if there are more admins + if (weAreTheOnlyAdmin || deleteGroup) { configFactory.withMutableGroupConfigs(groupId) { configs -> configs.groupInfo.destroyGroup() } @@ -164,15 +176,19 @@ class GroupLeavingWorker @AssistedInject constructor( private const val TAG = "GroupLeavingWorker" private const val KEY_GROUP_ID = "group_id" + private const val KEY_DELETE_GROUP = "delete_group" - fun schedule(context: Context, groupId: AccountId) { + fun schedule(context: Context, groupId: AccountId, deleteGroup : Boolean = false) { WorkManager.getInstance(context) .enqueue( OneTimeWorkRequestBuilder() .addTag(KEY_GROUP_ID) .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED)) .setInputData( - Data.Builder().putString(KEY_GROUP_ID, groupId.hexString).build() + Data.Builder() + .putString(KEY_GROUP_ID, groupId.hexString) + .putBoolean(KEY_DELETE_GROUP, deleteGroup) + .build() ) .build() ) 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 91a500b55c..9ac9c8b34f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -5,16 +5,18 @@ import com.google.protobuf.ByteString import com.squareup.phrase.Phrase import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull import network.loki.messenger.R -import network.loki.messenger.libsession_util.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.Namespace +import network.loki.messenger.libsession_util.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.allWithStatus import network.loki.messenger.libsession_util.util.Bytes.Companion.toBytes import network.loki.messenger.libsession_util.util.Conversation import network.loki.messenger.libsession_util.util.ExpiryMode @@ -36,49 +38,60 @@ import org.session.libsession.messaging.utilities.MessageAuthentication.buildDel import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature import org.session.libsession.messaging.utilities.UpdateMessageData +import org.session.libsession.network.SnodeClock import org.session.libsession.snode.OwnedSwarmAuth -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock import org.session.libsession.snode.SnodeMessage -import org.session.libsession.snode.model.BatchResponse -import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientData import org.session.libsession.utilities.waitUntilGroupConfigsPushed -import org.session.libsignal.protos.SignalServiceProtos.DataMessage -import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage -import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInfoChangeMessage -import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInviteResponseMessage -import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMemberChangeMessage -import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage +import org.session.libsession.utilities.withGroupConfigs +import org.session.libsession.utilities.withMutableGroupConfigs +import org.session.libsession.utilities.withMutableUserConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log +import org.session.protos.SessionProtos.GroupUpdateDeleteMemberContentMessage +import org.session.protos.SessionProtos.GroupUpdateInfoChangeMessage.Type +import org.session.protos.SessionProtos.GroupUpdateInfoChangeMessage.newBuilder +import org.session.protos.SessionProtos.GroupUpdateInviteResponseMessage +import org.session.protos.SessionProtos.GroupUpdateMemberChangeMessage +import org.session.protos.SessionProtos.GroupUpdateMessage +import org.session.protos.SessionProtos.GroupUpdatePromoteMessage +import org.thoughtcrime.securesms.api.snode.BatchApi +import org.thoughtcrime.securesms.api.snode.DeleteMessageApi +import org.thoughtcrime.securesms.api.snode.SnodeApi +import org.thoughtcrime.securesms.api.snode.StoreMessageApi +import org.thoughtcrime.securesms.api.snode.UnrevokeSubKeyApi +import org.thoughtcrime.securesms.api.swarm.SwarmApiExecutor +import org.thoughtcrime.securesms.api.swarm.SwarmApiRequest +import org.thoughtcrime.securesms.api.swarm.execute import org.thoughtcrime.securesms.configs.ConfigUploader import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.database.RecipientRepository -import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.util.SessionMetaProtocol import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds private const val TAG = "GroupManagerV2Impl" +data class MemberInvite(val id: AccountId, val shareHistory: Boolean) + @Singleton class GroupManagerV2Impl @Inject constructor( private val storage: StorageProtocol, private val configFactory: ConfigFactory, private val mmsSmsDatabase: MmsSmsDatabase, private val lokiDatabase: LokiMessageDatabase, - private val threadDatabase: ThreadDatabase, @param:ApplicationContext val application: Context, private val clock: SnodeClock, private val messageDataProvider: MessageDataProvider, @@ -90,6 +103,11 @@ class GroupManagerV2Impl @Inject constructor( private val recipientRepository: RecipientRepository, private val messageSender: MessageSender, private val inviteContactJobFactory: InviteContactsJob.Factory, + private val swarmApiExecutor: SwarmApiExecutor, + private val deleteMessageApiFactory: DeleteMessageApi.Factory, + private val storeSnodeMessageApiFactory: StoreMessageApi.Factory, + private val unrevokeSubKeyApiFactory: UnrevokeSubKeyApi.Factory, + private val batchApiFactory: BatchApi.Factory, ) : GroupManagerV2 { private val dispatcher = Dispatchers.Default @@ -120,7 +138,7 @@ class GroupManagerV2Impl @Inject constructor( val ourAccountId = requireNotNull(storage.getUserPublicKey()) { "Our account ID is not available" } - val groupCreationTimestamp = clock.currentTimeMills() + val groupCreationTimestamp = clock.currentTimeMillis() // Create a group in the user groups config val group = configFactory.withUserConfigs { configs -> @@ -208,7 +226,8 @@ class GroupManagerV2Impl @Inject constructor( JobQueue.shared.add( inviteContactJobFactory.create( groupSessionId = groupId.hexString, - memberSessionIds = members.map { it.hexString }.toTypedArray() + memberSessionIds = members.map { it.hexString }.toTypedArray(), + false ) ) @@ -223,53 +242,72 @@ class GroupManagerV2Impl @Inject constructor( } } - override suspend fun inviteMembers( group: AccountId, newMembers: List, shareHistory: Boolean, isReinvite: Boolean - ): Unit = scope.launchAndWait(group, "Invite members") { + ): Unit = inviteMembersInternal( + group = group, + memberInvites = newMembers.map { MemberInvite(it, shareHistory) }, + isReinvite = isReinvite + ) + + override suspend fun reinviteMembers( + group: AccountId, + invites: List + ): Unit = inviteMembersInternal( + group = group, + memberInvites = invites, + isReinvite = true + ) + + private suspend fun inviteMembersInternal( + group: AccountId, + memberInvites: List, + isReinvite: Boolean + ): Unit = scope.launchAndWait(group, if (isReinvite) "Reinvite members" else "Invite members") { val adminKey = requireAdminAccess(group) val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey) - val batchRequests = mutableListOf() + val batchApis = mutableListOf>() - // Construct the new members in our config val subAccountTokens = configFactory.withMutableGroupConfigs(group) { configs -> - // Construct the new members in the config - for (newMember in newMembers) { - val toSet = configs.groupMembers.get(newMember.hexString) + val shareHistoryHexes = mutableListOf() + + for ((id, shareHistory) in memberInvites) { + val hex = id.hexString + + val toSet = configs.groupMembers.get(hex) ?.also { existing -> val status = configs.groupMembers.status(existing) if (status == GroupMember.Status.INVITE_FAILED || status == GroupMember.Status.INVITE_SENT) { existing.setSupplement(shareHistory) } } - ?: configs.groupMembers.getOrConstruct(newMember.hexString).also { member -> - val contact = configFactory.withUserConfigs { configs -> - configs.contacts.get(newMember.hexString) - } - + ?: configs.groupMembers.getOrConstruct(hex).also { member -> + val contact = configFactory.withUserConfigs { it.contacts.get(hex) } member.setName(contact?.name.orEmpty()) member.setProfilePic(contact?.profilePicture ?: UserPic.DEFAULT) member.setSupplement(shareHistory) } + if (shareHistory) shareHistoryHexes += hex + toSet.setInvited() configs.groupMembers.set(toSet) } - if (shareHistory) { - val memberKey = configs.groupKeys.supplementFor(newMembers.map { it.hexString }) - batchRequests.add( - SnodeAPI.buildAuthenticatedStoreBatchInfo( + if (shareHistoryHexes.isNotEmpty()) { + val memberKey = configs.groupKeys.supplementFor(shareHistoryHexes) + batchApis.add( + storeSnodeMessageApiFactory.create( namespace = Namespace.GROUP_KEYS(), message = SnodeMessage( recipient = group.hexString, data = Base64.encodeBytes(memberKey), ttl = SnodeMessage.CONFIG_TTL, - timestamp = clock.currentTimeMills(), + timestamp = clock.currentTimeMillis(), ), auth = groupAuth, ) @@ -277,19 +315,21 @@ class GroupManagerV2Impl @Inject constructor( } configs.rekey() - newMembers.map { configs.groupKeys.getSubAccountToken(it.hexString) } + memberInvites.map { configs.groupKeys.getSubAccountToken(it.id.hexString) } } // Call un-revocate API on new members, in case they have been removed before - batchRequests += SnodeAPI.buildAuthenticatedUnrevokeSubKeyBatchRequest( - groupAdminAuth = groupAuth, + batchApis += unrevokeSubKeyApiFactory.create( + auth = groupAuth, subAccountTokens = subAccountTokens ) // Call the API try { - val swarmNode = SnodeAPI.getSingleTargetSnode(group.hexString).await() - val response = SnodeAPI.getBatchResponse(swarmNode, group.hexString, batchRequests) + val response = swarmApiExecutor.execute(SwarmApiRequest( + swarmPubKeyHex = group.hexString, + api = batchApiFactory.createFromApis(batchApis) + )) // Make sure every request is successful response.requireAllRequestsSuccessful("Failed to invite members") @@ -299,13 +339,12 @@ class GroupManagerV2Impl @Inject constructor( } catch (e: Exception) { // Update every member's status to "invite failed" and return group name val groupName = configFactory.withMutableGroupConfigs(group) { configs -> - for (newMember in newMembers) { - configs.groupMembers.get(newMember.hexString)?.apply { + for ((id, _) in memberInvites) { + configs.groupMembers.get(id.hexString)?.apply { setInviteFailed() configs.groupMembers.set(this) } } - configs.groupInfo.getName().orEmpty() } @@ -313,14 +352,19 @@ class GroupManagerV2Impl @Inject constructor( throw GroupInviteException( isPromotion = false, - inviteeAccountIds = newMembers.map { it.hexString }, + inviteeAccountIds = memberInvites.map { it.id.hexString }, groupName = groupName, - underlying = e + underlying = e, + isReinvite = isReinvite ) } finally { // Send a group update message to the group telling members someone has been invited if (!isReinvite) { - sendGroupUpdateForAddingMembers(group, adminKey, newMembers) + sendGroupUpdateForAddingMembers( + group, + adminKey, + memberInvites.map { it.id }, + shareHistory = memberInvites.any { it.shareHistory }) // This is the same for all members/contact invited } } @@ -328,7 +372,8 @@ class GroupManagerV2Impl @Inject constructor( JobQueue.shared.add( inviteContactJobFactory.create( groupSessionId = group.hexString, - memberSessionIds = newMembers.map { it.hexString }.toTypedArray() + memberSessionIds = memberInvites.map { it.id.hexString }.toTypedArray(), + isReinvite = isReinvite ) ) } @@ -340,8 +385,9 @@ class GroupManagerV2Impl @Inject constructor( group: AccountId, adminKey: ByteArray, newMembers: Collection, + shareHistory : Boolean = false ) { - val timestamp = clock.currentTimeMills() + val timestamp = clock.currentTimeMillis() val signature = ED25519.sign( message = buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.ADDED, timestamp), ed25519PrivateKey = adminKey @@ -354,13 +400,13 @@ class GroupManagerV2Impl @Inject constructor( .addAllMemberSessionIds(newMembers.sortedWith(groupMemberComparator).map { it.hexString }) .setType(GroupUpdateMemberChangeMessage.Type.ADDED) .setAdminSignature(ByteString.copyFrom(signature)) + .setHistoryShared(shareHistory) ) .build() ).apply { this.sentTimestamp = timestamp } - storage.insertGroupInfoChange(updatedMessage, group) - messageSender.send(updatedMessage, Address.fromSerialized(group.hexString)) + storage.insertGroupInfoChange(updatedMessage, group) } override suspend fun removeMembers( @@ -378,7 +424,7 @@ class GroupManagerV2Impl @Inject constructor( alsoRemoveMembersMessage = removeMessages, ) - val timestamp = clock.currentTimeMills() + val timestamp = clock.currentTimeMillis() val signature = ED25519.sign( message = buildMemberChangeSignature( GroupUpdateMemberChangeMessage.Type.REMOVED, @@ -431,7 +477,15 @@ class GroupManagerV2Impl @Inject constructor( OwnedSwarmAuth.ofClosedGroup(groupAccountId, it) } ?: return@launchAndWait - SnodeAPI.deleteMessage(groupAccountId.hexString, groupAdminAuth, messagesToDelete) + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = groupAccountId.hexString, + api = deleteMessageApiFactory.create( + messageHashes = messagesToDelete, + swarmAuth = groupAdminAuth + ) + ) + ) } override suspend fun clearAllMessagesForEveryone(groupAccountId: AccountId, deletedHashes: List) { @@ -445,9 +499,19 @@ class GroupManagerV2Impl @Inject constructor( configs.groupInfo.setDeleteBefore(clock.currentTimeSeconds()) } - // remove messages from swarm SnodeAPI.deleteMessage + // remove messages from swarm sessionClient.deleteMessage val cleanedHashes: List = deletedHashes.filter { !it.isNullOrEmpty() }.filterNotNull() - if(cleanedHashes.isNotEmpty()) SnodeAPI.deleteMessage(groupAccountId.hexString, groupAdminAuth, cleanedHashes) + if (cleanedHashes.isNotEmpty()) { + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = groupAccountId.hexString, + api = deleteMessageApiFactory.create( + messageHashes = cleanedHashes, + swarmAuth = groupAdminAuth + ) + ) + ) + } } override suspend fun handleMemberLeftMessage(memberId: AccountId, group: AccountId) = scope.launchAndWait(group, "Handle member left message") { @@ -464,12 +528,12 @@ class GroupManagerV2Impl @Inject constructor( } } - override suspend fun leaveGroup(groupId: AccountId) { + override suspend fun leaveGroup(groupId: AccountId, deleteGroup : Boolean) { // Insert the control message immediately so we can see the leaving message storage.insertGroupInfoLeaving(groupId) // The group leaving work could start or wait depend on the network condition - GroupLeavingWorker.schedule(context = application, groupId) + GroupLeavingWorker.schedule(context = application, groupId, deleteGroup) } override suspend fun promoteMember( @@ -477,22 +541,30 @@ class GroupManagerV2Impl @Inject constructor( members: List, isRepromote: Boolean ): Unit = scope.launchAndWait(group, "Promote member") { - withContext(SupervisorJob()) { + supervisorScope { val adminKey = requireAdminAccess(group) val groupName = configFactory.withMutableGroupConfigs(group) { configs -> // Update the group member's promotion status members.asSequence() .mapNotNull { configs.groupMembers.get(it.hexString) } - .onEach(GroupMember::setPromoted) + .onEach(GroupMember::setPromotionSent) .forEach(configs.groupMembers::set) configs.groupInfo.getName() } + // Ensure this push is complete before promotion messages go out + withTimeoutOrNull(10.seconds) { + configFactory.waitUntilGroupConfigsPushed(group) + } + // Build a group update message to the group telling members someone has been promoted - val timestamp = clock.currentTimeMills() + val timestamp = clock.currentTimeMillis() val signature = ED25519.sign( - message = buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.PROMOTED, timestamp), + message = buildMemberChangeSignature( + GroupUpdateMemberChangeMessage.Type.PROMOTED, + timestamp + ), ed25519PrivateKey = adminKey ) @@ -500,7 +572,8 @@ class GroupManagerV2Impl @Inject constructor( GroupUpdateMessage.newBuilder() .setMemberChangeMessage( GroupUpdateMemberChangeMessage.newBuilder() - .addAllMemberSessionIds(members.sortedWith(groupMemberComparator).map { it.hexString }) + .addAllMemberSessionIds( + members.sortedWith(groupMemberComparator).map { it.hexString }) .setType(GroupUpdateMemberChangeMessage.Type.PROMOTED) .setAdminSignature(ByteString.copyFrom(signature)) ) @@ -519,7 +592,7 @@ class GroupManagerV2Impl @Inject constructor( val promoteMessage = GroupUpdated( GroupUpdateMessage.newBuilder() .setPromoteMessage( - DataMessage.GroupUpdatePromoteMessage.newBuilder() + GroupUpdatePromoteMessage.newBuilder() .setGroupIdentitySeed(ByteString.copyFrom(adminKey).substring(0, 32)) .setName(groupName) ) @@ -539,18 +612,16 @@ class GroupManagerV2Impl @Inject constructor( // Wait and gather all the promote message sending result into a result map val promotedByMemberIDs = promotionDeferred - .mapValues { - runCatching { it.value.await() }.isSuccess + .mapValues { (_, deferred) -> + runCatching { deferred.await() } } // Update each member's status configFactory.withMutableGroupConfigs(group) { configs -> promotedByMemberIDs.asSequence() - .mapNotNull { (member, success) -> + .mapNotNull { (member, result) -> configs.groupMembers.get(member.hexString)?.apply { - if (success) { - setPromotionSent() - } else { + if (result.isFailure) { setPromotionFailed() } } @@ -558,12 +629,31 @@ class GroupManagerV2Impl @Inject constructor( .forEach(configs.groupMembers::set) } - if (!isRepromote) { messageSender.sendAndAwait(message, Address.fromSerialized(group.hexString)) } + + val failedMembers = promotedByMemberIDs + .filterValues { it.isFailure } + .keys + .map { it.hexString } + + if (failedMembers.isNotEmpty()) { + val cause = promotedByMemberIDs.values + .firstOrNull { it.isFailure }?.exceptionOrNull() + ?: RuntimeException("Failed to promote ${failedMembers.size} member(s)") + + throw GroupInviteException( + isPromotion = true, + inviteeAccountIds = failedMembers, + groupName = groupName ?: "", + isReinvite = isRepromote, + underlying = cause + ) + } } } + /** * Mark this member as "removed" in the group config. * @@ -615,10 +705,15 @@ class GroupManagerV2Impl @Inject constructor( if (groupInviteMessageHash != null) { val auth = requireNotNull(storage.userAuth) - SnodeAPI.deleteMessage( - publicKey = auth.accountId.hexString, - swarmAuth = auth, - serverHashes = listOf(groupInviteMessageHash) + + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = auth.accountId.hexString, + api = deleteMessageApiFactory.create( + messageHashes = listOf(groupInviteMessageHash), + swarmAuth = auth + ) + ) ) } } @@ -636,7 +731,7 @@ class GroupManagerV2Impl @Inject constructor( configFactory.withMutableUserConfigs { configs -> configs.userGroups.set(group.copy( invited = false, - joinedAtSecs = TimeUnit.MILLISECONDS.toSeconds(clock.currentTimeMills()) + joinedAtSecs = TimeUnit.MILLISECONDS.toSeconds(clock.currentTimeMillis()) )) } @@ -650,7 +745,7 @@ class GroupManagerV2Impl @Inject constructor( groupPollerManager.pollOnce(groupId) groupPollerManager.watchGroupPollingState(groupId) - .filter { it.hadAtLeastOneSuccessfulPoll } + .filter { it.lastPolledResult?.isSuccess == true } .first() } @@ -688,10 +783,14 @@ class GroupManagerV2Impl @Inject constructor( // Delete the invite once we have approved if (inviteMessageHash != null) { val auth = requireNotNull(storage.userAuth) - SnodeAPI.deleteMessage( - publicKey = auth.accountId.hexString, - swarmAuth = auth, - serverHashes = listOf(inviteMessageHash) + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = auth.accountId.hexString, + api = deleteMessageApiFactory.create( + messageHashes = listOf(inviteMessageHash), + swarmAuth = auth + ) + ) ) } } @@ -768,10 +867,14 @@ class GroupManagerV2Impl @Inject constructor( } // Delete the promotion message remotely - SnodeAPI.deleteMessage( - userAuth.accountId.hexString, - userAuth, - listOf(promoteMessageHash) + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = userAuth.accountId.hexString, + api = deleteMessageApiFactory.create( + messageHashes = listOf(promoteMessageHash), + swarmAuth = userAuth + ) + ) ) } @@ -911,18 +1014,18 @@ class GroupManagerV2Impl @Inject constructor( return@launchAndWait } - val timestamp = clock.currentTimeMills() + val timestamp = clock.currentTimeMillis() val signature = ED25519.sign( - message = buildInfoChangeSignature(GroupUpdateInfoChangeMessage.Type.NAME, timestamp), + message = buildInfoChangeSignature(Type.NAME, timestamp), ed25519PrivateKey = adminKey ) val message = GroupUpdated( GroupUpdateMessage.newBuilder() .setInfoChangeMessage( - GroupUpdateInfoChangeMessage.newBuilder() + newBuilder() .setUpdatedName(newName) - .setType(GroupUpdateInfoChangeMessage.Type.NAME) + .setType(Type.NAME) .setAdminSignature(ByteString.copyFrom(signature)) ) .build() @@ -975,15 +1078,19 @@ class GroupManagerV2Impl @Inject constructor( // If we are admin, we can delete the messages from the group swarm group.adminKey?.data?.let { adminKey -> - SnodeAPI.deleteMessage( - publicKey = groupId.hexString, - swarmAuth = OwnedSwarmAuth.ofClosedGroup(groupId, adminKey), - serverHashes = messageHashes.toList() + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = groupId.hexString, + api = deleteMessageApiFactory.create( + messageHashes = messageHashes, + swarmAuth = OwnedSwarmAuth.ofClosedGroup(groupId, adminKey) + ) + ) ) } // Construct a message to ask members to delete the messages, sign if we are admin, then send - val timestamp = clock.currentTimeMills() + val timestamp = clock.currentTimeMillis() val signature = group.adminKey?.data?.let { key -> ED25519.sign( message = buildDeleteMemberContentSignature( @@ -1073,10 +1180,14 @@ class GroupManagerV2Impl @Inject constructor( sender = sender.hexString, closedGroupId = groupId.hexString)) ) { - SnodeAPI.deleteMessage( - groupId.hexString, - OwnedSwarmAuth.ofClosedGroup(groupId, adminKey), - hashes + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = groupId.hexString, + api = deleteMessageApiFactory.create( + messageHashes = hashes, + swarmAuth = OwnedSwarmAuth.ofClosedGroup(groupId, adminKey) + ) + ) ) } @@ -1088,10 +1199,14 @@ class GroupManagerV2Impl @Inject constructor( } if (userMessageHashes.isNotEmpty()) { - SnodeAPI.deleteMessage( - groupId.hexString, - OwnedSwarmAuth.ofClosedGroup(groupId, adminKey), - userMessageHashes + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = groupId.hexString, + api = deleteMessageApiFactory.create( + messageHashes = userMessageHashes, + swarmAuth = OwnedSwarmAuth.ofClosedGroup(groupId, adminKey) + ) + ) ) } } @@ -1099,7 +1214,7 @@ class GroupManagerV2Impl @Inject constructor( } override fun handleGroupInfoChange(message: GroupUpdated, groupId: AccountId) { - if (message.inner.hasInfoChangeMessage() && message.inner.infoChangeMessage.hasUpdatedExpirationSeconds()) { + if (message.inner.hasInfoChangeMessage() && message.inner.infoChangeMessage.hasUpdatedExpiration()) { // If we receive a disappearing message update, we need to remove the existing timer control message storage.deleteGroupInfoMessages( groupId, @@ -1126,18 +1241,18 @@ class GroupManagerV2Impl @Inject constructor( val adminKey = requireAdminAccess(groupId) // Construct a message to notify the group members about the expiration timer change - val timestamp = clock.currentTimeMills() + val timestamp = clock.currentTimeMillis() val signature = ED25519.sign( - message = buildInfoChangeSignature(GroupUpdateInfoChangeMessage.Type.DISAPPEARING_MESSAGES, timestamp), + message = buildInfoChangeSignature(Type.DISAPPEARING_MESSAGES, timestamp), ed25519PrivateKey = adminKey ) val message = GroupUpdated( GroupUpdateMessage.newBuilder() .setInfoChangeMessage( - GroupUpdateInfoChangeMessage.newBuilder() - .setType(GroupUpdateInfoChangeMessage.Type.DISAPPEARING_MESSAGES) - .setUpdatedExpirationSeconds(mode.expirySeconds.toInt()) + newBuilder() + .setType(Type.DISAPPEARING_MESSAGES) + .setUpdatedExpiration(mode.expirySeconds.toInt()) .setAdminSignature(ByteString.copyFrom(signature)) ) @@ -1155,46 +1270,108 @@ class GroupManagerV2Impl @Inject constructor( override fun getLeaveGroupConfirmationDialogData(groupId: AccountId, name: String): GroupManagerV2.ConfirmDialogData? { val groupData = configFactory.getGroup(groupId) ?: return null - var title = R.string.groupDelete + val title = R.string.groupLeave + var message: CharSequence = Phrase.from(application, R.string.groupLeaveDescription) + .put(GROUP_NAME_KEY, name) + .format() + var positiveButton = R.string.leave + var negativeButton = R.string.cancel + val positiveQaTag = R.string.qa_conversation_settings_dialog_leave_group_confirm + val negativeQaTag = R.string.qa_conversation_settings_dialog_leave_group_cancel + var showCloseButton = false + + if (!groupData.shouldPoll) { + return getDeleteGroupConfirmationDialogData(groupId, name) + } + // if an admin tries to leave while being the only admin in the group + if (isCurrentUserLastAdmin(groupId)) { + message = Phrase.from(application, R.string.groupOnlyAdminLeave) + .put(GROUP_NAME_KEY, name) + .format() + + positiveButton = R.string.addAdminSingular + negativeButton = R.string.groupDelete + showCloseButton = true + } + + return GroupManagerV2.ConfirmDialogData( + title = application.getString(title), + message = message, + positiveText = positiveButton, + negativeText = negativeButton, + positiveQaTag = positiveQaTag, + negativeQaTag = negativeQaTag, + showCloseButton = showCloseButton + ) + } + + override fun getDeleteGroupConfirmationDialogData( + groupId: AccountId, + name: String + ): GroupManagerV2.ConfirmDialogData? { + val groupData = configFactory.getGroup(groupId) ?: return null + + val title = R.string.groupDelete var message: CharSequence = "" - var positiveButton = R.string.delete - var positiveQaTag = R.string.qa_conversation_settings_dialog_delete_group_confirm - var negativeQaTag = R.string.qa_conversation_settings_dialog_delete_group_cancel + val positiveButton = R.string.delete + val positiveQaTag = R.string.qa_conversation_settings_dialog_delete_group_confirm + val negativeQaTag = R.string.qa_conversation_settings_dialog_delete_group_cancel + val isAdmin = groupData.hasAdminKey() - if(!groupData.shouldPoll){ + // safety guard. You can't delete as a non admin that can poll this group + if(!isAdmin && groupData.shouldPoll) { + return getLeaveGroupConfirmationDialogData(groupId, name) + } + + if (!groupData.shouldPoll) { message = Phrase.from(application, R.string.groupDeleteDescriptionMember) .put(GROUP_NAME_KEY, name) .format() - } else if (groupData.hasAdminKey()) { message = Phrase.from(application, R.string.groupDeleteDescription) .put(GROUP_NAME_KEY, name) .format() - } else { - message = Phrase.from(application, R.string.groupLeaveDescription) - .put(GROUP_NAME_KEY, name) - .format() + } - title = R.string.groupLeave - positiveButton = R.string.leave - positiveQaTag = R.string.qa_conversation_settings_dialog_leave_group_confirm - negativeQaTag = R.string.qa_conversation_settings_dialog_leave_group_cancel + return GroupManagerV2.ConfirmDialogData( + title = application.getString(title), + message = message, + positiveText = positiveButton, + negativeText = R.string.cancel, + positiveQaTag = positiveQaTag, + negativeQaTag = negativeQaTag, + ) + } + + private fun adminMembers(groupId: AccountId): Sequence = + configFactory.withGroupConfigs(groupId) { + it.groupMembers.allWithStatus() + .filter { (member, status) -> + status == GroupMember.Status.PROMOTION_ACCEPTED && !member.isRemoved(status) + } + .map { (member, _) -> member } } + override fun isCurrentUserLastAdmin(groupId: AccountId): Boolean { + val currentUserId = checkNotNull(storage.getUserPublicKey()) { "User public key is null" } - return GroupManagerV2.ConfirmDialogData( - title = application.getString(title), - message = message, - positiveText = positiveButton, - negativeText = R.string.cancel, - positiveQaTag = positiveQaTag, - negativeQaTag = negativeQaTag, - ) + var adminCount = 0 + var amAdmin = false + + for (member in adminMembers(groupId)) { + adminCount++ + + if (!amAdmin && member.accountId() == currentUserId) { + amAdmin = true + } + } + + return amAdmin && adminCount == 1 } - private fun BatchResponse.requireAllRequestsSuccessful(errorMessage: String) { - val firstError = this.results.firstOrNull { it.code != 200 } + private fun BatchApi.Response.requireAllRequestsSuccessful(errorMessage: String) { + val firstError = this.responses.firstOrNull { it.code != 200 } require(firstError == null) { "$errorMessage: ${firstError!!.body}" } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersViewModel.kt index 976092aa0c..c4ba642cc8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersViewModel.kt @@ -18,7 +18,6 @@ import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.RecipientRepository -import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.util.AvatarUtils @@ -29,7 +28,7 @@ class GroupMembersViewModel @AssistedInject constructor( storage: StorageProtocol, configFactory: ConfigFactoryProtocol, avatarUtils: AvatarUtils, - recipientRepository: RecipientRepository, + recipientRepository: RecipientRepository ) : BaseGroupMembersViewModel(address, context, storage, configFactory, avatarUtils, recipientRepository) { private val _navigationActions = Channel() 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 76954f62e8..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,38 +7,34 @@ 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.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock -import org.session.libsession.snode.model.BatchResponse +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 import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigMessage import org.session.libsession.utilities.getGroup +import org.session.libsession.utilities.withGroupConfigs import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.api.snode.AlterTtlApi +import org.thoughtcrime.securesms.api.snode.RetrieveMessageApi +import org.thoughtcrime.securesms.api.swarm.SwarmApiExecutor +import org.thoughtcrime.securesms.api.swarm.SwarmApiRequest +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 org.thoughtcrime.securesms.util.getRootCause -import java.time.Instant +import org.thoughtcrime.securesms.util.NetworkConnectivity import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.days @@ -49,173 +45,31 @@ 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, private val receivedMessageProcessor: ReceivedMessageProcessor, + private val retrieveMessageFactory: RetrieveMessageApi.Factory, + 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) - } - } - - private class InternalPollState( - // The nodes for current swarm - var swarmNodes: Set = emptySet(), - - // The pool of snodes that are currently being used for polling - val pollPool: MutableSet = hashSetOf() - ) { - fun shouldFetchSwarmNodes(): Boolean { - return swarmNodes.isEmpty() - } - } - - // 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() - val internalPollState = InternalPollState() - - 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(internalPollState) - } - - 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(pollState: InternalPollState): PollResult { - val pollStartedAt = Instant.now() + override suspend fun doPollOnce(isFirstPollSinceApoStarted: Boolean): GroupPollResult = pollSemaphore.withPermit { var groupExpired: Boolean? = null - var currentSnode: Snode? = null - val result = runCatching { supervisorScope { - // Fetch snodes if we don't have any - val swarmNodes = if (pollState.shouldFetchSwarmNodes()) { - Log.d(TAG, "Fetching swarm nodes for $groupId") - val fetched = SnodeAPI.fetchSwarmNodes(groupId.hexString).toSet() - pollState.swarmNodes = fetched - fetched - } else { - pollState.swarmNodes - } - - // Ensure we have at least one snode - check(swarmNodes.isNotEmpty()) { - "No swarm nodes found for $groupId" - } - - // Fill the pool if it's empty - if (pollState.pollPool.isEmpty()) { - pollState.pollPool.addAll(swarmNodes) - } - - // Take a random snode from the pool - val snode = pollState.pollPool.random().also { - pollState.pollPool.remove(it) - currentSnode = it - } + val snode = swarmSnodeSelector.selectSnode(groupId.hexString) val groupAuth = configFactoryProtocol.getGroupAuth(groupId) ?: return@supervisorScope @@ -236,41 +90,44 @@ 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 val pollingTasks = mutableListOf>>() val receiveRevokeMessage = async { - SnodeAPI.sendBatchRequest( - snode, - groupId.hexString, - SnodeAPI.buildAuthenticatedRetrieveBatchRequest( - lastHash = lokiApiDatabase.getLastMessageHashValue( - snode, - groupId.hexString, - Namespace.REVOKED_GROUP_MESSAGES() - ).orEmpty(), - auth = groupAuth, - namespace = Namespace.REVOKED_GROUP_MESSAGES(), - maxSize = null, - ), - RetrieveMessageResponse.serializer() + swarmApiExecutor.execute( + SwarmApiRequest( + swarmNodeOverride = snode, + swarmPubKeyHex = groupId.hexString, + api = retrieveMessageFactory.create( + lastHash = lokiApiDatabase.getLastMessageHashValue( + snode, + groupId.hexString, + Namespace.REVOKED_GROUP_MESSAGES() + ).orEmpty(), + auth = groupAuth, + namespace = Namespace.REVOKED_GROUP_MESSAGES(), + maxSize = null, + ) + ) ).messages } if (configHashesToExtends.isNotEmpty() && adminKey != null) { pollingTasks += "extending group config TTL" to async { - SnodeAPI.sendBatchRequest( - snode, - groupId.hexString, - SnodeAPI.buildAuthenticatedAlterTtlBatchRequest( - messageHashes = configHashesToExtends.toList(), - auth = groupAuth, - newExpiry = clock.currentTimeMills() + 14.days.inWholeMilliseconds, - extend = true - ), + swarmApiExecutor.execute( + SwarmApiRequest( + swarmNodeOverride = snode, + swarmPubKeyHex = groupId.hexString, + api = alterTtlApiApiFactory.create( + messageHashes = configHashesToExtends, + auth = groupAuth, + alterType = AlterTtlApi.AlterType.Extend, + newExpiry = clock.currentTimeMillis() + 14.days.inWholeMilliseconds, + ) + ) ) } } @@ -283,16 +140,17 @@ class GroupPoller @AssistedInject constructor( ).orEmpty() - SnodeAPI.sendBatchRequest( - snode = snode, - publicKey = groupId.hexString, - request = SnodeAPI.buildAuthenticatedRetrieveBatchRequest( - lastHash = lastHash, - auth = groupAuth, - namespace = Namespace.GROUP_MESSAGES(), - maxSize = null, - ), - responseType = RetrieveMessageResponse.serializer() + swarmApiExecutor.execute( + SwarmApiRequest( + swarmNodeOverride = snode, + swarmPubKeyHex = groupId.hexString, + api = retrieveMessageFactory.create( + lastHash = lastHash, + auth = groupAuth, + namespace = Namespace.GROUP_MESSAGES(), + maxSize = null, + ) + ) ) } @@ -302,20 +160,21 @@ class GroupPoller @AssistedInject constructor( Namespace.GROUP_MEMBERS() ).map { ns -> async { - SnodeAPI.sendBatchRequest( - snode = snode, - publicKey = groupId.hexString, - request = SnodeAPI.buildAuthenticatedRetrieveBatchRequest( - lastHash = lokiApiDatabase.getLastMessageHashValue( - snode, - groupId.hexString, - ns - ).orEmpty(), - auth = groupAuth, - namespace = ns, - maxSize = null, - ), - responseType = RetrieveMessageResponse.serializer() + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = groupId.hexString, + swarmNodeOverride = snode, + api = retrieveMessageFactory.create( + lastHash = lokiApiDatabase.getLastMessageHashValue( + snode, + groupId.hexString, + ns + ).orEmpty(), + auth = groupAuth, + namespace = ns, + maxSize = null, + ) + ) ).messages } } @@ -376,35 +235,13 @@ class GroupPoller @AssistedInject constructor( } } - Log.d(TAG, "Group($groupId) polling completed, success = ${result.isSuccess}") - - if (result.isFailure) { - val error = result.exceptionOrNull() - Log.e(TAG, "Error polling group", error) - - // Find if any exception throws in the process has a root cause of a node returning bad response, - // then we will remove this snode from our swarm nodes set - if (error != null && currentSnode != null) { - val badResponse = (sequenceOf(error) + error.suppressedExceptions.asSequence()) - .firstOrNull { err -> - err.getRootCause()?.item?.let { it.isServerError || it.isSnodeNoLongerPartOfSwarm } == true - } + Log.d(logTag, "Group($groupId) polling completed, success = ${result.isSuccess}") - if (badResponse != null) { - Log.e(TAG, "Group polling failed due to a server error", badResponse) - pollState.swarmNodes -= currentSnode - } - } - } + result.getOrThrow() - val pollResult = PollResult( - startedAt = pollStartedAt, - finishedAt = Instant.now(), - result = result, + GroupPollResult( groupExpired = groupExpired ) - - return pollResult } private fun RetrieveMessageResponse.Message.toConfigMessage(): ConfigMessage { @@ -440,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})" @@ -469,12 +306,12 @@ 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 } try { - val (msg, proto) = messageParser.parseGroupMessage( + val result = messageParser.parseGroupMessage( data = message.data, serverHash = message.hash, groupId = groupId, @@ -484,25 +321,20 @@ class GroupPoller @AssistedInject constructor( receivedMessageProcessor.processSwarmMessage( threadAddress = threadAddress, - message = msg, - proto = proto, + message = result.message, + proto = result.proto, context = ctx, + 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 001d5e3756..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,8 +23,10 @@ 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 import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.auth.LoginStateRepository @@ -127,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() ) } @@ -152,7 +154,7 @@ class GroupPollerManager @Inject constructor( supervisorScope { groupPollers.value.values.map { async { - it.poller.requestPollOnce() + it.poller.manualPollOnce() } }.awaitAll() } @@ -164,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/GroupRevokedMessageHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupRevokedMessageHandler.kt index 5153ceb531..533db03e29 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupRevokedMessageHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupRevokedMessageHandler.kt @@ -4,6 +4,7 @@ import network.loki.messenger.libsession_util.util.MultiEncrypt import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.withGroupConfigs import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/InviteMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/InviteMembersViewModel.kt new file mode 100644 index 0000000000..737d773b28 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/InviteMembersViewModel.kt @@ -0,0 +1,228 @@ +package org.thoughtcrime.securesms.groups + +import android.content.Context +import androidx.lifecycle.viewModelScope +import com.squareup.phrase.Phrase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_KEY +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.util.AvatarUtils + +@HiltViewModel(assistedFactory = InviteMembersViewModel.Factory::class) +class InviteMembersViewModel @AssistedInject constructor( + @Assisted private val groupAddress: Address.Group?, + @Assisted private val excludingAccountIDs: Set
, + @param:ApplicationContext private val context: Context, + configFactory: ConfigFactory, + avatarUtils: AvatarUtils, + proStatusManager: ProStatusManager, + recipientRepository: RecipientRepository, +) : SelectContactsViewModel( + configFactory = configFactory, + excludingAccountIDs = excludingAccountIDs, + contactFiltering = SelectContactsViewModel.Factory.defaultFiltering, + avatarUtils = avatarUtils, + proStatusManager = proStatusManager, + recipientRepository = recipientRepository, + context = context +) { + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState + + private val footerCollapsed = MutableStateFlow(false) + private val showInviteContactsDialog = MutableStateFlow(false) + + init { + viewModelScope.launch { + combine(selectedContacts, footerCollapsed) { selected, isCollapsed -> + buildFooterState(selected, isCollapsed) + }.collect { footer -> + _uiState.update { it.copy(footer = footer) } + } + } + + viewModelScope.launch { + combine(selectedContacts, showInviteContactsDialog) { selected, showDialog -> + buildInviteContactsDialogState(showDialog, selected) + }.collect { state -> + _uiState.update { it.copy(inviteContactsDialog = state) } + } + } + } + + private fun buildFooterState( + selected: Set, + isCollapsed: Boolean + ): CollapsibleFooterState { + val count = selected.size + val visible = count > 0 + val title = if (count == 0) GetString("") + else GetString( + context.resources.getQuantityString(R.plurals.contactSelected, count, count) + ) + + return CollapsibleFooterState( + visible = visible, + collapsed = if (!visible) false else isCollapsed, + footerActionTitle = title + ) + } + + private fun buildInviteContactsDialogState( + visible: Boolean, + selected: Set, + ): InviteContactsDialogState { + val count = selected.size + val sortedMembers = selected.sortedBy { it.address } + val firstMember = sortedMembers.firstOrNull() + + val body: CharSequence = when (count) { + 1 -> { + if (firstMember != null && firstMember.name.isNotEmpty()) { + Phrase.from(context, R.string.membersInviteShareDescription) + .put(NAME_KEY, firstMember?.name) + .format() + } else { + context.getString(R.string.shareGroupMessageHistory) + } + } + 2 -> { + val secondMember = sortedMembers.elementAtOrNull(1)?.name + Phrase.from(context, R.string.membersInviteShareDescriptionTwo) + .put(NAME_KEY, firstMember?.name) + .put(OTHER_NAME_KEY, secondMember) + .format() + } + + 0 -> "" + else -> Phrase.from(context, R.string.membersInviteShareDescriptionMultiple) + .put(NAME_KEY, firstMember?.name) + .put(COUNT_KEY, count - 1) + .format() + } + + val inviteText = + context.resources.getQuantityString(R.plurals.membersInviteSend, count, count) + + return InviteContactsDialogState( + visible = visible, + inviteContactsBody = body, + inviteText = inviteText + ) + } + + fun toggleFooter() { + footerCollapsed.update { !it } + } + + fun onSearchFocusChanged(isFocused: Boolean) { + _uiState.update { it.copy(isSearchFocused = isFocused) } + } + + fun toggleInviteContactsDialog(visible: Boolean) { + showInviteContactsDialog.value = visible + } + + fun removeSearchState(clearSelection: Boolean) { + onSearchFocusChanged(false) + onSearchQueryChanged("") + + if (clearSelection) { + clearSelection() + } + } + + fun sendCommand(command: Commands) { + when (command) { + is Commands.ToggleFooter -> toggleFooter() + + is Commands.CloseFooter, + Commands.ClearSelection -> clearSelection() + + is Commands.ContactItemClick -> onContactItemClicked(command.address) + + is Commands.HandleAccountId -> { + setManuallySelectedAddress(command.address) + toggleInviteContactsDialog(true) + } + + is Commands.DismissSendInviteDialog -> toggleInviteContactsDialog(false) + + is Commands.ShowSendInviteDialog -> toggleInviteContactsDialog(true) + + is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) + + is Commands.SearchQueryChange -> onSearchQueryChanged(command.query) + + is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) + } + } + + sealed interface Commands { + data object ToggleFooter : Commands + + data object CloseFooter : Commands + + data object ShowSendInviteDialog : Commands + + data object DismissSendInviteDialog : Commands + + data object ClearSelection : Commands + + data class HandleAccountId(val address : Address) : Commands + + data class ContactItemClick(val address: Address) : Commands + + data class SearchFocusChange(val focus: Boolean) : Commands + + data class SearchQueryChange(val query: String) : Commands + + data class RemoveSearchState(val clearSelection: Boolean) : Commands + } + + + data class UiState( + val isSearchFocused: Boolean = false, + val ongoingAction: String? = null, + + val inviteContactsDialog: InviteContactsDialogState = InviteContactsDialogState(), + val footer: CollapsibleFooterState = CollapsibleFooterState() + ) + + data class InviteContactsDialogState( + val visible: Boolean = false, + val inviteContactsBody: CharSequence = "", + val inviteText: String = "", + ) + + data class CollapsibleFooterState( + val visible: Boolean = false, + val collapsed: Boolean = false, + val footerActionTitle: GetString = GetString("") + ) + + @AssistedFactory + interface Factory { + fun create( + groupAddress: Address.Group? = null, + excludingAccountIDs: Set
= emptySet(), + ): InviteMembersViewModel + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt new file mode 100644 index 0000000000..39dcfe406c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt @@ -0,0 +1,294 @@ +package org.thoughtcrime.securesms.groups + +import android.content.Context +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupInviteException +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.UINavigator +import org.thoughtcrime.securesms.util.AvatarUtils + +/** + * Admin screen: + * - Shows admins + their promotion status + * - Lets you select admins with failed/sent promotions + * - Bottom tray: "Resend promotions" + * + * No removing members, no invites here. + */ +@HiltViewModel(assistedFactory = ManageGroupAdminsViewModel.Factory::class) +class ManageGroupAdminsViewModel @AssistedInject constructor( + @Assisted private val groupAddress: Address.Group, + @Assisted private val navigator: UINavigator, + @Assisted private val openPromoteMembers: Boolean, + @ApplicationContext private val context: Context, + storage: StorageProtocol, + private val configFactory: ConfigFactoryProtocol, + private val groupManager: GroupManagerV2, + private val recipientRepository: RecipientRepository, + avatarUtils: AvatarUtils, +) : BaseGroupMembersViewModel( + groupAddress = groupAddress, + context = context, + storage = storage, + configFactory = configFactory, + avatarUtils = avatarUtils, + recipientRepository = recipientRepository +) { + private val groupId = groupAddress.accountId + + /** + * One option for admins for now: "Promote members" + */ + private val optionsList: List by lazy { + listOf( + OptionsItem( + // use plural version of this string resource + name = context.resources.getQuantityString(R.plurals.promoteMember, 2, 2), + icon = R.drawable.ic_add_admin_custom, + onClick = ::navigateToPromoteMembers, + qaTag = R.string.qa_manage_members_promote_members + ) + ) + } + + private val _mutableSelectedAdmins = MutableStateFlow(emptySet()) + val selectedAdmins: StateFlow> = _mutableSelectedAdmins + + private val footerCollapsed = MutableStateFlow(false) + + private val _uiState = MutableStateFlow(UiState(options = optionsList)) + val uiState: StateFlow = _uiState + + init { + // Build footer from selected admins + collapsed state + viewModelScope.launch { + combine( + selectedAdmins, + footerCollapsed, + ::buildFooterState + ).collect { footer -> + _uiState.update { it.copy(footer = footer) } + } + } + + if (openPromoteMembers) { + // Only runs once for this nav entry, so no loop on back + navigateToPromoteMembers() + } + } + + fun onAdminItemClicked(member: GroupMemberState) { + val newSet = _mutableSelectedAdmins.value.toHashSet() + if (!newSet.remove(member)) { + newSet.add(member) + } + _mutableSelectedAdmins.value = newSet + } + + fun onSearchFocusChanged(isFocused: Boolean) { + _uiState.update { it.copy(isSearchFocused = isFocused) } + } + + private fun navigateToPromoteMembers() { + viewModelScope.launch { + navigator.navigate( + destination = ConversationSettingsDestination.RoutePromoteMembers(groupAddress), + debounce = false + ) + } + } + + private fun setLoading(isLoading : Boolean){ + _uiState.update { it.copy(inProgress = isLoading) } + } + + /** + * Send promotions to all selected admins (explicit selection from caller). + */ + fun onSendPromotionsClicked(selectedAdmins: Set) { + sendPromotions(members = selectedAdmins, isRepromote = false) + } + + /** + * Resend promotions using locally selected admins. + * Used in the parent screen with admin list + */ + fun onResendPromotionsClicked() { + sendPromotions(isRepromote = true) + } + + private fun sendPromotions( + members: Set = selectedAdmins.value, + isRepromote: Boolean + ) { + if (members.isEmpty()) return + + val accountIds = members.map { it.accountId } + + val sendingPromotionText = context.resources.getQuantityString( + if (isRepromote) R.plurals.resendingPromotion else R.plurals.sendingPromotion, + accountIds.size, + accountIds.size + ) + + showToast(sendingPromotionText) + + performGroupOperationCore( + showLoading = false, + setLoading = ::setLoading, + errorMessage = { err -> + if (err is GroupInviteException) { + err.format(context, recipientRepository).toString() + } else { + null + } + } + ) { + removeSearchState(clearSelection = true) + + groupManager.promoteMember( + groupId, + accountIds, + isRepromote = isRepromote + ) + } + } + + fun removeSearchState(clearSelection: Boolean) { + onSearchFocusChanged(false) + onSearchQueryChanged("") + + if (clearSelection) { + clearSelection() + } + } + + fun clearSelection() { + _mutableSelectedAdmins.value = emptySet() + } + + fun toggleFooter() { + footerCollapsed.update { !it } + } + + private fun buildFooterState( + selected: Set, + isCollapsed: Boolean + ): CollapsibleFooterState { + val count = selected.size + val visible = count > 0 + + val title = + if (count == 0) GetString("") + else { + GetString( + context.resources.getQuantityString( + R.plurals.adminSelected, + count, + count + ) + ) + } + + val trayItems = listOf( + CollapsibleFooterItemData( + label = GetString( + context.resources.getQuantityString(R.plurals.resendPromotion, count, count) + ), + buttonLabel = GetString(context.getString(R.string.resend)), + isDanger = false, + onClick = { onResendPromotionsClicked() } + ) + ) + + return CollapsibleFooterState( + visible = visible, + collapsed = if (!visible) false else isCollapsed, + footerActionTitle = title, + footerActionItems = trayItems + ) + } + + fun onCommand(command: Commands) { + when (command) { + is Commands.ToggleFooter -> toggleFooter() + is Commands.CloseFooter, + is Commands.ClearSelection -> clearSelection() + is Commands.SelfClick -> showToast(context.getString(R.string.adminStatusYou)) + is Commands.MemberClick -> onAdminItemClicked(command.member) + is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) + is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) + is Commands.SearchQueryChange -> onSearchQueryChanged(command.query) + } + } + + data class UiState( + val options: List = emptyList(), + + val inProgress: Boolean = false, + + // search UI state: + val searchQuery: String = "", + val isSearchFocused: Boolean = false, + + //Collapsible footer + val footer: CollapsibleFooterState = CollapsibleFooterState(), + ) + + data class CollapsibleFooterState( + val visible: Boolean = false, + val collapsed: Boolean = false, + val footerActionTitle: GetString = GetString(""), + val footerActionItems: List = emptyList() + ) + + data class OptionsItem( + val name: String, + @DrawableRes val icon: Int, + @StringRes val qaTag: Int? = null, + val onClick: () -> Unit + ) + + sealed interface Commands { + data object ToggleFooter : Commands + data object CloseFooter : Commands + data object ClearSelection : Commands + + data object SelfClick : Commands + + class RemoveSearchState(val clearSelection: Boolean) : Commands + data class SearchQueryChange(val query: String) : Commands + data class SearchFocusChange(val focus: Boolean) : Commands + + data class MemberClick(val member: GroupMemberState) : Commands + } + + @AssistedFactory + interface Factory { + fun create( + groupAddress: Address.Group, + navigator: UINavigator, + navigateToPromoteMembers: Boolean + ): ManageGroupAdminsViewModel + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt new file mode 100644 index 0000000000..c2180aff0f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -0,0 +1,449 @@ +package org.thoughtcrime.securesms.groups + +import android.content.Context +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.lifecycle.viewModelScope +import com.squareup.phrase.Phrase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import network.loki.messenger.R +import network.loki.messenger.libsession_util.getOrNull +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupInviteException +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_KEY +import org.session.libsession.utilities.withGroupConfigs +import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.UINavigator +import org.thoughtcrime.securesms.util.AvatarUtils + + +@HiltViewModel(assistedFactory = ManageGroupMembersViewModel.Factory::class) +class ManageGroupMembersViewModel @AssistedInject constructor( + @Assisted private val groupAddress: Address.Group, + @Assisted private val navigator: UINavigator, + @param:ApplicationContext private val context: Context, + storage: StorageProtocol, + private val configFactory: ConfigFactoryProtocol, + private val groupManager: GroupManagerV2, + private val recipientRepository: RecipientRepository, + avatarUtils: AvatarUtils, +) : BaseGroupMembersViewModel(groupAddress, context, storage, configFactory, avatarUtils, recipientRepository) { + private val groupId = groupAddress.accountId + + // Output: whether we should show the "add members" button + val showAddMembers: StateFlow = groupInfo + .map { it?.first?.isUserAdmin == true } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) + + // Output: + val excludingAccountIDsFromContactSelection: Set + get() = groupInfo.value?.second?.mapTo(hashSetOf()) { it.accountId.hexString }.orEmpty() + + private val _mutableSelectedMembers = MutableStateFlow(emptySet()) + val selectedMembers: StateFlow> = _mutableSelectedMembers + + private val footerCollapsed = MutableStateFlow(false) + + private val optionsList: List by lazy { + listOf( + OptionsItem( + name = context.getString(R.string.membersInvite), + icon = R.drawable.ic_user_round_plus, + onClick = ::navigateToInviteContacts, + qaTag = R.string.qa_manage_members_invite_contacts + ), + OptionsItem( + name = context.getString(R.string.accountIdOrOnsInvite), + icon = R.drawable.ic_user_round_search, + onClick = ::navigateToInviteAccountId, + qaTag = R.string.qa_manage_members_invite_account_id + ) + ) + } + + private val adminOptionsList: List by lazy { + listOf( + OptionsItem( + // use plural version of this string resource + name = context.resources.getQuantityString(R.plurals.promoteMember,2,2), + icon = R.drawable.ic_add_admin_custom, + onClick = ::navigateToInviteContacts + ), + ) + } + + private val _uiState = + MutableStateFlow(UiState(options = optionsList, adminOptions = adminOptionsList)) + val uiState: StateFlow = _uiState + + private val showRemoveMembersDialog = MutableStateFlow(false) + + init { + viewModelScope.launch { + combine(showRemoveMembersDialog, selectedMembers, groupName) { showRemove, selected, group -> + buildRemoveMembersDialogState(showRemove, selected, group) + }.collect { state -> + _uiState.update { it.copy(removeMembersDialog = state) } + } + } + + viewModelScope.launch { + combine(selectedMembers, footerCollapsed) { selected, isCollapsed -> + buildFooterState(selected, isCollapsed) + }.collect { footer -> + _uiState.update { it.copy(footer = footer) } + } + } + } + fun onMemberItemClicked(member: GroupMemberState) { + val newSet = _mutableSelectedMembers.value.toHashSet() + if (!newSet.remove(member)) { + newSet.add(member) + } + _mutableSelectedMembers.value = newSet + } + fun onSearchFocusChanged(isFocused :Boolean){ + _uiState.update { it.copy(isSearchFocused = isFocused) } + } + + private fun navigateToInviteContacts() { + viewModelScope.launch { + navigator.navigate( + ConversationSettingsDestination.RouteInviteToGroup( + groupAddress, + excludingAccountIDsFromContactSelection.toList() + ) + ) + } + } + + private fun navigateToInviteAccountId(){ + viewModelScope.launch { + navigator.navigate( + ConversationSettingsDestination.RouteInviteAccountIdToGroup( + groupAddress, + excludingAccountIDsFromContactSelection.toList() + ) + ) + } + } + + fun onSendInviteClicked(contacts: Set
, shareHistory : Boolean) { + val sendInviteText = context.resources.getQuantityString( + R.plurals.groupInviteSending, + contacts.size, + contacts.size + ) + + showToast(sendInviteText) + + performGroupOperationCore( + showLoading = false, + setLoading = ::setLoading, + errorMessage = { err -> + if (err is GroupInviteException) { + err.format(context, recipientRepository).toString() + } else { + null + } + } + ) { + groupManager.inviteMembers( + groupId, + contacts.map { AccountId(it.toString()) }.toList(), + shareHistory = shareHistory, + isReinvite = false, + ) + } + } + + fun onResendInviteClicked() { + if (selectedMembers.value.isEmpty()) return + performGroupOperationCore( + showLoading = false, + setLoading = ::setLoading, + errorMessage = { err -> + if (err is GroupInviteException) { + err.format(context, recipientRepository).toString() + } else { + null + } + } + ) { + // Look up current member configs once + val invites: List = configFactory.withGroupConfigs(groupId) { cfg -> + selectedMembers.value.map { member -> + val shareHistory = + cfg.groupMembers.getOrNull(member.accountId.hexString)?.supplement == true + MemberInvite(id = member.accountId, shareHistory = shareHistory) + } + } + + removeSearchState(true) + + val errorText = context.resources.getQuantityString( + R.plurals.resendingInvite, + invites.size, + invites.size + ) + + // is it better move the invites list outside the operation? + withContext(Dispatchers.Main) { + showToast(errorText) // now safely on main thread + } + + // Reinvite with per-member shareHistory + groupManager.reinviteMembers( + group = groupId, + invites = invites + ) + } + } + + fun removeSearchState(clearSelection : Boolean){ + onSearchFocusChanged(false) + onSearchQueryChanged("") + + if(clearSelection){ + clearSelection() + } + } + + fun onRemoveContact(removeMessages: Boolean) { + val removeText = context.resources.getQuantityString( + R.plurals.removingMember, + selectedMembers.value.size, + selectedMembers.value.size + ) + + showToast(removeText) + + performGroupOperationCore(showLoading = false, setLoading = ::setLoading) { + val accountIdList = selectedMembers.value.map { it.accountId } + + removeSearchState(true) + + groupManager.removeMembers( + groupAccountId = groupId, + removedMembers = accountIdList, + removeMessages = removeMessages + ) + } + } + + fun clearSelection(){ + _mutableSelectedMembers.value = emptySet() + } + + fun toggleFooter() { + footerCollapsed.update { !it } + } + + private fun toggleRemoveMembersDialog(visible : Boolean){ + showRemoveMembersDialog.value = visible + } + + private fun setLoading(isLoading : Boolean){ + _uiState.update { it.copy(inProgress = true) } + } + + fun onCommand(command: Commands) { + when (command) { + is Commands.ShowRemoveMembersDialog -> toggleRemoveMembersDialog(true) + + is Commands.DismissRemoveMembersDialog -> toggleRemoveMembersDialog(false) + + is Commands.RemoveMembers -> onRemoveContact(command.removeMessages) + + is Commands.ClearSelection, + + is Commands.CloseFooter -> clearSelection() + + is Commands.ToggleFooter -> toggleFooter() + + is Commands.MemberClick -> onMemberItemClicked(command.member) + + is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) + + is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) + + is Commands.SearchQueryChange -> onSearchQueryChanged(command.query) + + is Commands.SendInvites -> onSendInviteClicked(command.address, command.shareHistory) + } + } + + private fun buildRemoveMembersDialogState( + visible: Boolean, + selected: Set, + group: String + ): RemoveMembersDialogState { + val count = selected.size + val sortedMembers = selected.sortedBy { it.accountId } + val firstMember = sortedMembers.firstOrNull() + + val body: CharSequence = when (count) { + 1 -> Phrase.from(context, R.string.groupRemoveDescription) + .put(NAME_KEY, firstMember?.name) + .put(GROUP_NAME_KEY, group) + .format() + + 2 -> { + val secondMember = sortedMembers.elementAtOrNull(1)?.name + Phrase.from(context, R.string.groupRemoveDescriptionTwo) + .put(NAME_KEY, firstMember?.name) + .put(OTHER_NAME_KEY, secondMember) + .put(GROUP_NAME_KEY, group) + .format() + } + + 0 -> "" + else -> Phrase.from(context, R.string.groupRemoveDescriptionMultiple) + .put(NAME_KEY, firstMember?.name) + .put(COUNT_KEY, count - 1) + .put(GROUP_NAME_KEY, group) + .format() + } + + val removeMemberOnly = + context.resources.getQuantityString(R.plurals.removeMemberLowercase, count, count) + val removeMessages = + context.resources.getQuantityString(R.plurals.removeMemberMessages, count, count) + + return RemoveMembersDialogState( + visible = visible, + removeMemberBody = body, + removeMemberText = removeMemberOnly, + removeMessagesText = removeMessages + ) + } + + private fun buildFooterState( + selected: Set, + isCollapsed: Boolean + ): CollapsibleFooterState { + val count = selected.size + val visible = count > 0 + val title = if (count == 0) GetString("") else GetString( + context.resources.getQuantityString(R.plurals.memberSelected, count, count) + ) + + val trayItems = listOf( + CollapsibleFooterItemData( + label = GetString( + context.resources.getQuantityString(R.plurals.resendInvite, count, count) + ), + buttonLabel = GetString(context.getString(R.string.resend)), + isDanger = false, + onClick = { onResendInviteClicked() } + ), + CollapsibleFooterItemData( + label = GetString( + context.resources.getQuantityString(R.plurals.removeMember, count, count) + ), + buttonLabel = GetString(context.getString(R.string.remove)), + isDanger = true, + onClick = { onCommand(Commands.ShowRemoveMembersDialog) } + ) + ) + + return CollapsibleFooterState( + visible = visible, + collapsed = if (!visible) false else isCollapsed, + footerActionTitle = title, + footerActionItems = trayItems + ) + } + + data class UiState( + val options : List = emptyList(), + val adminOptions : List = emptyList(), + + val inProgress: Boolean = false, + + // search UI state: + val searchQuery: String = "", + val isSearchFocused: Boolean = false, + + // Remove member dialog + val removeMembersDialog: RemoveMembersDialogState = RemoveMembersDialogState(), + + //Collapsible footer + val footer: CollapsibleFooterState = CollapsibleFooterState() + ) + + data class CollapsibleFooterState( + val visible: Boolean = false, + val collapsed: Boolean = false, + val footerActionTitle : GetString = GetString(""), + val footerActionItems : List = emptyList() + ) + + data class RemoveMembersDialogState( + val visible : Boolean = false, + val removeMemberBody : CharSequence = "", + val removeMemberText : String = "", + val removeMessagesText : String = "" + ) + + data class OptionsItem( + val name: String, + @DrawableRes val icon: Int, + @StringRes val qaTag: Int? = null, + val onClick: () -> Unit + ) + + sealed interface Commands { + data object ShowRemoveMembersDialog : Commands + data object DismissRemoveMembersDialog : Commands + + data object ToggleFooter : Commands + + data object CloseFooter : Commands + + data object ClearSelection : Commands + + data class SendInvites(val address : Set
, val shareHistory: Boolean) : Commands + + data class RemoveSearchState(val clearSelection : Boolean) : Commands + + data class SearchQueryChange(val query : String) : Commands + + data class SearchFocusChange(val focus : Boolean) : Commands + data class RemoveMembers(val removeMessages: Boolean) : Commands + + data class MemberClick(val member: GroupMemberState) : Commands + } + + @AssistedFactory + interface Factory { + fun create( + groupAddress: Address.Group, + navigator: UINavigator + ): ManageGroupMembersViewModel + } +} 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 93159ed2ef..92b3a06c19 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt @@ -5,11 +5,17 @@ import kotlinx.coroutines.flow.mapNotNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.messaging.open_groups.api.CommunityApiExecutor +import org.session.libsession.messaging.open_groups.api.CommunityApiRequest +import org.session.libsession.messaging.open_groups.api.GetCapsApi +import org.session.libsession.messaging.open_groups.api.execute import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerManager import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.withMutableUserConfigs import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.LokiAPIDatabase import javax.inject.Inject +import javax.inject.Provider import javax.inject.Singleton private const val TAG = "OpenGroupManager" @@ -22,6 +28,8 @@ class OpenGroupManager @Inject constructor( private val configFactory: ConfigFactoryProtocol, private val pollerManager: OpenGroupPollerManager, private val lokiAPIDatabase: LokiAPIDatabase, + private val communityApiExecutor: CommunityApiExecutor, + private val getCapsApi: Provider, ) { suspend fun add(server: String, room: String, publicKey: String) { // Fetch the server's capabilities upfront to see if this server is actually running @@ -29,7 +37,13 @@ class OpenGroupManager @Inject constructor( // for the user to see if the server they are adding is reachable. // The addition of the community to the config later will always succeed and the poller // will be started regardless of the server's status. - val caps = OpenGroupApi.getCapabilities(server, serverPubKeyHex = publicKey) + val caps = communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = server, + serverPubKey = publicKey, + api = getCapsApi.get() + ) + ) lokiAPIDatabase.setServerCapabilities(server, caps.capabilities) // We should be good, now go ahead and add the community to the config @@ -50,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 new file mode 100644 index 0000000000..fc21477ae3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt @@ -0,0 +1,247 @@ +package org.thoughtcrime.securesms.groups + +import android.content.Context +import androidx.lifecycle.viewModelScope +import com.squareup.phrase.Phrase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.database.StorageProtocol +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_KEY +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.util.AvatarUtils + +@HiltViewModel(assistedFactory = PromoteMembersViewModel.Factory::class) +class PromoteMembersViewModel @AssistedInject constructor( + @Assisted private val groupAddress: Address.Group, + @ApplicationContext private val context: Context, + storage: StorageProtocol, + private val configFactory: ConfigFactoryProtocol, + private val recipientRepository: RecipientRepository, + avatarUtils: AvatarUtils, +) : BaseGroupMembersViewModel( + groupAddress = groupAddress, + context = context, + storage = storage, + configFactory = configFactory, + avatarUtils = avatarUtils, + recipientRepository = recipientRepository +) { + + private val _mutableSelectedMembers = MutableStateFlow(emptySet()) + val selectedMembers: StateFlow> = _mutableSelectedMembers + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState + + private val _footerCollapsed = MutableStateFlow(false) + + init { + viewModelScope.launch { + combine( + selectedMembers, + _footerCollapsed, + ::buildFooterState + ).collect { footer -> + _uiState.update { it.copy(footer = footer) } + } + } + + viewModelScope.launch { + selectedMembers + .map { selected -> buildPromoteDialogBody(selected) } + .collect { body -> + _uiState.update { it.copy(promoteDialogBody = body) } + } + } + } + + fun onMemberItemClicked(member: GroupMemberState) { + val newSet = _mutableSelectedMembers.value.toHashSet() + if (!newSet.remove(member)) { + newSet.add(member) + } + _mutableSelectedMembers.value = newSet + } + + fun onSearchFocusChanged(isFocused: Boolean) { + _uiState.update { it.copy(isSearchFocused = isFocused) } + } + + fun toggleFooter() { + _footerCollapsed.update { !it } + } + + fun removeSearchState(clearSelection: Boolean) { + onSearchFocusChanged(false) + onSearchQueryChanged("") + + if (clearSelection) { + clearSelection() + } + } + + fun clearSelection() { + _mutableSelectedMembers.value = emptySet() + } + + private fun buildFooterState( + selected: Set, + isCollapsed: Boolean + ): CollapsibleFooterState { + val count = selected.size + val visible = count > 0 + + val title = + if (count == 0) GetString("") + else { + GetString( + context.resources.getQuantityString( + R.plurals.memberSelected, + count, + count + ) + ) + } + + val footerAction = GetString( + context.resources.getQuantityString( + R.plurals.promoteMember, + count, count + ) + ) + + return CollapsibleFooterState( + visible = visible, + collapsed = if (!visible) false else isCollapsed, + footerTitle = title, + footerActionLabel = footerAction + ) + } + + private fun buildPromoteDialogBody( + selected: Set + ): String { + val count = selected.size + val sortedMembers = selected.sortedBy { it.accountId } + val firstMember = sortedMembers.firstOrNull() + + val body: CharSequence = when (count) { + 1 -> { + Phrase.from(context, R.string.adminPromoteDescription) + .put(NAME_KEY, firstMember?.name) + .format() + } + + 2 -> { + val secondMember = sortedMembers.elementAtOrNull(1)?.name + Phrase.from(context, R.string.adminPromoteTwoDescription) + .put(NAME_KEY, firstMember?.name) + .put(OTHER_NAME_KEY, secondMember) + .format() + } + + 0 -> "" + else -> Phrase.from(context, R.string.adminPromoteMoreDescription) + .put(NAME_KEY, firstMember?.name) + .put(COUNT_KEY, count - 1) + .format() + } + + return body.toString() + } + + fun onCommand(command: Commands) { + when (command) { + is Commands.ShowPromoteDialog -> { + _uiState.update { it.copy(showPromoteDialog = true) } + } + + is Commands.DismissPromoteDialog -> { + _uiState.update { it.copy(showPromoteDialog = false) } + } + + is Commands.ShowConfirmDialog -> { + _uiState.update { it.copy(showConfirmDialog = true) } + } + + is Commands.DismissConfirmDialog -> { + _uiState.update { it.copy(showConfirmDialog = false) } + } + + is Commands.ToggleFooter -> toggleFooter() + + is Commands.CloseFooter, + is Commands.ClearSelection -> clearSelection() + + is Commands.MemberClick -> onMemberItemClicked(command.member) + + is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) + + is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) + + is Commands.SearchQueryChange -> onSearchQueryChanged(command.query) + } + } + + sealed interface Commands { + data object ShowPromoteDialog : Commands + data object DismissPromoteDialog : Commands + + data object ShowConfirmDialog : Commands + data object DismissConfirmDialog : Commands + + data object ToggleFooter : Commands + data object CloseFooter : Commands + data object ClearSelection : Commands + + data class RemoveSearchState(val clearSelection: Boolean) : Commands + data class SearchQueryChange(val query: String) : Commands + data class SearchFocusChange(val focus: Boolean) : Commands + + data class MemberClick(val member: GroupMemberState) : Commands + } + + data class UiState( + // search UI state: + val searchQuery: String = "", + val isSearchFocused: Boolean = false, + + val showConfirmDialog: Boolean = false, + + val showPromoteDialog: Boolean = false, + val promoteDialogBody: String = "", + + //Collapsible footer + val footer: CollapsibleFooterState = CollapsibleFooterState() + ) + + data class CollapsibleFooterState( + val visible: Boolean = false, + val collapsed: Boolean = false, + val footerTitle: GetString = GetString(""), + val footerActionLabel: GetString = GetString("") + ) + + + @AssistedFactory + interface Factory { + fun create( + groupAddress: Address.Group, + ): PromoteMembersViewModel + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt index 5ae4e18812..67db224033 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt @@ -17,21 +17,19 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext -import network.loki.messenger.R import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.withUserConfigs import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.home.search.searchName import org.thoughtcrime.securesms.pro.ProStatusManager -import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUtils @@ -50,7 +48,7 @@ open class SelectContactsViewModel @AssistedInject constructor( private val mutableSearchQuery = MutableStateFlow("") // Input: The selected contact account IDs - private val mutableSelectedContactAccountIDs = MutableStateFlow(emptySet
()) + private val mutableSelectedContacts = MutableStateFlow(emptySet()) // Input: The manually added items to select from. This will be combined (and deduped) with the contacts // the user has. This is useful for selecting contacts that are not in the user's contacts list. @@ -65,7 +63,7 @@ open class SelectContactsViewModel @AssistedInject constructor( val contacts: StateFlow> = combine( contactsFlow, mutableSearchQuery.debounce(100L), - mutableSelectedContactAccountIDs, + mutableSelectedContacts, ::filterContacts ).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) @@ -73,30 +71,12 @@ open class SelectContactsViewModel @AssistedInject constructor( .map { it.isNotEmpty() } .stateIn(viewModelScope, SharingStarted.Eagerly, false) - // Output + // Output: to be used by VMs extending this base VM + val selectedContacts: StateFlow> = mutableSelectedContacts + + // Output : snapshot helper val currentSelected: Set
- get() = mutableSelectedContactAccountIDs.value - - private val footerCollapsed = MutableStateFlow(false) - - val collapsibleFooterState: StateFlow = - combine(mutableSelectedContactAccountIDs, footerCollapsed) { selected, isCollapsed -> - val count = selected.size - val visible = count > 0 - val title = if (count == 0) GetString("") - else GetString( - context.resources.getQuantityString(R.plurals.contactSelected, count, count) - ) - - CollapsibleFooterState( - visible = visible, - // auto-expand when nothing is selected, otherwise keep user's choice - collapsed = if (!visible) false else isCollapsed, - footerActionTitle = title - ) - } - .distinctUntilChanged() - .stateIn(viewModelScope, SharingStarted.Eagerly, CollapsibleFooterState()) + get() = mutableSelectedContacts.value.map { it.address }.toSet() @OptIn(ExperimentalCoroutinesApi::class) private fun observeContacts() = (configFactory.configUpdateNotifications as Flow) @@ -127,9 +107,10 @@ open class SelectContactsViewModel @AssistedInject constructor( private fun filterContacts( contacts: Collection, query: String, - selectedAccountIDs: Set
+ selectedContacts: Set ): List { val items = mutableListOf() + val selectedAddresses = selectedContacts.asSequence().map { it.address }.toSet() for (contact in contacts) { if (query.isBlank() || contact.searchName.contains(query, ignoreCase = true)) { val avatarData = avatarUtils.getUIDataFromRecipient(contact) @@ -138,7 +119,7 @@ open class SelectContactsViewModel @AssistedInject constructor( name = contact.searchName, address = contact.address, avatarUIData = avatarData, - selected = selectedAccountIDs.contains(contact.address), + selected = selectedAddresses.contains(contact.address), showProBadge = contact.shouldShowProBadge ) ) @@ -151,36 +132,38 @@ open class SelectContactsViewModel @AssistedInject constructor( mutableManuallyAddedContacts.value = accountIDs } + // Used when getting results from a QR or AccountId input field + fun setManuallySelectedAddress(address : Address){ + val selectedItem = SelectedContact(address, "") + mutableSelectedContacts.value = setOf(selectedItem) + } + fun onSearchQueryChanged(query: String) { mutableSearchQuery.value = query } open fun onContactItemClicked(address: Address) { - val newSet = mutableSelectedContactAccountIDs.value.toHashSet() - if (!newSet.remove(address)) { - newSet.add(address) + val newSet = mutableSelectedContacts.value.toHashSet() + val selectedContact = contacts.value.find { it.address == address } + + if(selectedContact == null) return + + val item = SelectedContact(address = selectedContact.address, name = selectedContact.name) + if (!newSet.remove(item)) { + newSet.add(item) } - mutableSelectedContactAccountIDs.value = newSet + mutableSelectedContacts.value = newSet } fun selectAccountIDs(accountIDs: Set
) { - mutableSelectedContactAccountIDs.value += accountIDs + val toAdd = accountIDs.map { address -> SelectedContact(address) }.toSet() + mutableSelectedContacts.update { (it + toAdd).toSet() } } fun clearSelection(){ - mutableSelectedContactAccountIDs.value = emptySet() + mutableSelectedContacts.value = emptySet() } - fun toggleFooter() { - footerCollapsed.update { !it } - } - - data class CollapsibleFooterState( - val visible: Boolean = false, - val collapsed: Boolean = false, - val footerActionTitle : GetString = GetString("") - ) - @AssistedFactory interface Factory { fun create( @@ -201,3 +184,8 @@ data class ContactItem( val selected: Boolean, val showProBadge: Boolean ) + +data class SelectedContact( + val address: Address, + val name: String = "" +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt index c510d9d4b3..4b2d4d24ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt @@ -12,18 +12,31 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import network.loki.messenger.R import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.groups.ContactItem +import org.thoughtcrime.securesms.groups.GroupMemberState +import org.thoughtcrime.securesms.groups.InviteMembersViewModel +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.DialogButtonData +import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.ProBadgeText +import org.thoughtcrime.securesms.ui.RadioOption import org.thoughtcrime.securesms.ui.components.Avatar +import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton import org.thoughtcrime.securesms.ui.components.RadioButtonIndicator +import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -56,7 +69,7 @@ fun GroupMinimumVersionBanner(modifier: Modifier = Modifier) { } @Composable -fun MemberItem( +fun MemberItem( address: Address, title: String, avatarUIData: AvatarUIData, @@ -69,7 +82,7 @@ fun MemberItem( content: @Composable RowScope.() -> Unit = {}, ) { var itemModifier = modifier - if(onClick != null){ + if (onClick != null) { itemModifier = itemModifier.clickable(onClick = { onClick(address) }) } @@ -86,7 +99,7 @@ fun MemberItem( Avatar( size = LocalDimensions.current.iconLarge, data = avatarUIData, - badge = if (showAsAdmin) { AvatarBadge.Admin } else AvatarBadge.None + badge = if (showAsAdmin) { AvatarBadge.ResourceBadge.Admin } else AvatarBadge.None ) Column( @@ -125,7 +138,8 @@ fun RadioMemberItem( showProBadge: Boolean, modifier: Modifier = Modifier, subtitle: String? = null, - subtitleColor: Color = LocalColors.current.textSecondary + subtitleColor: Color = LocalColors.current.textSecondary, + showRadioButton: Boolean = true ) { MemberItem( address = address, @@ -133,15 +147,17 @@ fun RadioMemberItem( title = title, subtitle = subtitle, subtitleColor = subtitleColor, - onClick = if(enabled) onClick else null, + onClick = if (enabled) onClick else null, showAsAdmin = showAsAdmin, showProBadge = showProBadge, modifier = modifier - ){ - RadioButtonIndicator( - selected = selected, - enabled = enabled - ) + ) { + if (showRadioButton) { + RadioButtonIndicator( + selected = selected, + enabled = enabled + ) + } } } @@ -167,6 +183,93 @@ fun LazyListScope.multiSelectMemberList( } } +@Composable +fun InviteMembersDialog( + state: InviteMembersViewModel.InviteContactsDialogState, + modifier: Modifier = Modifier, + onInviteClicked: (Boolean) -> Unit, + onDismiss: () -> Unit, +) { + var shareHistory by remember { mutableStateOf(true) } + + AlertDialog( + modifier = modifier, + onDismissRequest = { + // hide dialog + onDismiss() + }, + title = annotatedStringResource(R.string.membersInviteTitle), + text = annotatedStringResource(state.inviteContactsBody), + content = { + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(LocalResources.current.getString(R.string.membersInviteShareMessageHistoryDays)), + selected = shareHistory, + qaTag = GetString(R.string.qa_manage_members_dialog_share_message_history) + ) + ) { + shareHistory = true + } + + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(LocalResources.current.getString(R.string.membersInviteShareNewMessagesOnly)), + selected = !shareHistory, + qaTag = GetString(R.string.qa_manage_members_dialog_share_new_messages) + ) + ) { + shareHistory = false + } + }, + buttons = listOf( + DialogButtonData( + text = GetString(state.inviteText), + color = LocalColors.current.danger, + dismissOnClick = false, + onClick = { + onDismiss() + onInviteClicked(shareHistory) + } + ), + DialogButtonData( + text = GetString(stringResource(R.string.cancel)), + onClick = { + onDismiss() + } + ) + ) + ) +} + +@Composable +fun ManageMemberItem( + member: GroupMemberState, + onClick: (address: Address) -> Unit, + modifier: Modifier = Modifier, + selected: Boolean = false +) { + RadioMemberItem( + address = Address.fromSerialized(member.accountId.hexString), + title = member.name, + subtitle = member.statusLabel, + subtitleColor = if (member.highlightStatus) { + LocalColors.current.danger + } else { + LocalColors.current.textSecondary + }, + showAsAdmin = member.showAsAdmin, + showProBadge = member.showProBadge, + avatarUIData = member.avatarUIData, + onClick = onClick, + modifier = modifier, + enabled = true, + selected = selected, + showRadioButton = !member.isSelf + ) +} + @Preview @Composable fun PreviewMemberList() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt deleted file mode 100644 index 2a5b7071b4..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt +++ /dev/null @@ -1,560 +0,0 @@ -package org.thoughtcrime.securesms.groups.compose - -import android.widget.Toast -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.layout.windowInsetsBottomHeight -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment.Companion.CenterHorizontally -import androidx.compose.ui.Alignment.Companion.CenterVertically -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import com.squareup.phrase.Phrase -import network.loki.messenger.BuildConfig -import network.loki.messenger.R -import network.loki.messenger.libsession_util.util.GroupMember -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY -import org.session.libsignal.utilities.AccountId -import org.thoughtcrime.securesms.groups.EditGroupViewModel -import org.thoughtcrime.securesms.groups.GroupMemberState -import org.thoughtcrime.securesms.ui.AlertDialog -import org.thoughtcrime.securesms.ui.DialogButtonData -import org.thoughtcrime.securesms.ui.GetString -import org.thoughtcrime.securesms.ui.LoadingDialog -import org.thoughtcrime.securesms.ui.components.ActionSheet -import org.thoughtcrime.securesms.ui.components.ActionSheetItemData -import org.thoughtcrime.securesms.ui.components.BackAppBar -import org.thoughtcrime.securesms.ui.components.AccentOutlineButton -import org.thoughtcrime.securesms.ui.components.annotatedStringResource -import org.thoughtcrime.securesms.ui.qaTag -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.ui.theme.PreviewTheme -import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider -import org.thoughtcrime.securesms.ui.theme.ThemeColors -import org.thoughtcrime.securesms.ui.theme.primaryBlue -import org.thoughtcrime.securesms.util.AvatarUIData -import org.thoughtcrime.securesms.util.AvatarUIElement - -@Composable -fun EditGroupScreen( - viewModel: EditGroupViewModel, - navigateToInviteContact: (Set) -> Unit, - onBack: () -> Unit, -) { - EditGroup( - onBack = onBack, - onAddMemberClick = { navigateToInviteContact(viewModel.excludingAccountIDsFromContactSelection) }, - onResendInviteClick = viewModel::onResendInviteClicked, - onPromoteClick = viewModel::onPromoteContact, - onRemoveClick = viewModel::onRemoveContact, - members = viewModel.members.collectAsState().value, - groupName = viewModel.groupName.collectAsState().value, - showAddMembers = viewModel.showAddMembers.collectAsState().value, - onResendPromotionClick = viewModel::onResendPromotionClicked, - showingError = viewModel.error.collectAsState().value, - onErrorDismissed = viewModel::onDismissError, - onMemberClicked = viewModel::onMemberClicked, - hideActionSheet = viewModel::hideActionBottomSheet, - clickedMember = viewModel.clickedMember.collectAsState().value, - showLoading = viewModel.inProgress.collectAsState().value, - ) -} - - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun EditGroup( - onBack: () -> Unit, - onAddMemberClick: () -> Unit, - onResendInviteClick: (accountId: AccountId) -> Unit, - onResendPromotionClick: (accountId: AccountId) -> Unit, - onPromoteClick: (accountId: AccountId) -> Unit, - onRemoveClick: (accountId: AccountId, removeMessages: Boolean) -> Unit, - onMemberClicked: (GroupMemberState) -> Unit, - hideActionSheet: () -> Unit, - clickedMember: GroupMemberState?, - groupName: String, - members: List, - showAddMembers: Boolean, - showingError: String?, - showLoading: Boolean, - onErrorDismissed: () -> Unit, -) { - val (showingConfirmRemovingMember, setShowingConfirmRemovingMember) = remember { - mutableStateOf(null) - } - - val maxNameWidth = 240.dp - - Scaffold( - topBar = { - BackAppBar( - title = stringResource(id = R.string.manageMembers), - onBack = onBack, - ) - }, - contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), - ) { paddingValues -> - Column(modifier = Modifier.padding(paddingValues).consumeWindowInsets(paddingValues)) { - GroupMinimumVersionBanner() - - // Group name title - Text( - text = groupName, - style = LocalType.current.h4, - textAlign = TextAlign.Center, - modifier = Modifier - .align(CenterHorizontally) - .widthIn(max = maxNameWidth) - .padding(vertical = LocalDimensions.current.smallSpacing), - ) - - // Header & Add member button - Row( - modifier = Modifier.padding( - horizontal = LocalDimensions.current.smallSpacing, - vertical = LocalDimensions.current.xxsSpacing - ), - verticalAlignment = CenterVertically - ) { - Text( - stringResource(R.string.groupMembers), - modifier = Modifier.weight(1f), - style = LocalType.current.large, - color = LocalColors.current.text - ) - - if (showAddMembers) { - AccentOutlineButton( - stringResource(R.string.membersInvite), - onClick = onAddMemberClick, - modifier = Modifier.qaTag(R.string.AccessibilityId_membersInvite) - ) - } - } - - - // List of members - LazyColumn(modifier = Modifier.weight(1f).imePadding()) { - items(members) { member -> - // Each member's view - EditMemberItem( - modifier = Modifier.fillMaxWidth(), - member = member, - onClick = { onMemberClicked(member) } - ) - } - - item { - Spacer( - modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars) - ) - } - } - } - } - - if (clickedMember != null) { - MemberActionSheet( - onDismissRequest = hideActionSheet, - onRemove = { - setShowingConfirmRemovingMember(clickedMember) - hideActionSheet() - }, - onPromote = { - onPromoteClick(clickedMember.accountId) - hideActionSheet() - }, - onResendInvite = { - onResendInviteClick(clickedMember.accountId) - hideActionSheet() - }, - onResendPromotion = { - onResendPromotionClick(clickedMember.accountId) - hideActionSheet() - }, - member = clickedMember, - ) - } - - if (showingConfirmRemovingMember != null) { - ConfirmRemovingMemberDialog( - onDismissRequest = { - setShowingConfirmRemovingMember(null) - }, - onConfirmed = onRemoveClick, - member = showingConfirmRemovingMember, - groupName = groupName, - ) - } - - if (showLoading) { - LoadingDialog() - } - - val context = LocalContext.current - - LaunchedEffect(showingError) { - if (showingError != null) { - Toast.makeText(context, showingError, Toast.LENGTH_SHORT).show() - onErrorDismissed() - } - } -} - -@Composable -private fun GroupNameContainer(content: @Composable RowScope.() -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 72.dp), - horizontalArrangement = Arrangement.spacedBy( - LocalDimensions.current.xxxsSpacing, - CenterHorizontally - ), - verticalAlignment = CenterVertically, - content = content - ) -} - -@Composable -private fun ConfirmRemovingMemberDialog( - onConfirmed: (accountId: AccountId, removeMessages: Boolean) -> Unit, - onDismissRequest: () -> Unit, - member: GroupMemberState, - groupName: String, -) { - val context = LocalContext.current - val buttons = buildList { - this += DialogButtonData( - text = GetString(R.string.remove), - color = LocalColors.current.danger, - onClick = { onConfirmed(member.accountId, false) } - ) - - this += DialogButtonData( - text = GetString(R.string.cancel), - onClick = onDismissRequest, - ) - } - - AlertDialog( - onDismissRequest = onDismissRequest, - text = annotatedStringResource(Phrase.from(context, R.string.groupRemoveDescription) - .put(NAME_KEY, member.name) - .put(GROUP_NAME_KEY, groupName) - .format()), - title = AnnotatedString(stringResource(R.string.remove)), - buttons = buttons - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun MemberActionSheet( - member: GroupMemberState, - onRemove: () -> Unit, - onPromote: () -> Unit, - onResendInvite: () -> Unit, - onResendPromotion: () -> Unit, - onDismissRequest: () -> Unit, -) { - val context = LocalContext.current - - val options = remember(member) { - buildList { - if (member.canRemove) { - this += ActionSheetItemData( - title = context.resources.getQuantityString(R.plurals.groupRemoveUserOnly, 1), - iconRes = R.drawable.ic_trash_2, - onClick = onRemove, - qaTag = R.string.AccessibilityId_removeContact - ) - } - - if (BuildConfig.BUILD_TYPE != "release" && member.canPromote) { - this += ActionSheetItemData( - title = context.getString(R.string.adminPromoteToAdmin), - iconRes = R.drawable.ic_user_filled_custom, - onClick = onPromote - ) - } - - if (member.canResendInvite) { - this += ActionSheetItemData( - title = "Resend invitation", - iconRes = R.drawable.ic_mail, - onClick = onResendInvite, - qaTag = R.string.AccessibilityId_resendInvite, - ) - } - - if (BuildConfig.BUILD_TYPE != "release" && member.canResendPromotion) { - this += ActionSheetItemData( - title = "Resend promotion", - iconRes = R.drawable.ic_mail, - onClick = onResendPromotion, - qaTag = R.string.AccessibilityId_resendInvite, - ) - } - } - } - - ActionSheet( - items = options, - onDismissRequest = onDismissRequest - ) -} - -@Composable -fun EditMemberItem( - member: GroupMemberState, - onClick: (address: Address) -> Unit, - modifier: Modifier = Modifier -) { - MemberItem( - address = Address.fromSerialized(member.accountId.hexString), - title = member.name, - subtitle = member.statusLabel, - subtitleColor = if (member.highlightStatus) { - LocalColors.current.danger - } else { - LocalColors.current.textSecondary - }, - showAsAdmin = member.showAsAdmin, - showProBadge = member.showProBadge, - avatarUIData = member.avatarUIData, - onClick = if(member.clickable) onClick else null, - modifier = modifier - ){ - if (member.canEdit) { - Icon( - painter = painterResource(R.drawable.ic_circle_dots_custom), - tint = LocalColors.current.text, - contentDescription = stringResource(R.string.AccessibilityId_sessionSettings) - ) - } - } -} - -@Preview -@Composable -private fun EditGroupPreviewSheet() { - PreviewTheme { - val oneMember = GroupMemberState( - accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"), - name = "Test User", - avatarUIData = AvatarUIData( - listOf( - AvatarUIElement( - name = "TOTO", - color = primaryBlue - ) - ) - ), - status = GroupMember.Status.INVITE_SENT, - highlightStatus = false, - canPromote = true, - canRemove = true, - canResendInvite = false, - canResendPromotion = false, - showAsAdmin = false, - showProBadge = true, - clickable = true, - statusLabel = "Invited" - ) - val twoMember = GroupMemberState( - accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), - name = "Test User 2", - avatarUIData = AvatarUIData( - listOf( - AvatarUIElement( - name = "TOTO", - color = primaryBlue - ) - ) - ), - status = GroupMember.Status.PROMOTION_FAILED, - highlightStatus = true, - canPromote = true, - canRemove = true, - canResendInvite = false, - canResendPromotion = false, - showAsAdmin = true, - showProBadge = true, - clickable = true, - statusLabel = "Promotion failed" - ) - val threeMember = GroupMemberState( - accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), - name = "Test User 3", - avatarUIData = AvatarUIData( - listOf( - AvatarUIElement( - name = "TOTO", - color = primaryBlue - ) - ) - ), - status = null, - highlightStatus = false, - canPromote = true, - canRemove = true, - canResendInvite = false, - canResendPromotion = false, - showAsAdmin = false, - showProBadge = false, - clickable = true, - statusLabel = "" - ) - - val (editingName, setEditingName) = remember { mutableStateOf(null) } - - EditGroup( - onBack = {}, - onAddMemberClick = {}, - onResendInviteClick = {}, - onPromoteClick = {}, - onRemoveClick = { _, _ -> }, - members = listOf(oneMember, twoMember, threeMember), - groupName = "Test ", - showAddMembers = true, - onResendPromotionClick = {}, - showingError = "Error", - onErrorDismissed = {}, - onMemberClicked = {}, - hideActionSheet = {}, - clickedMember = oneMember, - showLoading = false, - ) - } -} - - - -@Preview -@Composable -private fun EditGroupEditNamePreview( - @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors -) { - PreviewTheme(colors) { - val oneMember = GroupMemberState( - accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"), - name = "Test User", - avatarUIData = AvatarUIData( - listOf( - AvatarUIElement( - name = "TOTO", - color = primaryBlue - ) - ) - ), - status = GroupMember.Status.INVITE_SENT, - highlightStatus = false, - canPromote = true, - canRemove = true, - canResendInvite = false, - canResendPromotion = false, - showAsAdmin = false, - showProBadge = true, - clickable = true, - statusLabel = "Invited" - ) - val twoMember = GroupMemberState( - accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), - name = "Test User 2", - avatarUIData = AvatarUIData( - listOf( - AvatarUIElement( - name = "TOTO", - color = primaryBlue - ) - ) - ), - status = GroupMember.Status.PROMOTION_FAILED, - highlightStatus = true, - canPromote = true, - canRemove = true, - canResendInvite = false, - canResendPromotion = false, - showAsAdmin = true, - showProBadge = true, - clickable = true, - statusLabel = "Promotion failed" - ) - val threeMember = GroupMemberState( - accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), - name = "Test User 3", - avatarUIData = AvatarUIData( - listOf( - AvatarUIElement( - name = "TOTO", - color = primaryBlue - ) - ) - ), - status = null, - highlightStatus = false, - canPromote = true, - canRemove = true, - canResendInvite = false, - canResendPromotion = false, - showAsAdmin = false, - showProBadge = false, - clickable = true, - statusLabel = "" - ) - - EditGroup( - onBack = {}, - onAddMemberClick = {}, - onResendInviteClick = {}, - onPromoteClick = {}, - onRemoveClick = { _, _ -> }, - members = listOf(oneMember, twoMember, threeMember), - groupName = "Test name that is very very long indeed because many words in it", - showAddMembers = true, - onResendPromotionClick = {}, - showingError = "Error", - onErrorDismissed = {}, - onMemberClicked = {}, - hideActionSheet = {}, - clickedMember = null, - showLoading = false, - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt index 7b69716593..af8317d772 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt @@ -138,6 +138,7 @@ private fun EditGroupPreview() { ) ) ), + isSelf = false ) val twoMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), @@ -160,6 +161,7 @@ private fun EditGroupPreview() { ) ) ), + isSelf = false ) val threeMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), @@ -182,6 +184,7 @@ private fun EditGroupPreview() { ) ) ), + isSelf = false ) GroupMembers( diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt new file mode 100644 index 0000000000..e5b937f475 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt @@ -0,0 +1,119 @@ +package org.thoughtcrime.securesms.groups.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import org.thoughtcrime.securesms.groups.InviteMembersViewModel +import org.thoughtcrime.securesms.home.startconversation.newmessage.Callbacks +import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessage +import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessageViewModel +import org.thoughtcrime.securesms.home.startconversation.newmessage.State +import org.thoughtcrime.securesms.ui.OpenURLAlertDialog + +@Composable +internal fun InviteAccountIdScreen( + viewModel: InviteMembersViewModel, + state: State, // new message state + qrErrors: Flow = emptyFlow(), + callbacks: Callbacks = object : Callbacks {}, + onBack: () -> Unit = {}, + onHelp: () -> Unit = {}, + onDismissHelpDialog: () -> Unit, + onSendInvite: (shareHistory: Boolean) -> Unit, +) { + val uiState by viewModel.uiState.collectAsState() + + InviteAccountId( + state = state, + inviteState = uiState.inviteContactsDialog, + qrErrors = qrErrors, + callbacks = callbacks, + onBack = onBack, + onHelp = onHelp, + onDismissHelpDialog = onDismissHelpDialog, + onSendInvite = onSendInvite, + onDismissInviteDialog = { viewModel.sendCommand(InviteMembersViewModel.Commands.DismissSendInviteDialog) } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun InviteAccountId( + state: State, + inviteState: InviteMembersViewModel.InviteContactsDialogState, + qrErrors: Flow = emptyFlow(), + callbacks: Callbacks = object : Callbacks {}, + onBack: () -> Unit = {}, + onHelp: () -> Unit = {}, + onDismissHelpDialog: () -> Unit, + onSendInvite: (Boolean) -> Unit, + onDismissInviteDialog: () -> Unit +) { + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + ) { paddings -> + Box( + modifier = Modifier.padding( + top = paddings.calculateTopPadding(), + bottom = paddings.calculateBottomPadding() + ) + ) { + NewMessage( + state = state, + qrErrors = qrErrors, + callbacks = callbacks, + onBack = { onBack() }, + onClose = { onBack() }, + onHelp = { onHelp() }, + isInvite = true, + ) + } + } + + if (inviteState.visible) { + InviteMembersDialog( + state = inviteState, + onInviteClicked = onSendInvite, + onDismiss = onDismissInviteDialog + ) + } + + if(!state.showUrlDialog.isNullOrEmpty()) { + OpenURLAlertDialog( + url = state.showUrlDialog, + onDismissRequest = { onDismissHelpDialog() } + ) + } +} + +@Preview +@Composable +fun PreviewInviteAccountId() { + InviteAccountId( + state = State( + newMessageIdOrOns = "", + isTextErrorColor = false, + error = null, + loading = false, + showUrlDialog = null, + validIdFromQr = "", + ), + onBack = { }, + onHelp = { }, + onSendInvite = { _ -> }, + inviteState = InviteMembersViewModel.InviteContactsDialogState(), + qrErrors = emptyFlow(), + onDismissInviteDialog = {}, + onDismissHelpDialog = {}, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt index b0ece45a94..61586660fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.groups.compose +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -21,22 +22,29 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import network.loki.messenger.R import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.groups.ContactItem -import org.thoughtcrime.securesms.groups.SelectContactsViewModel -import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox +import org.thoughtcrime.securesms.groups.InviteMembersViewModel +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.CloseFooter +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.ContactItemClick +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.RemoveSearchState +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.SearchFocusChange +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.SearchQueryChange +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.ShowSendInviteDialog +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.ToggleFooter +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.DismissSendInviteDialog import org.thoughtcrime.securesms.ui.CollapsibleFooterAction import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData import org.thoughtcrime.securesms.ui.GetString -import org.thoughtcrime.securesms.ui.SearchBar +import org.thoughtcrime.securesms.ui.SearchBarWithClose import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors @@ -47,29 +55,24 @@ import org.thoughtcrime.securesms.ui.theme.primaryBlue import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUIElement - @Composable fun InviteContactsScreen( - viewModel: SelectContactsViewModel, - onDoneClicked: () -> Unit, + viewModel: InviteMembersViewModel, + onDoneClicked: (shareHistory: Boolean) -> Unit, onBack: () -> Unit, - banner: @Composable () -> Unit = {} + banner: @Composable () -> Unit = {}, + forCommunity: Boolean = false, ) { - val footerData by viewModel.collapsibleFooterState.collectAsState() - InviteContacts( contacts = viewModel.contacts.collectAsState().value, - onContactItemClicked = viewModel::onContactItemClicked, + uiState = viewModel.uiState.collectAsState().value, searchQuery = viewModel.searchQuery.collectAsState().value, - onSearchQueryChanged = viewModel::onSearchQueryChanged, - onSearchQueryClear = { viewModel.onSearchQueryChanged("") }, + hasContacts = viewModel.hasContacts.collectAsState().value, onDoneClicked = onDoneClicked, onBack = onBack, banner = banner, - data = footerData, - onToggleFooter = viewModel::toggleFooter, - onCloseFooter = viewModel::clearSelection - + sendCommand = viewModel::sendCommand, + forCommunity = forCommunity ) } @@ -77,33 +80,44 @@ fun InviteContactsScreen( @Composable fun InviteContacts( contacts: List, - onContactItemClicked: (address: Address) -> Unit, + uiState: InviteMembersViewModel.UiState, searchQuery: String, - onSearchQueryChanged: (String) -> Unit, - onSearchQueryClear: () -> Unit, - onDoneClicked: () -> Unit, + hasContacts: Boolean, + onDoneClicked: (shareHistory: Boolean) -> Unit, onBack: () -> Unit, banner: @Composable () -> Unit = {}, - data: SelectContactsViewModel.CollapsibleFooterState, - onToggleFooter: () -> Unit, - onCloseFooter: () -> Unit, + sendCommand: (command: InviteMembersViewModel.Commands) -> Unit, + forCommunity: Boolean = false ) { - val colors = LocalColors.current + val trayItems = listOf( CollapsibleFooterItemData( label = GetString(LocalResources.current.getString(R.string.membersInvite)), buttonLabel = GetString(LocalResources.current.getString(R.string.membersInviteTitle)), - buttonColor = colors.accent, - onClick = { onDoneClicked() } + isDanger = false, + onClick = { + if (forCommunity) onDoneClicked(false) // Community does not need the dialog + else sendCommand(ShowSendInviteDialog) + } ) ) + val handleBack: () -> Unit = { + when { + uiState.isSearchFocused -> sendCommand(RemoveSearchState(false)) + else -> onBack() + } + } + + // Intercept system back + BackHandler(enabled = true) { handleBack() } + Scaffold( contentWindowInsets = WindowInsets.safeDrawing, topBar = { BackAppBar( title = stringResource(id = R.string.membersInvite), - onBack = onBack, + onBack = handleBack, ) }, bottomBar = { @@ -115,13 +129,13 @@ fun InviteContacts( ) { CollapsibleFooterAction( data = CollapsibleFooterActionData( - title = data.footerActionTitle, - collapsed = data.collapsed, - visible = data.visible, + title = uiState.footer.footerActionTitle, + collapsed = uiState.footer.collapsed, + visible = uiState.footer.visible, items = trayItems ), - onCollapsedClicked = onToggleFooter, - onClosedClicked = onCloseFooter + onCollapsedClicked = { sendCommand(ToggleFooter) }, + onClosedClicked = { sendCommand(CloseFooter) } ) } } @@ -131,32 +145,39 @@ fun InviteContacts( .padding(paddings) .consumeWindowInsets(paddings), ) { - banner() - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - SearchBar( - query = searchQuery, - onValueChanged = onSearchQueryChanged, - onClear = onSearchQueryClear, - placeholder = stringResource(R.string.searchContacts), - modifier = Modifier - .padding(horizontal = LocalDimensions.current.smallSpacing) - .qaTag(R.string.AccessibilityId_groupNameSearch), - backgroundColor = LocalColors.current.backgroundSecondary, - ) + if (hasContacts) { + SearchBarWithClose( + query = searchQuery, + onValueChanged = { query -> sendCommand(SearchQueryChange(query)) }, + onClear = { sendCommand(SearchQueryChange("")) }, + placeholder = stringResource(R.string.searchContacts), + modifier = Modifier + .padding(horizontal = LocalDimensions.current.smallSpacing) + .qaTag(R.string.AccessibilityId_groupNameSearch), + backgroundColor = LocalColors.current.backgroundSecondary, + isFocused = uiState.isSearchFocused, + onFocusChanged = { isFocused -> sendCommand(SearchFocusChange(isFocused)) }, + enabled = true, + ) - val scrollState = rememberLazyListState() + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + } - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + val scrollState = rememberLazyListState() - Box(modifier = Modifier.weight(1f)) { - if (contacts.isEmpty() && searchQuery.isEmpty()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + if (!hasContacts && searchQuery.isEmpty()) { Text( - text = stringResource(id = R.string.contactNone), + text = stringResource(id = R.string.membersInviteNoContacts), modifier = Modifier - .padding(top = LocalDimensions.current.spacing) .align(Alignment.TopCenter), + textAlign = TextAlign.Center, style = LocalType.current.base.copy(color = LocalColors.current.textSecondary) ) } else { @@ -166,13 +187,21 @@ fun InviteContacts( ) { multiSelectMemberList( contacts = contacts, - onContactItemClicked = onContactItemClicked, + onContactItemClicked = { address -> sendCommand(ContactItemClick(address)) }, ) } } } } } + + if (uiState.inviteContactsDialog.visible) { + InviteMembersDialog( + state = uiState.inviteContactsDialog, + onInviteClicked = onDoneClicked, + onDismiss = {sendCommand(DismissSendInviteDialog) } + ) + } } @Preview @@ -199,19 +228,19 @@ private fun PreviewSelectContacts() { PreviewTheme { InviteContacts( contacts = contacts, - onContactItemClicked = {}, - searchQuery = "", - onSearchQueryChanged = {}, - onSearchQueryClear = {}, onDoneClicked = {}, onBack = {}, - data = SelectContactsViewModel.CollapsibleFooterState( - collapsed = false, - visible = true, - footerActionTitle = GetString("1 Contact Selected") + banner = {}, + sendCommand = {}, + uiState = InviteMembersViewModel.UiState( + footer = InviteMembersViewModel.CollapsibleFooterState( + collapsed = false, + visible = true, + footerActionTitle = GetString("1 Contact Selected") + ) ), - onToggleFooter = { }, - onCloseFooter = { }, + searchQuery = "", + hasContacts = true ) } } @@ -224,19 +253,19 @@ private fun PreviewSelectEmptyContacts() { PreviewTheme { InviteContacts( contacts = contacts, - onContactItemClicked = {}, - searchQuery = "", - onSearchQueryChanged = {}, - onSearchQueryClear = {}, onDoneClicked = {}, onBack = {}, - data = SelectContactsViewModel.CollapsibleFooterState( - collapsed = true, - visible = false, - footerActionTitle = GetString("") + banner = {}, + sendCommand = {}, + uiState = InviteMembersViewModel.UiState( + footer = InviteMembersViewModel.CollapsibleFooterState( + collapsed = true, + visible = false, + footerActionTitle = GetString("") + ) ), - onToggleFooter = { }, - onCloseFooter = { } + searchQuery = "Test", + hasContacts = false ) } } @@ -249,19 +278,19 @@ private fun PreviewSelectEmptyContactsWithSearch() { PreviewTheme { InviteContacts( contacts = contacts, - onContactItemClicked = {}, - searchQuery = "Test", - onSearchQueryChanged = {}, - onSearchQueryClear = {}, onDoneClicked = {}, onBack = {}, - data = SelectContactsViewModel.CollapsibleFooterState( - collapsed = true, - visible = false, - footerActionTitle = GetString("") + banner = {}, + sendCommand = {}, + uiState = InviteMembersViewModel.UiState( + footer = InviteMembersViewModel.CollapsibleFooterState( + collapsed = true, + visible = false, + footerActionTitle = GetString("") + ) ), - onToggleFooter = { }, - onCloseFooter = { } + searchQuery = "", + hasContacts = false ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt new file mode 100644 index 0000000000..d1e0f2953d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt @@ -0,0 +1,291 @@ +package org.thoughtcrime.securesms.groups.compose + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import network.loki.messenger.R +import org.thoughtcrime.securesms.groups.GroupMemberState +import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel +import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel.Commands.* +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel +import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.CollapsibleFooterAction +import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData +import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData +import org.thoughtcrime.securesms.ui.Divider +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.ItemButton +import org.thoughtcrime.securesms.ui.LoadingDialog +import org.thoughtcrime.securesms.ui.SearchBarWithClose +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.getCellBottomShape +import org.thoughtcrime.securesms.ui.getCellTopShape +import org.thoughtcrime.securesms.ui.qaTag +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.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors + +@Composable +fun ManageGroupAdminsScreen( + viewModel: ManageGroupAdminsViewModel, + onBack: () -> Unit, +) { + ManageAdmins( + onBack = onBack, + uiState = viewModel.uiState.collectAsState().value, + admins = viewModel.adminMembers.collectAsState().value, + selectedMembers = viewModel.selectedAdmins.collectAsState().value, + searchQuery = viewModel.searchQuery.collectAsState().value, + sendCommand = viewModel::onCommand, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManageAdmins( + onBack: () -> Unit, + uiState: ManageGroupAdminsViewModel.UiState, + searchQuery: String, + admins: List, + selectedMembers: Set = emptySet(), + sendCommand: (command: ManageGroupAdminsViewModel.Commands) -> Unit, +) { + + val searchFocused = uiState.isSearchFocused + + val handleBack: () -> Unit = { + when { + searchFocused -> sendCommand(RemoveSearchState(false)) + else -> onBack() + } + } + + // Intercept system back + BackHandler(enabled = true) { handleBack() } + + Scaffold( + topBar = { + BackAppBar( + title = stringResource(id = R.string.manageAdmins), + onBack = handleBack, + ) + }, + bottomBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) + .imePadding() + ) { + CollapsibleFooterAction( + data = CollapsibleFooterActionData( + title = uiState.footer.footerActionTitle, + collapsed = uiState.footer.collapsed, + visible = uiState.footer.visible, + items = uiState.footer.footerActionItems + ), + onCollapsedClicked = { sendCommand(ToggleFooter) }, + onClosedClicked = { sendCommand(CloseFooter) } + ) + } + }, + contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + ) { + Text( + modifier = Modifier + .padding(horizontal = LocalDimensions.current.mediumSpacing) + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally), + text = LocalResources.current.getString(R.string.adminCannotBeDemoted), + textAlign = TextAlign.Center, + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + + AnimatedVisibility( + // show only when add-members is enabled AND search is not focused + visible = !searchFocused, + enter = fadeIn(animationSpec = tween(150)) + + expandVertically( + animationSpec = tween(200), + expandFrom = Alignment.Top + ), + exit = fadeOut(animationSpec = tween(150)) + + shrinkVertically( + animationSpec = tween(180), + shrinkTowards = Alignment.Top + ) + ) { + Column { + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + Cell( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = LocalDimensions.current.smallSpacing), + ) { + Column { + uiState.options.forEachIndexed { index, option -> + ItemButton( + modifier = Modifier.qaTag(option.qaTag), + text = annotatedStringResource(option.name), + iconRes = option.icon, + shape = when (index) { + 0 -> getCellTopShape() + uiState.options.lastIndex -> getCellBottomShape() + else -> RectangleShape + }, + onClick = option.onClick, + ) + + if (index != uiState.options.lastIndex) Divider() + } + } + } + } + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + if (!searchFocused) { + Text( + modifier = Modifier.padding( + start = LocalDimensions.current.mediumSpacing, + bottom = LocalDimensions.current.smallSpacing + ), + text = LocalResources.current.getString(R.string.admins), + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + } + + SearchBarWithClose( + query = searchQuery, + onValueChanged = { query -> sendCommand(SearchQueryChange(query)) }, + onClear = { sendCommand(SearchQueryChange("")) }, + placeholder = if (searchFocused) "" else LocalResources.current.getString(R.string.search), + enabled = true, + isFocused = searchFocused, + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), + onFocusChanged = { isFocused -> sendCommand(SearchFocusChange(isFocused)) } + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // List of members + LazyColumn( + modifier = Modifier + .weight(1f) + .imePadding() + ) { + items(admins) { member -> + // Each member's view + ManageMemberItem( + modifier = Modifier.fillMaxWidth(), + member = member, + onClick = { + if (member.isSelf) sendCommand(SelfClick) + else sendCommand(MemberClick(member)) + }, + selected = member in selectedMembers + ) + } + + item { + Spacer( + modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars) + ) + } + } + } + } + + if (uiState.inProgress) { + LoadingDialog() + } +} + + +@Preview +@Composable +private fun PreviewManageAdmins( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + ManageAdmins( + onBack = {}, + admins = listOf(), + searchQuery = "", + selectedMembers = emptySet(), + sendCommand = {}, + uiState = ManageGroupAdminsViewModel.UiState( + options = emptyList(), + footer = ManageGroupAdminsViewModel.CollapsibleFooterState( + visible = false, + collapsed = true, + footerActionTitle = GetString("2 Admins Selected"), + footerActionItems = listOf( + CollapsibleFooterItemData( + label = GetString("Resend"), + buttonLabel = GetString("1"), + isDanger = false, + onClick = {} + ), + CollapsibleFooterItemData( + label = GetString("Remove"), + buttonLabel = GetString("1"), + isDanger = true, + onClick = { } + ) + ) + )), + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt new file mode 100644 index 0000000000..e84031445a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt @@ -0,0 +1,594 @@ +package org.thoughtcrime.securesms.groups.compose + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.GroupMember +import org.session.libsession.utilities.Address +import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.groups.GroupMemberState +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.CollapsibleFooterState +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.Commands.* +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.CollapsibleFooterAction +import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData +import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData +import org.thoughtcrime.securesms.ui.DialogButtonData +import org.thoughtcrime.securesms.ui.Divider +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.ItemButton +import org.thoughtcrime.securesms.ui.LoadingDialog +import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.ui.SearchBarWithClose +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.getCellBottomShape +import org.thoughtcrime.securesms.ui.getCellTopShape +import org.thoughtcrime.securesms.ui.qaTag +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.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.primaryBlue +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUIElement + +@Composable +fun ManageGroupMembersScreen( + viewModel: ManageGroupMembersViewModel, + onBack: () -> Unit, +) { + ManageMembers( + onBack = onBack, + uiState = viewModel.uiState.collectAsState().value, + members = viewModel.nonAdminMembers.collectAsState().value, + hasMembers = viewModel.hasNonAdminMembers.collectAsState().value, + selectedMembers = viewModel.selectedMembers.collectAsState().value, + showAddMembers = viewModel.showAddMembers.collectAsState().value, + searchQuery = viewModel.searchQuery.collectAsState().value, + sendCommand = viewModel::onCommand, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManageMembers( + onBack: () -> Unit, + uiState: ManageGroupMembersViewModel.UiState, + searchQuery: String, + members: List, + hasMembers: Boolean = false, + selectedMembers: Set = emptySet(), + showAddMembers: Boolean, + sendCommand: (command: ManageGroupMembersViewModel.Commands) -> Unit, +) { + + val searchFocused = uiState.isSearchFocused + + val handleBack: () -> Unit = { + when { + searchFocused -> sendCommand(RemoveSearchState(false)) + else -> onBack() + } + } + + // Intercept system back + BackHandler(enabled = true) { handleBack() } + + Scaffold( + topBar = { + BackAppBar( + title = stringResource(id = R.string.manageMembers), + onBack = handleBack, + ) + }, + bottomBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) + .imePadding() + ) { + CollapsibleFooterAction( + data = CollapsibleFooterActionData( + title = uiState.footer.footerActionTitle, + collapsed = uiState.footer.collapsed, + visible = uiState.footer.visible, + items = uiState.footer.footerActionItems + ), + onCollapsedClicked = { sendCommand(ToggleFooter) }, + onClosedClicked = { sendCommand(CloseFooter) } + ) + } + }, + contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + ) { + + AnimatedVisibility( + // show only when add-members is enabled AND search is not focused + visible = showAddMembers && !searchFocused, + enter = fadeIn(animationSpec = tween(150)) + + expandVertically( + animationSpec = tween(200), + expandFrom = Alignment.Top + ), + exit = fadeOut(animationSpec = tween(150)) + + shrinkVertically( + animationSpec = tween(180), + shrinkTowards = Alignment.Top + ) + ) { + Cell( + modifier = Modifier + .fillMaxWidth() + .padding(LocalDimensions.current.smallSpacing), + ) { + Column { + uiState.options.forEachIndexed { index, option -> + ItemButton( + modifier = Modifier.qaTag(option.qaTag), + text = annotatedStringResource(option.name), + iconRes = option.icon, + shape = when (index) { + 0 -> getCellTopShape() + uiState.options.lastIndex -> getCellBottomShape() + else -> RectangleShape + }, + onClick = option.onClick, + ) + + if (index != uiState.options.lastIndex) Divider() + } + } + } + } + + if (hasMembers) { + if (!searchFocused) { + Text( + modifier = Modifier.padding( + start = LocalDimensions.current.mediumSpacing, + bottom = LocalDimensions.current.smallSpacing + ), + text = LocalResources.current.getString(R.string.membersNonAdmins), + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + } + + SearchBarWithClose( + query = searchQuery, + onValueChanged = { query -> sendCommand(SearchQueryChange(query)) }, + onClear = { sendCommand(SearchQueryChange("")) }, + placeholder = if (searchFocused) "" else LocalResources.current.getString(R.string.search), + enabled = true, + isFocused = searchFocused, + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), + onFocusChanged = { isFocused -> sendCommand(SearchFocusChange(isFocused)) } + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // List of members + LazyColumn( + modifier = Modifier + .weight(1f) + .imePadding() + ) { + items(members) { member -> + // Each member's view + ManageMemberItem( + modifier = Modifier.fillMaxWidth(), + member = member, + onClick = { sendCommand(MemberClick(member)) }, + selected = member in selectedMembers + ) + } + + item { + Spacer( + modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars) + ) + } + } + } else { + Text( + modifier = Modifier + .padding(horizontal = LocalDimensions.current.mediumSpacing) + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally), + text = LocalResources.current.getString(R.string.noNonAdminsInGroup), + textAlign = TextAlign.Center, + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + } + } + } + + if (uiState.removeMembersDialog.visible) { + RemoveMembersDialog( + state = uiState.removeMembersDialog, + sendCommand = sendCommand + ) + } + + if (uiState.inProgress) { + LoadingDialog() + } +} + +@Composable +fun RemoveMembersDialog( + state: ManageGroupMembersViewModel.RemoveMembersDialogState, + modifier: Modifier = Modifier, + sendCommand: (ManageGroupMembersViewModel.Commands) -> Unit +) { + var deleteMessages by remember { mutableStateOf(false) } + + AlertDialog( + modifier = modifier, + onDismissRequest = { + // hide dialog + sendCommand(DismissRemoveMembersDialog) + }, + title = annotatedStringResource(R.string.remove), + text = annotatedStringResource(state.removeMemberBody), + content = { + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(state.removeMemberText), + selected = !deleteMessages, + qaTag = GetString(R.string.qa_manage_members_dialog_remove_member) + ) + ) { + deleteMessages = false + } + + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(state.removeMessagesText), + selected = deleteMessages, + qaTag = GetString(R.string.qa_manage_members_dialog_remove_member_messages) + ) + ) { + deleteMessages = true + } + }, + buttons = listOf( + DialogButtonData( + text = GetString(stringResource(id = R.string.remove)), + color = LocalColors.current.danger, + dismissOnClick = false, + onClick = { + sendCommand(DismissRemoveMembersDialog) + sendCommand(RemoveMembers(deleteMessages)) + } + ), + DialogButtonData( + text = GetString(stringResource(R.string.cancel)), + onClick = { + sendCommand(DismissRemoveMembersDialog) + } + ) + ) + ) +} + +@Preview +@Composable +private fun EditGroupPreviewSheet() { + val title = GetString("3 Members Selected") + + // build tray items + val trayItems = listOf( + CollapsibleFooterItemData( + label = GetString("Reseaand"), + buttonLabel = GetString("Resend"), + isDanger = false, + onClick = {} + ), + CollapsibleFooterItemData( + label = GetString("Remove"), + buttonLabel = GetString("Remove"), + isDanger = true, + onClick = { } + ) + ) + + PreviewTheme { + val oneMember = GroupMemberState( + accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"), + name = "Test User", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), + status = GroupMember.Status.INVITE_SENT, + highlightStatus = false, + canPromote = true, + canRemove = true, + canResendInvite = false, + canResendPromotion = false, + showAsAdmin = false, + showProBadge = true, + clickable = true, + statusLabel = "Invited", + isSelf = false + ) + val twoMember = GroupMemberState( + accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), + name = "Test User 2", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), + status = GroupMember.Status.PROMOTION_FAILED, + highlightStatus = true, + canPromote = true, + canRemove = true, + canResendInvite = false, + canResendPromotion = false, + showAsAdmin = true, + showProBadge = true, + clickable = true, + statusLabel = "Promotion failed", + isSelf = false + ) + val threeMember = GroupMemberState( + accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), + name = "Test User 3", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), + status = null, + highlightStatus = false, + canPromote = true, + canRemove = true, + canResendInvite = false, + canResendPromotion = false, + showAsAdmin = false, + showProBadge = false, + clickable = true, + statusLabel = "", + isSelf = true + ) + + val (_, _) = remember { mutableStateOf(null) } + + ManageMembers( + onBack = {}, + members = listOf(oneMember, twoMember, threeMember), + showAddMembers = true, + searchQuery = "Test", + selectedMembers = emptySet(), + sendCommand = {}, + uiState = ManageGroupMembersViewModel.UiState( + options = emptyList(), + footer = CollapsibleFooterState( + visible = true, + collapsed = false, + footerActionTitle = title, + footerActionItems = trayItems + ) + ), + hasMembers = true, + ) + } +} + +@Preview +@Composable +private fun EditGroupEditNamePreview( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + val oneMember = GroupMemberState( + accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"), + name = "Test User", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), + status = GroupMember.Status.INVITE_SENT, + highlightStatus = false, + canPromote = true, + canRemove = true, + canResendInvite = false, + canResendPromotion = false, + showAsAdmin = false, + showProBadge = true, + clickable = true, + statusLabel = "Invited", + isSelf = false + ) + val twoMember = GroupMemberState( + accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), + name = "Test User 2", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), + status = GroupMember.Status.PROMOTION_FAILED, + highlightStatus = true, + canPromote = true, + canRemove = true, + canResendInvite = false, + canResendPromotion = false, + showAsAdmin = true, + showProBadge = true, + clickable = true, + statusLabel = "Promotion failed", + isSelf = false + ) + val threeMember = GroupMemberState( + accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), + name = "Test User 3", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), + status = null, + highlightStatus = false, + canPromote = true, + canRemove = true, + canResendInvite = false, + canResendPromotion = false, + showAsAdmin = false, + showProBadge = false, + clickable = true, + statusLabel = "", + isSelf = false + ) + + ManageMembers( + onBack = {}, + members = listOf(oneMember, twoMember, threeMember), + showAddMembers = true, + searchQuery = "", + selectedMembers = emptySet(), + sendCommand = {}, + uiState = ManageGroupMembersViewModel.UiState( + options = emptyList(), + footer = CollapsibleFooterState( + visible = true, + collapsed = false, + footerActionTitle = GetString("3 Members Selected"), + footerActionItems = listOf( + CollapsibleFooterItemData( + label = GetString("Resend"), + buttonLabel = GetString("1"), + isDanger = false, + onClick = {} + ), + CollapsibleFooterItemData( + label = GetString("Remove"), + buttonLabel = GetString("1"), + isDanger = true, + onClick = { } + ) + ) + )), + hasMembers = true, + ) + } +} + +@Preview +@Composable +private fun EditGroupEmptyPreview( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + ManageMembers( + onBack = {}, + members = listOf(), + showAddMembers = true, + searchQuery = "", + selectedMembers = emptySet(), + sendCommand = {}, + uiState = ManageGroupMembersViewModel.UiState( + options = emptyList(), + footer = CollapsibleFooterState( + visible = false, + collapsed = true, + footerActionTitle = GetString("3 Members Selected"), + footerActionItems = listOf( + CollapsibleFooterItemData( + label = GetString("Resend"), + buttonLabel = GetString("1"), + isDanger = false, + onClick = {} + ), + CollapsibleFooterItemData( + label = GetString("Remove"), + buttonLabel = GetString("1"), + isDanger = true, + onClick = { } + ) + ) + )), + hasMembers = true, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt new file mode 100644 index 0000000000..59ce50cd97 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt @@ -0,0 +1,288 @@ +package org.thoughtcrime.securesms.groups.compose + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import network.loki.messenger.R +import org.thoughtcrime.securesms.groups.GroupMemberState +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.CloseFooter +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.DismissConfirmDialog +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.DismissPromoteDialog +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.MemberClick +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.SearchFocusChange +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.SearchQueryChange +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ShowConfirmDialog +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ShowPromoteDialog +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ToggleFooter +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.CollapsibleFooterAction +import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData +import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData +import org.thoughtcrime.securesms.ui.DialogButtonData +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.SearchBarWithClose +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType + +@Composable +fun PromoteMembersScreen( + viewModel: PromoteMembersViewModel, + onBack: () -> Unit, + onPromoteClicked: (Set) -> Unit +) { + val uiState = viewModel.uiState.collectAsState().value + val searchQuery = viewModel.searchQuery.collectAsState().value + val hasActiveMembers = viewModel.hasActiveMembers.collectAsState().value + val members = viewModel.activeMembers.collectAsState().value + val selectedMembers = viewModel.selectedMembers.collectAsState().value + + PromoteMembers( + onBack = onBack, + uiState = uiState, + searchQuery = searchQuery, + sendCommand = viewModel::onCommand, + members = members, + selectedMembers = selectedMembers, + hasActiveMembers = hasActiveMembers, + onPromoteClicked = onPromoteClicked + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PromoteMembers( + onBack: () -> Unit, + uiState: PromoteMembersViewModel.UiState, + searchQuery: String, + sendCommand: (command: Commands) -> Unit, + members: List, + selectedMembers: Set = emptySet(), + hasActiveMembers: Boolean = false, + onPromoteClicked: (Set) -> Unit +) { + val searchFocused = uiState.isSearchFocused + + val handleBack: () -> Unit = { + when { + searchFocused -> sendCommand(Commands.RemoveSearchState(false)) + else -> onBack() + } + } + + // Intercept system back + BackHandler(enabled = true) { handleBack() } + + + Scaffold( + topBar = { + BackAppBar( + title = pluralStringResource(id = R.plurals.promoteMember, 2), + onBack = handleBack, + ) + }, + bottomBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) + .imePadding() + ) { + CollapsibleFooterAction( + data = CollapsibleFooterActionData( + title = uiState.footer.footerTitle, + collapsed = uiState.footer.collapsed, + visible = uiState.footer.visible, + items = listOf( + CollapsibleFooterItemData( + label = uiState.footer.footerActionLabel, + buttonLabel = GetString(LocalResources.current.getString(R.string.promote)), + isDanger = false, + onClick = { sendCommand(ShowPromoteDialog) } + ) + ) + ), + onCollapsedClicked = { sendCommand(ToggleFooter) }, + onClosedClicked = { sendCommand(CloseFooter) } + ) + } + }, + contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + ) { + Text( + modifier = Modifier + .padding(horizontal = LocalDimensions.current.mediumSpacing) + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally), + text = LocalResources.current.getString(if (!hasActiveMembers) R.string.noNonAdminsInGroup else R.string.membersGroupPromotionAcceptInvite), + textAlign = TextAlign.Center, + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + + if (hasActiveMembers) { + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + SearchBarWithClose( + query = searchQuery, + onValueChanged = { query -> sendCommand(SearchQueryChange(query)) }, + onClear = { sendCommand(SearchQueryChange("")) }, + placeholder = if (searchFocused) "" else LocalResources.current.getString(R.string.search), + enabled = true, + isFocused = searchFocused, + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), + onFocusChanged = { isFocused -> sendCommand(SearchFocusChange(isFocused)) } + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // List of members + LazyColumn( + modifier = Modifier + .weight(1f) + .imePadding() + ) { + items(members) { member -> + // Each member's view + ManageMemberItem( + modifier = Modifier.fillMaxWidth(), + member = member, + onClick = { sendCommand(MemberClick(member)) }, + selected = member in selectedMembers + ) + } + + item { + Spacer( + modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars) + ) + } + } + } + } + } + + if (uiState.showConfirmDialog) { + ConfirmDialog( + sendCommand = sendCommand, + onConfirmClicked = { onPromoteClicked(selectedMembers) }) + } + + if (uiState.showPromoteDialog) { + PromotionDialog(sendCommand = sendCommand, bodyText = uiState.promoteDialogBody) + } +} + +@Composable +fun ConfirmDialog( + modifier: Modifier = Modifier, + onConfirmClicked: () -> Unit, + sendCommand: (Commands) -> Unit +) { + AlertDialog( + modifier = modifier, + onDismissRequest = { + // hide dialog + sendCommand(DismissConfirmDialog) + }, + title = annotatedStringResource(R.string.confirmPromotion), + text = annotatedStringResource(R.string.confirmPromotionDescription), + buttons = listOf( + DialogButtonData( + text = GetString(stringResource(R.string.cancel)), + onClick = { + sendCommand(DismissConfirmDialog) + } + ), + DialogButtonData( + text = GetString(stringResource(id = R.string.confirm)), + color = LocalColors.current.danger, + dismissOnClick = false, + onClick = { + sendCommand(DismissConfirmDialog) + onConfirmClicked() + } + ) + ) + ) +} + +@Composable +fun PromotionDialog( + modifier: Modifier = Modifier, + sendCommand: (Commands) -> Unit, + bodyText: String +) { + AlertDialog( + onDismissRequest = { + // hide dialog + sendCommand(DismissPromoteDialog) + }, + title = stringResource(R.string.promote), + text = bodyText, + showCloseButton = true, + content = { + Text( + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), + text = LocalResources.current.getString(R.string.promoteAdminsWarning), + style = LocalType.current.small, + color = LocalColors.current.warning, + textAlign = TextAlign.Center + ) + }, + buttons = listOf( + DialogButtonData( + text = GetString(stringResource(id = R.string.promote)), + color = LocalColors.current.danger, + dismissOnClick = false, + onClick = { + sendCommand(DismissPromoteDialog) + sendCommand(ShowConfirmDialog) + + } + ), + DialogButtonData( + text = GetString(stringResource(R.string.cancel)), + onClick = { + sendCommand(DismissPromoteDialog) + } + ) + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/AdminStateSync.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/AdminStateSync.kt index 169321ce09..02a966990b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/AdminStateSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/AdminStateSync.kt @@ -1,17 +1,16 @@ package org.thoughtcrime.securesms.groups.handler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch import network.loki.messenger.libsession_util.util.GroupInfo import network.loki.messenger.libsession_util.util.GroupMember import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.userConfigsChanged +import org.session.libsession.utilities.withGroupConfigs +import org.session.libsession.utilities.withMutableGroupConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.AccountId -import org.thoughtcrime.securesms.auth.LoginStateRepository -import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import org.thoughtcrime.securesms.auth.AuthAwareComponent +import org.thoughtcrime.securesms.auth.LoggedInState import java.util.EnumSet import javax.inject.Inject import javax.inject.Singleton @@ -26,19 +25,11 @@ import javax.inject.Singleton @Singleton class AdminStateSync @Inject constructor( private val configFactory: ConfigFactoryProtocol, - private val loginStateRepository: LoginStateRepository, - @param:ManagerScope private val scope: CoroutineScope -) : OnAppStartupComponent { - private var job: Job? = null - - override fun onPostAppStarted() { - require(job == null) { "Already started" } - - job = scope.launch { - loginStateRepository.flowWithLoggedInState { - configFactory.userConfigsChanged(onlyConfigTypes = setOf(UserConfigType.USER_GROUPS)) - }.collect { - val localNumber = loginStateRepository.requireLocalNumber() +) : AuthAwareComponent { + override suspend fun doWhileLoggedIn(loggedInState: LoggedInState) { + configFactory.userConfigsChanged(onlyConfigTypes = setOf(UserConfigType.USER_GROUPS)) + .collect { + val localNumber = loggedInState.accountId.hexString // Go through evey user groups and if we are admin of any of the groups, // make sure we mark any pending group promotion status as "accepted" @@ -67,7 +58,6 @@ class AdminStateSync @Inject constructor( } } } - } } private fun isMemberPromotionPending(groupId: AccountId, localNumber: String): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/CleanupInvitationHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/CleanupInvitationHandler.kt index 5ae78bff80..0fc08444cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/CleanupInvitationHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/CleanupInvitationHandler.kt @@ -1,16 +1,14 @@ package org.thoughtcrime.securesms.groups.handler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch import network.loki.messenger.libsession_util.allWithStatus import network.loki.messenger.libsession_util.util.GroupMember import org.session.libsession.messaging.groups.GroupScope import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.withMutableGroupConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.AccountId -import org.thoughtcrime.securesms.auth.LoginStateRepository -import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import org.thoughtcrime.securesms.auth.AuthAwareComponent +import org.thoughtcrime.securesms.auth.LoggedInState import javax.inject.Inject /** @@ -25,35 +23,28 @@ import javax.inject.Inject class CleanupInvitationHandler @Inject constructor( private val configFactory: ConfigFactoryProtocol, private val groupScope: GroupScope, - private val loginStateRepository: LoginStateRepository, - @param:ManagerScope private val scope: CoroutineScope -) : OnAppStartupComponent { - override fun onPostAppStarted() { - scope.launch { - // Wait for the local number to be available - loginStateRepository.loggedInState.first { it != null } - - val allGroups = configFactory.withUserConfigs { - it.userGroups.allClosedGroupInfo() - } +) : AuthAwareComponent { + override suspend fun doWhileLoggedIn(loggedInState: LoggedInState) { + val allGroups = configFactory.withUserConfigs { + it.userGroups.allClosedGroupInfo() + } - allGroups - .asSequence() - .filter { !it.kicked && !it.destroyed && it.hasAdminKey() } - .forEach { group -> - val groupId = AccountId(group.groupAccountId) - groupScope.launch(groupId, debugName = "CleanupInvitationHandler") { - configFactory.withMutableGroupConfigs(groupId) { configs -> - configs.groupMembers - .allWithStatus() - .filter { it.second == GroupMember.Status.INVITE_SENDING } - .forEach { (member, _) -> - member.setInviteFailed() - configs.groupMembers.set(member) - } - } + allGroups + .asSequence() + .filter { !it.kicked && !it.destroyed && it.hasAdminKey() } + .forEach { group -> + val groupId = AccountId(group.groupAccountId) + groupScope.launch(groupId, debugName = "CleanupInvitationHandler") { + configFactory.withMutableGroupConfigs(groupId) { configs -> + configs.groupMembers + .allWithStatus() + .filter { it.second == GroupMember.Status.INVITE_SENDING } + .forEach { (member, _) -> + member.setInviteFailed() + configs.groupMembers.set(member) + } } } - } + } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/DestroyedGroupSync.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/DestroyedGroupSync.kt index 7c8c69ed9e..c623386ca2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/DestroyedGroupSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/DestroyedGroupSync.kt @@ -1,19 +1,18 @@ package org.thoughtcrime.securesms.groups.handler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.launch import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupScope import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigUpdateNotification import org.session.libsession.utilities.waitUntilGroupConfigsPushed +import org.session.libsession.utilities.withGroupConfigs +import org.session.libsession.utilities.withMutableUserConfigs import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import org.thoughtcrime.securesms.auth.AuthAwareComponent +import org.thoughtcrime.securesms.auth.LoggedInState import javax.inject.Inject import javax.inject.Singleton @@ -26,45 +25,38 @@ class DestroyedGroupSync @Inject constructor( private val configFactory: ConfigFactoryProtocol, private val groupScope: GroupScope, private val storage: StorageProtocol, - @param:ManagerScope private val scope: CoroutineScope -) : OnAppStartupComponent { - private var job: Job? = null - - override fun onPostAppStarted() { - require(job == null) { "Already started" } - - job = scope.launch { - configFactory.configUpdateNotifications - .filterIsInstance() - .filter { it.fromMerge } - .collect { update -> - val isDestroyed = configFactory.withGroupConfigs(update.groupId) { - it.groupInfo.isDestroyed() - } +) : AuthAwareComponent { + override suspend fun doWhileLoggedIn(loggedInState: LoggedInState) { + configFactory.configUpdateNotifications + .filterIsInstance() + .filter { it.fromMerge } + .collect { update -> + val isDestroyed = configFactory.withGroupConfigs(update.groupId) { + it.groupInfo.isDestroyed() + } - Log.d("DestroyedGroupSync", "Group is destroyed: $isDestroyed") + Log.d("DestroyedGroupSync", "Group is destroyed: $isDestroyed") - if (isDestroyed) { - groupScope.launch(update.groupId, "DestroyedGroupSync") { - // If there's un-pushed group config updates, wait until they are pushed. - // This is important, as the pushing process might need to access the UserGroupConfig, - // if we delete the UserGroupConfig before the pushing process, the pushing - // process will fail. - configFactory.waitUntilGroupConfigsPushed(update.groupId) + if (isDestroyed) { + groupScope.launch(update.groupId, "DestroyedGroupSync") { + // If there's un-pushed group config updates, wait until they are pushed. + // This is important, as the pushing process might need to access the UserGroupConfig, + // if we delete the UserGroupConfig before the pushing process, the pushing + // process will fail. + configFactory.waitUntilGroupConfigsPushed(update.groupId) - configFactory.withMutableUserConfigs { configs -> - configs.userGroups.getClosedGroup(update.groupId.hexString)?.let { group -> - configs.userGroups.set(group.copy(destroyed = true)) - } + configFactory.withMutableUserConfigs { configs -> + configs.userGroups.getClosedGroup(update.groupId.hexString)?.let { group -> + configs.userGroups.set(group.copy(destroyed = true)) } } + } - // Also clear all messages in the group - storage.getThreadId(Address.fromSerialized(update.groupId.hexString))?.let { threadId -> - storage.clearMessages(threadId) - } + // Also clear all messages in the group + storage.getThreadId(Address.fromSerialized(update.groupId.hexString))?.let { threadId -> + storage.clearMessages(threadId) } } - } + } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt index 0b9272a3df..41579eecd5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt @@ -3,11 +3,9 @@ package org.thoughtcrime.securesms.groups.handler import android.content.Context import com.google.protobuf.ByteString import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.Namespace @@ -22,23 +20,29 @@ import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.MessageAuthentication +import org.session.libsession.network.SnodeClock import org.session.libsession.snode.OwnedSwarmAuth -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock import org.session.libsession.snode.SnodeMessage -import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigUpdateNotification import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.waitUntilGroupConfigsPushed -import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsession.utilities.withGroupConfigs +import org.session.libsession.utilities.withMutableGroupConfigs import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.auth.LoginStateRepository -import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import org.session.protos.SessionProtos +import org.thoughtcrime.securesms.api.snode.BatchApi +import org.thoughtcrime.securesms.api.snode.RevokeSubKeyApi +import org.thoughtcrime.securesms.api.snode.SnodeApi +import org.thoughtcrime.securesms.api.snode.StoreMessageApi +import org.thoughtcrime.securesms.api.swarm.SwarmApiExecutor +import org.thoughtcrime.securesms.api.swarm.SwarmApiRequest +import org.thoughtcrime.securesms.api.swarm.execute +import org.thoughtcrime.securesms.auth.AuthAwareComponent +import org.thoughtcrime.securesms.auth.LoggedInState import javax.inject.Inject import javax.inject.Singleton @@ -58,27 +62,27 @@ class RemoveGroupMemberHandler @Inject constructor( private val messageDataProvider: MessageDataProvider, private val storage: StorageProtocol, private val groupScope: GroupScope, - @ManagerScope scope: CoroutineScope, private val messageSender: MessageSender, - private val loginStateRepository: LoginStateRepository, -) : OnAppStartupComponent { - init { - scope.launch { - loginStateRepository.flowWithLoggedInState { configFactory.configUpdateNotifications } - .filterIsInstance() - .collect { update -> - val adminKey = configFactory.getGroup(update.groupId)?.adminKey?.data - if (adminKey != null) { - groupScope.launch(update.groupId, "Handle possible group removals") { - try { - processPendingRemovalsForGroup(update.groupId, adminKey) - } catch (ec: Exception) { - Log.e("RemoveGroupMemberHandler", "Error processing pending removals", ec) - } + private val swarmApiExecutor: SwarmApiExecutor, + private val storeSnodeMessageApiFactory: StoreMessageApi.Factory, + private val revokeSubKeyApiFactory: RevokeSubKeyApi.Factory, + private val batchApiFactory: BatchApi.Factory, +) : AuthAwareComponent { + override suspend fun doWhileLoggedIn(loggedInState: LoggedInState) { + configFactory.configUpdateNotifications + .filterIsInstance() + .collect { update -> + val adminKey = configFactory.getGroup(update.groupId)?.adminKey?.data + if (adminKey != null) { + groupScope.launch(update.groupId, "Handle possible group removals") { + try { + processPendingRemovalsForGroup(update.groupId, adminKey) + } catch (ec: Exception) { + Log.e("RemoveGroupMemberHandler", "Error processing pending removals", ec) } } } - } + } } private suspend fun processPendingRemovalsForGroup( @@ -92,7 +96,7 @@ class RemoveGroupMemberHandler @Inject constructor( if (pendingRemovals.isEmpty()) { // Skip if there are no pending removals - return@withGroupConfigs pendingRemovals to emptyList() + return@withGroupConfigs pendingRemovals to emptyList>() } Log.d(TAG, "Processing ${pendingRemovals.size} pending removals for group") @@ -102,22 +106,20 @@ class RemoveGroupMemberHandler @Inject constructor( // 2. Send a message to a special namespace on the group to inform the removed members they have been removed // 3. Conditionally, send a `GroupUpdateDeleteMemberContent` to the group so the message deletion // can be performed by everyone in the group. - val calls = ArrayList(3) + val apis = ArrayList>(3) val groupAuth = OwnedSwarmAuth.ofClosedGroup(groupAccountId, adminKey) // Call No 1. Revoke sub-key. This call is crucial and must not fail for the rest of the operation to be successful. - calls += checkNotNull( - SnodeAPI.buildAuthenticatedRevokeSubKeyBatchRequest( - groupAdminAuth = groupAuth, - subAccountTokens = pendingRemovals.map { (member, _) -> - configs.groupKeys.getSubAccountToken(member.accountId()) - } - ) - ) { "Fail to create a revoke request" } + apis += revokeSubKeyApiFactory.create( + auth = groupAuth, + subAccountTokens = pendingRemovals.map { (member, _) -> + configs.groupKeys.getSubAccountToken(member.accountId()) + } + ) // Call No 2. Send a "kicked" message to the revoked namespace - calls += SnodeAPI.buildAuthenticatedStoreBatchInfo( + apis += storeSnodeMessageApiFactory.create( namespace = Namespace.REVOKED_GROUP_MESSAGES(), message = buildGroupKickMessage( groupAccountId.hexString, @@ -130,7 +132,7 @@ class RemoveGroupMemberHandler @Inject constructor( // Call No 3. Conditionally send the `GroupUpdateDeleteMemberContent` if (pendingRemovals.any { (member, status) -> member.shouldRemoveMessages(status) }) { - calls += SnodeAPI.buildAuthenticatedStoreBatchInfo( + apis += storeSnodeMessageApiFactory.create( namespace = Namespace.GROUP_MESSAGES(), message = buildDeleteGroupMemberContentMessage( adminKey = adminKey, @@ -144,23 +146,21 @@ class RemoveGroupMemberHandler @Inject constructor( ) } - pendingRemovals to (calls as List) + pendingRemovals to apis } if (pendingRemovals.isEmpty() || batchCalls.isEmpty()) { return } - val node = SnodeAPI.getSingleTargetSnode(groupAccountId.hexString).await() - val response = - SnodeAPI.getBatchResponse( - node, - groupAccountId.hexString, - batchCalls, - sequence = true + val response = swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = groupAccountId.hexString, + api = batchApiFactory.createFromApis(batchCalls) ) + ) - val firstError = response.results.firstOrNull { !it.isSuccessful } + val firstError = response.responses.firstOrNull { !it.isSuccessful } check(firstError == null) { "Error processing pending removals for group: code = ${firstError?.code}, body = ${firstError?.body}" } @@ -187,7 +187,7 @@ class RemoveGroupMemberHandler @Inject constructor( if (deletingMessagesForMembers.isNotEmpty()) { val threadId = storage.getThreadId(Address.fromSerialized(groupAccountId.hexString)) if (threadId != null) { - val until = clock.currentTimeMills() + val until = clock.currentTimeMillis() for ((member, _) in deletingMessagesForMembers) { try { messageDataProvider.markUserMessagesAsDeleted( @@ -209,14 +209,14 @@ class RemoveGroupMemberHandler @Inject constructor( groupAccountId: String, memberSessionIDs: Sequence ): SnodeMessage { - val timestamp = clock.currentTimeMills() + val timestamp = clock.currentTimeMillis() return messageSender.buildWrappedMessageToSnode( destination = Destination.ClosedGroup(groupAccountId), message = GroupUpdated( - SignalServiceProtos.DataMessage.GroupUpdateMessage.newBuilder() + SessionProtos.GroupUpdateMessage.newBuilder() .setDeleteMemberContent( - SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage.newBuilder() + SessionProtos.GroupUpdateDeleteMemberContentMessage.newBuilder() .apply { for (id in memberSessionIDs) { addMemberSessionIds(id) @@ -263,6 +263,6 @@ class RemoveGroupMemberHandler @Inject constructor( ) ), ttl = SnodeMessage.DEFAULT_TTL, - timestamp = clock.currentTimeMills() + timestamp = clock.currentTimeMillis() ) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt index 04c3611a7b..f3f27e947b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.home -import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -11,11 +10,13 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding +import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupRecord -import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.GroupDatabase @@ -40,6 +41,7 @@ class ConversationOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClick @Inject lateinit var groupDatabase: GroupDatabase @Inject lateinit var loginStateRepository: LoginStateRepository + @Inject lateinit var groupManager : GroupManagerV2 var onViewDetailsTapped: (() -> Unit?)? = null var onCopyConversationId: (() -> Unit?)? = null @@ -48,6 +50,8 @@ class ConversationOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClick var onBlockTapped: (() -> Unit)? = null var onUnblockTapped: (() -> Unit)? = null var onDeleteTapped: (() -> Unit)? = null + + var onAdminLeaveTapped: (() -> Unit)? = null var onMarkAllAsReadTapped: (() -> Unit)? = null var onMarkAsUnreadTapped : (() -> Unit)? = null var onNotificationTapped: (() -> Unit)? = null @@ -68,6 +72,7 @@ class ConversationOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClick binding.blockTextView -> onBlockTapped?.invoke() binding.unblockTextView -> onUnblockTapped?.invoke() binding.deleteTextView -> onDeleteTapped?.invoke() + binding.adminLeaveGroupTextView ->onAdminLeaveTapped?.invoke() binding.markAllAsReadTextView -> onMarkAllAsReadTapped?.invoke() binding.markAsUnreadTextView -> onMarkAsUnreadTapped?.invoke() binding.notificationsTextView -> onNotificationTapped?.invoke() @@ -116,6 +121,19 @@ class ConversationOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClick binding.notificationsTextView.isVisible = !recipient.isLocalNumber && !isDeprecatedLegacyGroup binding.notificationsTextView.setOnClickListener(this) + // leave group for admin + binding.adminLeaveGroupTextView.apply { + if (recipient.isGroupV2Recipient) { + val accountId = AccountId(recipient.address.toString()) + val group = configFactory.getGroup(accountId) ?: return + + setOnClickListener(this@ConversationOptionsBottomSheet) + + // Only visible if admin is one of many group admins + this.isVisible = group.hasAdminKey() + && !groupManager.isCurrentUserLastAdmin(accountId) + } + } // delete binding.deleteTextView.apply { setOnClickListener(this@ConversationOptionsBottomSheet) @@ -144,11 +162,13 @@ class ConversationOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClick // groups and communities recipient.isGroupV2Recipient -> { val accountId = AccountId(recipient.address.toString()) - val group = configFactory.withUserConfigs { it.userGroups.getClosedGroup(accountId.hexString) } ?: return + val group = configFactory.withUserConfigs { it.userGroups.getClosedGroup(accountId.hexString) } + ?: return + // if you are in a group V2 and have been kicked of that group, or the group was destroyed, - // or if the user is an admin + // or if the user is the only admin (multi-admin groups) // the button should read 'Delete' instead of 'Leave' - if (!group.shouldPoll || group.hasAdminKey()) { + if (!group.shouldPoll || group.hasAdminKey()) { text = context.getString(R.string.delete) contentDescription = context.getString(R.string.AccessibilityId_delete) drawableStartRes = R.drawable.ic_trash_2 diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index b571d0828d..e64503fa73 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -15,6 +15,7 @@ import network.loki.messenger.databinding.ViewConversationBinding import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.displayName +import org.thoughtcrime.securesms.conversation.v2.messages.MessageFormatter import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.model.NotifyType @@ -35,6 +36,7 @@ class ConversationView : LinearLayout { @Inject lateinit var proStatusManager: ProStatusManager @Inject lateinit var avatarUtils: AvatarUtils @Inject lateinit var recipientRepository: RecipientRepository + @Inject lateinit var messageFormatter: MessageFormatter private val binding: ViewConversationBinding by lazy { ViewConversationBinding.bind(this) } private val screenWidth = Resources.getSystem().displayMetrics.widthPixels @@ -105,7 +107,10 @@ class ConversationView : LinearLayout { binding.muteIndicatorImageView.setImageResource(drawableRes) val snippet = highlightMentions( - text = thread.getDisplayBody(context), + text = messageFormatter.formatThreadSnippet( + context = context, + thread = thread, + ), formatOnly = true, // no styling here, only text formatting recipientRepository = recipientRepository, context = context diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 75cf52c824..67184ad12e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -10,8 +10,10 @@ import android.os.Build import android.os.Bundle import android.widget.Toast import androidx.activity.viewModels +import androidx.compose.animation.Crossfade import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.offset import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -46,7 +48,9 @@ import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock +import org.session.libsession.network.model.PathStatus +import org.session.libsession.network.onion.PathManager import org.session.libsession.utilities.Address import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY @@ -54,10 +58,12 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.RecipientData import org.session.libsession.utilities.recipients.displayName import org.session.libsession.utilities.updateContact +import org.session.libsession.utilities.withMutableUserConfigs import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.conversation.v2.messages.MessageFormatter import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsActivity import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.database.GroupDatabase @@ -84,9 +90,14 @@ import org.thoughtcrime.securesms.reviews.ui.InAppReview import org.thoughtcrime.securesms.reviews.ui.InAppReviewViewModel import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.tokenpage.TokenPageNotificationManager +import org.thoughtcrime.securesms.ui.PathDot import org.thoughtcrime.securesms.ui.components.Avatar +import org.thoughtcrime.securesms.ui.requestDozeWhitelist import org.thoughtcrime.securesms.ui.setThemedContent +import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.primaryGreen +import org.thoughtcrime.securesms.util.AvatarBadge import org.thoughtcrime.securesms.util.AvatarUtils import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.applySafeInsetsMargins @@ -133,6 +144,8 @@ class HomeActivity : ScreenLockActionBarActivity(), @Inject lateinit var recipientRepository: RecipientRepository @Inject lateinit var avatarUtils: AvatarUtils @Inject lateinit var loginStateRepository: LoginStateRepository + @Inject lateinit var messageFormatter: MessageFormatter + @Inject lateinit var pathManager: PathManager private val globalSearchViewModel by viewModels() private val homeViewModel by viewModels() @@ -141,7 +154,13 @@ class HomeActivity : ScreenLockActionBarActivity(), private val publicKey: String by lazy { loginStateRepository.requireLocalNumber() } private val homeAdapter: HomeAdapter by lazy { - HomeAdapter(context = this, configFactory = configFactory, listener = this, ::showMessageRequests, ::hideMessageRequests) + HomeAdapter( + context = this, + messageFormatter = messageFormatter, + listener = this, + showMessageRequests = ::showMessageRequests, + hideMessageRequests = ::hideMessageRequests, + ) } private val globalSearchAdapter by lazy { @@ -215,6 +234,8 @@ class HomeActivity : ScreenLockActionBarActivity(), val recipient by recipientRepository.observeSelf() .collectAsState(null) + val pathStatus by pathManager.status.collectAsState() + Avatar( size = LocalDimensions.current.iconMediumAvatar, data = avatarUtils.getUIDataFromRecipient(recipient), @@ -222,6 +243,24 @@ class HomeActivity : ScreenLockActionBarActivity(), interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = ::openSettings + ), + badge = AvatarBadge.ComposeBadge( + content = { + val glowSize = LocalDimensions.current.xxxsSpacing + Crossfade( + targetState = when (pathStatus){ + PathStatus.BUILDING -> LocalColors.current.warning + PathStatus.ERROR -> LocalColors.current.danger + else -> primaryGreen + }, label = "path") { + PathDot( + modifier = Modifier.offset(glowSize*0.5f, glowSize*0.5f), + dotSize = LocalDimensions.current.xxsSpacing, + glowSize = glowSize, + color = it + ) + } + } ) ) } @@ -250,6 +289,10 @@ class HomeActivity : ScreenLockActionBarActivity(), ) ) } + + is HomeViewModel.UiEvent.ShowWhiteListSystemDialog -> { + requestDozeWhitelist() + } } } } @@ -611,9 +654,13 @@ class HomeActivity : ScreenLockActionBarActivity(), unblockConversation(thread) } } + bottomSheet.onAdminLeaveTapped = { + bottomSheet.dismiss() + deleteConversation(thread, false) + } bottomSheet.onDeleteTapped = { bottomSheet.dismiss() - deleteConversation(thread) + deleteConversation(thread, true) } bottomSheet.onNotificationTapped = { bottomSheet.dismiss() @@ -705,7 +752,7 @@ class HomeActivity : ScreenLockActionBarActivity(), private fun markAllAsRead(thread: ThreadRecord) { lifecycleScope.launch(Dispatchers.Default) { - storage.markConversationAsRead(thread.threadId, clock.currentTimeMills()) + storage.markConversationAsRead(thread.threadId, clock.currentTimeMillis()) } } @@ -715,14 +762,19 @@ class HomeActivity : ScreenLockActionBarActivity(), } } - private fun deleteConversation(thread: ThreadRecord) { + /** + * @param isAdminDeleteGroup will determine if the group will be deleted by admin + * false : admin will only leave the group (group has > 1 admin) + * true : admin will delete the group (can delete even if > 1 admin) + */ + private fun deleteConversation(thread: ThreadRecord, isAdminDeleteGroup : Boolean) { val recipient = thread.recipient if (recipient.address is Address.Group) { confirmAndLeaveGroup( - dialogData = groupManagerV2.getLeaveGroupConfirmationDialogData(recipient.address.accountId, recipient.displayName()) + dialogData = homeViewModel.getLeaveGroupConfirmationDialog(thread, isAdminDeleteGroup) ) { - homeViewModel.leaveGroup(recipient.address.accountId) + homeViewModel.leaveGroup(recipient.address.accountId, isAdminDeleteGroup) } return diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt index b5d4e6bbb9..35c9d01637 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt @@ -10,14 +10,14 @@ import androidx.recyclerview.widget.RecyclerView.NO_ID import com.bumptech.glide.RequestManager import network.loki.messenger.R import network.loki.messenger.databinding.ViewMessageRequestBannerBinding -import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.conversation.v2.messages.MessageFormatter class HomeAdapter( private val context: Context, - private val configFactory: ConfigFactory, private val listener: ConversationClickListener, private val showMessageRequests: () -> Unit, private val hideMessageRequests: () -> Unit, + private val messageFormatter: MessageFormatter, ) : RecyclerView.Adapter() { companion object { @@ -29,7 +29,7 @@ class HomeAdapter( set(newData) { if (field === newData) return - val diff = HomeDiffUtil(field, newData, context, configFactory) + val diff = HomeDiffUtil(field, newData, context, messageFormatter) val diffResult = DiffUtil.calculateDiff(diff) field = newData diffResult.dispatchUpdatesTo(this) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt index 186f9efc6e..e6c3c06383 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt @@ -19,12 +19,17 @@ import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY import org.thoughtcrime.securesms.home.HomeViewModel.Commands.* import org.thoughtcrime.securesms.home.startconversation.StartConversationSheet import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination +import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.AnimatedSessionProCTA import org.thoughtcrime.securesms.ui.CTAFeature +import org.thoughtcrime.securesms.ui.DialogButtonData +import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.OpenURLAlertDialog import org.thoughtcrime.securesms.ui.PinProCTA import org.thoughtcrime.securesms.ui.SimpleSessionProCTA import org.thoughtcrime.securesms.ui.UserProfileModal +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme @Composable @@ -33,6 +38,42 @@ fun HomeDialogs( sendCommand: (HomeViewModel.Commands) -> Unit ) { SessionMaterialTheme { + // Simple dialogs + if (dialogsState.showSimpleDialog != null) { + val buttons = mutableListOf() + if(dialogsState.showSimpleDialog.positiveText != null) { + buttons.add( + DialogButtonData( + text = GetString(dialogsState.showSimpleDialog.positiveText), + color = if (dialogsState.showSimpleDialog.positiveStyleDanger) LocalColors.current.danger + else LocalColors.current.text, + qaTag = dialogsState.showSimpleDialog.positiveQaTag, + onClick = dialogsState.showSimpleDialog.onPositive + ) + ) + } + if(dialogsState.showSimpleDialog.negativeText != null){ + buttons.add( + DialogButtonData( + text = GetString(dialogsState.showSimpleDialog.negativeText), + qaTag = dialogsState.showSimpleDialog.negativeQaTag, + onClick = dialogsState.showSimpleDialog.onNegative + ) + ) + } + + AlertDialog( + onDismissRequest = { + // hide dialog + sendCommand(HideSimpleDialog) + }, + title = annotatedStringResource(dialogsState.showSimpleDialog.title), + text = annotatedStringResource(dialogsState.showSimpleDialog.message), + showCloseButton = dialogsState.showSimpleDialog.showXIcon, + buttons = buttons + ) + } + // pin CTA if(dialogsState.pinCTA != null){ PinProCTA( diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt index 7d3b80f05f..0749b6838e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt @@ -2,14 +2,13 @@ package org.thoughtcrime.securesms.home import android.content.Context import androidx.recyclerview.widget.DiffUtil -import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.util.getConversationUnread +import org.thoughtcrime.securesms.conversation.v2.messages.MessageFormatter class HomeDiffUtil( private val old: HomeViewModel.Data, private val new: HomeViewModel.Data, private val context: Context, - private val configFactory: ConfigFactory + private val messageFormatter: MessageFormatter, ): DiffUtil.Callback() { override fun getOldListSize(): Int = old.items.size @@ -60,7 +59,8 @@ class HomeDiffUtil( if (isSameItem) { isSameItem = (oldItem.recipient == newItem.recipient) } // Note: Two instances of 'SpannableString' may not equate even though their content matches - if (isSameItem) { isSameItem = (oldItem.getDisplayBody(context).toString() == newItem.getDisplayBody(context).toString()) } + if (isSameItem) { isSameItem = (messageFormatter.formatThreadSnippet(context, oldItem).toString() + == messageFormatter.formatThreadSnippet(context, newItem).toString()) } if (isSameItem) { isSameItem = ( diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index 3333e6ad61..d6be5d64ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -4,11 +4,13 @@ import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope +import com.squareup.phrase.Phrase import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -30,6 +32,7 @@ import network.loki.messenger.libsession_util.PRIORITY_HIDDEN import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.utilities.Address +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.displayName import org.session.libsignal.utilities.AccountId @@ -44,6 +47,8 @@ import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository +import org.thoughtcrime.securesms.ui.SimpleDialogData +import org.thoughtcrime.securesms.ui.isWhitelistedFromDoze import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DonationManager import org.thoughtcrime.securesms.util.DonationManager.Companion.URL_DONATE @@ -154,13 +159,48 @@ class HomeViewModel @Inject constructor( val shouldShowCurrentUserProBadge: StateFlow = recipientRepository .observeSelf() - .map { it.shouldShowProBadge } + .map { it.isPro } // this is one place where the badge shows even if you decided to hide it - always show it on the home screen is the user is pro .stateIn(viewModelScope, SharingStarted.Eagerly, false) private var userProfileModalJob: Job? = null private var userProfileModalUtils: UserProfileUtils? = null init { + // check for white list status in case of slow mode + if(!prefs.hasCheckedDozeWhitelist() // the user has not yet seen the dialog + && !prefs.pushEnabled.value // the user is in slow mode + && !context.isWhitelistedFromDoze() // the user isn't yet whitelisted + ){ + prefs.setHasCheckedDozeWhitelist(true) + viewModelScope.launch { + delay(1500) + _dialogsState.update { + it.copy( + showSimpleDialog = SimpleDialogData( + title = Phrase.from(context, R.string.runSessionBackground) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .format().toString(), + message = Phrase.from(context, R.string.runSessionBackgroundDescription) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .format().toString(), + positiveText = context.getString(R.string.allow), + negativeText = context.getString(R.string.cancel), + positiveQaTag = context.getString(R.string.qa_conversation_settings_dialog_whitelist_confirm), + negativeQaTag = context.getString(R.string.qa_conversation_settings_dialog_whitelist_cancel), + positiveStyleDanger = false, + onPositive = { + // show system whitelist dialog + viewModelScope.launch { + _uiEvents.emit(UiEvent.ShowWhiteListSystemDialog) + } + }, + onNegative = {} + ) + ) + } + } + } + // observe subscription status viewModelScope.launch { proStatusManager.proDataState.collect { subscription -> @@ -175,7 +215,7 @@ class HomeViewModel @Inject constructor( if(subscription.type is ProStatus.Active.Expiring && !prefs.hasSeenProExpiring() ){ - val validUntil = subscription.type.validUntil + val validUntil = subscription.type.renewingAt showExpiring = validUntil.isBefore(now.plus(7, ChronoUnit.DAYS)) Log.d(DebugLogGroup.PRO_DATA.label, "Home: Pro active but not auto renewing (expiring). Valid until: $validUntil - Should show Expiring CTA? $showExpiring") if (showExpiring) { @@ -261,9 +301,9 @@ class HomeViewModel @Inject constructor( configFactory.removeContactOrBlindedContact(address) } - fun leaveGroup(accountId: AccountId) { + fun leaveGroup(accountId: AccountId, deleteGroup : Boolean) { viewModelScope.launch(Dispatchers.Default) { - groupManager.leaveGroup(accountId) + groupManager.leaveGroup(accountId, deleteGroup) } } @@ -331,6 +371,10 @@ class HomeViewModel @Inject constructor( } } + is Commands.HideSimpleDialog -> { + _dialogsState.update { it.copy(showSimpleDialog = null) } + } + is Commands.HideDonationCTADialog -> { _dialogsState.update { it.copy(donationCTA = false) } } @@ -386,12 +430,39 @@ class HomeViewModel @Inject constructor( } } + fun getLeaveGroupConfirmationDialog(thread: ThreadRecord, isDeleteGroup : Boolean): GroupManagerV2.ConfirmDialogData? { + val recipient = thread.recipient + if (recipient.address is Address.Group) { + val accountId = recipient.address.accountId + // Admin will delete the group + return if (isDeleteGroup) { + groupManager.getDeleteGroupConfirmationDialogData( + accountId, + recipient.displayName() + ) + } else { + // more than 1 admin will leave + groupManager.getLeaveGroupConfirmationDialogData( + accountId, + recipient.displayName() + ) + } + } + + return null + } + + fun isCurrentUserLastAdmin(groupId : AccountId) : Boolean{ + return groupManager.isCurrentUserLastAdmin(groupId) + } + data class DialogsState( val pinCTA: PinProCTA? = null, val userProfileModal: UserProfileModalData? = null, val showStartConversationSheet: StartConversationSheetData? = null, val proExpiringCTA: ProExpiringCTA? = null, val proExpiredCTA: Boolean = false, + val showSimpleDialog: SimpleDialogData? = null, val donationCTA: Boolean = false, val showUrlDialog: String? = null, ) @@ -411,6 +482,7 @@ class HomeViewModel @Inject constructor( sealed interface UiEvent { data class OpenProSettings(val start: ProSettingsDestination) : UiEvent + data object ShowWhiteListSystemDialog: UiEvent // once confirmed, this is for the system whitelist dialog } sealed interface Commands { @@ -430,6 +502,8 @@ class HomeViewModel @Inject constructor( data object ShowStartConversationSheet : Commands data object HideStartConversationSheet : Commands + data object HideSimpleDialog: Commands + data class GotoProSettings( val destination: ProSettingsDestination ): Commands @@ -438,8 +512,8 @@ class HomeViewModel @Inject constructor( companion object { private val CONVERSATION_COMPARATOR = compareByDescending { it.recipient.isPinned } .thenByDescending { it.recipient.priority } - .thenByDescending { it.lastMessage?.timestamp ?: 0L } .thenByDescending { it.date } + .thenByDescending { it.lastMessage?.timestamp ?: 0L } .thenBy { it.recipient.displayName() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt index 82f4623fba..ac8b83e60d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt @@ -1,8 +1,6 @@ package org.thoughtcrime.securesms.home import android.content.Context -import android.content.Intent -import android.net.Uri import android.os.Bundle import android.util.AttributeSet import android.util.TypedValue @@ -12,7 +10,6 @@ import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.RelativeLayout import android.widget.TextView -import android.widget.Toast import androidx.annotation.ColorRes import androidx.core.content.ContextCompat import androidx.core.view.doOnLayout @@ -32,7 +29,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.ActivityPathBinding -import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.network.onion.PathManager import org.session.libsession.utilities.NonTranslatableStringConstants.APP_NAME import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.getColorFromAttr @@ -60,6 +57,9 @@ class PathActivity : ScreenLockActionBarActivity() { @Inject lateinit var inAppReviewManager: InAppReviewManager + @Inject + lateinit var pathManager: PathManager + // region Lifecycle override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) @@ -83,9 +83,7 @@ class PathActivity : ScreenLockActionBarActivity() { lifecycleScope.launch { // Check if the repeatOnLifecycle(Lifecycle.State.STARTED) { - OnionRequestAPI.paths - .map { it.isEmpty() } - .distinctUntilChanged() + pathManager.paths .collectLatest { update(true) } @@ -127,13 +125,12 @@ class PathActivity : ScreenLockActionBarActivity() { private fun update(isAnimated: Boolean) { binding.pathRowsContainer.removeAllViews() - val paths = OnionRequestAPI.paths.value + val paths = pathManager.paths.value if (paths.isNotEmpty()) { val path = paths.firstOrNull() ?: return finish() val dotAnimationRepeatInterval = path.count().toLong() * 1000 + 1000 val pathRows = path.mapIndexed { index, snode -> - val isGuardSnode = (OnionRequestAPI.guardSnodes.contains(snode)) - getPathRow(snode, LineView.Location.Middle, index.toLong() * 1000 + 2000, dotAnimationRepeatInterval, isGuardSnode) + getPathRow(snode, LineView.Location.Middle, index.toLong() * 1000 + 2000, dotAnimationRepeatInterval, index == 0) } val youRow = getPathRow(resources.getString(R.string.you), null, LineView.Location.Top, 1000, dotAnimationRepeatInterval) val destinationRow = getPathRow(resources.getString(R.string.onionRoutingPathDestination), null, LineView.Location.Bottom, path.count().toLong() * 1000 + 2000, dotAnimationRepeatInterval) @@ -209,10 +206,14 @@ class PathActivity : ScreenLockActionBarActivity() { private var dotAnimationRepeatInterval: Long = 0 private var job: Job? = null + private val validColor by lazy { + ContextCompat.getColor(context, R.color.accent_green) + } + private val dotView by lazy { val result = PathDotView(context) result.setBackgroundResource(R.drawable.accent_dot) - result.mainColor = context.getAccentColor() + result.mainColor = validColor result } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt deleted file mode 100644 index 5a03844f38..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt +++ /dev/null @@ -1,92 +0,0 @@ -package org.thoughtcrime.securesms.home - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Paint -import android.util.AttributeSet -import android.view.View -import androidx.annotation.ColorInt -import androidx.core.content.ContextCompat -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import network.loki.messenger.R -import org.session.libsession.snode.OnionRequestAPI -import org.thoughtcrime.securesms.util.toPx - -class PathStatusView : View { - @ColorInt var mainColor: Int = 0 - set(newValue) { field = newValue; paint.color = newValue } - @ColorInt var sessionShadowColor: Int = 0 - set(newValue) { field = newValue; paint.setShadowLayer(toPx(8, resources).toFloat(), 0.0f, 0.0f, newValue) } - - private val paint: Paint by lazy { - val result = Paint() - result.style = Paint.Style.FILL - result.isAntiAlias = true - result - } - - private var updateJob: Job? = null - - constructor(context: Context) : super(context) { - initialize() - } - - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { - initialize() - } - - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { - initialize() - } - - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) { - initialize() - } - - private fun initialize() { - setWillNotDraw(false) - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - - updateJob = GlobalScope.launch { - OnionRequestAPI.hasPath - .collectLatest { pathsBuilt -> - withContext(Dispatchers.Main) { - if (pathsBuilt) { - setBackgroundResource(R.drawable.accent_dot) - val hasPathsColor = context.getColor(R.color.accent_green) - mainColor = hasPathsColor - sessionShadowColor = hasPathsColor - } else { - setBackgroundResource(R.drawable.paths_building_dot) - val pathsBuildingColor = - ContextCompat.getColor(context, R.color.paths_building) - mainColor = pathsBuildingColor - sessionShadowColor = pathsBuildingColor - } - } - } - } - } - - - override fun onDetachedFromWindow() { - updateJob?.cancel() - super.onDetachedFromWindow() - } - - override fun onDraw(c: Canvas) { - val w = width.toFloat() - val h = height.toFloat() - c.drawCircle(w / 2, h / 2, w / 2, paint) - super.onDraw(c) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt index fa3f8c8726..bae01dc62d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce @@ -21,10 +22,7 @@ import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.search.SearchRepository -import org.thoughtcrime.securesms.search.model.SearchResult import javax.inject.Inject -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel @@ -45,9 +43,9 @@ class GlobalSearchViewModel @Inject constructor( configFactory.configUpdateNotifications ) - val noteToSelfString by lazy { application.getString(R.string.noteToSelf).lowercase() } + val noteToSelfString: String by lazy { application.getString(R.string.noteToSelf).lowercase() } - val result = combine( + val result: SharedFlow = combine( _queryText, observeChangesAffectingSearch().onStart { emit(Unit) } ) { query, _ -> query } @@ -64,7 +62,7 @@ class GlobalSearchViewModel @Inject constructor( ) } } else { - val results = searchRepository.suspendQuery(query).toGlobalSearchResult() + val results = searchRepository.query(query).toGlobalSearchResult() // show "Note to Self" is the user searches for parts of"Note to Self" if(noteToSelfString.contains(query.lowercase())){ @@ -85,8 +83,3 @@ class GlobalSearchViewModel @Inject constructor( } } -private suspend fun SearchRepository.suspendQuery(query: String): SearchResult { - return suspendCoroutine { cont -> - query(query, cont::resume) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/SearchContactActionBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/SearchContactActionBottomSheet.kt index 4633925d2b..79f551591f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/SearchContactActionBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/SearchContactActionBottomSheet.kt @@ -1,15 +1,24 @@ package org.thoughtcrime.securesms.home.search import android.content.Context +import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.FrameLayout import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.core.os.BundleCompat +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint @@ -58,7 +67,7 @@ class SearchContactActionBottomSheet : BottomSheetDialogFragment() { if (address is Address.Standard) { ActionSheetItem( text = stringResource(R.string.block), - leadingIcon = R.drawable.ic_user_round_x, + leadingIcon = R.drawable.ic_user_round_block, qaTag = stringResource(R.string.AccessibilityId_block), onClick = { showBlockConfirmation() diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt index fd1737a3a9..c426fcfa6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt @@ -154,38 +154,36 @@ fun StartConversationNavHost( val viewModel = hiltViewModel() val uiState by viewModel.state.collectAsState(State()) - val helpUrl = "https://getsession.org/account-ids" - - LaunchedEffect(Unit) { - scope.launch { - viewModel.success.collect { - context.startActivity( - ConversationActivityV2.createIntent( - context, - address = it.address + LaunchedEffect(Unit) { + scope.launch { + viewModel.success.collect { + context.startActivity( + ConversationActivityV2.createIntent( + context, + address = it.address + ) ) - ) - onClose() + onClose() + } } } - } - NewMessage( - uiState, - viewModel.qrErrors, - viewModel, - onBack = { scope.launch { navigator.navigateUp() } }, - onClose = onClose, - onHelp = { viewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) } - ) - if (uiState.showUrlDialog) { - OpenURLAlertDialog( - url = helpUrl, - onDismissRequest = { viewModel.onCommand(NewMessageViewModel.Commands.DismissUrlDialog) } + NewMessage( + uiState, + viewModel.qrErrors, + viewModel, + onBack = { scope.launch { navigator.navigateUp() } }, + onClose = onClose, + onHelp = { viewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) } ) + if (uiState.showUrlDialog != null) { + OpenURLAlertDialog( + url = uiState.showUrlDialog!!, + onDismissRequest = { viewModel.onCommand(NewMessageViewModel.Commands.DismissUrlDialog) } + ) + } } - } // Create Group horizontalSlideComposable { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityScreen.kt index 34caae50a9..6387bb7817 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityScreen.kt @@ -267,7 +267,9 @@ private fun PreviewCommunityChip( group = OpenGroupApi.DefaultGroup( id = "id", name = "Session community", - image = null + image = null, + serverUrl = "url", + publicKey = "publicKey" ), onClick = {} ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityViewModel.kt index ab7961b1b6..a4f70c8c98 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -16,9 +17,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R -import nl.komponents.kovenant.functional.map -import okhttp3.HttpUrl.Companion.toHttpUrl -import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.open_groups.OfficialCommunityRepository import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.utilities.Address import org.session.libsession.utilities.OpenGroupUrlParser @@ -33,6 +32,7 @@ import javax.inject.Inject class JoinCommunityViewModel @Inject constructor( @param:ApplicationContext private val appContext: Context, private val openGroupManager: OpenGroupManager, + private val officialCommunityRepository: OfficialCommunityRepository, ): ViewModel() { private val _state = MutableStateFlow(JoinCommunityState(defaultCommunities = State.Loading)) @@ -45,16 +45,20 @@ class JoinCommunityViewModel @Inject constructor( private val qrDebounceTime = 3000L init { - viewModelScope.launch(Dispatchers.Default) { - runCatching { - OpenGroupApi.getDefaultServerCapabilities() - OpenGroupApi.getDefaultRoomsIfNeeded() + viewModelScope.launch { + val groups = try { + officialCommunityRepository.fetchOfficialCommunities() + } + catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e("JoinCommunityViewModel", "Couldn't fetch official communities.", e) + _state.update { it.copy(defaultCommunities = State.Error(e)) } + return@launch } - } - viewModelScope.launch(Dispatchers.Default) { - OpenGroupApi.defaultRooms.collect { defaultCommunities -> - _state.update { it.copy(defaultCommunities = State.Success(defaultCommunities)) } + _state.update { + it.copy(loading = false, defaultCommunities = State.Success(groups)) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt index f45bad3bfc..80f53f2607 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt @@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.ui.SearchBar import org.thoughtcrime.securesms.ui.components.AccentOutlineButton import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField +import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -86,6 +87,7 @@ fun CreateGroupScreen( onContactItemClicked = viewModel::onContactItemClicked, showLoading = viewModel.isLoading.collectAsState().value, items = viewModel.contacts.collectAsState().value, + hasContacts = viewModel.hasContacts.collectAsState().value, onCreateClicked = viewModel::onCreateClicked, onBack = onBack, ) @@ -103,6 +105,7 @@ fun CreateGroup( onContactItemClicked: (address: Address) -> Unit, showLoading: Boolean, items: List, + hasContacts: Boolean?, onCreateClicked: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier @@ -124,8 +127,6 @@ fun CreateGroup( modifier = modifier.padding(paddings).consumeWindowInsets(paddings), horizontalAlignment = Alignment.CenterHorizontally, ) { - GroupMinimumVersionBanner() - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) SessionOutlinedTextField( @@ -160,7 +161,13 @@ fun CreateGroup( .nestedScroll(rememberNestedScrollInteropConnection()), fadingColor = LocalColors.current.backgroundSecondary ) { bottomContentPadding -> - if(items.isEmpty() && contactSearchQuery.isEmpty()){ + if(hasContacts == null){ + SmallCircularProgressIndicator( + modifier = Modifier.padding(top = LocalDimensions.current.spacing) + .align(Alignment.TopCenter), + ) + } + else if(!hasContacts && contactSearchQuery.isEmpty()){ Text( modifier = Modifier.fillMaxWidth() .padding(top = LocalDimensions.current.xsSpacing), @@ -245,6 +252,7 @@ private fun CreateGroupPreview( onContactItemClicked = {}, showLoading = false, items = previewMembers, + hasContacts = true, onCreateClicked = {}, onBack = {}, modifier = Modifier.background(LocalColors.current.backgroundSecondary), @@ -270,6 +278,7 @@ private fun CreateEmptyGroupPreview( onContactItemClicked = {}, showLoading = false, items = previewMembers, + hasContacts = false, onCreateClicked = {}, onBack = {}, modifier = Modifier.background(LocalColors.current.backgroundSecondary), @@ -294,6 +303,7 @@ private fun CreateEmptyGroupPreviewWithSearch( onContactItemClicked = {}, showLoading = false, items = previewMembers, + hasContacts = true, onCreateClicked = {}, onBack = {}, modifier = Modifier.background(LocalColors.current.backgroundSecondary), diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/Callbacks.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/Callbacks.kt index a70ebfd709..723f897f19 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/Callbacks.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/Callbacks.kt @@ -4,4 +4,6 @@ internal interface Callbacks { fun onChange(value: String) {} fun onContinue() {} fun onScanQrCode(value: String) {} + + fun onClearQrCode() {} } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt index 8981f6c99c..7de7b69602 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt @@ -18,11 +18,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -53,32 +55,49 @@ private val TITLES = listOf(R.string.accountIdEnter, R.string.qrScan) internal fun NewMessage( state: State, qrErrors: Flow = emptyFlow(), - callbacks: Callbacks = object: Callbacks {}, + callbacks: Callbacks = object : Callbacks {}, onClose: () -> Unit = {}, onBack: () -> Unit = {}, onHelp: () -> Unit = {}, + isInvite: Boolean = false, ) { val pagerState = rememberPagerState { TITLES.size } - Column(modifier = Modifier.background( - LocalColors.current.backgroundSecondary, - shape = MaterialTheme.shapes.small - )) { + LaunchedEffect(state.validIdFromQr) { + if (state.validIdFromQr.isNotBlank()) { + if (isInvite) { + // switch back to the 1st tab and proceed with invite flow + pagerState.animateScrollToPage(0) + } else { + // auto-run the normal flow () + callbacks.onContinue() + } + + callbacks.onClearQrCode() + } + } + + Column( + modifier = Modifier.background( + if (isInvite) LocalColors.current.background else LocalColors.current.backgroundSecondary, + shape = MaterialTheme.shapes.small + ) + ) { // `messageNew` is now a plurals string so get the singular version - val context = LocalContext.current - val newMessageTitleTxt:String = context.resources.getQuantityString(R.plurals.messageNew, 1, 1) + val newMessageTitleTxt: String = if(isInvite) LocalResources.current.getString(R.string.membersInviteTitle) else + LocalResources.current.getQuantityString(R.plurals.messageNew, 1, 1) BackAppBar( title = newMessageTitleTxt, backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container onBack = onBack, - actions = { AppBarCloseIcon(onClose = onClose) }, + actions = { if(!isInvite) AppBarCloseIcon(onClose = onClose) }, windowInsets = WindowInsets(0, 0, 0, 0), // Insets handled by the dialog ) SessionTabRow(pagerState, TITLES) HorizontalPager(pagerState) { when (TITLES[it]) { - R.string.accountIdEnter -> EnterAccountId(state, callbacks, onHelp) + R.string.accountIdEnter -> EnterAccountId(state, callbacks, onHelp, isInvite) R.string.qrScan -> QRScannerScreen(qrErrors, onScan = callbacks::onScanQrCode) } } @@ -89,9 +108,10 @@ internal fun NewMessage( private fun EnterAccountId( state: State, callbacks: Callbacks, - onHelp: () -> Unit = {} + onHelp: () -> Unit = {}, + isInvite: Boolean = false, ) { - Surface(color = LocalColors.current.backgroundSecondary) { + Surface(color = if (isInvite) LocalColors.current.background else LocalColors.current.backgroundSecondary) { Column( modifier = Modifier .fillMaxSize() @@ -117,7 +137,7 @@ private fun EnterAccountId( Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) BorderlessButtonWithIcon( - text = stringResource(R.string.messageNewDescriptionMobile), + text = stringResource(if(isInvite) R.string.inviteNewMemberGroupNoLink else R.string.messageNewDescriptionMobile), modifier = Modifier .qaTag(R.string.AccessibilityId_messageNewDescriptionMobile) .padding(horizontal = LocalDimensions.current.mediumSpacing) @@ -129,7 +149,9 @@ private fun EnterAccountId( ) } - Spacer(Modifier.weight(1f).heightIn(min = LocalDimensions.current.smallSpacing)) + Spacer(Modifier + .weight(1f) + .heightIn(min = LocalDimensions.current.smallSpacing)) AccentOutlineButton( modifier = Modifier @@ -143,12 +165,11 @@ private fun EnterAccountId( onClick = callbacks::onContinue ) { LoadingArcOr(state.loading) { - Text(stringResource(R.string.next)) + Text(stringResource(if(isInvite) R.string.membersInviteTitle else R.string.next)) } } } } - } @Preview diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt index d76cdcf457..a244df5594 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.home.startconversation.newmessage import android.app.Application -import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.phrase.Phrase @@ -16,15 +15,12 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import network.loki.messenger.R -import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress -import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY -import org.session.libsession.utilities.upsertContact import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.PublicKeyValidation -import org.thoughtcrime.securesms.preferences.SettingsViewModel +import org.thoughtcrime.securesms.api.error.UnhandledStatusCodeException import org.thoughtcrime.securesms.ui.GetString import java.net.IDN import javax.inject.Inject @@ -32,8 +28,9 @@ import javax.inject.Inject @HiltViewModel class NewMessageViewModel @Inject constructor( private val application: Application, - private val configFactory: ConfigFactoryProtocol, -): ViewModel(), Callbacks { + private val onsResolver: OnsResolver, +) : ViewModel(), Callbacks { + private val HELP_URL : String = "https://getsession.org/account-ids" private val _state = MutableStateFlow(State()) val state = _state.asStateFlow() @@ -41,7 +38,10 @@ class NewMessageViewModel @Inject constructor( private val _success = MutableSharedFlow() val success get() = _success - private val _qrErrors = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private val _qrErrors = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) val qrErrors = _qrErrors.asSharedFlow() private var loadOnsJob: Job? = null @@ -52,7 +52,13 @@ class NewMessageViewModel @Inject constructor( override fun onChange(value: String) { loadOnsJob?.cancel() loadOnsJob = null - _state.update { it.copy(newMessageIdOrOns = value, isTextErrorColor = false, loading = false) } + _state.update { + it.copy( + newMessageIdOrOns = value, + isTextErrorColor = false, + loading = false + ) + } } override fun onContinue() { @@ -98,13 +104,19 @@ class NewMessageViewModel @Inject constructor( isPrefixRequired = false ) && PublicKeyValidation.hasValidPrefix(value) ) { - onPublicKey(value) + onChange(value) + _state.update { it.copy(validIdFromQr = value) } } else { _qrErrors.tryEmit(application.getString(R.string.qrNotAccountId)) + _state.update { it.copy(validIdFromQr = "") } } } } + override fun onClearQrCode() { + _state.update {it.copy(validIdFromQr = "") } + } + private fun resolveONS(ons: String) { if (loadOnsJob?.isActive == true) return @@ -114,7 +126,7 @@ class NewMessageViewModel @Inject constructor( loadOnsJob = viewModelScope.launch { try { val publicKey = withTimeout(30_000L, { - SnodeAPI.getAccountID(ons) + onsResolver.resolve(ons) }) onPublicKey(publicKey) } catch (e: Exception) { @@ -125,7 +137,12 @@ class NewMessageViewModel @Inject constructor( } private fun onError(e: Exception) { - _state.update { it.copy(loading = false, isTextErrorColor = true, error = GetString(e) { it.toMessage() }) } + _state.update { + it.copy( + loading = false, + isTextErrorColor = true, + error = GetString(e) { it.toMessage() }) + } } private fun onPublicKey(publicKey: String) { @@ -141,12 +158,18 @@ class NewMessageViewModel @Inject constructor( if (PublicKeyValidation.hasValidPrefix(publicKey)) { onPublicKey(publicKey) } else { - _state.update { it.copy(isTextErrorColor = true, error = GetString(R.string.accountIdErrorInvalid), loading = false) } + _state.update { + it.copy( + isTextErrorColor = true, + error = GetString(R.string.accountIdErrorInvalid), + loading = false + ) + } } } private fun Exception.toMessage() = when (this) { - is SnodeAPI.Error.Generic -> application.getString(R.string.errorUnregisteredOns) + is UnhandledStatusCodeException -> application.getString(R.string.errorUnregisteredOns) else -> Phrase.from(application, R.string.errorNoLookupOns) .put(APP_NAME_KEY, application.getString(R.string.app_name)) .format().toString() @@ -155,13 +178,13 @@ class NewMessageViewModel @Inject constructor( fun onCommand(commands: Commands) { when (commands) { is Commands.ShowUrlDialog -> { - _state.update { it.copy(showUrlDialog = true) } + _state.update { it.copy(showUrlDialog = HELP_URL) } } is Commands.DismissUrlDialog -> { _state.update { it.copy( - showUrlDialog = false + showUrlDialog = null ) } } @@ -179,12 +202,11 @@ data class State( val isTextErrorColor: Boolean = false, val error: GetString? = null, val loading: Boolean = false, - val showUrlDialog : Boolean = false + val showUrlDialog: String? = null, + val validIdFromQr: String = "", ) { val isNextButtonEnabled: Boolean get() = newMessageIdOrOns.isNotBlank() } - - data class Success(val address: Address.Standard) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/OnsResolver.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/OnsResolver.kt new file mode 100644 index 0000000000..acc92f9375 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/OnsResolver.kt @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.home.startconversation.newmessage + +import org.session.libsession.network.snode.SnodeDirectory +import org.thoughtcrime.securesms.api.snode.OnsResolveApi +import org.thoughtcrime.securesms.api.snode.SnodeApiExecutor +import org.thoughtcrime.securesms.api.snode.SnodeApiRequest +import org.thoughtcrime.securesms.api.snode.execute +import javax.inject.Inject + +class OnsResolver @Inject constructor( + private val snodeApiExecutor: SnodeApiExecutor, + private val snodeDirectory: SnodeDirectory, + private val onsResolveApiFactory: OnsResolveApi.Factory, +) { + suspend fun resolve(name: String): String { + val validationCount = 3 + + val results = List(validationCount) { + snodeApiExecutor.execute( + SnodeApiRequest( + snode = snodeDirectory.getRandomSnode(), + onsResolveApiFactory.create(name) + ) + ) + } + + check(results.toSet().size == 1) { + "ONS resolution results do not match: $results" + } + + return results.first() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt index 6d8c645b3e..43214116cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt @@ -9,35 +9,25 @@ import androidx.lifecycle.viewModelScope import dagger.assisted.Assisted import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter -import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.messaging.messages.control.DataExtractionNotification import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.isGroupOrCommunity import org.session.libsession.utilities.recipients.displayName import org.thoughtcrime.securesms.MediaPreviewActivity -import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.MediaDatabase import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord import org.thoughtcrime.securesms.database.RecipientRepository @@ -49,6 +39,12 @@ import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.SaveAttachmentTask import org.thoughtcrime.securesms.util.asSequence +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale @HiltViewModel(assistedFactory = MediaOverviewViewModel.Factory::class) class MediaOverviewViewModel @AssistedInject constructor( @@ -59,6 +55,7 @@ class MediaOverviewViewModel @AssistedInject constructor( private val dateUtils: DateUtils, recipientRepository: RecipientRepository, private val messageSender: MessageSender, + private val snodeClock: SnodeClock, ) : AndroidViewModel(application) { private val timeBuckets by lazy { FixedTimeBuckets() } @@ -291,7 +288,7 @@ class MediaOverviewViewModel @AssistedInject constructor( successCount > 0 && !address.isGroupOrCommunity) { withContext(Dispatchers.Default) { - val timestamp = SnodeAPI.nowWithOffset + val timestamp = snodeClock.currentTimeMillis() val kind = DataExtractionNotification.Kind.MediaSaved(timestamp) val message = DataExtractionNotification(kind) messageSender.send(message, address) 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 54a264477d..aaedc5e497 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt @@ -14,7 +14,6 @@ import android.view.animation.OvershootInterpolator import android.view.animation.ScaleAnimation import android.widget.Toast import androidx.activity.viewModels -import androidx.core.content.ContentProviderCompat.requireContext import androidx.core.view.ViewGroupCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager @@ -33,7 +32,6 @@ import org.session.libsession.utilities.concurrent.SimpleTask import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ScreenLockActionBarActivity -import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.mediasend.MediaSendViewModel.CountButtonState import org.thoughtcrime.securesms.permissions.Permissions @@ -320,6 +318,21 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme ) { 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, 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 601c2c60e0..9581143f42 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt @@ -30,6 +30,9 @@ import dagger.hilt.android.lifecycle.withCreationCallback import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.future.await import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope @@ -123,7 +126,7 @@ class MediaSendFragment : Fragment(), RailItemListener, InputBarDelegate { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel?.inputBarState?.collect { state -> + viewModel?.inputBarState?.collectLatest { state -> binding.inputBar.setState(state) } } 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 ed6a675c60..899bf57b5c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt @@ -5,7 +5,6 @@ import android.content.Context import android.net.Uri import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import com.annimon.stream.Stream import dagger.hilt.android.lifecycle.HiltViewModel import org.session.libsession.utilities.Util.equals @@ -25,7 +24,6 @@ import javax.inject.Inject /** * Manages the observable datasets available in [MediaSendActivity]. */ - @HiltViewModel internal class MediaSendViewModel @Inject constructor( private val application: Application, @@ -75,72 +73,81 @@ internal class MediaSendViewModel @Inject constructor( } fun onSelectedMediaChanged(context: Context, newMedia: List) { - repository.getPopulatedMedia(context, newMedia, - { populatedMedia: List -> - runOnMain( - { - var filteredMedia: List = - getFilteredMedia(context, populatedMedia, mediaConstraints) - if (filteredMedia.size != newMedia.size) { - error.setValue(Error.ITEM_TOO_LARGE) - } else if (filteredMedia.size > MAX_SELECTED_FILES) { - filteredMedia = filteredMedia.subList(0, MAX_SELECTED_FILES) - error.setValue(Error.TOO_MANY_ITEMS) - } + 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) + + // Report errors if they occurred + if (errors.contains(Error.ITEM_TOO_LARGE)) { + error.setValue(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) + } + + if (filteredMedia.size > MAX_SELECTED_FILES) { + filteredMedia = filteredMedia.subList(0, MAX_SELECTED_FILES) + error.setValue(Error.TOO_MANY_ITEMS) + } - if (filteredMedia.size > 0) { - val computedId: String = Stream.of(filteredMedia) - .skip(1) - .reduce(filteredMedia.get(0).bucketId ?: Media.ALL_MEDIA_BUCKET_ID, - { id: String?, m: Media -> - if (equals(id, m.bucketId ?: Media.ALL_MEDIA_BUCKET_ID)) { - return@reduce id - } else { - return@reduce Media.ALL_MEDIA_BUCKET_ID - } - }) - bucketId.setValue(computedId) - } else { - bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID) - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL + 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 + } } + bucketId.setValue(computedId) + } else { + bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID) + countButtonVisibility = CountButtonState.Visibility.CONDITIONAL + } - selectedMedia.setValue(filteredMedia) - countButtonState.setValue( - CountButtonState( - filteredMedia.size, - countButtonVisibility - ) - ) - }) - }) + selectedMedia.setValue(filteredMedia) + countButtonState.setValue( + CountButtonState( + filteredMedia.size, + countButtonVisibility + ) + ) + } + } } fun onSingleMediaSelected(context: Context, media: Media) { - repository.getPopulatedMedia(context, listOf(media), - { populatedMedia: List -> - runOnMain( - { - val filteredMedia: List = - getFilteredMedia(context, populatedMedia, mediaConstraints) - if (filteredMedia.isEmpty()) { - error.setValue(Error.ITEM_TOO_LARGE) - bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID) - } else { - bucketId.setValue(filteredMedia.get(0).bucketId ?: Media.ALL_MEDIA_BUCKET_ID) - } + repository.getPopulatedMedia(context, listOf(media)) { populatedMedia: List -> + runOnMain { + val (filteredMedia, errors) = getFilteredMedia(context, populatedMedia, mediaConstraints) + + if (filteredMedia.isEmpty()) { + if (errors.contains(Error.ITEM_TOO_LARGE)) { + error.setValue(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) + } + bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID) + } else { + bucketId.setValue(filteredMedia[0].bucketId ?: Media.ALL_MEDIA_BUCKET_ID) + } + + countButtonVisibility = CountButtonState.Visibility.FORCED_OFF - countButtonVisibility = CountButtonState.Visibility.FORCED_OFF - - selectedMedia.value = filteredMedia - countButtonState.setValue( - CountButtonState( - filteredMedia.size, - countButtonVisibility - ) - ) - }) - }) + selectedMedia.value = filteredMedia + countButtonState.setValue( + CountButtonState( + filteredMedia.size, + countButtonVisibility + ) + ) + } + } } fun onMultiSelectStarted() { @@ -273,14 +280,12 @@ internal class MediaSendViewModel @Inject constructor( } fun getMediaInBucket(context: Context, bucketId: String): LiveData> { - repository.getMediaInBucket(context, bucketId, - { value: List -> bucketMedia.postValue(value) }) + repository.getMediaInBucket(context, bucketId) { value: List -> bucketMedia.postValue(value) } return bucketMedia } fun getFolders(context: Context): LiveData> { - repository.getFolders(context, - { value: List -> folders.postValue(value) }) + repository.getFolders(context) { value: List -> folders.postValue(value) } return folders } @@ -308,57 +313,92 @@ internal class MediaSendViewModel @Inject constructor( get() = if (selectedMedia.value == null) emptyList() else selectedMedia.value!! + /** + * Filters the input list of media. + * @return A Pair containing: + * 1. A List of Valid Media items. + * 2. A Set of Errors encountered during filtering (e.g. ITEM_TOO_LARGE, INVALID_TYPE). + */ private fun getFilteredMedia( context: Context, media: List, mediaConstraints: MediaConstraints - ): List { - return Stream.of(media).filter( - { m: Media -> - MediaUtil.isGif(m.mimeType) || - MediaUtil.isImageType(m.mimeType) || - MediaUtil.isVideoType(m.mimeType) - }) - .filter({ m: Media -> - (MediaUtil.isImageType(m.mimeType) && !MediaUtil.isGif(m.mimeType)) || - (MediaUtil.isGif(m.mimeType) && m.size < mediaConstraints.getGifMaxSize( - context - )) || - (MediaUtil.isVideoType(m.mimeType) && m.size < mediaConstraints.getVideoMaxSize( - context - )) - }).toList() + ): Pair, Set> { + val validMedia = ArrayList() + val errors = HashSet() + + // when sharing multiple media, only certain types are valid: images and video + // currently we can't multi-share other types + val validMultiMediaCount = media.count { + MediaUtil.isGif(it.mimeType) + || MediaUtil.isImageType(it.mimeType) + || MediaUtil.isVideoType(it.mimeType) + } + + // if there are no valid types at all, return early + 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) + val isImage = MediaUtil.isImageType(m.mimeType) + + // Check Type - Not a valid multi share? + if (!isGif && !isImage && !isVideo) { + errors.add(Error.MIXED_TYPE) + continue + } + + // Check Size constraints + val isSizeValid = when { + isGif -> m.size < mediaConstraints.getGifMaxSize(context) + isVideo -> m.size < mediaConstraints.getVideoMaxSize(context) + else -> true + } + + if (!isSizeValid) { + errors.add(Error.ITEM_TOO_LARGE) + continue + } + + validMedia.add(m) + } + + return Pair(validMedia, errors) } override fun onCleared() { if (!sentMedia) { Stream.of(selectedMediaOrDefault) - .map({ obj: Media -> obj.uri }) - .filter({ uri: Uri? -> + .map { obj: Media -> obj.uri } + .filter { uri: Uri? -> BlobUtils.isAuthority( uri!! ) - }) - .forEach({ uri: Uri? -> + } + .forEach { uri: Uri? -> BlobUtils.getInstance().delete( application.applicationContext, uri!! ) - }) + } } } internal enum class Error { - ITEM_TOO_LARGE, TOO_MANY_ITEMS + ITEM_TOO_LARGE, TOO_MANY_ITEMS, INVALID_TYPE_ONLY, MIXED_TYPE } internal class CountButtonState(val count: Int, private val visibility: Visibility) { val isVisible: Boolean get() { - when (visibility) { - Visibility.FORCED_ON -> return true - Visibility.FORCED_OFF -> return false - Visibility.CONDITIONAL -> return count > 0 - else -> return false + return when (visibility) { + Visibility.FORCED_ON -> true + Visibility.FORCED_OFF -> false + Visibility.CONDITIONAL -> count > 0 } } @@ -373,4 +413,4 @@ internal class MediaSendViewModel @Inject constructor( // the maximum amount of files that can be selected to send as attachment const val MAX_SELECTED_FILES: Int = 32 } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt index 617fd51087..a49cc8b47e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt @@ -14,6 +14,7 @@ import network.loki.messenger.R import network.loki.messenger.databinding.ViewMessageRequestBinding import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.displayName +import org.thoughtcrime.securesms.conversation.v2.messages.MessageFormatter import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.model.ThreadRecord @@ -45,6 +46,9 @@ class MessageRequestView : LinearLayout { @Inject lateinit var recipientRepository: RecipientRepository + @Inject + lateinit var messageFormatter: MessageFormatter + // region Lifecycle constructor(context: Context) : super(context) { initialize() } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } @@ -94,7 +98,7 @@ class MessageRequestView : LinearLayout { val snippet = highlightMentions( recipientRepository = recipientRepository, - text = thread.getDisplayBody(context), + text = messageFormatter.formatThreadSnippet(context, thread), formatOnly = true, // no styling here, only text formatting context = context ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java b/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java index 2db9ea596b..12c7e22b9f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java @@ -2,7 +2,7 @@ import android.content.Context; -import org.session.libsession.messaging.file_server.FileServerApi; +import org.session.libsession.messaging.file_server.FileServerApis; public class PushMediaConstraints extends MediaConstraints { @@ -21,26 +21,26 @@ public int getImageMaxHeight(Context context) { @Override public int getImageMaxSize(Context context) { - return FileServerApi.MAX_FILE_SIZE; + return FileServerApis.MAX_FILE_SIZE; } @Override public int getGifMaxSize(Context context) { - return FileServerApi.MAX_FILE_SIZE; + return FileServerApis.MAX_FILE_SIZE; } @Override public int getVideoMaxSize(Context context) { - return FileServerApi.MAX_FILE_SIZE; + return FileServerApis.MAX_FILE_SIZE; } @Override public int getAudioMaxSize(Context context) { - return FileServerApi.MAX_FILE_SIZE; + return FileServerApis.MAX_FILE_SIZE; } @Override public int getDocumentMaxSize(Context context) { - return FileServerApi.MAX_FILE_SIZE; + return FileServerApis.MAX_FILE_SIZE; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.kt index 9972ca2685..6e9c764009 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.kt @@ -28,9 +28,8 @@ import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier -import org.session.libsession.snode.SnodeAPI.nowWithOffset +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.isGroupOrCommunity import org.session.libsignal.utilities.Log @@ -39,6 +38,7 @@ import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.mms.MmsException +import org.thoughtcrime.securesms.pro.ProStatusManager import javax.inject.Inject /** @@ -67,6 +67,13 @@ class AndroidAutoReplyReceiver : BroadcastReceiver() { @Inject lateinit var messageSender: MessageSender + @Inject + lateinit var proStatusManager: ProStatusManager + + @Inject + lateinit var snodeClock: SnodeClock + + @SuppressLint("StaticFieldLeak") override fun onReceive(context: Context, intent: Intent) { if (REPLY_ACTION != intent.getAction()) return @@ -92,7 +99,8 @@ class AndroidAutoReplyReceiver : BroadcastReceiver() { val message = VisibleMessage() message.text = responseText.toString() - message.sentTimestamp = nowWithOffset + proStatusManager.addProFeatures(message) + message.sentTimestamp = snodeClock.currentTimeMillis() messageSender.send(message, address!!) val expiryMode = recipientRepository.getRecipientSync(address).expiryMode val expiresInMillis = expiryMode.expiryMillis @@ -132,7 +140,7 @@ class AndroidAutoReplyReceiver : BroadcastReceiver() { replyThreadId, reply, false, - nowWithOffset, + snodeClock.currentTimeMillis(), true ) } 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 3506df7782..52aa16d28c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollManager.kt @@ -6,15 +6,12 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.launch import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.auth.LoginStateRepository -import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import org.thoughtcrime.securesms.auth.AuthAwareComponent +import org.thoughtcrime.securesms.auth.LoggedInState import org.thoughtcrime.securesms.util.AppVisibilityManager import javax.inject.Inject import javax.inject.Singleton @@ -26,29 +23,27 @@ import javax.inject.Singleton @OptIn(FlowPreview::class) @Singleton class BackgroundPollManager @Inject constructor( - application: Application, - appVisibilityManager: AppVisibilityManager, - loginStateRepository: LoginStateRepository, -) : OnAppStartupComponent { - init { - @Suppress("OPT_IN_USAGE") - GlobalScope.launch { - combine( - loginStateRepository.loggedInState, - // Debounce to avoid rapid toggling on visible app starts - appVisibilityManager.isAppVisible.debounce(1_000L) - ) { loggedInState, appVisible -> loggedInState != null && !appVisible } - .distinctUntilChanged() - .collectLatest { shouldSchedule -> - if (shouldSchedule) { - Log.i(TAG, "Scheduling background polling work.") - BackgroundPollWorker.schedulePeriodic(application) - } else { - Log.i(TAG, "Cancelling background polling work.") - BackgroundPollWorker.cancelPeriodic(application) - } + private val application: Application, + private val appVisibilityManager: AppVisibilityManager, +) : AuthAwareComponent { + override suspend fun doWhileLoggedIn(loggedInState: LoggedInState) { + appVisibilityManager.isAppVisible + .debounce(1_000L) + .distinctUntilChanged() + .collectLatest { isAppVisible -> + if (!isAppVisible) { + Log.i(TAG, "Scheduling background polling work.") + BackgroundPollWorker.schedulePeriodic(application) + } else { + Log.i(TAG, "Cancelling background polling work.") + BackgroundPollWorker.cancelPeriodic(application) } - } + } + } + + override fun onLoggedOut() { + Log.i(TAG, "Cancelling background polling work on logout.") + BackgroundPollWorker.cancelPeriodic(application) } class BootBroadcastReceiver : BroadcastReceiver() { 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/notifications/DefaultMessageNotifier.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt index c1d398a451..652c45371a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt @@ -25,8 +25,8 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import coil3.ImageLoader import com.squareup.phrase.Phrase +import dagger.Lazy import network.loki.messenger.R -import network.loki.messenger.libsession_util.util.BlindKeyAPI import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.ServiceUtil @@ -36,10 +36,10 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotifi import org.session.libsession.utilities.TextSecurePreferences.Companion.removeHasHiddenMessageRequests import org.session.libsession.utilities.recipients.RecipientData import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.auth.LoginStateRepository +import org.thoughtcrime.securesms.conversation.v2.messages.MessageFormatter import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions import org.thoughtcrime.securesms.database.MmsSmsColumns.NOTIFIED import org.thoughtcrime.securesms.database.MmsSmsDatabase @@ -75,6 +75,7 @@ class DefaultMessageNotifier @Inject constructor( private val mmsSmsDatabase: MmsSmsDatabase, private val imageLoader: Provider, private val loginStateRepository: LoginStateRepository, + private val messageFormatter: Lazy, ) : MessageNotifier { override fun setVisibleThread(threadId: Long) { visibleThread = threadId @@ -572,21 +573,22 @@ class DefaultMessageNotifier @Inject constructor( if (record == null) break // Bail if there are no more MessageRecords val threadId = record.threadId - val threadRecipients = if (threadId != -1L) { + val threadRecipient = if (threadId != -1L) { threadDatabase.getRecipientForThreadId(threadId) ?.let(recipientRepository::getRecipientSync) } else null + if (threadRecipient == null) continue + // Start by checking various scenario that we should skip // Skip if muted or calls - if (threadRecipients?.isMuted() == true) continue + if (threadRecipient.isMuted()) continue if (record.isIncomingCall || record.isOutgoingCall) continue // Handle message requests early - val isMessageRequest = threadRecipients != null && - !threadRecipients.isGroupOrCommunityRecipient && - !threadRecipients.approved && + val isMessageRequest = !threadRecipient.isGroupOrCommunityRecipient && + !threadRecipient.approved && !threadDatabase.getLastSeenAndHasSent(threadId).second() // Do not repeat request notifications once the thread has >1 messages @@ -597,12 +599,18 @@ class DefaultMessageNotifier @Inject constructor( } // Check notification settings - if (threadRecipients?.notifyType == NotifyType.NONE) continue + if (threadRecipient.notifyType == NotifyType.NONE) continue val userPublicKey = loginStateRepository.requireLocalNumber() + var body = messageFormatter.get().formatMessageBody( + context = context, + message = record, + threadRecipient = threadRecipient, + ) + // Check mentions-only setting - if (threadRecipients?.notifyType == NotifyType.MENTIONS) { + if (threadRecipient.notifyType == NotifyType.MENTIONS) { var blindedPublicKey = cache[threadId] if (blindedPublicKey == null) { blindedPublicKey = generateBlindedId(threadId, context) @@ -610,7 +618,6 @@ class DefaultMessageNotifier @Inject constructor( } var isMentioned = false - val body = record.getDisplayBody(context).toString() // Check for @mentions if (body.contains("@$userPublicKey") || @@ -658,7 +665,6 @@ class DefaultMessageNotifier @Inject constructor( } // Prepare message body - var body: CharSequence = record.getDisplayBody(context) var slideDeck: SlideDeck? = null if (isMessageRequest) { @@ -695,7 +701,7 @@ class DefaultMessageNotifier @Inject constructor( record.isMms || record.isMmsNotification, record.individualRecipient, record.recipient, - threadRecipients, + threadRecipient, threadId, body, record.timestamp, @@ -708,8 +714,7 @@ class DefaultMessageNotifier @Inject constructor( // Only if: it's OUR message AND it has reactions AND it's NOT an unread incoming message else if (record.isOutgoing && hasUnreadReactions && - threadRecipients != null && - !threadRecipients.isGroupOrCommunityRecipient + !threadRecipient.isGroupOrCommunityRecipient ) { var blindedPublicKey = cache[threadId] @@ -750,7 +755,7 @@ class DefaultMessageNotifier @Inject constructor( record.isMms || record.isMmsNotification, reactor, reactor, - threadRecipients, + threadRecipient, threadId, emoji, latestReaction.dateSent, null, @@ -800,16 +805,16 @@ class DefaultMessageNotifier @Inject constructor( private fun generateBlindedId(threadId: Long, context: Context): String? { val threadRecipient = recipientRepository.getRecipientSync(threadDatabase.getRecipientForThreadId(threadId) ?: return null) - val serverPubKey = (threadRecipient.data as? RecipientData.Community)?.serverPubKey - val edKeyPair = loginStateRepository.peekLoginState()?.accountEd25519KeyPair - if (serverPubKey != null && edKeyPair != null) { - val blindedKeyPair = BlindKeyAPI.blind15KeyPairOrNull( - ed25519SecretKey = edKeyPair.secretKey.data, - serverPubKey = Hex.fromStringCondensed(serverPubKey), + val recipientData = threadRecipient.data + val serverPubKey = (recipientData as? RecipientData.Community)?.serverPubKey + val loginState = loginStateRepository.peekLoginState() + if (serverPubKey != null && loginState != null) { + val blindedKeyPair = loginState.getBlindedKeyPair( + serverUrl = recipientData.serverUrl, + serverPubKeyHex = serverPubKey ) - if (blindedKeyPair != null) { - return AccountId(IdPrefix.BLINDED, blindedKeyPair.pubKey.data).hexString - } + + return AccountId(IdPrefix.BLINDED, blindedKeyPair.pubKey.data).hexString } return null } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt index 04cfd04597..94a1f34d2d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt @@ -2,20 +2,24 @@ package org.thoughtcrime.securesms.notifications import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth import org.session.libsession.messaging.messages.control.ReadReceipt import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled import org.session.libsession.utilities.associateByNotNull import org.session.libsession.utilities.isGroupOrCommunity import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientData import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.api.snode.AlterTtlApi +import org.thoughtcrime.securesms.api.swarm.SwarmApiExecutor +import org.thoughtcrime.securesms.api.swarm.SwarmApiRequest +import org.thoughtcrime.securesms.api.swarm.execute import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.MarkedMessageInfo @@ -25,6 +29,7 @@ import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate +import org.thoughtcrime.securesms.dependencies.ManagerScope import javax.inject.Inject class MarkReadProcessor @Inject constructor( @@ -38,6 +43,9 @@ class MarkReadProcessor @Inject constructor( private val storage: StorageProtocol, private val snodeClock: SnodeClock, private val lokiMessageDatabase: LokiMessageDatabase, + private val swarmApiExecutor: SwarmApiExecutor, + private val alterTtyFactory: AlterTtlApi.Factory, + @param:ManagerScope private val coroutineScope: CoroutineScope, ) { fun process( markedReadMessages: List @@ -64,7 +72,7 @@ class MarkReadProcessor @Inject constructor( smsDatabase } - db.markExpireStarted(it.expirationInfo.id.id, snodeClock.currentTimeMills()) + db.markExpireStarted(it.expirationInfo.id.id, snodeClock.currentTimeMillis()) } hashToDisappearAfterReadMessage(context, markedReadMessages)?.let { hashToMessages -> @@ -91,18 +99,27 @@ class MarkReadProcessor @Inject constructor( private fun shortenExpiryOfDisappearingAfterRead( hashToMessage: Map ) { - hashToMessage.entries - .groupBy( - keySelector = { it.value.expirationInfo.expiresIn }, - valueTransform = { it.key } - ).forEach { (expiresIn, hashes) -> - SnodeAPI.alterTtl( - messageHashes = hashes, - newExpiry = snodeClock.currentTimeMills() + expiresIn, - auth = checkNotNull(storage.userAuth) { "No authorized user" }, - shorten = true - ) - } + coroutineScope.launch { + val userAuth = checkNotNull(storage.userAuth) { "No authorized user" } + + hashToMessage.entries + .groupBy( + keySelector = { it.value.expirationInfo.expiresIn }, + valueTransform = { it.key } + ).forEach { (expiresIn, hashes) -> + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = userAuth.accountId.hexString, + api = alterTtyFactory.create( + messageHashes = hashes, + auth = userAuth, + alterType = AlterTtlApi.AlterType.Shorten, + newExpiry = snodeClock.currentTimeMillis() + expiresIn + ) + ) + ) + } + } } private val Recipient.shouldSendReadReceipt: Boolean @@ -123,7 +140,7 @@ class MarkReadProcessor @Inject constructor( .forEach { (address, messages) -> messages.map { it.timetamp } .let(::ReadReceipt) - .apply { sentTimestamp = snodeClock.currentTimeMills() } + .apply { sentTimestamp = snodeClock.currentTimeMillis() } .let { messageSender.send(it, address) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt index 6b1cd80d4d..5f98b954dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt @@ -8,7 +8,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import org.session.libsession.database.StorageProtocol -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsignal.utilities.Log import javax.inject.Inject @@ -26,7 +26,7 @@ class MarkReadReceiver : BroadcastReceiver() { val threadIds = intent.getLongArrayExtra(THREAD_IDS_EXTRA) ?: return NotificationManagerCompat.from(context).cancel(intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)) GlobalScope.launch { - val currentTime = clock.currentTimeMills() + val currentTime = clock.currentTimeMillis() threadIds.forEach { Log.i(TAG, "Marking as read: $it") storage.markConversationAsRead( diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushApiBatcher.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushApiBatcher.kt new file mode 100644 index 0000000000..d51b035c37 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushApiBatcher.kt @@ -0,0 +1,82 @@ +package org.thoughtcrime.securesms.notifications + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import org.thoughtcrime.securesms.api.server.ServerApiErrorManager +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.batch.Batcher +import org.thoughtcrime.securesms.api.http.HttpBody +import org.thoughtcrime.securesms.api.http.HttpResponse +import org.thoughtcrime.securesms.api.server.JsonServerApi +import org.thoughtcrime.securesms.api.server.ServerApiRequest +import javax.inject.Inject + +class PushApiBatcher @Inject constructor( + private val json: Json, + private val serverApiErrorManager: ServerApiErrorManager, +) : Batcher, Any, JsonElement> { + override fun batchKey(req: ServerApiRequest<*>): Any? { + return when (req.api) { + is PushRegisterApi -> "PushRegisterApi" + is PushUnregisterApi -> "PushUnregisterApi" + else -> null + } + } + + override fun transformRequestForBatching( + ctx: ApiExecutorContext, + req: ServerApiRequest<*> + ): JsonElement { + return when (req.api) { + is PushRegisterApi -> req.api.buildJsonPayload() + is PushUnregisterApi -> req.api.buildJsonPayload() + else -> error("Unsupported API for batching: ${req.api::class.java}") + } + } + + override fun constructBatchRequest( + firstRequest: ServerApiRequest<*>, + intermediateRequests: List + ): ServerApiRequest<*> { + firstRequest.api as JsonServerApi<*> + + return ServerApiRequest( + serverBaseUrl = firstRequest.serverBaseUrl, + serverX25519PubKeyHex = firstRequest.serverX25519PubKeyHex, + api = object : JsonServerApi(json, serverApiErrorManager) { + override val httpMethod: String get() = firstRequest.api.httpMethod + override val httpEndpoint: String get() = firstRequest.api.httpEndpoint + override val responseSerializer: DeserializationStrategy + get() = JsonArray.serializer() + + override fun buildJsonPayload() = JsonArray(intermediateRequests) + } + ) + } + + override suspend fun deconstructBatchResponse( + requests: List>>, + response: Any + ): List> { + response as JsonArray + + return List(requests.size) { index -> + val respElement = response[index] + val (ctx, req) = requests[index] + + runCatching { + req.api.processResponse( + executorContext = ctx, + baseUrl = req.serverBaseUrl, + response = HttpResponse( + statusCode = 200, + headers = emptyMap(), + body = HttpBody.Text(json.encodeToString(respElement)) + ) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt index e256c17feb..1d1ea38a76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt @@ -16,7 +16,6 @@ import kotlinx.serialization.json.Json import network.loki.messenger.R import network.loki.messenger.libsession_util.Namespace import network.loki.messenger.libsession_util.SessionEncrypt -import okio.ByteString.Companion.decodeHex 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 @@ -31,15 +30,12 @@ import org.session.libsession.utilities.getGroup import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.auth.LoginStateRepository -import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.groups.GroupRevokedMessageHandler import org.thoughtcrime.securesms.home.HomeActivity -import java.security.SecureRandom import javax.inject.Inject private const val TAG = "PushHandler" @@ -62,7 +58,7 @@ class PushReceiver @Inject constructor( */ fun onPushDataReceived(dataMap: Map?) { Log.d(TAG, "Push data received: $dataMap") - addMessageReceiveJob(dataMap?.asPushData()) + onPushDataReceived(dataMap?.asPushData()) } /** @@ -70,10 +66,10 @@ class PushReceiver @Inject constructor( * but it shouldn't happen. Old code used to send different data so this is kept as a safety */ fun onPushDataReceived(data: ByteArray?) { - addMessageReceiveJob(PushData(data = data, metadata = null)) + onPushDataReceived(PushData(data = data, metadata = null)) } - private fun addMessageReceiveJob(pushData: PushData?) { + private fun onPushDataReceived(pushData: PushData?) { try { val namespace = pushData?.metadata?.namespace when { @@ -105,7 +101,7 @@ class PushReceiver @Inject constructor( hash = pushData.metadata.msg_hash )) { receivedMessageProcessor.startProcessing("GroupPushReceive($groupId)") { ctx -> - val (msg, proto) = messageParser.parseGroupMessage( + val result = messageParser.parseGroupMessage( data = pushData.data, serverHash = pushData.metadata.msg_hash, groupId = groupId, @@ -115,9 +111,10 @@ class PushReceiver @Inject constructor( receivedMessageProcessor.processSwarmMessage( threadAddress = Address.Group(groupId), - message = msg, - proto = proto, + message = result.message, + proto = result.proto, context = ctx, + pro = result.pro, ) } } @@ -175,7 +172,7 @@ class PushReceiver @Inject constructor( if (!isDuplicated) { receivedMessageProcessor.startProcessing("PushReceiver") { ctx -> - val (message, proto) = messageParser.parse1o1Message( + val result = messageParser.parse1o1Message( data = pushData.data, serverHash = pushData.metadata?.msg_hash, currentUserId = ctx.currentUserId, @@ -183,10 +180,11 @@ class PushReceiver @Inject constructor( ) receivedMessageProcessor.processSwarmMessage( - threadAddress = message.senderOrSync.toAddress() as Address.Conversable, - message = message, - proto = proto, + threadAddress = result.message.senderOrSync.toAddress() as Address.Conversable, + message = result.message, + proto = result.proto, context = ctx, + pro = result.pro ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegisterApi.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegisterApi.kt new file mode 100644 index 0000000000..e3ba4c6f6e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegisterApi.kt @@ -0,0 +1,66 @@ +package org.thoughtcrime.securesms.notifications + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject +import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionRequest +import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionResponse +import org.thoughtcrime.securesms.api.server.ServerApiErrorManager +import org.session.libsession.network.SnodeClock +import org.session.libsession.snode.SwarmAuth +import org.session.libsession.utilities.Device +import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.api.server.JsonServerApi +import org.thoughtcrime.securesms.auth.LoginStateRepository + +class PushRegisterApi @AssistedInject constructor( + @Assisted private val token: String, + @Assisted private val swarmAuth: SwarmAuth, + @Assisted private val namespaces: List, + private val clock: SnodeClock, + private val device: Device, + private val loginStateRepository: LoginStateRepository, + json: Json, + errorManager: ServerApiErrorManager +) : JsonServerApi(json, errorManager) { + override val httpMethod: String get() = "POST" + override val httpEndpoint: String get() = "subscribe" + override val responseSerializer: DeserializationStrategy + get() = SubscriptionResponse.serializer() + + override fun buildJsonPayload(): JsonElement { + val timestamp = clock.currentTimeSeconds() + val publicKey = swarmAuth.accountId.hexString + val sortedNamespace = namespaces.sorted() + val signed = JsonObject(swarmAuth.sign( + "MONITOR${publicKey}${timestamp}1${sortedNamespace.joinToString(separator = ",")}".encodeToByteArray() + ).mapValues { JsonPrimitive(it.value) }) + + return JsonObject(SubscriptionRequest( + pubkey = publicKey, + session_ed25519 = swarmAuth.ed25519PublicKeyHex, + namespaces = sortedNamespace, + data = true, // only permit data subscription for now (?) + service = device.service, + sig_ts = timestamp, + service_info = mapOf("token" to token), + enc_key = loginStateRepository.requireLoggedInState().notificationKey.data.toHexString(), + ).let(Json::encodeToJsonElement).jsonObject + signed) + } + + @AssistedFactory + interface Factory { + fun create( + token: String, + swarmAuth: SwarmAuth, + namespaces: List + ): PushRegisterApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt index fedb28109f..7fb8fb2d9d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt @@ -3,29 +3,24 @@ package org.thoughtcrime.securesms.notifications import android.content.Context import androidx.work.await import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit -import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.notifications.TokenFetcher import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.userConfigsChanged +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.auth.LoginStateRepository +import org.thoughtcrime.securesms.auth.AuthAwareComponent +import org.thoughtcrime.securesms.auth.LoggedInState import org.thoughtcrime.securesms.database.PushRegistrationDatabase import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.util.castAwayType import java.util.EnumSet import java.util.concurrent.atomic.AtomicBoolean @@ -42,55 +37,42 @@ class PushRegistrationHandler @Inject constructor( private val preferences: TextSecurePreferences, private val tokenFetcher: TokenFetcher, @param:ApplicationContext private val context: Context, - @param:ManagerScope private val scope: CoroutineScope, @param:PushNotificationModule.PushProcessingSemaphore private val semaphore: Semaphore, private val pushRegistrationDatabase: PushRegistrationDatabase, - private val loginStateRepository: LoginStateRepository, -) : OnAppStartupComponent { +) : AuthAwareComponent { - private var job: Job? = null + private val firstRun = AtomicBoolean(true) - @OptIn(FlowPreview::class) - override fun onPostAppStarted() { - require(job == null) { "Job is already running" } - - - job = scope.launch { - val firstRun = AtomicBoolean(true) - - loginStateRepository - .flowWithLoggedInState { - combine( - configFactory.userConfigsChanged( - onlyConfigTypes = EnumSet.of(UserConfigType.USER_GROUPS), - debounceMills = 500 - ) - .castAwayType() - .onStart { emit(Unit) }, - preferences.pushEnabled, - tokenFetcher.token.filterNotNull().filter { !it.isBlank() } - ) { _, enabled, token -> - if (enabled) { - desiredSubscriptions(loginStateRepository.requireLocalNumber(), token) - } else { - emptyList() - } - } + override suspend fun doWhileLoggedIn(loggedInState: LoggedInState) { + combine( + configFactory.userConfigsChanged( + onlyConfigTypes = EnumSet.of(UserConfigType.USER_GROUPS), + debounceMills = 500 + ) + .castAwayType() + .onStart { emit(Unit) }, + preferences.pushEnabled, + tokenFetcher.token.filterNotNull().filter { !it.isBlank() } + ) { _, enabled, token -> + if (enabled) { + desiredSubscriptions(loggedInState.accountId.hexString, token) + } else { + emptyList() + } + } + .distinctUntilChanged() + .collectLatest { desiredRegistrations -> + val changes = semaphore.withPermit { + pushRegistrationDatabase.ensureRegistrations(desiredRegistrations) } - .distinctUntilChanged() - .collectLatest { desiredRegistrations -> - val changes = semaphore.withPermit { - pushRegistrationDatabase.ensureRegistrations(desiredRegistrations) - } - Log.d(TAG, "Push registration changes: $changes") + Log.d(TAG, "Push registration changes: $changes") - if (firstRun.compareAndSet(true, false) || changes > 0) { - PushRegistrationWorker.enqueue(context, delay = null).await() - } + if (firstRun.compareAndSet(true, false) || changes > 0) { + PushRegistrationWorker.enqueue(context, delay = null).await() } - } + } } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt index da6be333f9..bb0091436b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt @@ -13,24 +13,31 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll 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.database.StorageProtocol import org.session.libsession.database.userAuth -import org.session.libsession.messaging.sending_receiving.notifications.Response +import org.session.libsession.messaging.sending_receiving.notifications.NotificationServer import org.session.libsession.snode.SwarmAuth import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.api.batch.BatchApiExecutor +import org.thoughtcrime.securesms.api.error.UnhandledStatusCodeException +import org.thoughtcrime.securesms.api.server.ServerApiExecutor +import org.thoughtcrime.securesms.api.server.ServerApiRequest +import org.thoughtcrime.securesms.api.server.execute import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.PushRegistrationDatabase -import org.thoughtcrime.securesms.util.getRootCause +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.util.findCause import java.time.Duration import java.time.Instant @@ -41,14 +48,26 @@ import java.time.Instant class PushRegistrationWorker @AssistedInject constructor( @Assisted private val context: Context, @Assisted params: WorkerParameters, - private val registry: PushRegistryV2, private val storage: StorageProtocol, private val pushRegistrationDatabase: PushRegistrationDatabase, private val configFactory: ConfigFactoryProtocol, private val loginStateRepository: LoginStateRepository, @param:PushNotificationModule.PushProcessingSemaphore private val semaphore: Semaphore, + pushApiBatcher: PushApiBatcher, + serverApiExecutor: ServerApiExecutor, + @ManagerScope scope: CoroutineScope, + private val pushRegisterApiFactory: PushRegisterApi.Factory, + private val pushUnregisterApiFactory: PushUnregisterApi.Factory, ) : CoroutineWorker(context, params) { + private val serverApiExecutor: ServerApiExecutor by lazy { + BatchApiExecutor( + actualExecutor = serverApiExecutor, + batcher = pushApiBatcher, + scope = scope, + ) + } + override suspend fun doWork(): Result = semaphore.withPermit { val work = pushRegistrationDatabase.getPendingRegistrationWork( limit = MAX_REGISTRATIONS_PER_RUN @@ -60,55 +79,64 @@ class PushRegistrationWorker @AssistedInject constructor( ) supervisorScope { - val unregisterResults = async { - batchRequest( - items = work.unregister, - buildRequest = { r -> - registry.buildUnregisterRequest( - r.input.pushToken, - swarmAuthForAccount(AccountId(r.accountId)) - ) - }, - sendBatchRequest = registry::unregister - ) - } - - val registerResults = async { - batchRequest( - items = work.register, - buildRequest = { r -> - val accountId = AccountId(r.accountId) - registry.buildRegisterRequest( - token = r.input.pushToken, - swarmAuth = swarmAuthForAccount(accountId), - namespaces = if (accountId.prefix == IdPrefix.GROUP) { - GROUP_PUSH_NAMESPACES - } else { - REGULAR_PUSH_NAMESPACES - } - ) - }, - sendBatchRequest = registry::register - ) - } - + val unregisterJobs = work.unregister + .map { r -> + async { + r to runCatching { + serverApiExecutor.execute( + ServerApiRequest( + serverBaseUrl = NotificationServer.LATEST.url, + serverX25519PubKeyHex = NotificationServer.LATEST.publicKey, + api = pushUnregisterApiFactory.create( + token = r.input.pushToken, + swarmAuth = swarmAuthForAccount(AccountId(r.accountId)), + ) + ) + ) + } + } + } + val registerJobs = work.register + .map { r -> + async { + r to runCatching { + serverApiExecutor.execute( + ServerApiRequest( + serverBaseUrl = NotificationServer.LATEST.url, + serverX25519PubKeyHex = NotificationServer.LATEST.publicKey, + api = pushRegisterApiFactory.create( + token = r.input.pushToken, + swarmAuth = swarmAuthForAccount(AccountId(r.accountId)), + namespaces = if (AccountId(r.accountId).prefix == IdPrefix.GROUP) { + GROUP_PUSH_NAMESPACES + } else { + REGULAR_PUSH_NAMESPACES + } + ) + ) + ) + } + } + } pushRegistrationDatabase.updateRegistrations( - registerResults.await().map { (r, result) -> + registerJobs.awaitAll().map { (r, result) -> PushRegistrationDatabase.RegistrationWithState( accountId = r.accountId, input = r.input, state = when { result.isSuccess -> { PushRegistrationDatabase.RegistrationState.Registered( - due = Instant.now().plus(Duration.ofDays(RE_REGISTER_INTERVAL_DAYS)), + due = Instant.now() + .plus(Duration.ofDays(RE_REGISTER_INTERVAL_DAYS)), ) } result.isFailure -> { val exception = result.exceptionOrNull()!! - if (exception.getRootCause() != null) { + if (exception.findCause() != null || + exception.findCause()?.code == 403) { Log.e(TAG, "Push registration failed permanently", exception) PushRegistrationDatabase.RegistrationState.PermanentError } else { @@ -141,9 +169,12 @@ class PushRegistrationWorker @AssistedInject constructor( } ) - pushRegistrationDatabase.removeRegistrations(unregisterResults.await().map { + pushRegistrationDatabase.removeRegistrations(unregisterJobs.awaitAll().map { if (it.second.isFailure) { - Log.e(TAG, "Push unregistration failed: (${it.second.exceptionOrNull()?.message})") + Log.e( + TAG, + "Push unregistration failed: (${it.second.exceptionOrNull()?.message})" + ) } PushRegistrationDatabase.Registration( @@ -167,51 +198,6 @@ class PushRegistrationWorker @AssistedInject constructor( return Result.success() } - private suspend inline fun batchRequest( - items: List, - buildRequest: (T) -> Req, - sendBatchRequest: suspend (Collection) -> List, - ): List>> { - if (items.isEmpty()) { - return emptyList() - } - - val results = ArrayList>>(items.size) - - val batchRequestItems = mutableListOf() - val batchRequests = mutableListOf() - - for (item in items) { - try { - val request = buildRequest(item) - batchRequestItems += item - batchRequests += request - } catch (ec: Exception) { - results += item to kotlin.Result.failure(NonRetryableException("Failed to build a request", ec)) - } - } - - try { - val responses = sendBatchRequest(batchRequests) - responses.forEachIndexed { idx, response -> - val item = batchRequestItems[idx] - results += item to when { - response.isSuccess() -> kotlin.Result.success(Unit) - response.error == 403 -> kotlin.Result.failure(NonRetryableException("Request failed: code = ${response.error}, message = ${response.message}")) - else -> kotlin.Result.failure(RuntimeException("Request failed: code = ${response.error}, message = ${response.message}")) - } - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - // If the batch API fails, mark all requests in this batch as failed. - batchRequestItems.forEach { item -> - results += item to kotlin.Result.failure(e) - } - } - - return results - } private fun swarmAuthForAccount(accountId: AccountId): SwarmAuth { return when { @@ -271,4 +257,4 @@ class PushRegistrationWorker @AssistedInject constructor( return op } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt deleted file mode 100644 index a83c0b882d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt +++ /dev/null @@ -1,130 +0,0 @@ -package org.thoughtcrime.securesms.notifications - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.decodeFromStream -import kotlinx.serialization.json.encodeToJsonElement -import kotlinx.serialization.json.jsonObject -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import org.session.libsession.messaging.sending_receiving.notifications.Server -import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionRequest -import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionResponse -import org.session.libsession.messaging.sending_receiving.notifications.UnsubscribeResponse -import org.session.libsession.messaging.sending_receiving.notifications.UnsubscriptionRequest -import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.SnodeClock -import org.session.libsession.snode.SwarmAuth -import org.session.libsession.snode.Version -import org.session.libsession.snode.utilities.await -import org.session.libsession.utilities.Device -import org.session.libsignal.utilities.toHexString -import org.thoughtcrime.securesms.auth.LoginStateRepository -import javax.inject.Inject -import javax.inject.Singleton - -typealias SignedSubscriptionRequest = JsonObject -typealias SignedUnsubscriptionRequest = JsonObject - -@Singleton -class PushRegistryV2 @Inject constructor( - private val device: Device, - private val clock: SnodeClock, - private val loginStateRepository: LoginStateRepository, -) { - - suspend fun register( - requests: Collection - ): List { - return getResponseBody( - "subscribe", - Json.encodeToString(requests) - ) - } - - fun buildRegisterRequest( - token: String, - swarmAuth: SwarmAuth, - namespaces: List - ): SignedSubscriptionRequest { - val timestamp = clock.currentTimeMills() / 1000 // get timestamp in ms -> s - val publicKey = swarmAuth.accountId.hexString - val sortedNamespace = namespaces.sorted() - val signed = swarmAuth.sign( - "MONITOR${publicKey}${timestamp}1${sortedNamespace.joinToString(separator = ",")}".encodeToByteArray() - ) - - return SubscriptionRequest( - pubkey = publicKey, - session_ed25519 = swarmAuth.ed25519PublicKeyHex, - namespaces = sortedNamespace, - data = true, // only permit data subscription for now (?) - service = device.service, - sig_ts = timestamp, - service_info = mapOf("token" to token), - enc_key = requireNotNull(loginStateRepository.peekLoginState()) { - "User must be logged in to register for push notifications" - }.notificationKey.data.toHexString(), - ).let(Json::encodeToJsonElement).jsonObject + signed - } - - suspend fun unregister( - requests: Collection - ): List { - return getResponseBody("unsubscribe", Json.encodeToString(requests)) - } - - fun buildUnregisterRequest( - token: String, - swarmAuth: SwarmAuth - ): SignedUnsubscriptionRequest { - val publicKey = swarmAuth.accountId.hexString - val timestamp = clock.currentTimeMills() / 1000 // get timestamp in ms -> s - // if we want to support passing namespace list, here is the place to do it - val signature = swarmAuth.sign( - "UNSUBSCRIBE${publicKey}${timestamp}".encodeToByteArray() - ) - - return UnsubscriptionRequest( - pubkey = publicKey, - session_ed25519 = swarmAuth.ed25519PublicKeyHex, - service = device.service, - sig_ts = timestamp, - service_info = mapOf("token" to token), - ).let(Json::encodeToJsonElement).jsonObject + signature - } - - private operator fun JsonObject.plus(additional: Map): JsonObject { - return JsonObject(buildMap { - putAll(this@plus) - for ((key, value) in additional) { - put(key, JsonPrimitive(value)) - } - }) - } - - @OptIn(ExperimentalSerializationApi::class) - private suspend inline fun getResponseBody(path: String, requestParameters: String): T { - val server = Server.LATEST - val url = "${server.url}/$path" - val body = requestParameters.toRequestBody("application/json".toMediaType()) - val request = Request.Builder().url(url).post(body).build() - val response = OnionRequestAPI.sendOnionRequest( - request = request, - server = server.url, - x25519PublicKey = server.publicKey, - version = Version.V4 - ).await() - - return withContext(Dispatchers.IO) { - requireNotNull(response.body) { "Response doesn't have a body" } - .inputStream() - .use { Json.decodeFromStream(it) } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushUnregisterApi.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushUnregisterApi.kt new file mode 100644 index 0000000000..69bf4ac4ae --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushUnregisterApi.kt @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms.notifications + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject +import org.session.libsession.messaging.sending_receiving.notifications.UnsubscribeResponse +import org.session.libsession.messaging.sending_receiving.notifications.UnsubscriptionRequest +import org.thoughtcrime.securesms.api.server.ServerApiErrorManager +import org.session.libsession.network.SnodeClock +import org.session.libsession.snode.SwarmAuth +import org.session.libsession.utilities.Device +import org.thoughtcrime.securesms.api.server.JsonServerApi + +class PushUnregisterApi @AssistedInject constructor( + @Assisted private val token: String, + @Assisted private val swarmAuth: SwarmAuth, + private val clock: SnodeClock, + private val device: Device, + json: Json, + errorManager: ServerApiErrorManager +) : JsonServerApi(json, errorManager) { + override val httpMethod: String get() = "POST" + override val httpEndpoint: String get() = "unsubscribe" + override val responseSerializer: DeserializationStrategy + get() = UnsubscribeResponse.serializer() + + override fun buildJsonPayload(): JsonElement { + val timestamp = clock.currentTimeSeconds() + val publicKey = swarmAuth.accountId.hexString + val signed = JsonObject(swarmAuth.sign( + "UNSUBSCRIBE${publicKey}${timestamp}".encodeToByteArray() + ).mapValues { JsonPrimitive(it.value) }) + + return JsonObject(UnsubscriptionRequest( + pubkey = publicKey, + session_ed25519 = swarmAuth.ed25519PublicKeyHex, + service = device.service, + sig_ts = timestamp, + service_info = mapOf("token" to token), + ).let(Json::encodeToJsonElement).jsonObject + signed) + } + + @AssistedFactory + interface Factory { + fun create( + token: String, + swarmAuth: SwarmAuth, + ): PushUnregisterApi + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.kt index f9e18340ea..27ff5975d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.kt @@ -28,9 +28,8 @@ import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.MmsDatabase @@ -40,6 +39,7 @@ import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.mms.MmsException +import org.thoughtcrime.securesms.pro.ProStatusManager import javax.inject.Inject /** @@ -74,6 +74,10 @@ class RemoteReplyReceiver : BroadcastReceiver() { @Inject lateinit var messageSender: MessageSender + + @Inject + lateinit var proStatusManager: ProStatusManager + @SuppressLint("StaticFieldLeak") override fun onReceive(context: Context, intent: Intent) { if (REPLY_ACTION != intent.getAction()) return @@ -94,8 +98,9 @@ class RemoteReplyReceiver : BroadcastReceiver() { override fun doInBackground(vararg params: Void?): Void? { val threadId = threadDatabase.getOrCreateThreadIdFor(address) val message = VisibleMessage() - message.sentTimestamp = clock.currentTimeMills() + message.sentTimestamp = clock.currentTimeMillis() message.text = responseText.toString() + proStatusManager.addProFeatures(message) val expiryMode = recipientRepository.getRecipientSync(address).expiryMode val expiresInMillis = expiryMode.expiryMillis diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingViewModel.kt index cc373aaf1c..82cb14bb32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingViewModel.kt @@ -25,6 +25,7 @@ import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.userConfigsChanged +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.util.castAwayType diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt index 67b75450f1..107c7f0bc2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import network.loki.messenger.libsession_util.PRIORITY_HIDDEN import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.withMutableUserConfigs import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.auth.LoggedInState diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameViewModel.kt index 2d8274f511..52f62f2b24 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameViewModel.kt @@ -18,6 +18,7 @@ import network.loki.messenger.R import org.session.libsession.messaging.messages.ProfileUpdateHandler import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.withMutableUserConfigs @HiltViewModel(assistedFactory = PickDisplayNameViewModel.Factory::class) class PickDisplayNameViewModel @AssistedInject constructor( diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsScreen.kt index d3b704a1c8..2f9a94bcd5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsScreen.kt @@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.SearchBar import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.OutlineButton +import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors @@ -83,7 +84,7 @@ fun BlockedContactsScreen( @Composable fun BlockedContacts( contacts: List, - hasContacts: Boolean, + hasContacts: Boolean?, onContactItemClicked: (address: Address) -> Unit, searchQuery: String, onSearchQueryChanged: (String) -> Unit, @@ -122,15 +123,21 @@ fun BlockedContacts( Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) BottomFadingEdgeBox(modifier = Modifier.weight(1f)) { bottomContentPadding -> - if(!hasContacts){ - Text( + when(hasContacts) { + // null means we don't yet have a result, so show a loader + null -> SmallCircularProgressIndicator( + modifier = Modifier.padding(top = LocalDimensions.current.spacing) + .align(Alignment.TopCenter), + ) + + false -> Text( text = stringResource(id = R.string.blockBlockedNone), modifier = Modifier.padding(top = LocalDimensions.current.spacing) .align(Alignment.TopCenter), style = LocalType.current.base.copy(color = LocalColors.current.textSecondary) ) - } else { - LazyColumn( + + true -> LazyColumn( state = scrollState, contentPadding = PaddingValues(bottom = bottomContentPadding), ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt index 61f3af1fd1..2704579131 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.preferences import android.annotation.SuppressLint import android.app.Activity +import android.content.ActivityNotFoundException import android.content.Intent import android.media.RingtoneManager import android.net.Uri @@ -9,16 +10,40 @@ import android.os.AsyncTask import android.os.Bundle import android.provider.Settings import android.text.TextUtils -import androidx.preference.ListPreference +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.preference.Preference +import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import network.loki.messenger.BuildConfig import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.components.SwitchPreferenceCompat import org.thoughtcrime.securesms.notifications.NotificationChannels import org.thoughtcrime.securesms.preferences.widgets.DropDownPreference +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.DialogButtonData +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.isWhitelistedFromDoze +import org.thoughtcrime.securesms.ui.requestDozeWhitelist +import org.thoughtcrime.securesms.ui.setThemedContent +import org.thoughtcrime.securesms.ui.theme.LocalColors import java.util.Arrays import javax.inject.Inject @@ -27,8 +52,131 @@ class NotificationsPreferenceFragment : CorrectedPreferenceFragment() { @Inject lateinit var prefs: TextSecurePreferences + private var showWhitelistEnableDialog by mutableStateOf(false) + private var showWhitelistDisableDialog by mutableStateOf(false) + + private var whiteListControl: SwitchPreferenceCompat? = null + + private var composeView: ComposeView? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + // We will wrap the existing screen in a framelayout in order to add custom compose content + val preferenceView = super.onCreateView(inflater, container, savedInstanceState) + + val wrapper = FrameLayout(requireContext()).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + + wrapper.addView( + preferenceView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + ) + + composeView = ComposeView(requireContext()) + wrapper.addView( + composeView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.TOP + ) + ) + + return wrapper + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + //set up compose content + composeView?.apply { + setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed + ) + setThemedContent { + if(showWhitelistEnableDialog) { + AlertDialog( + onDismissRequest = { + // hide dialog + showWhitelistEnableDialog = false + }, + title = Phrase.from(context, R.string.runSessionBackground) + .put(APP_NAME_KEY, getString(R.string.app_name)) + .format().toString(), + text = Phrase.from(context, R.string.runSessionBackgroundDescription) + .put(APP_NAME_KEY, getString(R.string.app_name)) + .format().toString(), + buttons = listOf( + DialogButtonData( + text = GetString(getString(R.string.allow)), + qaTag = getString(R.string.qa_conversation_settings_dialog_whitelist_confirm), + onClick = { + openSystemBgWhitelist() + } + ), + DialogButtonData( + text = GetString(getString(R.string.cancel)), + qaTag = getString(R.string.qa_conversation_settings_dialog_whitelist_cancel), + ), + ) + ) + } + + if(showWhitelistDisableDialog) { + AlertDialog( + onDismissRequest = { + // hide dialog + showWhitelistDisableDialog = false + }, + title = stringResource(R.string.limitBackgroundActivity), + text = Phrase.from(context, R.string.limitBackgroundActivityDescription) + .put(APP_NAME_KEY, getString(R.string.app_name)) + .format().toString(), + buttons = listOf( + DialogButtonData( + text = GetString("Change Setting"), + qaTag = getString(R.string.qa_conversation_settings_dialog_whitelist_confirm), + color = LocalColors.current.danger, + onClick = { + // we can't disable it ourselves, but we can take the user to the right settings instead + openBatteryOptimizationSettings() + } + ), + DialogButtonData( + text = GetString(getString(R.string.cancel)), + qaTag = getString(R.string.qa_conversation_settings_dialog_whitelist_cancel), + ), + ) + ) + } + } + } + } + override fun onCreate(paramBundle: Bundle?) { super.onCreate(paramBundle) + // whitelist control + whiteListControl = findPreference("whitelist_background")!! + whiteListControl?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + // if already whitelisted, show toast + if(requireContext().isWhitelistedFromDoze()){ + showWhitelistDisableDialog = true + } else { + openSystemBgWhitelist() + } + true + } // Set up FCM toggle val fcmKey = "pref_key_use_fcm" @@ -36,6 +184,11 @@ class NotificationsPreferenceFragment : CorrectedPreferenceFragment() { fcmPreference.isChecked = prefs.pushEnabled.value fcmPreference.setOnPreferenceChangeListener { _: Preference, newValue: Any -> prefs.setPushEnabled(newValue as Boolean) + // open whitelist dialog when setting to slow mode if first time + if(!newValue && !prefs.hasCheckedDozeWhitelist()){ + showWhitelistEnableDialog = true + prefs.setHasCheckedDozeWhitelist(true) + } true } @@ -86,7 +239,7 @@ class NotificationsPreferenceFragment : CorrectedPreferenceFragment() { true } - findPreference(TextSecurePreferences.NOTIFICATION_PRIORITY_PREF)!!.onPreferenceClickListener = + findPreference("system_notifications")!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) intent.putExtra( @@ -101,6 +254,33 @@ class NotificationsPreferenceFragment : CorrectedPreferenceFragment() { initializeMessageVibrateSummary(findPreference(TextSecurePreferences.VIBRATE_PREF) as SwitchPreferenceCompat?) } + override fun onResume() { + super.onResume() + + whiteListControl?.isChecked = requireContext().isWhitelistedFromDoze() + } + + // Opens the system Battery Optimization settings + private fun openBatteryOptimizationSettings() { + try { + val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply { + data = Uri.parse("package:${requireContext().packageName}") + } + + startActivity(intent) + } catch (e: ActivityNotFoundException) { + // Fallback: open the generic Battery Optimization settings screen + val fallbackIntent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(fallbackIntent) + } + } + + private fun openSystemBgWhitelist(){ + requireActivity().requestDozeWhitelist() + } + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.preferences_notifications) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt index 37cd8c84a7..9b29e081f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt @@ -12,22 +12,21 @@ import androidx.preference.PreferenceDataStore import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import javax.inject.Inject import network.loki.messenger.BuildConfig import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences.Companion.isPasswordDisabled import org.session.libsession.utilities.TextSecurePreferences.Companion.setScreenLockEnabled +import org.session.libsession.utilities.withMutableUserConfigs import org.thoughtcrime.securesms.components.SwitchPreferenceCompat import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.service.KeyCachingService import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository -import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.Companion.areNotificationsEnabled import org.thoughtcrime.securesms.util.IntentUtils -import java.time.Instant -import java.time.ZonedDateTime +import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.Companion.areNotificationsEnabled +import javax.inject.Inject @AndroidEntryPoint class PrivacySettingsPreferenceFragment : CorrectedPreferenceFragment() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index eaa28dd05d..661c2672bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -53,8 +53,7 @@ class SettingsActivity : FullComposeScreenLockActivity() { if(viewModel.isAnimated(uri)){ // no cropping for animated images viewModel.onAvatarPicked(uri) } else { - val outputFile = Uri.fromFile(File(cacheDir, "cropped")) - cropImage(it, outputFile) + cropImage(it) } } @@ -65,13 +64,12 @@ class SettingsActivity : FullComposeScreenLockActivity() { if (success) { viewModel.hideAvatarPickerOptions() // close the bottom sheet - val outputFile = Uri.fromFile(File(cacheDir, "cropped")) - val inputFile = viewModel.getTempFile()?.let(Uri::fromFile) + val inputFile = viewModel.getTempFile()?.let { FileProviderUtil.getUriFor(this, it) } if (inputFile == null) { Toast.makeText(this, R.string.errorUnknown, Toast.LENGTH_SHORT).show() return@registerForActivityResult } - cropImage(inputFile, outputFile) + cropImage(inputFile) } else { Toast.makeText(this, R.string.errorUnknown, Toast.LENGTH_SHORT).show() } @@ -130,12 +128,13 @@ class SettingsActivity : FullComposeScreenLockActivity() { .execute() } - private fun cropImage(inputFile: Uri, outputFile: Uri){ + private fun cropImage(inputFile: Uri){ lifecycleScope.launch { try { - val inputType = withContext(Dispatchers.Default) { - contentResolver.getType(inputFile) - } + val file = File(cacheDir, "cropped.webp") + if(file.exists()) file.delete() + + val outputFile = FileProviderUtil.getUriFor(this@SettingsActivity, file) onAvatarCropped.launch( CropImageContractOptions( @@ -158,11 +157,7 @@ class SettingsActivity : FullComposeScreenLockActivity() { activityMenuIconColor = txtColor, activityMenuTextColor = txtColor, activityTitle = activityTitle, - outputCompressFormat = when { - inputType?.startsWith("image/png") == true -> Bitmap.CompressFormat.PNG - inputType?.startsWith("image/webp") == true -> Bitmap.CompressFormat.WEBP - else -> Bitmap.CompressFormat.JPEG - } + outputCompressFormat = Bitmap.CompressFormat.WEBP ) ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt index 06a27e8455..acadb2c13f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt @@ -65,6 +65,7 @@ import com.bumptech.glide.integration.compose.GlideImage import com.squareup.phrase.Phrase import network.loki.messenger.BuildConfig import network.loki.messenger.R +import org.session.libsession.network.model.PathStatus import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.NonTranslatableStringConstants.NETWORK_NAME import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY @@ -75,7 +76,26 @@ import org.thoughtcrime.securesms.home.PathActivity import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.TempAvatar import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.UserAvatar -import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.* +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.ClearData +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.HideAnimatedProCTA +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.HideAvatarPickerOptions +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.HideClearDataDialog +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.HideSimpleDialog +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.HideUrlDialog +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.HideUsernameDialog +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.OnAvatarDialogDismissed +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.OnDonateClicked +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.OnLinkCopied +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.OnLinkOpened +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.RemoveAvatar +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.SaveAvatar +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.SetUsername +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.ShowAnimatedProCTA +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.ShowAvatarDialog +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.ShowClearDataDialog +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.ShowUrlDialog +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.ShowUsernameDialog +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.UpdateUsername import org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsActivity import org.thoughtcrime.securesms.pro.ProDataState @@ -125,7 +145,6 @@ import org.thoughtcrime.securesms.ui.theme.dangerButtonColors import org.thoughtcrime.securesms.ui.theme.monospace import org.thoughtcrime.securesms.ui.theme.primaryBlue import org.thoughtcrime.securesms.ui.theme.primaryGreen -import org.thoughtcrime.securesms.ui.theme.primaryYellow import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUIElement import org.thoughtcrime.securesms.util.State @@ -297,7 +316,7 @@ fun Settings( // Buttons Buttons( recoveryHidden = uiState.recoveryHidden, - hasPaths = uiState.hasPath, + pathStatus = uiState.pathStatus, postPro = uiState.isPostPro, proDataState = uiState.proDataState, sendCommand = sendCommand @@ -389,11 +408,52 @@ fun Settings( } // Animated avatar CTA - if(uiState.showAnimatedProCTA){ - AnimatedProCTA( - proSubscription = uiState.proDataState.type, - sendCommand = sendCommand - ) + when(uiState.avatarCTAState){ + is SettingsViewModel.AvatarCTAState.Pro -> { + SessionProCTA ( + title = stringResource(R.string.proActivated), + badgeAtStart = true, + textContent = { + ProBadgeText( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = stringResource(R.string.proAlreadyPurchased), + textStyle = LocalType.current.base.copy(color = LocalColors.current.textSecondary) + ) + + Spacer(Modifier.height(2.dp)) + + // main message + Text( + modifier = Modifier + .qaTag(R.string.qa_cta_body) + .align(Alignment.CenterHorizontally), + text = stringResource(R.string.proAnimatedDisplayPicture), + textAlign = TextAlign.Center, + style = LocalType.current.base.copy( + color = LocalColors.current.textSecondary + ) + ) + }, + content = { + CTAAnimatedImages( + heroImageBg = R.drawable.cta_hero_animated_bg, + heroImageAnimatedFg = R.drawable.cta_hero_animated_fg, + ) + }, + positiveButtonText = null, + negativeButtonText = stringResource(R.string.close), + onCancel = { sendCommand(HideAnimatedProCTA) } + ) + } + + is SettingsViewModel.AvatarCTAState.NonPro -> { + AnimatedProfilePicProCTA( + expired = uiState.avatarCTAState.expired, + onDismissRequest = { sendCommand(HideAnimatedProCTA) }, + ) + } + + else -> {} } // donate confirmation @@ -453,6 +513,7 @@ fun Settings( DialogButtonData( text = GetString(stringResource(id = R.string.save)), enabled = uiState.usernameDialog.setEnabled, + dismissOnClick = false, onClick = { sendCommand(SetUsername) }, qaTag = stringResource(R.string.qa_settings_dialog_username_save), ), @@ -478,7 +539,7 @@ fun Settings( @Composable fun Buttons( recoveryHidden: Boolean, - hasPaths: Boolean, + pathStatus: PathStatus, postPro: Boolean, proDataState: ProDataState, sendCommand: (SettingsViewModel.Commands) -> Unit, @@ -589,7 +650,11 @@ fun Buttons( } Divider() - Crossfade(if (hasPaths) primaryGreen else primaryYellow, label = "path") { + Crossfade(when (pathStatus){ + PathStatus.BUILDING -> LocalColors.current.warning + PathStatus.ERROR -> LocalColors.current.danger + else -> primaryGreen + }, label = "path") { ItemButton( modifier = Modifier.qaTag(R.string.qa_settings_item_path), text = annotatedStringResource(R.string.onionRoutingPath), @@ -990,54 +1055,6 @@ fun AvatarDialog( ) } -@Composable -fun AnimatedProCTA( - proSubscription: ProStatus, - sendCommand: (SettingsViewModel.Commands) -> Unit, -){ - if(proSubscription is ProStatus.Active) { - SessionProCTA ( - title = stringResource(R.string.proActivated), - badgeAtStart = true, - textContent = { - ProBadgeText( - modifier = Modifier.align(Alignment.CenterHorizontally), - text = stringResource(R.string.proAlreadyPurchased), - textStyle = LocalType.current.base.copy(color = LocalColors.current.textSecondary) - ) - - Spacer(Modifier.height(2.dp)) - - // main message - Text( - modifier = Modifier - .qaTag(R.string.qa_cta_body) - .align(Alignment.CenterHorizontally), - text = stringResource(R.string.proAnimatedDisplayPicture), - textAlign = TextAlign.Center, - style = LocalType.current.base.copy( - color = LocalColors.current.textSecondary - ) - ) - }, - content = { - CTAAnimatedImages( - heroImageBg = R.drawable.cta_hero_animated_bg, - heroImageAnimatedFg = R.drawable.cta_hero_animated_fg, - ) - }, - positiveButtonText = null, - negativeButtonText = stringResource(R.string.close), - onCancel = { sendCommand(HideAnimatedProCTA) } - ) - } else { - AnimatedProfilePicProCTA( - proSubscription = proSubscription, - onDismissRequest = { sendCommand(HideAnimatedProCTA) }, - ) - } -} - @OptIn(ExperimentalSharedTransitionApi::class) @SuppressLint("UnusedContentLambdaTargetStateParameter") @Preview @@ -1053,7 +1070,7 @@ private fun SettingsScreenPreview() { showAvatarDialog = false, showAvatarPickerOptionCamera = false, showAvatarPickerOptions = false, - showAnimatedProCTA = false, + avatarCTAState = SettingsViewModel.AvatarCTAState.Hidden, avatarData = AvatarUIData( listOf( AvatarUIElement( @@ -1070,7 +1087,7 @@ private fun SettingsScreenPreview() { ), username = "Atreyu", accountID = "053d30141d0d35d9c4b30a8f8880f8464e221ee71a8aff9f0dcefb1e60145cea5144", - hasPath = true, + pathStatus = PathStatus.READY, version = "1.26.0", ), sendCommand = {}, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt index f9fb3fad79..3f49c0801f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt @@ -4,7 +4,6 @@ import android.content.Context import android.net.Uri import android.provider.OpenableColumns import android.widget.Toast -import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.canhub.cropper.CropImage @@ -34,17 +33,25 @@ import okio.buffer import okio.source import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth -import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.utilities.await +import org.session.libsession.messaging.open_groups.api.CommunityApiExecutor +import org.session.libsession.messaging.open_groups.api.CommunityApiRequest +import org.session.libsession.messaging.open_groups.api.DeleteAllInboxMessagesApi +import org.session.libsession.messaging.open_groups.api.execute +import org.session.libsession.network.model.PathStatus +import org.session.libsession.network.onion.PathManager import org.session.libsession.utilities.StringSubstitutionConstants.VERSION_KEY import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.displayName +import org.session.libsession.utilities.withMutableUserConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.ExternalStorageUtil.getImageDir import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.NoExternalStorageException +import org.thoughtcrime.securesms.api.snode.DeleteAllMessageApi +import org.thoughtcrime.securesms.api.swarm.SwarmApiExecutor +import org.thoughtcrime.securesms.api.swarm.SwarmApiRequest +import org.thoughtcrime.securesms.api.swarm.execute import org.thoughtcrime.securesms.attachments.AttachmentProcessor import org.thoughtcrime.securesms.attachments.AvatarUploadManager import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.textSizeInBytes @@ -53,6 +60,7 @@ import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.pro.ProDataState import org.thoughtcrime.securesms.pro.ProDetailsRepository +import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.getDefaultSubscriptionStateData import org.thoughtcrime.securesms.reviews.InAppReviewManager @@ -68,6 +76,7 @@ import org.thoughtcrime.securesms.util.mapToStateFlow import java.io.File import java.io.IOException import javax.inject.Inject +import javax.inject.Provider @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel @@ -86,6 +95,11 @@ class SettingsViewModel @Inject constructor( private val attachmentProcessor: AttachmentProcessor, private val proDetailsRepository: ProDetailsRepository, private val donationManager: DonationManager, + private val pathManager: PathManager, + private val swarmApiExecutor: SwarmApiExecutor, + private val deleteAllMessageApiFactory: DeleteAllMessageApi.Factory, + private val communityApiExecutor: CommunityApiExecutor, + private val deleteAllInboxMessagesApi: Provider, ) : ViewModel() { private val TAG = "SettingsViewModel" @@ -97,7 +111,7 @@ class SettingsViewModel @Inject constructor( private val _uiState = MutableStateFlow(UIState( username = "", accountID = selfRecipient.value.address.address, - hasPath = true, + pathStatus = PathStatus.BUILDING, version = getVersionNumber(), recoveryHidden = prefs.getHidePassword(), isPostPro = proStatusManager.isPostPro(), @@ -144,8 +158,8 @@ class SettingsViewModel @Inject constructor( } viewModelScope.launch { - OnionRequestAPI.hasPath.collect { data -> - _uiState.update { it.copy(hasPath = data) } + pathManager.status.collect { status -> + _uiState.update { it.copy(pathStatus = status) } } } @@ -190,7 +204,9 @@ class SettingsViewModel @Inject constructor( fun onAvatarPicked(result: CropImageView.CropResult) { when { result.isSuccessful -> { - onAvatarPicked("file://${result.getUriFilePath(context)!!}".toUri()) + result.uriContent?.let { uri -> + onAvatarPicked(uri) + } ?: Log.e(TAG, "Cropping successful but uriContent was null") } result is CropImage.CancelledResult -> { @@ -305,6 +321,7 @@ class SettingsViewModel @Inject constructor( return } + // close avatar dialog when removing picture onAvatarDialogDismissed() // otherwise this action is for removing the existing avatar @@ -333,6 +350,7 @@ class SettingsViewModel @Inject constructor( if (profilePicture.isEmpty()) { configFactory.withMutableUserConfigs { it.userProfile.setPic(UserPic.DEFAULT) + it.userProfile.setAnimatedAvatar(false) } // update dialog state @@ -370,11 +388,21 @@ class SettingsViewModel @Inject constructor( && AnimatedImageUtils.isAnimated(rawImageData) private fun showAnimatedProCTA() { - _uiState.update { it.copy(showAnimatedProCTA = true) } + // show the right CTA based on pro state + _uiState.update { + it.copy( + avatarCTAState = + if(it.proDataState.type is ProStatus.Active) AvatarCTAState.Pro + else AvatarCTAState.NonPro( + expired = it.proDataState.type is ProStatus.Expired + )) + } } private fun hideAnimatedProCTA() { - _uiState.update { it.copy(showAnimatedProCTA = false) } + _uiState.update { it.copy( + avatarCTAState = AvatarCTAState.Hidden + ) } } fun showAvatarDialog() { @@ -457,13 +485,26 @@ class SettingsViewModel @Inject constructor( coroutineScope { allCommunityServers.map { server -> launch { - runCatching { OpenGroupApi.deleteAllInboxMessages(server) } - .onFailure { Log.e(TAG, "Error deleting messages for $server", it) } + runCatching { + communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = server, + api = deleteAllInboxMessagesApi.get() + ) + ) + }.onFailure { Log.e(TAG, "Error deleting messages for $server", it) } } }.joinAll() } - SnodeAPI.deleteAllMessages(checkNotNull(storage.userAuth)).await() + val userAuth = checkNotNull(storage.userAuth) + + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = userAuth.accountId.hexString, + api = deleteAllMessageApiFactory.create(userAuth) + ) + ) } catch (e: Exception) { Log.e(TAG, "Failed to delete network messages - offering user option to delete local data only.", e) null @@ -656,7 +697,7 @@ class SettingsViewModel @Inject constructor( data class UIState( val username: String, val accountID: String, - val hasPath: Boolean, + val pathStatus: PathStatus, val version: CharSequence = "", val showLoader: Boolean = false, val avatarDialogState: AvatarDialogState = AvatarDialogState.NoAvatar, @@ -667,13 +708,19 @@ class SettingsViewModel @Inject constructor( val showAvatarDialog: Boolean = false, val showAvatarPickerOptionCamera: Boolean = false, val showAvatarPickerOptions: Boolean = false, - val showAnimatedProCTA: Boolean = false, + val avatarCTAState: AvatarCTAState = AvatarCTAState.Hidden, val usernameDialog: UsernameDialogData? = null, val showSimpleDialog: SimpleDialogData? = null, val isPostPro: Boolean, val proDataState: ProDataState, ) + sealed interface AvatarCTAState { + data object Hidden : AvatarCTAState + data object Pro : AvatarCTAState + data class NonPro(val expired: Boolean) : AvatarCTAState + } + sealed interface Commands { data object ShowClearDataDialog: Commands data object HideClearDataDialog: Commands diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt index 2e50075736..12c6dd8e22 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign @@ -50,6 +51,7 @@ import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.DangerFillButtonRect import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.components.inlineContentMap +import org.thoughtcrime.securesms.ui.sessionDropShadow import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -309,9 +311,19 @@ fun NonOriginatingLinkCell( ) { // icon Box(modifier = Modifier - .background( - color = LocalColors.current.accent.copy(alpha = 0.2f), - shape = MaterialTheme.shapes.small + .then( + if (LocalColors.current.isLight) + Modifier.sessionDropShadow() + else Modifier + ) + .clip(MaterialTheme.shapes.small) + .background(color = LocalColors.current.backgroundSecondary) + .then( + if (!LocalColors.current.isLight) + Modifier.background( + color = LocalColors.current.accent.copy(alpha = 0.2f), + ) + else Modifier ) .padding(10.dp) ){ @@ -319,7 +331,7 @@ fun NonOriginatingLinkCell( modifier = Modifier.align(Center) .size(LocalDimensions.current.iconMedium), painter = painterResource(id = data.iconRes), - tint = LocalColors.current.accent, + tint = LocalColors.current.accentText, contentDescription = null ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt index 0cee7d8c4d..9bcdf61581 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt @@ -52,7 +52,10 @@ fun CancelPlanNonOriginating( .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .put(PLATFORM_ACCOUNT_KEY, providerData.platformAccount) .format(), - linkCellsInfo = stringResource(R.string.proCancellationOptions), + linkCellsInfo = + Phrase.from(context.getText(R.string.proCancellationOptions)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), linkCells = listOf( NonOriginatingLinkCellData( title = Phrase.from(context.getText(R.string.onDevice)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt index 924c9059e9..30ce6e5056 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt @@ -102,7 +102,7 @@ fun CancelPlan( BaseCellButtonProSettingsScreen( disabled = true, onBack = onBack, - buttonText = Phrase.from(context.getText(R.string.cancelProPlan)) + buttonText = Phrase.from(context.getText(R.string.cancelAccess)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString(), dangerButton = true, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt index c8d596e956..b96bf97e43 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt @@ -61,9 +61,12 @@ fun PlanConfirmationScreen( onBack: () -> Unit, ) { val proData by viewModel.proSettingsUIState.collectAsState() + val previousState by viewModel.choosePlanState.collectAsState() PlanConfirmation( proData = proData, + previousProState = (previousState as? State.Success) + ?.value?.proStatus ?: ProStatus.NeverSubscribed, sendCommand = viewModel::onCommand, onBack = onBack, ) @@ -73,6 +76,7 @@ fun PlanConfirmationScreen( @Composable fun PlanConfirmation( proData: ProSettingsViewModel.ProSettingsState, + previousProState: ProStatus, sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ) { @@ -113,7 +117,7 @@ fun PlanConfirmation( Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) - val description = when (proData.proDataState.type) { + val description = when (previousProState) { is ProStatus.Active -> { Phrase.from(context.getText(R.string.proAllSetDescription)) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) @@ -148,7 +152,7 @@ fun PlanConfirmation( Spacer(Modifier.height(LocalDimensions.current.spacing)) - val buttonLabel = when (proData.proDataState.type) { + val buttonLabel = when (previousProState) { is ProStatus.Active -> stringResource(R.string.theReturn) else -> { @@ -189,6 +193,7 @@ private fun PreviewPlanConfirmationActive( showProBadge = false, ), ), + previousProState = previewAutoRenewingApple, sendCommand = {}, onBack = {}, ) @@ -209,6 +214,7 @@ private fun PreviewPlanConfirmationExpired( showProBadge = true, ), ), + previousProState = previewExpiredApple, sendCommand = {}, onBack = {}, ) @@ -229,6 +235,7 @@ private fun PreviewPlanConfirmationNeverSub( showProBadge = true, ), ), + previousProState = ProStatus.NeverSubscribed, sendCommand = {}, onBack = {}, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsDialogs.kt index ef4458ad16..9e0279a2ac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsDialogs.kt @@ -55,6 +55,8 @@ fun ProSettingsDialogs( buttons.add( DialogButtonData( text = GetString(dialogsState.showSimpleDialog.negativeText), + color = if (dialogsState.showSimpleDialog.negativeStyleDanger) LocalColors.current.danger + else LocalColors.current.text, qaTag = dialogsState.showSimpleDialog.negativeQaTag, onClick = dialogsState.showSimpleDialog.onNegative ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index bdea2b9621..93ac299622 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -276,6 +276,7 @@ fun ProSettingsHome( proStatus = data.proDataState.type, subscriptionRefreshState = data.proDataState.refreshState, inSheet = inSheet, + inGracePeriod = data.inGracePeriod, expiry = data.subscriptionExpiryLabel, sendCommand = sendCommand, ) @@ -524,6 +525,7 @@ fun ProSettings( subscriptionRefreshState: State, inSheet: Boolean, expiry: CharSequence, + inGracePeriod: Boolean, sendCommand: (ProSettingsViewModel.Commands) -> Unit, ){ CategoryCell( @@ -572,7 +574,8 @@ fun ProSettings( .put(PLATFORM_KEY, proStatus.providerData.platform) .format().toString() else expiry, - LocalColors.current.text, + if(inGracePeriod) LocalColors.current.warning + else LocalColors.current.text, if(refunding){{ Icon( modifier = Modifier.align(Alignment.Center) @@ -804,6 +807,7 @@ fun ProManage( title = Phrase.from(LocalContext.current, R.string.managePro) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString(), + dropShadow = LocalColors.current.isLight && data is ProStatus.Expired ) { // Cell content Column( @@ -832,7 +836,7 @@ fun ProManage( icon = R.drawable.ic_refresh_cw, qaTag = R.string.qa_pro_settings_action_recover_plan, onClick = { - sendCommand(RefeshProDetails) + sendCommand(RecoverAccount) } ) } @@ -896,7 +900,7 @@ fun ProManage( is State.Success<*> -> Triple Unit>( null, - LocalColors.current.text, renewIcon(LocalColors.current.accent) + LocalColors.current.text, renewIcon(LocalColors.current.accentText) ) } @@ -906,7 +910,7 @@ fun ProManage( .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() ), - titleColor = if(subscriptionRefreshState is State.Success ) LocalColors.current.accent + titleColor = if(subscriptionRefreshState is State.Success ) LocalColors.current.accentText else LocalColors.current.text, subtitle = if(subtitle == null) null else annotatedStringResource(subtitle), subtitleColor = subColor, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt index 33fe94fe89..85c4f8ee2d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt @@ -98,7 +98,7 @@ fun ProSettingsNavHost( // handle the custom case of dealing with the post "choose plan confirmation"screen ProNavHostCustomActions.ON_POST_PLAN_CONFIRMATION, ProNavHostCustomActions.ON_POST_CANCELLATION -> { - // we get here where we either hit back or hit the "ok" button on the plan confirmation screen + // we get here when we either hit back or hit the "ok" button on the plan confirmation screen // if we are in a sheet we need to close it if (inSheet) { onBack() diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 4a6451be95..62fbf7e31b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -22,11 +22,13 @@ import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.database.StorageProtocol +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.StringSubstitutionConstants.ACTION_TYPE_KEY @@ -36,14 +38,18 @@ import org.session.libsession.utilities.StringSubstitutionConstants.CURRENT_PLAN import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.MONTHLY_PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PERCENT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACCOUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_LENGTH_KEY import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_LENGTH_SINGULAR_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.withMutableUserConfigs import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.debugmenu.DebugLogGroup +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.ProDataState import org.thoughtcrime.securesms.pro.ProDetailsRepository @@ -54,13 +60,15 @@ import org.thoughtcrime.securesms.pro.isFromAnotherPlatform import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration import org.thoughtcrime.securesms.pro.subscription.SubscriptionCoordinator import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager -import org.thoughtcrime.securesms.pro.subscription.expiryFromNow import org.thoughtcrime.securesms.ui.SimpleDialogData import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.util.CurrencyFormatter import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.State import java.math.BigDecimal +import java.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @@ -75,6 +83,7 @@ class ProSettingsViewModel @AssistedInject constructor( private val proDetailsRepository: ProDetailsRepository, private val configFactory: Lazy, private val storage: StorageProtocol, + private val clock: SnodeClock, ) : ViewModel() { @AssistedFactory @@ -97,12 +106,14 @@ class ProSettingsViewModel @AssistedInject constructor( private val _cancelPlanState: MutableStateFlow> = MutableStateFlow(State.Loading) val cancelPlanState: StateFlow> = _cancelPlanState + private var recovering: Boolean = false + init { // observe subscription status viewModelScope.launch { - proStatusManager.proDataState.collect { - generateState(it) - } + proStatusManager + .proDataState + .collectLatest(::generateState) } // observe purchase events @@ -187,35 +198,123 @@ class ProSettingsViewModel @AssistedInject constructor( } } - private fun generateState(proDataState: ProDataState){ + private suspend fun generateState(proDataState: ProDataState){ val subType = proDataState.type // calculate stats for pro users - if(subType is ProStatus.Active) refreshProStats() - - _proSettingsUIState.update { - it.copy( - proDataState = proDataState, - subscriptionExpiryLabel = when(subType){ - is ProStatus.Active.AutoRenewing -> - Phrase.from(context, R.string.proAutoRenewTime) + if (subType is ProStatus.Active) refreshProStats() + + // we got a new state - if we were recovering, we can mark it as done + if(proDataState.refreshState is State.Success && recovering){ + // we are back with a state after attempting to recover + // show a confirmation dialog whose text depends on the current pro status + // if we are now pro after recovery: + if(proDataState.type is ProStatus.Active){ + _dialogState.update { + it.copy( + showSimpleDialog = SimpleDialogData( + title = Phrase.from(context, R.string.proAccessRestored) .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(TIME_KEY, dateUtils.getExpiryString(subType.validUntil)) - .format() + .format().toString(), + message = Phrase.from(context, R.string.proAccessRestoredDescription) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format(), + positiveText = context.getString(R.string.okay), + positiveStyleDanger = false, + ) + ) + } + } else { + _dialogState.update { + it.copy( + showSimpleDialog = SimpleDialogData( + title = Phrase.from(context, R.string.proAccessNotFound) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), + message = Phrase.from(context, R.string.proAccessNotFoundDescription) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format(), + positiveText = context.getString(R.string.helpSupport), + negativeText = context.getString(R.string.close), + positiveStyleDanger = false, + negativeStyleDanger = true, + onPositive = { onCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) }, + ) + ) + } + } + } - is ProStatus.Active.Expiring -> - Phrase.from(context, R.string.proExpiringTime) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(TIME_KEY, dateUtils.getExpiryString(subType.validUntil)) - .format() - - else -> "" - }, - subscriptionExpiryDate = when(subType){ - is ProStatus.Active -> subType.duration.expiryFromNow() - else -> "" - }, - ) + // clear recovery on non loads + if(proDataState.refreshState !is State.Loading){ + recovering = false + } + + while (true) { + val now = clock.currentTime() + + _proSettingsUIState.update { + it.copy( + proDataState = proDataState, + inGracePeriod = (subType as? ProStatus.Active.AutoRenewing)?.inGracePeriod ?: false, + subscriptionExpiryLabel = when(subType){ + is ProStatus.Active.AutoRenewing -> { + // in grace period + if(subType.inGracePeriod) { + Phrase.from(context, R.string.proRenewalUnsuccessful) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + } else { + Phrase.from(context, R.string.proAutoRenewTime) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put( + TIME_KEY, dateUtils.getExpiryString( + remaining = Duration.between(now, subType.renewingAt) + .coerceAtLeast(Duration.ZERO) + ) + ) + .format() + } + } + + is ProStatus.Active.Expiring -> + Phrase.from(context, R.string.proExpiringTime) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(TIME_KEY, dateUtils.getExpiryString( + remaining = Duration.between(now, subType.renewingAt) + .coerceAtLeast(Duration.ZERO))) + .format() + + else -> "" + }, + subscriptionExpiryDate = when(subType){ + is ProStatus.Active -> subType.renewingAtFormatted() + else -> "" + }, + ) + } + + if (subType is ProStatus.Active.AutoRenewing || subType is ProStatus.Active.Expiring) { + if (subType.renewingAt.isAfter(now)) { + val secondsTilExpired = subType.renewingAt.epochSecond - now.epochSecond + if (secondsTilExpired > 120) { + // Tick every minute + delay(1.minutes) + } else if (secondsTilExpired > 60) { + // Tick once until we reach the last minute + delay((secondsTilExpired - 60).seconds) + } else { + // Tick every seconds + delay(1.seconds) + } + } else { + break // subscription is supposed to be expired now + } + } else { + break // pro not active, no need to refresh any UI + } } } @@ -293,7 +392,7 @@ class ProSettingsViewModel @AssistedInject constructor( viewModelScope.launch { _refundPlanState.update { - val isQuickRefund = if(prefs.getDebugIsWithinQuickRefund() && prefs.forceCurrentUserAsPro()) true // debug mode + val isQuickRefund = if(prefs.forceCurrentUserAsPro()) prefs.getDebugIsWithinQuickRefund()// debug mode else sub.isWithinQuickRefundWindow() State.Success( @@ -398,8 +497,35 @@ class ProSettingsViewModel @AssistedInject constructor( } } - // go to the "choose plan" screen - else -> goToChoosePlan() + // Not loading nor error. If in grace period show a dialog + // otherwise go to the "choose plan" screen + else -> { + val provider = (_proSettingsUIState.value.proDataState.type as? ProStatus.Active)?.providerData + if(_proSettingsUIState.value.inGracePeriod){ + _dialogState.update { + it.copy( + showSimpleDialog = SimpleDialogData( + title = Phrase.from(context, R.string.proRenewalUnsuccessfulTitle) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), + message = Phrase.from(context, R.string.proUnsuccessfulRenewalDescription) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(PLATFORM_ACCOUNT_KEY, provider?.platformAccount ?: "") + .put(PLATFORM_STORE_KEY, provider?.store ?: "") + .format(), + positiveText = context.getString(R.string.theContinue), + positiveStyleDanger = false, + onPositive = { + goToChoosePlan() + }, + showXIcon = true + ) + ) + } + } else { + goToChoosePlan() + } + } } } @@ -442,7 +568,8 @@ class ProSettingsViewModel @AssistedInject constructor( } } - is Commands.RefeshProDetails -> { + is Commands.RecoverAccount -> { + recovering = true refreshProDetails(true) } @@ -489,7 +616,7 @@ class ProSettingsViewModel @AssistedInject constructor( val selectedPlan = getSelectedPlan() ?: return if(currentSubscription is ProStatus.Active){ - val newSubscriptionExpiryString = selectedPlan.durationType.expiryFromNow() + val newSubscriptionExpiryString = currentSubscription.renewingAtFormatted() val currentSubscriptionDuration = DateUtils.getLocalisedTimeDuration( context = context, @@ -514,14 +641,14 @@ class ProSettingsViewModel @AssistedInject constructor( .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(DATE_KEY, newSubscriptionExpiryString) .put(CURRENT_PLAN_LENGTH_KEY, currentSubscriptionDuration) - .put(SELECTED_PLAN_LENGTH_KEY, selectedSubscriptionDuration) + .put(SELECTED_PLAN_LENGTH_KEY, selectedSubscriptionDuration.lowercase()) // for this string below, we want to remove the 's' at the end if there is one: 12 Months becomes 12 Month .put(SELECTED_PLAN_LENGTH_SINGULAR_KEY, selectedSubscriptionDuration.removeSuffix("s")) .format() else Phrase.from(context.getText(R.string.proUpdateAccessExpireDescription)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(DATE_KEY, newSubscriptionExpiryString) - .put(SELECTED_PLAN_LENGTH_KEY, selectedSubscriptionDuration) + .put(SELECTED_PLAN_LENGTH_KEY, selectedSubscriptionDuration.lowercase()) .format(), positiveText = context.getString(R.string.update), negativeText = context.getString(R.string.cancel), @@ -667,7 +794,7 @@ class ProSettingsViewModel @AssistedInject constructor( } private fun getSelectedPlan(): ProPlan? { - return (_choosePlanState.value as? State.Success)?.value?.plans?.first { it.selected } + return (_choosePlanState.value as? State.Success)?.value?.plans?.firstOrNull { it.selected } } private fun goToChoosePlan(){ @@ -814,10 +941,18 @@ class ProSettingsViewModel @AssistedInject constructor( private fun refreshProStats(){ viewModelScope.launch { + // if we have a debug toggle for the loading state, respect it + val currentDebugState = prefs.getDebugProPlanStatus() + val debugState = when(currentDebugState) { + DebugMenuViewModel.DebugProPlanStatus.LOADING -> State.Loading + DebugMenuViewModel.DebugProPlanStatus.ERROR -> State.Error(Exception()) + else -> null + } + // show a loader for the stats _proSettingsUIState.update { it.copy( - proStats = State.Loading + proStats = debugState ?: State.Loading ) } @@ -846,14 +981,14 @@ class ProSettingsViewModel @AssistedInject constructor( // update ui with results _proSettingsUIState.update { - it.copy(proStats = State.Success(stats)) + it.copy(proStats = debugState ?: State.Success(stats)) } } catch (e: Exception) { // currently the UI doesn't have an error display // it will look like it's still loading // but the logic is there in case we have a look for stats errors _proSettingsUIState.update { - it.copy(proStats = State.Error(e)) + it.copy(proStats = debugState ?: State.Error(e)) } } } @@ -882,7 +1017,7 @@ class ProSettingsViewModel @AssistedInject constructor( data class OnHeaderClicked(val inSheet: Boolean): Commands data object OnProStatsClicked: Commands - data object RefeshProDetails: Commands + data object RecoverAccount: Commands } data class ProSettingsState( @@ -890,6 +1025,7 @@ class ProSettingsViewModel @AssistedInject constructor( val proStats: State = State.Loading, val subscriptionExpiryLabel: CharSequence = "", // eg: "Pro auto renewing in 3 days" val subscriptionExpiryDate: CharSequence = "", // eg: "May 21st, 2025" + val inGracePeriod: Boolean = false ) data class ChoosePlanState( 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 da75c980b1..24e7c1625c 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 @@ -68,7 +68,6 @@ import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.P import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.ProPlanBadge import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration -import org.thoughtcrime.securesms.pro.subscription.expiryFromNow import org.thoughtcrime.securesms.ui.LoadingArcOr import org.thoughtcrime.securesms.ui.SpeechBubbleTooltip import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect @@ -78,6 +77,7 @@ import org.thoughtcrime.securesms.ui.components.iconExternalLink import org.thoughtcrime.securesms.ui.components.inlineContentMap import org.thoughtcrime.securesms.ui.components.radioButtonColors import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.sessionDropShadow import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -110,7 +110,7 @@ fun ChoosePlan( val title = when (planData.proStatus) { is ProStatus.Active.Expiring -> Phrase.from(context.getText(R.string.proAccessActivatedNotAuto)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(DATE_KEY, planData.proStatus.duration.expiryFromNow()) + .put(DATE_KEY, planData.proStatus.renewingAtFormatted()) .format() is ProStatus.Active.AutoRenewing -> Phrase.from(context.getText(R.string.proAccessActivatesAuto)) @@ -122,7 +122,7 @@ fun ChoosePlan( unit = MeasureUnit.MONTH ) ) - .put(DATE_KEY, planData.proStatus.duration.expiryFromNow()) + .put(DATE_KEY, planData.proStatus.renewingAtFormatted()) .format() else -> @@ -269,6 +269,7 @@ private fun PlanItem( onClick: () -> Unit ){ val density = LocalDensity.current + val isLight = LocalColors.current.isLight // outer box Box(modifier = modifier.fillMaxWidth()) { @@ -276,13 +277,17 @@ private fun PlanItem( Box( modifier = modifier .padding(top = if(proPlan.badges.isNotEmpty()) maxOf(badgePadding, 9.dp) else 0.dp) // 9.dp is a simple fallback to match default styling + .then( + if(isLight) Modifier.sessionDropShadow() + else Modifier + ) .background( color = LocalColors.current.backgroundSecondary, shape = MaterialTheme.shapes.small ) .border( width = 1.dp, - color = if(proPlan.selected) LocalColors.current.accent else LocalColors.current.borders, + color = if(proPlan.selected) LocalColors.current.accentText else LocalColors.current.borders, shape = MaterialTheme.shapes.small ) .clip(MaterialTheme.shapes.small) @@ -320,7 +325,7 @@ private fun PlanItem( enabled = enabled, colors = radioButtonColors( unselectedBorder = LocalColors.current.borders, - selectedBorder = LocalColors.current.accent, + selectedBorder = LocalColors.current.accentText, ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt index 135bad2487..689edc28fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt @@ -36,6 +36,8 @@ fun ChoosePlanHomeScreen( // there is an active subscription but from a different platform or from the // same platform but a different account // or we have no billing APIs + // This check is to cover the case where the back end tells us we have a subscription, + // but the local subscription store sees no subscription for the logged user (logged on the subscription store) subscription.providerData.isFromAnotherPlatform() || !planData.hasValidSubscription || !planData.hasBillingCapacity -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt index e262b7a669..b52106a833 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt @@ -26,7 +26,6 @@ import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.C import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.pro.getPlatformDisplayName import org.thoughtcrime.securesms.pro.previewAutoRenewingApple -import org.thoughtcrime.securesms.pro.subscription.expiryFromNow import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors @@ -46,7 +45,7 @@ fun ChoosePlanNonOriginating( val headerTitle = when(subscription) { is ProStatus.Active.Expiring -> Phrase.from(context.getText(R.string.proAccessExpireDate)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(DATE_KEY, subscription.duration.expiryFromNow()) + .put(DATE_KEY, subscription.renewingAtFormatted()) .format() is ProStatus.Active.AutoRenewing -> Phrase.from(context.getText(R.string.proAccessActivatedAutoShort)) @@ -56,7 +55,7 @@ fun ChoosePlanNonOriginating( amount = subscription.duration.duration.months, unit = MeasureUnit.MONTH )) - .put(DATE_KEY, subscription.duration.expiryFromNow()) + .put(DATE_KEY, subscription.renewingAtFormatted()) .format() else -> "" diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ConfigExt.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ConfigExt.kt index 85c8432bd0..5369a0b6ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ConfigExt.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ConfigExt.kt @@ -7,6 +7,7 @@ import network.loki.messenger.libsession_util.pro.ProConfig import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.userConfigsChanged +import org.session.libsession.utilities.withUserConfigs import org.thoughtcrime.securesms.util.castAwayType import java.util.EnumSet diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt index 2140886556..6efe79e888 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt @@ -18,18 +18,23 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.mapNotNull -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.withMutableUserConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.api.server.ServerApiExecutor +import org.thoughtcrime.securesms.api.server.execute import org.thoughtcrime.securesms.auth.LoginStateRepository -import org.thoughtcrime.securesms.pro.api.GetProDetailsRequest -import org.thoughtcrime.securesms.pro.api.ProApiExecutor +import org.thoughtcrime.securesms.pro.api.GetProDetailsApi import org.thoughtcrime.securesms.pro.api.ProDetails +import org.thoughtcrime.securesms.pro.api.ServerApiRequest import org.thoughtcrime.securesms.pro.api.successOrThrow import org.thoughtcrime.securesms.pro.db.ProDatabase import java.time.Duration +import javax.inject.Provider /** * A worker that fetches the user's Pro details from the server and updates the local database. @@ -43,8 +48,9 @@ import java.time.Duration class FetchProDetailsWorker @AssistedInject constructor( @Assisted private val context: Context, @Assisted params: WorkerParameters, - private val apiExecutor: ProApiExecutor, - private val getProDetailsRequestFactory: GetProDetailsRequest.Factory, + private val proBackendConfig: Provider, + private val serverApiExecutor: ServerApiExecutor, + private val getProDetailsApiFactory: GetProDetailsApi.Factory, private val proDatabase: ProDatabase, private val loginStateRepository: LoginStateRepository, private val snodeClock: SnodeClock, @@ -64,20 +70,30 @@ class FetchProDetailsWorker @AssistedInject constructor( return try { Log.d(TAG, "Fetching Pro details from server") - val details = apiExecutor.executeRequest( - request = getProDetailsRequestFactory.create(proMasterKey) + val details = serverApiExecutor.execute( + ServerApiRequest( + proBackendConfig = proBackendConfig.get(), + api = getProDetailsApiFactory.create(proMasterKey) + ) ).successOrThrow() Log.d( TAG, - "Fetched pro details, status = ${details.status}, expiry = ${details.expiry}" + "Fetched pro details, status = ${details.status}, " + + "autoRenew = ${details.autoRenewing}, expiry = ${details.expiry}" ) - configFactory.withMutableUserConfigs { + configFactory.withMutableUserConfigs { configs -> if (details.expiry != null) { - it.userProfile.setProAccessExpiryMs(details.expiry.toEpochMilli()) + configs.userProfile.setProAccessExpiryMs(details.expiry.toEpochMilli()) } else { - it.userProfile.removeProAccessExpiry() + configs.userProfile.removeProAccessExpiry() + } + + // Remove the pro config immediately if we know we are not pro anymore. + // We will schedule proof generation below if we are still pro. + if (details.status != ProDetails.DETAILS_STATUS_ACTIVE) { + configs.userProfile.removeProConfig() } } proDatabase.updateProDetails(proDetails = details, updatedAt = snodeClock.currentTime()) @@ -99,14 +115,11 @@ class FetchProDetailsWorker @AssistedInject constructor( private suspend fun scheduleProofGenerationIfNeeded(details: ProDetails) { - val now = snodeClock.currentTimeMills() + val now = snodeClock.currentTimeMillis() if (details.status != ProDetails.DETAILS_STATUS_ACTIVE) { - Log.d(TAG, "Pro is not active, clearing proof") + Log.d(TAG, "Pro is not active, cancelling any existing proof generation work") ProProofGenerationWorker.cancel(context) - configFactory.withMutableUserConfigs { - it.userProfile.removeProConfig() - } } else { val currentProof = configFactory.withUserConfigs { it.userProfile.getProConfig() }?.proProof diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProBackendConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProBackendConfig.kt new file mode 100644 index 0000000000..410f3e1caf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProBackendConfig.kt @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.pro + +import network.loki.messenger.libsession_util.Curve25519 +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl + +data class ProBackendConfig( + val url: HttpUrl, + val ed25519PubKeyHex: String, +) { + val ed25519PubKey: ByteArray = ed25519PubKeyHex.hexToByteArray() + val x25519PubKey: ByteArray by lazy { Curve25519.pubKeyFromED25519(ed25519PubKey) } + val x25519PubKeyHex: String by lazy { x25519PubKey.toHexString() } + + constructor( + url: String, + ed25519PubKeyHex: String, + ) : this( + url = url.toHttpUrl(), + ed25519PubKeyHex = ed25519PubKeyHex, + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt index 873d0cd6a0..ebb7f58687 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt @@ -13,22 +13,37 @@ import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration import java.time.Duration import java.time.Instant -fun ProDetails.toProStatus(): ProStatus { +fun ProDetails.toProStatus(nowMs: Long): ProStatus { return when (status) { ProDetails.DETAILS_STATUS_ACTIVE -> { val paymentItem = paymentItems.first() - if (autoRenewing == true) { + val expiryInstant = expiry!! + val expiryMs = expiryInstant.toEpochMilli() + val graceMs = graceDurationMs ?: 0L + + // beginAutoRenew / renew-due timestamp + val renewingAtMs = expiryMs - graceMs + val renewingAtInstant = Instant.ofEpochMilli(renewingAtMs) + + val isAutoRenewing = autoRenewing == true + val inGracePeriod = + isAutoRenewing && + nowMs >= renewingAtMs && + nowMs < expiryMs + + if (isAutoRenewing) { ProStatus.Active.AutoRenewing( - validUntil = expiry!!, + renewingAt = renewingAtInstant, duration = paymentItem.planDuration.toSubscriptionDuration(), providerData = paymentItem.paymentProvider.getMetadata(), quickRefundExpiry = paymentItem.platformExpiry, - refundInProgress = refundRequestedAtMs > 0 + refundInProgress = refundRequestedAtMs > 0, + inGracePeriod = inGracePeriod ) } else { ProStatus.Active.Expiring( - validUntil = expiry!!, + renewingAt = renewingAtInstant, // will equal expiry when graceMs == 0 duration = paymentItem.planDuration.toSubscriptionDuration(), providerData = paymentItem.paymentProvider.getMetadata(), quickRefundExpiry = paymentItem.platformExpiry, @@ -93,11 +108,12 @@ val previewAppleMetaData = PaymentProviderMetadata( ) val previewAutoRenewingApple = ProStatus.Active.AutoRenewing( - validUntil = Instant.now() + Duration.ofDays(14), + renewingAt = Instant.now() + Duration.ofDays(14), duration = ProSubscriptionDuration.THREE_MONTHS, providerData = previewAppleMetaData, quickRefundExpiry = Instant.now() + Duration.ofDays(14), - refundInProgress = false + refundInProgress = false, + inGracePeriod = false ) val previewExpiredApple = ProStatus.Expired( diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt index 97a52d7844..626c500e66 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.auth.LoginStateRepository @@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.pro.api.ProDetails import org.thoughtcrime.securesms.pro.db.ProDatabase +import org.thoughtcrime.securesms.util.NetworkConnectivity import java.time.Instant import javax.inject.Inject import javax.inject.Singleton @@ -31,6 +32,7 @@ class ProDetailsRepository @Inject constructor( @ManagerScope scope: CoroutineScope, loginStateRepository: LoginStateRepository, private val prefs: TextSecurePreferences, + private val networkConnectivity: NetworkConnectivity, ) { sealed interface LoadState { val lastUpdated: Pair? @@ -56,12 +58,14 @@ class ProDetailsRepository @Inject constructor( .map { it.state } .distinctUntilChanged(), + networkConnectivity.networkAvailable, + db.proDetailsChangeNotification .onStart { emit(Unit) } .map { db.getProDetailsAndLastUpdated() } - ) { state, last -> + ) { state, isOnline, last -> when (state) { - WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> LoadState.Loading(last, waitingForNetwork = true) + WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> LoadState.Loading(last, waitingForNetwork = !isOnline) WorkInfo.State.RUNNING -> LoadState.Loading(last, waitingForNetwork = false) WorkInfo.State.SUCCEEDED -> { if (last != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProModule.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProModule.kt new file mode 100644 index 0000000000..b84eb66e91 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProModule.kt @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.pro + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import network.loki.messenger.BuildConfig + +@Module +@InstallIn(SingletonComponent::class) +class ProModule { + @Provides + fun provideProBackendConfig(): ProBackendConfig { + return BuildConfig.PRO_BACKEND_DEV + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt index 4617a46315..e81346c957 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt @@ -16,18 +16,22 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.pro.ProConfig -import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.withMutableUserConfigs import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.api.error.UnhandledStatusCodeException +import org.thoughtcrime.securesms.api.server.ServerApiExecutor +import org.thoughtcrime.securesms.api.server.execute import org.thoughtcrime.securesms.auth.LoginStateRepository -import org.thoughtcrime.securesms.pro.api.GenerateProProofRequest -import org.thoughtcrime.securesms.pro.api.ProApiExecutor +import org.thoughtcrime.securesms.pro.api.GenerateProProofApi import org.thoughtcrime.securesms.pro.api.ProDetails +import org.thoughtcrime.securesms.pro.api.ServerApiRequest import org.thoughtcrime.securesms.pro.api.successOrThrow -import org.thoughtcrime.securesms.util.getRootCause +import org.thoughtcrime.securesms.util.findCause import java.time.Duration import java.time.Instant +import javax.inject.Provider /** * A worker that generates a new [network.loki.messenger.libsession_util.pro.ProProof] and stores it @@ -40,8 +44,9 @@ import java.time.Instant class ProProofGenerationWorker @AssistedInject constructor( @Assisted context: Context, @Assisted params: WorkerParameters, - private val proApiExecutor: ProApiExecutor, - private val generateProProofRequest: GenerateProProofRequest.Factory, + private val apiExecutor: ServerApiExecutor, + private val proBackendConfig: Provider, + private val generateProProofApi: GenerateProProofApi.Factory, private val proDetailsRepository: ProDetailsRepository, private val loginStateRepository: LoginStateRepository, private val configFactory: ConfigFactoryProtocol, @@ -62,10 +67,13 @@ class ProProofGenerationWorker @AssistedInject constructor( return try { val rotatingPrivateKey = ED25519.generate(null).secretKey.data - val proof = proApiExecutor.executeRequest( - request = generateProProofRequest.create( - masterPrivateKey = proMasterKey, - rotatingPrivateKey = rotatingPrivateKey + val proof = apiExecutor.execute( + ServerApiRequest( + proBackendConfig = proBackendConfig.get(), + api = generateProProofApi.create( + masterPrivateKey = proMasterKey, + rotatingPrivateKey = rotatingPrivateKey + ), ) ).successOrThrow() @@ -84,7 +92,7 @@ class ProProofGenerationWorker @AssistedInject constructor( Log.e(WORK_NAME, "Error generating Pro proof", e) if (e is NonRetryableException || // HTTP 403 indicates that the user is not - e.getRootCause()?.statusCode == 403) { + e.findCause()?.code == 403) { Result.failure() } else { Result.retry() diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofs.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofs.kt index 1d13b218f4..b2fa47abf0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofs.kt @@ -2,14 +2,14 @@ package org.thoughtcrime.securesms.pro import com.google.protobuf.ByteString import network.loki.messenger.libsession_util.pro.ProProof -import org.session.libsignal.protos.SignalServiceProtos +import org.session.protos.SessionProtos /** * Copies values from a libsession ProProof into a protobuf-based ProProof. */ -fun SignalServiceProtos.ProProof.Builder.copyFromLibSession( +fun SessionProtos.ProProof.Builder.copyFromLibSession( proProof: ProProof -): SignalServiceProtos.ProProof.Builder = setVersion(proProof.version) +): SessionProtos.ProProof.Builder = setVersion(proProof.version) .setExpiryUnixTs(proProof.expiryMs) .setGenIndexHash(ByteString.copyFrom(proProof.genIndexHashHex.hexToByteArray())) .setRotatingPublicKey(ByteString.copyFrom(proProof.rotatingPubKeyHex.hexToByteArray())) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt index 3f0d4aee49..7f11072558 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt @@ -1,7 +1,9 @@ package org.thoughtcrime.securesms.pro +import network.loki.messenger.BuildConfig import network.loki.messenger.libsession_util.protocol.PaymentProviderMetadata import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.State import java.time.Instant @@ -9,34 +11,41 @@ sealed interface ProStatus{ data object NeverSubscribed: ProStatus sealed interface Active: ProStatus{ - val validUntil: Instant + val renewingAt: Instant //this takes into account the expiry and the grace period val duration: ProSubscriptionDuration val providerData: PaymentProviderMetadata val quickRefundExpiry: Instant? val refundInProgress: Boolean data class AutoRenewing( - override val validUntil: Instant, + override val renewingAt: Instant, override val duration: ProSubscriptionDuration, override val providerData: PaymentProviderMetadata, override val quickRefundExpiry: Instant?, - override val refundInProgress: Boolean + override val refundInProgress: Boolean, + val inGracePeriod: Boolean ): Active data class Expiring( - override val validUntil: Instant, + override val renewingAt: Instant, override val duration: ProSubscriptionDuration, override val providerData: PaymentProviderMetadata, - override val quickRefundExpiry: Instant? - , - override val refundInProgress: Boolean + override val quickRefundExpiry: Instant?, + override val refundInProgress: Boolean, ): Active fun isWithinQuickRefundWindow(): Boolean { return quickRefundExpiry != null && quickRefundExpiry!!.isAfter(Instant.now()) } - + fun renewingAtFormatted(): String { + val pattern = if (BuildConfig.BUILD_TYPE != "release") + "MMMM d, yyyy, h:mm a" // non prod builds can show seconds for debugging purposes + else "MMMM d, yyyy" + return DateUtils.getLocaleFormattedDate( + renewingAt.toEpochMilli(), pattern + ) + } } data class Expired( diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt index 5bc6b09671..b33b2b704a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -1,10 +1,13 @@ package org.thoughtcrime.securesms.pro import android.app.Application +import androidx.collection.ArraySet +import androidx.collection.arraySetOf import dagger.Lazy import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -12,8 +15,10 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -22,9 +27,10 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.time.delay +import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withTimeout import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.pro.BackendRequests @@ -32,36 +38,50 @@ import network.loki.messenger.libsession_util.pro.BackendRequests.PAYMENT_PROVID import network.loki.messenger.libsession_util.pro.BackendRequests.PAYMENT_PROVIDER_GOOGLE_PLAY import network.loki.messenger.libsession_util.pro.ProConfig import network.loki.messenger.libsession_util.protocol.ProFeature +import network.loki.messenger.libsession_util.protocol.ProMessageFeature +import network.loki.messenger.libsession_util.protocol.ProProfileFeature import network.loki.messenger.libsession_util.util.Conversation +import network.loki.messenger.libsession_util.util.Util +import network.loki.messenger.libsession_util.util.asSequence +import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.visible.VisibleMessage -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.ConfigUpdateNotification import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.userConfigsChanged +import org.session.libsession.utilities.withMutableUserConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.api.server.ServerApiExecutor +import org.thoughtcrime.securesms.api.server.execute +import org.thoughtcrime.securesms.auth.AuthAwareComponent +import org.thoughtcrime.securesms.auth.LoggedInState import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.pro.api.AddPaymentErrorStatus -import org.thoughtcrime.securesms.pro.api.AddProPaymentRequest -import org.thoughtcrime.securesms.pro.api.ProApiExecutor +import org.thoughtcrime.securesms.pro.api.AddProPaymentApi import org.thoughtcrime.securesms.pro.api.ProApiResponse +import org.thoughtcrime.securesms.pro.api.ServerApiRequest import org.thoughtcrime.securesms.pro.db.ProDatabase import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager import org.thoughtcrime.securesms.util.State +import org.thoughtcrime.securesms.util.castAwayType import java.time.Duration import java.time.Instant import java.util.EnumSet import javax.inject.Inject +import javax.inject.Provider import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds @OptIn(ExperimentalCoroutinesApi::class) @Singleton @@ -70,17 +90,27 @@ class ProStatusManager @Inject constructor( private val prefs: TextSecurePreferences, recipientRepository: RecipientRepository, @param:ManagerScope private val scope: CoroutineScope, - private val apiExecutor: ProApiExecutor, + private val serverApiExecutor: ServerApiExecutor, + private val addProPaymentApiFactory: AddProPaymentApi.Factory, + private val backendConfig: Provider, private val loginState: LoginStateRepository, private val proDatabase: ProDatabase, private val snodeClock: SnodeClock, private val proDetailsRepository: Lazy, private val configFactory: Lazy, -) : OnAppStartupComponent { +) : AuthAwareComponent { val proDataState: StateFlow = loginState.flowWithLoggedInState { combine( - recipientRepository.observeSelf().map { it.shouldShowProBadge }.distinctUntilChanged(), + configFactory.get().userConfigsChanged(onlyConfigTypes = arraySetOf(UserConfigType.USER_PROFILE)) + .castAwayType() + .onStart { emit(Unit) } + .map { + configFactory.get().withUserConfigs { configs -> + configs.userProfile.getProFeatures().contains(ProProfileFeature.PRO_BADGE) + } + } + .distinctUntilChanged(), proDetailsRepository.get().loadState, (TextSecurePreferences.events.filter { it == TextSecurePreferences.DEBUG_SUBSCRIPTION_STATUS } as Flow<*>) .onStart { emit(Unit) } @@ -91,14 +121,18 @@ class ProStatusManager @Inject constructor( (TextSecurePreferences.events.filter { it == TextSecurePreferences.SET_FORCE_CURRENT_USER_PRO } as Flow<*>) .onStart { emit(Unit) } .map { prefs.forceCurrentUserAsPro() }, - ){ shouldShowProBadge, proDetailsState, debugSubscription, debugProPlanStatus, forceCurrentUserAsPro -> + ){ showProBadgePreference, proDetailsState, + debugSubscription, debugProPlanStatus, forceCurrentUserAsPro -> val proDataRefreshState = when(debugProPlanStatus){ DebugMenuViewModel.DebugProPlanStatus.LOADING -> State.Loading DebugMenuViewModel.DebugProPlanStatus.ERROR -> State.Error(Exception()) else -> { // calculate the real refresh state here when(proDetailsState){ - is ProDetailsRepository.LoadState.Loading -> State.Loading + is ProDetailsRepository.LoadState.Loading -> { + if(proDetailsState.waitingForNetwork) State.Error(Exception()) + else State.Loading + } is ProDetailsRepository.LoadState.Error -> State.Error(Exception()) else -> State.Success(Unit) } @@ -107,10 +141,11 @@ class ProStatusManager @Inject constructor( if(!forceCurrentUserAsPro){ Log.d(DebugLogGroup.PRO_DATA.label, "ProStatusManager: Getting REAL Pro data state") + val nowMs = snodeClock.currentTimeMillis() ProDataState( - type = proDetailsState.lastUpdated?.first?.toProStatus() ?: ProStatus.NeverSubscribed, - showProBadge = shouldShowProBadge, + type = proDetailsState.lastUpdated?.first?.toProStatus(nowMs) ?: ProStatus.NeverSubscribed, + showProBadge = showProBadgePreference, refreshState = proDataRefreshState ) }// debug data @@ -121,23 +156,25 @@ class ProStatusManager @Inject constructor( ProDataState( type = when(subscriptionState){ DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE -> ProStatus.Active.AutoRenewing( - validUntil = Instant.now() + Duration.ofDays(14), + renewingAt = Instant.now() + Duration.ofDays(14), duration = ProSubscriptionDuration.THREE_MONTHS, providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY)!!, quickRefundExpiry = Instant.now() + Duration.ofDays(7), - refundInProgress = false + refundInProgress = false, + inGracePeriod = false ) DebugMenuViewModel.DebugSubscriptionStatus.AUTO_APPLE_REFUNDING -> ProStatus.Active.AutoRenewing( - validUntil = Instant.now() + Duration.ofDays(14), + renewingAt = Instant.now() + Duration.ofDays(14), duration = ProSubscriptionDuration.THREE_MONTHS, providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_APP_STORE)!!, quickRefundExpiry = Instant.now() + Duration.ofDays(7), - refundInProgress = true + refundInProgress = true, + inGracePeriod = false ) DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_GOOGLE -> ProStatus.Active.Expiring( - validUntil = Instant.now() + Duration.ofDays(2), + renewingAt = Instant.now() + Duration.ofDays(2), duration = ProSubscriptionDuration.TWELVE_MONTHS, providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY)!!, quickRefundExpiry = Instant.now() + Duration.ofDays(7), @@ -145,7 +182,7 @@ class ProStatusManager @Inject constructor( ) DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_GOOGLE_LATER -> ProStatus.Active.Expiring( - validUntil = Instant.now() + Duration.ofDays(40), + renewingAt = Instant.now() + Duration.ofDays(40), duration = ProSubscriptionDuration.TWELVE_MONTHS, providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY)!!, quickRefundExpiry = Instant.now() + Duration.ofDays(7), @@ -153,15 +190,16 @@ class ProStatusManager @Inject constructor( ) DebugMenuViewModel.DebugSubscriptionStatus.AUTO_APPLE -> ProStatus.Active.AutoRenewing( - validUntil = Instant.now() + Duration.ofDays(14), + renewingAt = Instant.now() + Duration.ofDays(14), duration = ProSubscriptionDuration.ONE_MONTH, providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_APP_STORE)!!, quickRefundExpiry = Instant.now() + Duration.ofDays(7), - refundInProgress = false + refundInProgress = false, + inGracePeriod = false ) DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_APPLE -> ProStatus.Active.Expiring( - validUntil = Instant.now() + Duration.ofDays(2), + renewingAt = Instant.now() + Duration.ofDays(2), duration = ProSubscriptionDuration.ONE_MONTH, providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_APP_STORE)!!, quickRefundExpiry = Instant.now() + Duration.ofDays(7), @@ -183,7 +221,7 @@ class ProStatusManager @Inject constructor( }, refreshState = proDataRefreshState, - showProBadge = shouldShowProBadge, + showProBadge = showProBadgePreference, ) } } @@ -194,14 +232,17 @@ class ProStatusManager @Inject constructor( private val _postProLaunchStatus = MutableStateFlow(isPostPro()) val postProLaunchStatus: StateFlow = _postProLaunchStatus + init { scope.launch { prefs.watchPostProStatus().collect { _postProLaunchStatus.update { isPostPro() } } } + } - loginState.runWhileLoggedIn(scope) { + override suspend fun doWhileLoggedIn(loggedInState: LoggedInState): Unit = supervisorScope { + launch { postProLaunchStatus .collectLatest { postLaunch -> if (postLaunch) { @@ -212,155 +253,154 @@ class ProStatusManager @Inject constructor( } } - manageProDetailsRefreshScheduling() - manageCurrentProProofRevocation() - manageOtherPeoplePro() + launch { manageOtherPeoplePro() } + launch { manageProDetailsRefreshScheduling() } + launch { manageCurrentProProofRevocation() } + launch { + postProLaunchStatus + .collectLatest { postLaunch -> + if (postLaunch) { + RevocationListPollingWorker.schedule(application) + } else { + RevocationListPollingWorker.cancel(application) + } + } + } } - private fun manageOtherPeoplePro() { - loginState.runWhileLoggedIn(scope) { - postProLaunchStatus.collectLatest { postLaunch -> - if (postLaunch) { - merge( - configFactory.get().userConfigsChanged(EnumSet.of(UserConfigType.CONVO_INFO_VOLATILE)), - proDatabase.revocationChangeNotification, - ).onStart { emit(Unit) } - .collect { - // Go through all convo's pro proof and remove the ones that are revoked - val revokedConversations = configFactory.get() - .withUserConfigs { it.convoInfoVolatile.all() } - .asSequence() - .filterIsInstance() - .filter { convo -> - convo.proProofInfo?.genIndexHash?.let { proDatabase.isRevoked(it.data.toHexString()) } == true - } - .onEach { convo -> - convo.proProofInfo = null - } - .toList() + override fun onLoggedOut() { + scope.launch { + RevocationListPollingWorker.cancel(application) + } + } - if (revokedConversations.isNotEmpty()) { - Log.d( - DebugLogGroup.PRO_DATA.label, - "Clearing Pro proof info for ${revokedConversations.size} conversations due to revocation" - ) + private suspend fun manageOtherPeoplePro() { + postProLaunchStatus.collectLatest { postLaunch -> + if (postLaunch) { + merge( + configFactory.get().userConfigsChanged(EnumSet.of(UserConfigType.CONVO_INFO_VOLATILE)), + proDatabase.revocationChangeNotification, + ).onStart { emit(Unit) } + .collect { + // Go through all convo's pro proof and remove the ones that are revoked + val revokedConversations = configFactory.get() + .withUserConfigs { it.convoInfoVolatile.all() } + .asSequence() + .filterIsInstance() + .filter { convo -> + convo.proProofInfo?.genIndexHash?.let { proDatabase.isRevoked(it.data.toHexString()) } == true + } + .onEach { convo -> + convo.proProofInfo = null + } + .toList() - configFactory.get() - .withMutableUserConfigs { configs -> - for (convo in revokedConversations) { - configs.convoInfoVolatile.set(convo) - } + if (revokedConversations.isNotEmpty()) { + Log.d( + DebugLogGroup.PRO_DATA.label, + "Clearing Pro proof info for ${revokedConversations.size} conversations due to revocation" + ) + + configFactory.get() + .withMutableUserConfigs { configs -> + for (convo in revokedConversations) { + configs.convoInfoVolatile.set(convo) } - } + } } - } + } } } + } - private fun manageProDetailsRefreshScheduling() { - loginState.runWhileLoggedIn(scope) { - postProLaunchStatus - .collectLatest { postLaunch -> - if (postLaunch) { - merge( - configFactory.get() - .userConfigsChanged(EnumSet.of(UserConfigType.USER_PROFILE)) - .map { - configFactory.get().withUserConfigs { configs -> - configs.userProfile.getProAccessExpiryMs() - } + @OptIn(FlowPreview::class) + private suspend fun manageProDetailsRefreshScheduling() { + postProLaunchStatus + .collectLatest { postLaunch -> + if (postLaunch) { + merge( + configFactory.get() + .userConfigsChanged(EnumSet.of(UserConfigType.USER_PROFILE)) + .map { + configFactory.get().withUserConfigs { configs -> + configs.userProfile.getProAccessExpiryMs() } - .distinctUntilChanged() - .map { "ProAccessExpiry in config changes" }, - - proDetailsRepository.get().loadState - .mapNotNull { it.lastUpdated?.first?.expiry } - .distinctUntilChanged() - .mapLatest { expiry -> - // Schedule a refresh 30seconds after access expiry - val refreshTime = expiry.plusSeconds(30) - - val now = snodeClock.currentTime() - if (now < refreshTime) { - val duration = Duration.between(now, refreshTime) - Log.d( - DebugLogGroup.PRO_SUBSCRIPTION.label, - "Delaying ProDetails refresh until $refreshTime due to access expiry" - ) - delay(duration) - } - - "ProDetails expiry reached" - }, - - configFactory.get() - .watchUserProConfig() - .filterNotNull() - .mapLatest { proConfig -> - val expiry = Instant.ofEpochMilli(proConfig.proProof.expiryMs) - // Schedule a refresh for a random number between 10 and 60 minutes before proof expiry - val now = snodeClock.currentTime() - - val refreshTime = - expiry.minus(Duration.ofMinutes((10..60).random().toLong())) - - if (now < refreshTime) { - Log.d( - DebugLogGroup.PRO_SUBSCRIPTION.label, - "Delaying ProDetails refresh until $refreshTime due to proof expiry" - ) - delay(Duration.between(now, expiry)) - } - }, + } + .distinctUntilChanged() + .map { "ProAccessExpiry in config changes" }, + + proDetailsRepository.get().loadState + .mapNotNull { it.lastUpdated?.first?.expiry } + .distinctUntilChanged() + .transformLatest { expiry -> + // Schedule a refresh for 30 seconds after access expiry + if (snodeClock.delayUntil(expiry.plusSeconds(30))) { + emit("30 seconds after Access expiry reached") + } + }, - flowOf("App starting up") - ).collect { refreshReason -> + configFactory.get() + .watchUserProConfig() + .filterNotNull() + .distinctUntilChanged() + .mapLatest { proConfig -> + val expiry = Instant.ofEpochMilli(proConfig.proProof.expiryMs) + // Schedule a refresh for a random number between 10 and 60 minutes before proof expiry + + val refreshTime = + expiry.minus(Duration.ofMinutes((10..60).random().toLong())) + + snodeClock.delayUntil(refreshTime) + "Pro proof expiry reached" + }, + + flowOf("App starting up") + ).debounce(500.milliseconds) + .collect { refreshReason -> Log.d( DebugLogGroup.PRO_SUBSCRIPTION.label, "Scheduling ProDetails fetch due to: $refreshReason" ) - proDetailsRepository.get().requestRefresh() + proDetailsRepository.get().requestRefresh(force = true) } - } else { - FetchProDetailsWorker.cancel(application) - } + } else { + FetchProDetailsWorker.cancel(application) } - } + } } - private fun manageCurrentProProofRevocation() { - loginState.runWhileLoggedIn(scope) { - postProLaunchStatus.collectLatest { postLaunch -> - if (postLaunch) { - combine( - configFactory.get() - .watchUserProConfig() - .mapNotNull { it?.proProof?.genIndexHashHex }, + private suspend fun manageCurrentProProofRevocation() { + postProLaunchStatus.collectLatest { postLaunch -> + if (postLaunch) { + combine( + configFactory.get() + .watchUserProConfig() + .mapNotNull { it?.proProof?.genIndexHashHex }, - proDatabase.revocationChangeNotification - .onStart { emit(Unit) }, + proDatabase.revocationChangeNotification + .onStart { emit(Unit) }, - { proofGenIndexHash, _ -> - proofGenIndexHash.takeIf { proDatabase.isRevoked(it) } - } - ) - .filterNotNull() - .collectLatest { revokedHash -> - configFactory.get().withMutableUserConfigs { configs -> - if (configs.userProfile.getProConfig()?.proProof?.genIndexHashHex == revokedHash) { - Log.w( - DebugLogGroup.PRO_SUBSCRIPTION.label, - "Current Pro proof has been revoked, clearing Pro config" - ) - configs.userProfile.removeProConfig() - } + { proofGenIndexHash, _ -> + proofGenIndexHash.takeIf { proDatabase.isRevoked(it) } + } + ) + .filterNotNull() + .collectLatest { revokedHash -> + configFactory.get().withMutableUserConfigs { configs -> + if (configs.userProfile.getProConfig()?.proProof?.genIndexHashHex == revokedHash) { + Log.w( + DebugLogGroup.PRO_SUBSCRIPTION.label, + "Current Pro proof has been revoked, clearing Pro config" + ) + configs.userProfile.removeProConfig() } } - } + } } } + } /** @@ -375,10 +415,14 @@ class ProStatusManager @Inject constructor( */ fun getIncomingMessageMaxLength(message: VisibleMessage): Int { // if the debug is set, return that - if (prefs.forceIncomingMessagesAsPro()) return MAX_CHARACTER_PRO + // of if we are in pre-pro world + if (prefs.forceIncomingMessagesAsPro() || !isPostPro()) return MAX_CHARACTER_PRO + + if (message.proFeatures.contains(ProMessageFeature.HIGHER_CHARACTER_LIMIT)) { + return MAX_CHARACTER_PRO + } - // otherwise return the true value - return if(isPostPro()) MAX_CHARACTER_REGULAR else MAX_CHARACTER_PRO //todo PRO implement real logic once it's in + return MAX_CHARACTER_REGULAR } // Temporary method and concept that we should remove once Pro is out @@ -408,6 +452,28 @@ class ProStatusManager @Inject constructor( return message.proFeatures } + /** + * Adds Pro features, if any, to an outgoing visible message + */ + fun addProFeatures(message: Message) { + if (proDataState.value.type !is ProStatus.Active) { + return + } + + val proFeatures = ArraySet() + + configFactory.get().withUserConfigs { configs -> + proFeatures += configs.userProfile.getProFeatures().asSequence() + } + + if (message is VisibleMessage && + Util.countCodepoints(message.text.orEmpty()) > MAX_CHARACTER_REGULAR){ + proFeatures += ProMessageFeature.HIGHER_CHARACTER_LIMIT + } + + message.proFeatures = proFeatures + } + /** * To be called once a subscription has successfully gone through a provider. * This will link that payment to our back end. @@ -425,14 +491,21 @@ class ProStatusManager @Inject constructor( try { // 5s timeout as per PRD val paymentResponse = withTimeout(5_000L) { - apiExecutor.executeRequest( - request = AddProPaymentRequest( - googlePaymentToken = paymentId, - googleOrderId = orderId, - masterPrivateKey = keyData.seeded.proMasterPrivateKey, - rotatingPrivateKey = rotatingKeyPair.secretKey.data + runCatching { + serverApiExecutor.execute( + ServerApiRequest( + proBackendConfig = backendConfig.get(), + api = addProPaymentApiFactory.create( + googlePaymentToken = paymentId, + googleOrderId = orderId, + masterPrivateKey = keyData.seeded.proMasterPrivateKey, + rotatingPrivateKey = rotatingKeyPair.secretKey.data + ) + ) ) - ) + }.getOrElse { + ProApiResponse.Failure(AddPaymentErrorStatus.GenericError, emptyList()) + } } when (paymentResponse) { @@ -450,7 +523,7 @@ class ProStatusManager @Inject constructor( configs.userProfile.setProBadge(true) } // refresh the pro details - proDetailsRepository.get().requestRefresh() + proDetailsRepository.get().requestRefresh(force = true) } is ProApiResponse.Failure -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt index 6c4b238fd6..249919b1a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt @@ -13,15 +13,18 @@ import androidx.work.WorkerParameters import androidx.work.await import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.pro.api.GetProRevocationRequest -import org.thoughtcrime.securesms.pro.api.ProApiExecutor +import org.thoughtcrime.securesms.api.server.ServerApiExecutor +import org.thoughtcrime.securesms.api.server.execute +import org.thoughtcrime.securesms.pro.api.GetProRevocationApi +import org.thoughtcrime.securesms.pro.api.ServerApiRequest import org.thoughtcrime.securesms.pro.api.successOrThrow import org.thoughtcrime.securesms.pro.db.ProDatabase import java.time.Duration import java.util.concurrent.TimeUnit +import javax.inject.Provider import kotlin.coroutines.cancellation.CancellationException /** @@ -32,14 +35,20 @@ class RevocationListPollingWorker @AssistedInject constructor( @Assisted context: Context, @Assisted params: WorkerParameters, private val proDatabase: ProDatabase, - private val getProRevocationRequestFactory: GetProRevocationRequest.Factory, - private val proApiExecutor: ProApiExecutor, + private val getProRevocationApiFactory: GetProRevocationApi.Factory, + private val proBackendConfig: Provider, + private val serverApiExecutor: ServerApiExecutor, private val snodeClock: SnodeClock, ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { try { val lastTicket = proDatabase.getLastRevocationTicket() - val response = proApiExecutor.executeRequest(request = getProRevocationRequestFactory.create(lastTicket)).successOrThrow() + val response = serverApiExecutor.execute( + ServerApiRequest( + proBackendConfig = proBackendConfig.get(), + api = getProRevocationApiFactory.create(lastTicket) + ) + ).successOrThrow() proDatabase.updateRevocations( data = response.items, newTicket = response.ticket diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPayment.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPaymentApi.kt similarity index 62% rename from app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPayment.kt rename to app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPaymentApi.kt index 9918ee7f35..f1770b7005 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPayment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPaymentApi.kt @@ -1,16 +1,20 @@ package org.thoughtcrime.securesms.pro.api +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.serialization.DeserializationStrategy import network.loki.messenger.libsession_util.pro.BackendRequests import network.loki.messenger.libsession_util.pro.ProProof import org.session.libsignal.utilities.Log -class AddProPaymentRequest( - private val googlePaymentToken: String, - private val googleOrderId: String, - private val masterPrivateKey: ByteArray, - private val rotatingPrivateKey: ByteArray, -) : ApiRequest { +class AddProPaymentApi @AssistedInject constructor( + @Assisted("token") private val googlePaymentToken: String, + @Assisted private val googleOrderId: String, + @Assisted("master") private val masterPrivateKey: ByteArray, + @Assisted private val rotatingPrivateKey: ByteArray, + deps: ProApiDependencies +) : ProApi(deps) { override val endpoint: String get() = "add_pro_payment" @@ -34,6 +38,15 @@ class AddProPaymentRequest( override val responseDeserializer: DeserializationStrategy get() = ProProof.serializer() + @AssistedFactory + interface Factory { + fun create( + @Assisted("token") googlePaymentToken: String, + googleOrderId: String, + @Assisted("master") masterPrivateKey: ByteArray, + rotatingPrivateKey: ByteArray, + ): AddProPaymentApi + } } enum class AddPaymentErrorStatus(val apiValue: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ApiRequest.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ApiRequest.kt deleted file mode 100644 index a6ffb030b6..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ApiRequest.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.thoughtcrime.securesms.pro.api - -import kotlinx.serialization.DeserializationStrategy - -/** - * Represents a generic API request to the Pro backend. - * - * @param ErrorStatus The type of error status returned by the API. - * @param Res The type of the expected response. - */ -interface ApiRequest { - /** - * The endpoint (path) for this API request, e.g. "v1/pro/payments" - */ - val endpoint: String - - val responseDeserializer: DeserializationStrategy - - fun convertErrorStatus(status: Int): ErrorStatus - - fun buildJsonBody(): String -} - - -/** - * Represents the response from a Pro API request. - * - * @param Res The type of the successful response data. - */ -sealed interface ProApiResponse { - data class Success(val data: T) : ProApiResponse - data class Failure(val status: S, val errors: List) : ProApiResponse -} - -fun ProApiResponse.successOrThrow(): T { - return when (this) { - is ProApiResponse.Success -> this.data - is ProApiResponse.Failure -> throw RuntimeException("Fail with status = $status, errors = $errors") - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProof.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProofApi.kt similarity index 85% rename from app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProof.kt rename to app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProofApi.kt index bc0c2ae364..f243de7306 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProof.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProofApi.kt @@ -6,13 +6,14 @@ import dagger.assisted.AssistedInject import kotlinx.serialization.DeserializationStrategy import network.loki.messenger.libsession_util.pro.BackendRequests import network.loki.messenger.libsession_util.pro.ProProof -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock -class GenerateProProofRequest @AssistedInject constructor( +class GenerateProProofApi @AssistedInject constructor( @Assisted("master") private val masterPrivateKey: ByteArray, @Assisted private val rotatingPrivateKey: ByteArray, private val snodeClock: SnodeClock, -) : ApiRequest { + deps: ProApiDependencies, +) : ProApi(deps) { override val endpoint: String get() = "generate_pro_proof" @@ -37,7 +38,7 @@ class GenerateProProofRequest @AssistedInject constructor( fun create( @Assisted("master") masterPrivateKey: ByteArray, rotatingPrivateKey: ByteArray, - ): GenerateProProofRequest + ): GenerateProProofApi } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetails.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt similarity index 93% rename from app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetails.kt rename to app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt index d8a117399c..9b082e12bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetails.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt @@ -8,14 +8,15 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import network.loki.messenger.libsession_util.pro.BackendRequests import network.loki.messenger.libsession_util.pro.PaymentProvider -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.serializable.InstantAsMillisSerializer import java.time.Instant -class GetProDetailsRequest @AssistedInject constructor( +class GetProDetailsApi @AssistedInject constructor( private val snodeClock: SnodeClock, @Assisted private val masterPrivateKey: ByteArray, -) : ApiRequest { + deps: ProApiDependencies, +) : ProApi(deps) { override val endpoint: String get() = "get_pro_details" @@ -23,7 +24,7 @@ class GetProDetailsRequest @AssistedInject constructor( return BackendRequests.buildGetProDetailsRequestJson( version = 0, proMasterPrivateKey = masterPrivateKey, - nowMs = snodeClock.currentTimeMills(), + nowMs = snodeClock.currentTimeMillis(), count = 10, ) } @@ -35,7 +36,7 @@ class GetProDetailsRequest @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(masterPrivateKey: ByteArray): GetProDetailsRequest + fun create(masterPrivateKey: ByteArray): GetProDetailsApi } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocations.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocationsApi.kt similarity index 88% rename from app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocations.kt rename to app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocationsApi.kt index a800df5fd5..ac24b12b53 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocationsApi.kt @@ -10,10 +10,11 @@ import kotlinx.serialization.json.Json import org.session.libsession.utilities.serializable.InstantAsMillisSerializer import java.time.Instant -class GetProRevocationRequest @AssistedInject constructor( +class GetProRevocationApi @AssistedInject constructor( @Assisted private val ticket: Long?, private val json: Json, -) : ApiRequest { + deps: ProApiDependencies, +) : ProApi(deps) { override val responseDeserializer: DeserializationStrategy get() = ProRevocations.serializer() @@ -33,7 +34,7 @@ class GetProRevocationRequest @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(ticket: Long?): GetProRevocationRequest + fun create(ticket: Long?): GetProRevocationApi } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt new file mode 100644 index 0000000000..23f4ff07ea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt @@ -0,0 +1,117 @@ +package org.thoughtcrime.securesms.pro.api + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromStream +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.thoughtcrime.securesms.api.server.ServerApiErrorManager +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpBody +import org.thoughtcrime.securesms.api.http.HttpRequest +import org.thoughtcrime.securesms.api.http.HttpResponse +import org.thoughtcrime.securesms.api.server.ServerApi +import org.thoughtcrime.securesms.api.server.ServerApiRequest +import org.thoughtcrime.securesms.pro.ProBackendConfig +import javax.inject.Inject + +/** + * Represents a generic API request to the Pro backend. + * + * @param ErrorStatus The type of error status returned by the API. + * @param Res The type of the expected response. + */ +abstract class ProApi(private val deps: ProApiDependencies) + : ServerApi>(deps.errorManager) { + /** + * The endpoint (path) for this API request, e.g. "v1/pro/payments" + */ + abstract val endpoint: String + + abstract val responseDeserializer: DeserializationStrategy + + abstract fun convertErrorStatus(status: Int): ErrorStatus + + abstract fun buildJsonBody(): String + + override fun buildRequest( + baseUrl: String, + x25519PubKeyHex: String + ): HttpRequest { + return HttpRequest( + method = "POST", + url = "$baseUrl/$endpoint".toHttpUrl(), + headers = mapOf( + "Content-Type" to "application/json" + ), + body = HttpBody.Text(buildJsonBody()) + ) + } + + @Suppress("OPT_IN_USAGE") + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): ProApiResponse { + val rawResp: RawProApiResponse = response.body + .asInputStream() + .use { deps.json.decodeFromStream(it) } + + return if (rawResp.status == 0) { + val data = deps.json.decodeFromJsonElement( + responseDeserializer, + requireNotNull(rawResp.result) { + "Expected 'result' field to be present on successful response" + }) + ProApiResponse.Success(data) + } else { + ProApiResponse.Failure( + status = convertErrorStatus(rawResp.status), + errors = rawResp.errors.orEmpty() + ) + } + } + + class ProApiDependencies @Inject constructor( + val errorManager: ServerApiErrorManager, + val json: Json, + ) + + @Serializable + private data class RawProApiResponse( + val status: Int, + val result: JsonElement? = null, + val errors: List? = null, + ) +} + + +/** + * Represents the response from a Pro API request. + * + * @param Res The type of the successful response data. + */ +sealed interface ProApiResponse { + data class Success(val data: T) : ProApiResponse + data class Failure(val status: S, val errors: List) : ProApiResponse +} + +fun ProApiResponse.successOrThrow(): T { + return when (this) { + is ProApiResponse.Success -> this.data + is ProApiResponse.Failure -> throw RuntimeException("Fail with status = $status, errors = $errors") + } +} + +fun ServerApiRequest( + proBackendConfig: ProBackendConfig, + api: ProApi +): ServerApiRequest> { + return ServerApiRequest>( + serverBaseUrl = proBackendConfig.url.toString(), + serverX25519PubKeyHex = proBackendConfig.x25519PubKeyHex, + api = api + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApiExecutor.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApiExecutor.kt deleted file mode 100644 index c0560ac600..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApiExecutor.kt +++ /dev/null @@ -1,88 +0,0 @@ -package org.thoughtcrime.securesms.pro.api - -import kotlinx.serialization.DeserializationStrategy -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.decodeFromStream -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import org.session.libsession.snode.OnionRequestAPI.sendOnionRequest -import org.session.libsession.snode.utilities.await -import javax.inject.Inject - -class ProApiExecutor @Inject constructor( - private val json: Json -) { - @Serializable - private data class RawProApiResponse( - val status: Int, - val result: JsonElement? = null, - val errors: List? = null, - ) { - fun toProApiResponse( - deserializer: DeserializationStrategy, - json: Json - ): ProApiResponse { - return if (status == 0) { - val data = json.decodeFromJsonElement(deserializer, requireNotNull(result) { - "Expected 'result' field to be present on successful response" - }) - ProApiResponse.Success(data) - } else { - ProApiResponse.Failure( - status = status, - errors = errors.orEmpty() - ) - } - } - } - - - /** - * Executes the given [ApiRequest] against the specified server using an onion request. - * - * @return A [ProApiResponse] containing either the successful response data or error information. - * Note that network errors, json deserialization will throw exceptions and are not represented - * in the [ProApiResponse]: you must catch and handle those separately. - */ - @OptIn(ExperimentalSerializationApi::class) - suspend fun executeRequest( - serverUrl: HttpUrl = "https://pro-backend-dev.getsession.org".toHttpUrl(), - serverX25519PubKeyHex: String = "920b81e9bf1a06e70814432668c61487d6fdbe13faaee3b09ebc56223061f140", - request: ApiRequest - ): ProApiResponse { - val rawResp = sendOnionRequest( - request = Request.Builder() - .url(serverUrl.resolve(request.endpoint)!!) - .post( - request.buildJsonBody().toRequestBody( - "application/json".toMediaType() - ) - ) - .build(), - server = serverUrl.host, - x25519PublicKey = serverX25519PubKeyHex - ).await().body!!.inputStream().use { - json.decodeFromStream(it) - } - - return if (rawResp.status == 0) { - val data = json.decodeFromJsonElement( - request.responseDeserializer, - requireNotNull(rawResp.result) { - "Expected 'result' field to be present on successful response" - }) - ProApiResponse.Success(data) - } else { - ProApiResponse.Failure( - status = request.convertErrorStatus(rawResp.status), - errors = rawResp.errors.orEmpty() - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt index e07be952ac..e0b2af4b08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt @@ -1,9 +1,6 @@ package org.thoughtcrime.securesms.pro.subscription -import org.thoughtcrime.securesms.util.DateUtils -import java.time.Duration import java.time.Period -import java.time.ZonedDateTime enum class ProSubscriptionDuration(val duration: Period, val id: String) { ONE_MONTH(Period.ofMonths(1), "session-pro-1-month"), @@ -13,15 +10,3 @@ enum class ProSubscriptionDuration(val duration: Period, val id: String) { fun ProSubscriptionDuration.getById(id: String): ProSubscriptionDuration? = ProSubscriptionDuration.entries.find { it.id == id } - -private val proSettingsDateFormat = "MMMM d, yyyy" - -fun ProSubscriptionDuration.expiryFromNow(): String { - val newSubscriptionExpiryDate = ZonedDateTime.now() - .plus(duration) - .toInstant() - .toEpochMilli() - return DateUtils.getLocaleFormattedDate( - newSubscriptionExpiryDate, proSettingsDateFormat - ) -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index a78897ac18..675a3510cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -1,67 +1,12 @@ package org.thoughtcrime.securesms.repository -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.withContext -import network.loki.messenger.libsession_util.util.ExpiryMode -import network.loki.messenger.libsession_util.util.GroupInfo -import org.session.libsession.database.MessageDataProvider -import org.session.libsession.database.userAuth -import org.session.libsession.messaging.groups.GroupManagerV2 -import org.session.libsession.messaging.messages.MarkAsDeletedMessage -import org.session.libsession.messaging.messages.control.MessageRequestResponse -import org.session.libsession.messaging.messages.control.UnsendRequest -import org.session.libsession.messaging.messages.signal.OutgoingTextMessage -import org.session.libsession.messaging.messages.visible.OpenGroupInvitation -import org.session.libsession.messaging.messages.visible.VisibleMessage -import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock import org.session.libsession.utilities.Address -import org.session.libsession.utilities.Address.Companion.toAddress -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.UserConfigType -import org.session.libsession.utilities.isGroupV2 -import org.session.libsession.utilities.isLegacyGroup -import org.session.libsession.utilities.isStandard import org.session.libsession.utilities.recipients.Recipient -import org.session.libsession.utilities.recipients.RecipientData -import org.session.libsession.utilities.upsertContact -import org.session.libsession.utilities.userConfigsChanged import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.auth.LoginStateRepository -import org.thoughtcrime.securesms.database.CommunityDatabase -import org.thoughtcrime.securesms.database.DraftDatabase -import org.thoughtcrime.securesms.database.LokiMessageDatabase -import org.thoughtcrime.securesms.database.MmsSmsDatabase -import org.thoughtcrime.securesms.database.RecipientRepository -import org.thoughtcrime.securesms.database.RecipientSettingsDatabase -import org.thoughtcrime.securesms.database.SmsDatabase -import org.thoughtcrime.securesms.database.Storage -import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.ThreadRecord -import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.util.castAwayType -import java.util.EnumSet -import javax.inject.Inject -import javax.inject.Singleton interface ConversationRepository { fun observeConversationList(): Flow> @@ -74,7 +19,7 @@ interface ConversationRepository { fun getConversationList(): List - val conversationListAddressesFlow: StateFlow> + val conversationListAddressesFlow: Flow> fun saveDraft(threadId: Long, text: String) fun getDraft(threadId: Long): String? @@ -107,6 +52,7 @@ interface ConversationRepository { suspend fun deleteGroupV2MessagesRemotely(recipient: Address, messages: Set) suspend fun banUser(community: Address.Community, userId: AccountId): Result + suspend fun unbanUser(community: Address.Community, userId: AccountId): Result suspend fun banAndDeleteAll(community: Address.Community, userId: AccountId): Result suspend fun deleteMessageRequest(thread: ThreadRecord): Result suspend fun clearAllMessageRequests(): Result @@ -125,489 +71,3 @@ interface ConversationRepository { */ suspend fun clearAllMessages(threadId: Long, groupId: AccountId?): Int } - -@Singleton -class DefaultConversationRepository @Inject constructor( - private val messageDataProvider: MessageDataProvider, - private val threadDb: ThreadDatabase, - private val communityDatabase: CommunityDatabase, - private val draftDb: DraftDatabase, - private val smsDb: SmsDatabase, - private val mmsSmsDb: MmsSmsDatabase, - private val storage: Storage, - private val lokiMessageDb: LokiMessageDatabase, - private val configFactory: ConfigFactory, - private val groupManager: GroupManagerV2, - private val clock: SnodeClock, - private val recipientDatabase: RecipientSettingsDatabase, - private val recipientRepository: RecipientRepository, - @param:ManagerScope private val scope: CoroutineScope, - private val messageSender: MessageSender, - private val loginStateRepository: LoginStateRepository, -) : ConversationRepository { - - override val conversationListAddressesFlow = loginStateRepository.flowWithLoggedInState { - configFactory - .userConfigsChanged(EnumSet.of( - UserConfigType.CONTACTS, - UserConfigType.USER_PROFILE, - UserConfigType.USER_GROUPS - )) - .castAwayType() - .onStart { - emit(Unit) - } - .map { getConversationListAddresses() } - }.stateIn(scope, SharingStarted.Eagerly, getConversationListAddresses()) - - private fun getConversationListAddresses() = buildSet { - val myAddress = loginStateRepository.getLocalNumber()?.toAddress() as? Address.Standard - ?: return@buildSet - - // Always have NTS - we should only "hide" them on home screen - the convo should never be deleted - add(myAddress) - - configFactory.withUserConfigs { configs -> - // Contacts - for (contact in configs.contacts.all()) { - if (contact.priority >= 0 && (!contact.blocked || contact.approved)) { - add(Address.Standard(AccountId(contact.id))) - } - } - - // Blinded Contacts - for (blindedContact in configs.contacts.allBlinded()) { - if (blindedContact.priority >= 0) { - add(Address.CommunityBlindedId( - serverUrl = blindedContact.communityServer, - blindedId = Address.Blinded(AccountId(blindedContact.id)) - )) - } - } - - // Groups - for (group in configs.userGroups.all()) { - when (group) { - is GroupInfo.ClosedGroupInfo -> { - add(Address.Group(AccountId(group.groupAccountId))) - } - - is GroupInfo.LegacyGroupInfo -> { - add(Address.LegacyGroup(group.accountId)) - } - - is GroupInfo.CommunityGroupInfo -> { - add(Address.Community( - serverUrl = group.community.baseUrl, - room = group.community.room - )) - } - } - } - } - } - - - @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) - override fun observeConversationList(): Flow> { - return conversationListAddressesFlow - .flatMapLatest { allAddresses -> - merge( - configFactory.configUpdateNotifications, - recipientDatabase.changeNotification.filter { it in allAddresses }, - communityDatabase.changeNotification.filter { it in allAddresses }, - threadDb.updateNotifications, - // If pro status pref changes, the convo is likely needing changes too - TextSecurePreferences.events.filter { - it == TextSecurePreferences.SET_FORCE_OTHER_USERS_PRO || - it == TextSecurePreferences.SET_FORCE_CURRENT_USER_PRO - it == TextSecurePreferences.SET_FORCE_POST_PRO - } - ).debounce(500) - .onStart { emit(allAddresses) } - .map { allAddresses } - } - .map { addresses -> - withContext(Dispatchers.Default) { - threadDb.getThreads(addresses) - } - } - } - - override fun getConversationList(): List { - return threadDb.getThreads(getConversationListAddresses()) - } - - override fun saveDraft(threadId: Long, text: String) { - if (text.isEmpty()) return - val drafts = DraftDatabase.Drafts() - drafts.add(DraftDatabase.Draft(DraftDatabase.Draft.TEXT, text)) - draftDb.insertDrafts(threadId, drafts) - } - - override fun getDraft(threadId: Long): String? { - val drafts = draftDb.getDrafts(threadId) - return drafts.find { it.type == DraftDatabase.Draft.TEXT }?.value - } - - override fun clearDrafts(threadId: Long) { - draftDb.clearDrafts(threadId) - } - - override fun inviteContactsToCommunity( - communityRecipient: Recipient, - contacts: Collection
- ) { - val community = communityRecipient.data as? RecipientData.Community - val info = community?.roomInfo ?: return - for (contact in contacts) { - val message = VisibleMessage() - message.sentTimestamp = clock.currentTimeMills() - val openGroupInvitation = OpenGroupInvitation().apply { - name = info.details.name - url = community.joinURL - } - message.openGroupInvitation = openGroupInvitation - val contactThreadId = threadDb.getOrCreateThreadIdFor(contact) - val expirationConfig = recipientRepository.getRecipientSync(contact).expiryMode - val expireStartedAt = if (expirationConfig is ExpiryMode.AfterSend) message.sentTimestamp!! else 0 - val outgoingTextMessage = OutgoingTextMessage.fromOpenGroupInvitation( - openGroupInvitation, - contact, - message.sentTimestamp!!, - expirationConfig.expiryMillis, - expireStartedAt - )!! - - message.id = MessageId( - smsDb.insertMessageOutbox(contactThreadId, outgoingTextMessage, false, message.sentTimestamp!!, true), - false - ) - - messageSender.send(message, contact) - } - } - - override fun isGroupReadOnly(recipient: Recipient): Boolean { - // We only care about group v2 recipient - if (!recipient.isGroupV2Recipient) { - return false - } - - val groupId = recipient.address.toString() - return configFactory.withUserConfigs { configs -> - configs.userGroups.getClosedGroup(groupId)?.let { it.kicked || it.destroyed } == true - } - } - - override fun getLastSentMessageID(threadId: Long): Flow { - return (threadDb.updateNotifications.filter { it == threadId } as Flow<*>) - .onStart { emit(Unit) } - .map { - withContext(Dispatchers.Default) { - mmsSmsDb.getLastSentMessageID(threadId) - } - } - } - - // This assumes that recipient.isContactRecipient is true - override fun setBlocked(recipient: Address, blocked: Boolean) { - if (recipient.isStandard) { - storage.setBlocked(listOf(recipient), blocked) - } - } - - /** - * This will delete these messages from the db - * Not to be confused with 'marking messages as deleted' - */ - override fun deleteMessages(messages: Set) { - // split the messages into mms and sms - val (mms, sms) = messages.partition { it.isMms } - - if(mms.isNotEmpty()){ - messageDataProvider.deleteMessages(mms.map { it.id }, isSms = false) - } - - if(sms.isNotEmpty()){ - messageDataProvider.deleteMessages(sms.map { it.id }, isSms = true) - } - } - - /** - * This will mark the messages as deleted. - * They won't be removed from the db but instead will appear as a special type - * of message that says something like "This message was deleted" - */ - override fun markAsDeletedLocally(messages: Set, displayedMessage: String) { - // split the messages into mms and sms - val (mms, sms) = messages.partition { it.isMms } - - if(mms.isNotEmpty()){ - messageDataProvider.markMessagesAsDeleted( - mms.map { MarkAsDeletedMessage( - messageId = it.messageId, - isOutgoing = it.isOutgoing - ) }, - displayedMessage = displayedMessage - ) - - // delete reactions - storage.deleteReactions(messageIds = mms.map { it.id }, mms = true) - } - - if(sms.isNotEmpty()){ - messageDataProvider.markMessagesAsDeleted( - sms.map { MarkAsDeletedMessage( - messageId = it.messageId, - isOutgoing = it.isOutgoing - ) }, - displayedMessage = displayedMessage - ) - - // delete reactions - storage.deleteReactions(messageIds = sms.map { it.id }, mms = false) - } - } - - override fun deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord: MessageRecord) { - val threadId = messageRecord.threadId - val senderId = messageRecord.recipient.address.address - val messageRecordsToRemoveFromLocalStorage = mmsSmsDb.getAllMessageRecordsFromSenderInThread(threadId, senderId) - for (message in messageRecordsToRemoveFromLocalStorage) { - messageDataProvider.deleteMessage(messageId = message.messageId) - } - } - - override suspend fun deleteCommunityMessagesRemotely( - community: Address.Community, - messages: Set - ) { - messages.forEach { message -> - lokiMessageDb.getServerID(message.messageId)?.let { messageServerID -> - OpenGroupApi.deleteMessage(messageServerID, community.room, community.serverUrl) - } - } - } - - override suspend fun delete1on1MessagesRemotely( - recipient: Address, - messages: Set - ) { - // delete the messages remotely - val userAuth = requireNotNull(storage.userAuth) { - "User auth is required to delete messages remotely" - } - val userAddress = userAuth.accountId.toAddress() - - messages.forEach { message -> - // delete from swarm - messageDataProvider.getServerHashForMessage(message.messageId) - ?.let { serverHash -> - SnodeAPI.deleteMessage(recipient.address, userAuth, listOf(serverHash)) - } - - // send an UnsendRequest to user's swarm - buildUnsendRequest(message).let { unsendRequest -> - messageSender.send(unsendRequest, userAddress) - } - - // send an UnsendRequest to recipient's swarm - buildUnsendRequest(message).let { unsendRequest -> - messageSender.send(unsendRequest, recipient) - } - } - } - - override suspend fun deleteLegacyGroupMessagesRemotely( - recipient: Address, - messages: Set - ) { - if (recipient.isLegacyGroup) { - messages.forEach { message -> - // send an UnsendRequest to group's swarm - buildUnsendRequest(message).let { unsendRequest -> - messageSender.send(unsendRequest, recipient) - } - } - } - } - - override suspend fun deleteGroupV2MessagesRemotely( - recipient: Address, - messages: Set - ) { - require(recipient.isGroupV2) { "Recipient is not a group v2 recipient" } - - val groupId = AccountId(recipient.address) - val hashes = messages.mapNotNullTo(mutableSetOf()) { msg -> - messageDataProvider.getServerHashForMessage(msg.messageId) - } - - groupManager.requestMessageDeletion(groupId, hashes) - } - - override suspend fun deleteNoteToSelfMessagesRemotely( - recipient: Address, - messages: Set - ) { - // delete the messages remotely - val userAuth = requireNotNull(storage.userAuth) { - "User auth is required to delete messages remotely" - } - val userAddress = userAuth.accountId.toAddress() - - messages.forEach { message -> - // delete from swarm - messageDataProvider.getServerHashForMessage(message.messageId) - ?.let { serverHash -> - SnodeAPI.deleteMessage(recipient.address, userAuth, listOf(serverHash)) - } - - // send an UnsendRequest to user's swarm - buildUnsendRequest(message).let { unsendRequest -> - messageSender.send(unsendRequest, userAddress) - } - } - } - - private fun buildUnsendRequest(message: MessageRecord): UnsendRequest { - return UnsendRequest( - author = message.takeUnless { it.isOutgoing }?.run { individualRecipient.address.address } - ?: loginStateRepository.requireLocalNumber(), - timestamp = message.timestamp - ) - } - - override suspend fun banUser(community: Address.Community, userId: AccountId): Result = runCatching { - OpenGroupApi.ban( - publicKey = userId.hexString, - room = community.room, - server = community.serverUrl, - ) - } - - override suspend fun banAndDeleteAll(community: Address.Community, userId: AccountId) = runCatching { - // Note: This accountId could be the blinded Id - OpenGroupApi.banAndDeleteAll( - publicKey = userId.hexString, - room = community.room, - server = community.serverUrl - ) - } - - override suspend fun deleteMessageRequest(thread: ThreadRecord): Result { - val address = thread.recipient.address as? Address.Conversable ?: return Result.success(Unit) - - return declineMessageRequest( - address - ) - } - - override suspend fun clearAllMessageRequests() = runCatching { - - configFactory.withMutableUserConfigs { configs -> - // Go through all contacts - configs.contacts.all() - .asSequence() - .filter { !it.approved } - .forEach { - configs.contacts.erase(it.id) - } - - - // Go through all invited groups - configs.userGroups.allClosedGroupInfo() - .asSequence() - .filter { it.invited } - .forEach { g -> - configs.userGroups.eraseClosedGroup(g.groupAccountId) - } - } - } - - override suspend fun clearAllMessages(threadId: Long, groupId: AccountId?): Int { - return withContext(Dispatchers.Default) { - // delete data locally - val deletedHashes = storage.clearAllMessages(threadId) - Log.i("", "Cleared messages with hashes: $deletedHashes") - - // if required, also sync groupV2 data - if (groupId != null) { - groupManager.clearAllMessagesForEveryone(groupId, deletedHashes) - } - - deletedHashes.size - } - } - - override suspend fun acceptMessageRequest(recipient: Address.Conversable) = runCatching { - when (recipient) { - is Address.Standard -> { - configFactory.withMutableUserConfigs { configs -> - configs.contacts.upsertContact(recipient) { - approved = true - } - } - - withContext(Dispatchers.Default) { - messageSender.send(message = MessageRequestResponse(true), address = recipient) - - // add a control message for our user - storage.insertMessageRequestResponseFromYou(threadDb.getOrCreateThreadIdFor(recipient)) - } - } - - is Address.Group -> { - groupManager.respondToInvitation( - recipient.accountId, - approved = true - ) - } - - is Address.Community, - is Address.CommunityBlindedId, - is Address.LegacyGroup -> { - // These addresses are not supported for message requests - } - } - - Unit - } - - override suspend fun declineMessageRequest(recipient: Address.Conversable): Result = runCatching { - when (recipient) { - is Address.Standard -> { - configFactory.removeContactOrBlindedContact(recipient) - } - - is Address.Group -> { - groupManager.respondToInvitation( - recipient.accountId, - approved = false - ) - } - - is Address.Community, - is Address.CommunityBlindedId, - is Address.LegacyGroup -> { - // These addresses are not supported for message requests - } - } - } - - override fun hasReceived(threadId: Long): Boolean { - val cursor = mmsSmsDb.getConversation(threadId, true) - mmsSmsDb.readerFor(cursor).use { reader -> - while (reader.next != null) { - if (!reader.current.isOutgoing) { return true } - } - } - return false - } - - // Only call this with a closed group thread ID - override fun getInvitingAdmin(threadId: Long): Address? { - return lokiMessageDb.groupInviteReferrer(threadId)?.let(Address::fromSerialized) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt new file mode 100644 index 0000000000..fc373ac95d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt @@ -0,0 +1,641 @@ +package org.thoughtcrime.securesms.repository + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.withContext +import network.loki.messenger.libsession_util.util.ExpiryMode +import network.loki.messenger.libsession_util.util.GroupInfo +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.userAuth +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.messaging.messages.MarkAsDeletedMessage +import org.session.libsession.messaging.messages.control.MessageRequestResponse +import org.session.libsession.messaging.messages.control.UnsendRequest +import org.session.libsession.messaging.messages.signal.OutgoingTextMessage +import org.session.libsession.messaging.messages.visible.OpenGroupInvitation +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.open_groups.api.BanUserApi +import org.session.libsession.messaging.open_groups.api.CommunityApiExecutor +import org.session.libsession.messaging.open_groups.api.CommunityApiRequest +import org.session.libsession.messaging.open_groups.api.DeleteUserMessagesApi +import org.session.libsession.messaging.open_groups.api.UnbanUserApi +import org.session.libsession.messaging.open_groups.api.execute +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.network.SnodeClock +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.toAddress +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.UserConfigType +import org.session.libsession.utilities.isGroupV2 +import org.session.libsession.utilities.isLegacyGroup +import org.session.libsession.utilities.isStandard +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.RecipientData +import org.session.libsession.utilities.upsertContact +import org.session.libsession.utilities.userConfigsChanged +import org.session.libsession.utilities.withMutableUserConfigs +import org.session.libsession.utilities.withUserConfigs +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.api.swarm.SwarmApiExecutor +import org.thoughtcrime.securesms.api.swarm.SwarmApiRequest +import org.thoughtcrime.securesms.api.swarm.execute +import org.thoughtcrime.securesms.auth.LoginStateRepository +import org.thoughtcrime.securesms.database.CommunityDatabase +import org.thoughtcrime.securesms.database.DraftDatabase +import org.thoughtcrime.securesms.database.LokiMessageDatabase +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.database.RecipientSettingsDatabase +import org.thoughtcrime.securesms.database.SmsDatabase +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.util.castAwayType +import java.util.EnumSet +import javax.inject.Inject +import javax.inject.Singleton +import org.session.libsession.messaging.open_groups.api.DeleteMessageApi as DeleteCommunityMessageApi +import org.thoughtcrime.securesms.api.snode.DeleteMessageApi as DeleteSnodeMessageApi + +@Singleton +class DefaultConversationRepository @Inject constructor( + private val messageDataProvider: MessageDataProvider, + private val threadDb: ThreadDatabase, + private val communityDatabase: CommunityDatabase, + private val draftDb: DraftDatabase, + private val smsDb: SmsDatabase, + private val mmsSmsDb: MmsSmsDatabase, + private val storage: Storage, + private val lokiMessageDb: LokiMessageDatabase, + private val configFactory: ConfigFactory, + private val groupManager: GroupManagerV2, + private val clock: SnodeClock, + private val recipientDatabase: RecipientSettingsDatabase, + private val recipientRepository: RecipientRepository, + private val messageSender: MessageSender, + private val loginStateRepository: LoginStateRepository, + private val proStatusManager: ProStatusManager, + private val swarmApiExecutor: SwarmApiExecutor, + private val communityApiExecutor: CommunityApiExecutor, + private val deleteSwarmMessageApiFactory: DeleteSnodeMessageApi.Factory, + private val deleteCommunityMessageApiFactory: DeleteCommunityMessageApi.Factory, + private val banUserApiFactory: BanUserApi.Factory, + private val unbanUserApiFactory: UnbanUserApi.Factory, + private val deleteUserMessageApiFactory: DeleteUserMessagesApi.Factory, +) : ConversationRepository { + + override val conversationListAddressesFlow get() = loginStateRepository.flowWithLoggedInState { + configFactory + .userConfigsChanged( + EnumSet.of( + UserConfigType.CONTACTS, + UserConfigType.USER_PROFILE, + UserConfigType.USER_GROUPS + )) + .castAwayType() + .onStart { + emit(Unit) + } + .map { getConversationListAddresses() } + } + + private fun getConversationListAddresses() = buildSet { + val myAddress = loginStateRepository.getLocalNumber()?.toAddress() as? Address.Standard + ?: return@buildSet + + // Always have NTS - we should only "hide" them on home screen - the convo should never be deleted + add(myAddress) + + configFactory.withUserConfigs { configs -> + // Contacts + for (contact in configs.contacts.all()) { + if (contact.priority >= 0 && (!contact.blocked || contact.approved)) { + add(Address.Standard(AccountId(contact.id))) + } + } + + // Blinded Contacts + for (blindedContact in configs.contacts.allBlinded()) { + if (blindedContact.priority >= 0) { + add( + Address.CommunityBlindedId( + serverUrl = blindedContact.communityServer, + blindedId = Address.Blinded(AccountId(blindedContact.id)) + )) + } + } + + // Groups + for (group in configs.userGroups.all()) { + when (group) { + is GroupInfo.ClosedGroupInfo -> { + add(Address.Group(AccountId(group.groupAccountId))) + } + + is GroupInfo.LegacyGroupInfo -> { + add(Address.LegacyGroup(group.accountId)) + } + + is GroupInfo.CommunityGroupInfo -> { + add( + Address.Community( + serverUrl = group.community.baseUrl, + room = group.community.room + )) + } + } + } + } + } + + + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) + override fun observeConversationList(): Flow> { + return conversationListAddressesFlow + .flatMapLatest { allAddresses -> + merge( + configFactory.configUpdateNotifications, + recipientDatabase.changeNotification.filter { it in allAddresses }, + communityDatabase.changeNotification.filter { it in allAddresses }, + threadDb.updateNotifications, + // If pro status pref changes, the convo is likely needing changes too + TextSecurePreferences.Companion.events.filter { + it == TextSecurePreferences.Companion.SET_FORCE_OTHER_USERS_PRO || + it == TextSecurePreferences.Companion.SET_FORCE_CURRENT_USER_PRO + it == TextSecurePreferences.Companion.SET_FORCE_POST_PRO + } + ).debounce(500) + .onStart { emit(allAddresses) } + .map { allAddresses } + } + .map { addresses -> + withContext(Dispatchers.Default) { + threadDb.getThreads(addresses) + } + } + } + + override fun getConversationList(): List { + return threadDb.getThreads(getConversationListAddresses()) + } + + override fun saveDraft(threadId: Long, text: String) { + if (text.isEmpty()) return + val drafts = DraftDatabase.Drafts() + drafts.add(DraftDatabase.Draft(DraftDatabase.Draft.TEXT, text)) + draftDb.insertDrafts(threadId, drafts) + } + + override fun getDraft(threadId: Long): String? { + val drafts = draftDb.getDrafts(threadId) + return drafts.find { it.type == DraftDatabase.Draft.TEXT }?.value + } + + override fun clearDrafts(threadId: Long) { + draftDb.clearDrafts(threadId) + } + + override fun inviteContactsToCommunity( + communityRecipient: Recipient, + contacts: Collection
+ ) { + val community = communityRecipient.data as? RecipientData.Community + val info = community?.roomInfo ?: return + for (contact in contacts) { + val message = VisibleMessage() + message.sentTimestamp = clock.currentTimeMillis() + val openGroupInvitation = OpenGroupInvitation().apply { + name = info.details.name + url = community.joinURL + } + message.openGroupInvitation = openGroupInvitation + proStatusManager.addProFeatures(message) + val contactThreadId = threadDb.getOrCreateThreadIdFor(contact) + val expirationConfig = recipientRepository.getRecipientSync(contact).expiryMode + val expireStartedAt = if (expirationConfig is ExpiryMode.AfterSend) message.sentTimestamp!! else 0 + val outgoingTextMessage = OutgoingTextMessage.Companion.fromOpenGroupInvitation( + openGroupInvitation, + contact, + message.sentTimestamp!!, + expirationConfig.expiryMillis, + expireStartedAt, + proFeatures = message.proFeatures + )!! + + message.id = MessageId( + smsDb.insertMessageOutbox( + contactThreadId, + outgoingTextMessage, + false, + message.sentTimestamp!!, + true + ), + false + ) + + messageSender.send(message, contact) + } + } + + override fun isGroupReadOnly(recipient: Recipient): Boolean { + // We only care about group v2 recipient + if (!recipient.isGroupV2Recipient) { + return false + } + + val groupId = recipient.address.toString() + return configFactory.withUserConfigs { configs -> + configs.userGroups.getClosedGroup(groupId)?.let { it.kicked || it.destroyed } == true + } + } + + override fun getLastSentMessageID(threadId: Long): Flow { + return (threadDb.updateNotifications.filter { it == threadId } as Flow<*>) + .onStart { emit(Unit) } + .map { + withContext(Dispatchers.Default) { + mmsSmsDb.getLastSentMessageID(threadId) + } + } + } + + // This assumes that recipient.isContactRecipient is true + override fun setBlocked(recipient: Address, blocked: Boolean) { + if (recipient.isStandard) { + storage.setBlocked(listOf(recipient), blocked) + } + } + + /** + * This will delete these messages from the db + * Not to be confused with 'marking messages as deleted' + */ + override fun deleteMessages(messages: Set) { + // split the messages into mms and sms + val (mms, sms) = messages.partition { it.isMms } + + if(mms.isNotEmpty()){ + messageDataProvider.deleteMessages(mms.map { it.id }, isSms = false) + } + + if(sms.isNotEmpty()){ + messageDataProvider.deleteMessages(sms.map { it.id }, isSms = true) + } + } + + /** + * This will mark the messages as deleted. + * They won't be removed from the db but instead will appear as a special type + * of message that says something like "This message was deleted" + */ + override fun markAsDeletedLocally(messages: Set, displayedMessage: String) { + // split the messages into mms and sms + val (mms, sms) = messages.partition { it.isMms } + + if(mms.isNotEmpty()){ + messageDataProvider.markMessagesAsDeleted( + mms.map { + MarkAsDeletedMessage( + messageId = it.messageId, + isOutgoing = it.isOutgoing + ) + }, + displayedMessage = displayedMessage + ) + + // delete reactions + storage.deleteReactions(messageIds = mms.map { it.id }, mms = true) + } + + if(sms.isNotEmpty()){ + messageDataProvider.markMessagesAsDeleted( + sms.map { + MarkAsDeletedMessage( + messageId = it.messageId, + isOutgoing = it.isOutgoing + ) + }, + displayedMessage = displayedMessage + ) + + // delete reactions + storage.deleteReactions(messageIds = sms.map { it.id }, mms = false) + } + } + + override fun deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord: MessageRecord) { + val threadId = messageRecord.threadId + val senderId = messageRecord.recipient.address.address + val messageRecordsToRemoveFromLocalStorage = mmsSmsDb.getAllMessageRecordsFromSenderInThread(threadId, senderId) + for (message in messageRecordsToRemoveFromLocalStorage) { + messageDataProvider.deleteMessage(messageId = message.messageId) + } + } + + override suspend fun deleteCommunityMessagesRemotely( + community: Address.Community, + messages: Set + ) { + messages.forEach { message -> + lokiMessageDb.getServerID(message.messageId)?.let { messageServerID -> + communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = community.serverUrl, + api = deleteCommunityMessageApiFactory.create( + room = community.room, + messageId = messageServerID + ) + ) + ) + } + } + } + + override suspend fun delete1on1MessagesRemotely( + recipient: Address, + messages: Set + ) { + // delete the messages remotely + val userAuth = requireNotNull(storage.userAuth) { + "User auth is required to delete messages remotely" + } + val userAddress = userAuth.accountId.toAddress() + + messages.forEach { message -> + // delete from swarm + messageDataProvider.getServerHashForMessage(message.messageId) + ?.let { serverHash -> + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = userAuth.accountId.hexString, + api = deleteSwarmMessageApiFactory.create( + messageHashes = listOf(serverHash), + swarmAuth = userAuth + ) + ) + ) + } + + // send an UnsendRequest to user's swarm + buildUnsendRequest(message).let { unsendRequest -> + messageSender.send(unsendRequest, userAddress) + } + + // send an UnsendRequest to recipient's swarm + buildUnsendRequest(message).let { unsendRequest -> + messageSender.send(unsendRequest, recipient) + } + } + } + + override suspend fun deleteLegacyGroupMessagesRemotely( + recipient: Address, + messages: Set + ) { + if (recipient.isLegacyGroup) { + messages.forEach { message -> + // send an UnsendRequest to group's swarm + buildUnsendRequest(message).let { unsendRequest -> + messageSender.send(unsendRequest, recipient) + } + } + } + } + + override suspend fun deleteGroupV2MessagesRemotely( + recipient: Address, + messages: Set + ) { + require(recipient.isGroupV2) { "Recipient is not a group v2 recipient" } + + val groupId = AccountId(recipient.address) + val hashes = messages.mapNotNullTo(mutableSetOf()) { msg -> + messageDataProvider.getServerHashForMessage(msg.messageId) + } + + groupManager.requestMessageDeletion(groupId, hashes) + } + + override suspend fun deleteNoteToSelfMessagesRemotely( + recipient: Address, + messages: Set + ) { + // delete the messages remotely + val userAuth = requireNotNull(storage.userAuth) { + "User auth is required to delete messages remotely" + } + val userAddress = userAuth.accountId.toAddress() + + messages.forEach { message -> + // delete from swarm + messageDataProvider.getServerHashForMessage(message.messageId) + ?.let { serverHash -> + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = userAuth.accountId.hexString, + api = deleteSwarmMessageApiFactory.create( + messageHashes = listOf(serverHash), + swarmAuth = userAuth + ) + ) + ) + } + + // send an UnsendRequest to user's swarm + buildUnsendRequest(message).let { unsendRequest -> + messageSender.send(unsendRequest, userAddress) + } + } + } + + private fun buildUnsendRequest(message: MessageRecord): UnsendRequest { + return UnsendRequest( + author = message.takeUnless { it.isOutgoing } + ?.run { individualRecipient.address.address } + ?: loginStateRepository.requireLocalNumber(), + timestamp = message.timestamp + ) + } + + override suspend fun banUser(community: Address.Community, userId: AccountId): Result = runCatching { + communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = community.serverUrl, + api = banUserApiFactory.create( + userToBan = userId.hexString, + room = community.room + ) + ) + ) + } + + override suspend fun unbanUser(community: Address.Community, userId: AccountId): Result = runCatching { + communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = community.serverUrl, + api = unbanUserApiFactory.create( + userToBan = userId.hexString, + room = community.room + ) + ) + ) + } + + override suspend fun banAndDeleteAll(community: Address.Community, userId: AccountId) = runCatching { + communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = community.serverUrl, + api = banUserApiFactory.create( + userToBan = userId.hexString, + room = community.room + ) + ) + ) + + communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = community.serverUrl, + api = deleteUserMessageApiFactory.create( + userToDelete = userId.hexString, + room = community.room + ) + ) + ) + } + + override suspend fun deleteMessageRequest(thread: ThreadRecord): Result { + val address = thread.recipient.address as? Address.Conversable ?: return Result.success(Unit) + + return declineMessageRequest( + address + ) + } + + override suspend fun clearAllMessageRequests() = runCatching { + + configFactory.withMutableUserConfigs { configs -> + // Go through all contacts + configs.contacts.all() + .asSequence() + .filter { !it.approved } + .forEach { + configs.contacts.erase(it.id) + } + + + // Go through all invited groups + configs.userGroups.allClosedGroupInfo() + .asSequence() + .filter { it.invited } + .forEach { g -> + configs.userGroups.eraseClosedGroup(g.groupAccountId) + } + } + } + + override suspend fun clearAllMessages(threadId: Long, groupId: AccountId?): Int { + return withContext(Dispatchers.Default) { + // delete data locally + val deletedHashes = storage.clearAllMessages(threadId) + Log.i("", "Cleared messages with hashes: $deletedHashes") + + // if required, also sync groupV2 data + if (groupId != null) { + groupManager.clearAllMessagesForEveryone(groupId, deletedHashes) + } + + deletedHashes.size + } + } + + override suspend fun acceptMessageRequest(recipient: Address.Conversable) = runCatching { + when (recipient) { + is Address.Standard -> { + configFactory.withMutableUserConfigs { configs -> + configs.contacts.upsertContact(recipient) { + approved = true + } + } + + withContext(Dispatchers.Default) { + messageSender.send( + message = MessageRequestResponse(true) + .also(proStatusManager::addProFeatures), + address = recipient + ) + + // add a control message for our user + storage.insertMessageRequestResponseFromYou( + threadDb.getOrCreateThreadIdFor( + recipient + ) + ) + } + } + + is Address.Group -> { + groupManager.respondToInvitation( + recipient.accountId, + approved = true + ) + } + + is Address.Community, + is Address.CommunityBlindedId, + is Address.LegacyGroup -> { + // These addresses are not supported for message requests + } + } + + Unit + } + + override suspend fun declineMessageRequest(recipient: Address.Conversable): Result = runCatching { + when (recipient) { + is Address.Standard -> { + configFactory.removeContactOrBlindedContact(recipient) + } + + is Address.Group -> { + groupManager.respondToInvitation( + recipient.accountId, + approved = false + ) + } + + is Address.Community, + is Address.CommunityBlindedId, + is Address.LegacyGroup -> { + // These addresses are not supported for message requests + } + } + } + + override fun hasReceived(threadId: Long): Boolean { + val cursor = mmsSmsDb.getConversation(threadId, true) + mmsSmsDb.readerFor(cursor).use { reader -> + while (reader.next != null) { + if (!reader.current.isOutgoing) { return true } + } + } + return false + } + + // Only call this with a closed group thread ID + override fun getInvitingAdmin(threadId: Long): Address? { + return lokiMessageDb.groupInviteReferrer(threadId)?.let(Address.Companion::fromSerialized) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/reviews/InAppReviewManager.kt b/app/src/main/java/org/thoughtcrime/securesms/reviews/InAppReviewManager.kt index 25df99c30c..b00d41ffbb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reviews/InAppReviewManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/reviews/InAppReviewManager.kt @@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.serialization.json.Json -import org.session.libsession.snode.SnodeClock import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.ManagerScope @@ -31,7 +30,6 @@ import java.util.EnumSet import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.days -import kotlin.time.Duration.Companion.seconds @OptIn(DelicateCoroutinesApi::class) @Singleton diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.kt index 16bde5de03..a8367be60c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.kt @@ -1,68 +1,60 @@ package org.thoughtcrime.securesms.search -import android.content.Context import android.database.Cursor -import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.Lazy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext import network.loki.messenger.libsession_util.util.GroupInfo import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol -import org.session.libsession.utilities.concurrent.SignalExecutors import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientData import org.session.libsession.utilities.recipients.displayName +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.database.CursorList import org.thoughtcrime.securesms.database.MmsSmsColumns import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.SearchDatabase +import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.search.model.MessageResult import org.thoughtcrime.securesms.search.model.SearchResult -import org.thoughtcrime.securesms.util.Stopwatch import javax.inject.Inject import javax.inject.Singleton // Class to manage data retrieval for search @Singleton class SearchRepository @Inject constructor( - @param:ApplicationContext private val context: Context, private val searchDatabase: SearchDatabase, private val recipientRepository: RecipientRepository, - private val conversationRepository: ConversationRepository, + private val conversationRepository: Lazy, private val configFactory: ConfigFactoryProtocol, + @param:ManagerScope private val scope: CoroutineScope, ) { - private val executor = SignalExecutors.SERIAL + private val searchSemaphore = Semaphore(1) - fun query(query: String, callback: (SearchResult) -> Unit) { - // If the sanitized search is empty then abort without search - val cleanQuery = sanitizeQuery(query).trim { it <= ' ' } - - executor.execute { - val timer = - Stopwatch("FtsQuery") - timer.split("clean") - - val contacts = - queryContacts(cleanQuery) - timer.split("Contacts") - - val conversations = - queryConversations(cleanQuery) - timer.split("Conversations") + suspend fun query(query: String): SearchResult = withContext(Dispatchers.Default) { + searchSemaphore.withPermit { + // If the sanitized search is empty then abort without search + val cleanQuery = sanitizeQuery(query).trim { it <= ' ' } + val contacts = queryContacts(cleanQuery) + val conversations = queryConversations(cleanQuery) val messages = queryMessages(cleanQuery) - timer.split("Messages") - - timer.stop(TAG) - callback( - SearchResult( - cleanQuery, - contacts, - conversations, - messages - ) + + SearchResult( + cleanQuery, + contacts, + conversations, + messages ) } } @@ -75,9 +67,10 @@ class SearchRepository @Inject constructor( return } - executor.execute { - val messages = queryMessages(cleanQuery, threadId) - callback(messages) + scope.launch { + searchSemaphore.withPermit { + callback(queryMessages(cleanQuery, threadId)) + } } } @@ -157,8 +150,8 @@ class SearchRepository @Inject constructor( .toList() } - private fun queryMessages(query: String): CursorList { - val allConvo = conversationRepository.conversationListAddressesFlow.value + private suspend fun queryMessages(query: String): CursorList { + val allConvo = conversationRepository.get().conversationListAddressesFlow.first() val messages = searchDatabase.queryMessages(query, allConvo) return if (messages != null) CursorList(messages, MessageModelBuilder()) @@ -213,10 +206,6 @@ class SearchRepository @Inject constructor( } } - interface Callback { - fun onResult(result: E) - } - companion object { private val TAG: String = SearchRepository::class.java.simpleName diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt index 02e744038c..28b26294b2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt @@ -1,24 +1,23 @@ package org.thoughtcrime.securesms.service -import android.content.Context import dagger.Lazy -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first -import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withTimeoutOrNull import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.signal.IncomingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.auth.AuthAwareComponent +import org.thoughtcrime.securesms.auth.LoggedInState import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.MessagingDatabase import org.thoughtcrime.securesms.database.MmsDatabase @@ -28,8 +27,6 @@ import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate -import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.mms.MmsException import java.io.IOException import javax.inject.Inject @@ -48,7 +45,6 @@ private val TAG = ExpiringMessageManager::class.java.simpleName */ @Singleton class ExpiringMessageManager @Inject constructor( - @param:ApplicationContext private val context: Context, private val smsDatabase: SmsDatabase, private val mmsDatabase: MmsDatabase, private val clock: SnodeClock, @@ -56,15 +52,13 @@ class ExpiringMessageManager @Inject constructor( private val loginStateRepository: LoginStateRepository, private val recipientRepository: RecipientRepository, private val threadDatabase: ThreadDatabase, - @ManagerScope scope: CoroutineScope, -) : MessageExpirationManagerProtocol, OnAppStartupComponent { - - init { - scope.launch { - listOf( - launch { processDatabase(smsDatabase) }, - launch { processDatabase(mmsDatabase) } - ).joinAll() +) : MessageExpirationManagerProtocol, AuthAwareComponent { + + + override suspend fun doWhileLoggedIn(loggedInState: LoggedInState) { + supervisorScope { + launch { processDatabase(smsDatabase) } + launch { processDatabase(mmsDatabase) } } } @@ -192,7 +186,7 @@ class ExpiringMessageManager @Inject constructor( val messageId = message.id if (message.expiryMode != ExpiryMode.NONE && messageId != null) { getDatabase(messageId.mms) - .markExpireStarted(messageId.id, clock.currentTimeMills()) + .markExpireStarted(messageId.id, clock.currentTimeMillis()) } } @@ -206,13 +200,13 @@ class ExpiringMessageManager @Inject constructor( if (message.expiryMode is ExpiryMode.AfterSend || (message.expiryMode != ExpiryMode.NONE && message.isSenderSelf)) { getDatabase(messageId.mms) - .markExpireStarted(messageId.id, clock.currentTimeMills()) + .markExpireStarted(messageId.id, message.sentTimestamp!!) } } private suspend fun processDatabase(db: MessagingDatabase) { while (true) { - val expiredMessages = db.getExpiredMessageIDs(clock.currentTimeMills()) + val expiredMessages = db.getExpiredMessageIDs(clock.currentTimeMillis()) if (expiredMessages.isNotEmpty()) { Log.d(TAG, "Deleting ${expiredMessages.size} expired messages from ${db.javaClass.simpleName}") @@ -226,7 +220,7 @@ class ExpiringMessageManager @Inject constructor( } val nextExpiration = db.nextExpiringTimestamp - val now = clock.currentTimeMills() + val now = clock.currentTimeMillis() if (nextExpiration > 0 && nextExpiration <= now) { continue // Proceed to the next iteration if the next expiration is already or about go to in the past diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/GetTokenApi.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/GetTokenApi.kt new file mode 100644 index 0000000000..7adf2a78c3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/GetTokenApi.kt @@ -0,0 +1,66 @@ +package org.thoughtcrime.securesms.tokenpage + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import network.loki.messenger.libsession_util.util.BlindKeyAPI +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.thoughtcrime.securesms.api.server.ServerApiErrorManager +import org.session.libsession.network.SnodeClock +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.http.HttpRequest +import org.thoughtcrime.securesms.api.http.HttpResponse +import org.thoughtcrime.securesms.api.server.ServerApi +import org.thoughtcrime.securesms.auth.LoginStateRepository +import javax.inject.Inject + +class GetTokenApi @Inject constructor( + private val loginStateRepository: LoginStateRepository, + private val clock: SnodeClock, + private val json: Json, + errorManager: ServerApiErrorManager +) : ServerApi(errorManager) { + override fun buildRequest( + baseUrl: String, + x25519PubKeyHex: String + ): HttpRequest { + val userEd25519SecKey = loginStateRepository + .requireLoggedInState() + .accountEd25519KeyPair + .secretKey + .data + + val userBlindedKeys = BlindKeyAPI.blindVersionKeyPair(userEd25519SecKey) + val timestampSeconds = clock.currentTimeSeconds() + + val signature = BlindKeyAPI.blindVersionSignRequest( + ed25519SecretKey = userEd25519SecKey, + timestamp = timestampSeconds, + path = "/info", + body = null, + method = "GET" + ) + + return HttpRequest( + method = "GET", + url = "$baseUrl/info".toHttpUrl(), + headers = mapOf( + "X-FS-Pubkey" to "07" + userBlindedKeys.pubKey.data.toHexString(), + "X-FS-Timestamp" to timestampSeconds.toString(), + "X-FS-Signature" to Base64.encodeBytes(signature), + ), + body = null, + ) + } + + @OptIn(ExperimentalSerializationApi::class) + override suspend fun handleSuccessResponse( + executorContext: ApiExecutorContext, + baseUrl: String, + response: HttpResponse + ): InfoResponse { + return response.body.asInputStream().use(json::decodeFromStream) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPage.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPage.kt index 0c1ba1b02f..c2805accac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPage.kt @@ -237,7 +237,6 @@ fun SessionNetworkInfoSection(modifier: Modifier = Modifier) { // 2.) Session network description val sessionNetworkDetailsAnnotatedString = annotatedStringResource( - highlightColor = LocalColors.current.accentText, text = Phrase.from(context.getText(R.string.sessionNetworkDescription)) .put(NETWORK_NAME_KEY, NETWORK_NAME) .put(TOKEN_NAME_LONG_KEY, TOKEN_NAME_LONG) @@ -342,14 +341,12 @@ fun NodeDetailsBox( val appName = context.getString(R.string.app_name) val nodesInSwarmAS = annotatedStringResource( - highlightColor = LocalColors.current.accentText, text = Phrase.from(context, R.string.sessionNetworkNodesSwarm) .put(APP_NAME_KEY, appName) .format() ) val nodesSecuringMessagesAS = annotatedStringResource( - highlightColor = LocalColors.current.accentText, text = Phrase.from(context, R.string.sessionNetworkNodesSecuring) .put(APP_NAME_KEY, appName) .format() diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt index b8e9b44582..79cf6fd7b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt @@ -17,11 +17,9 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R -import nl.komponents.kovenant.Promise import org.session.libsession.LocalisedTimeUtil.toShortSinglePartString -import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.utilities.await +import org.session.libsession.network.onion.PathManager +import org.session.libsession.network.snode.SwarmDirectory import org.session.libsession.utilities.NonTranslatableStringConstants.SESSION_NETWORK_DATA_PRICE import org.session.libsession.utilities.NonTranslatableStringConstants.TOKEN_NAME_SHORT import org.session.libsession.utilities.NonTranslatableStringConstants.USD_NAME_SHORT @@ -29,7 +27,6 @@ import org.session.libsession.utilities.StringSubstitutionConstants.DATE_TIME_KE import org.session.libsession.utilities.StringSubstitutionConstants.RELATIVE_TIME_KEY import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.Snode import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.util.DateUtils @@ -48,6 +45,8 @@ class TokenPageViewModel @Inject constructor( private val dateUtils: DateUtils, private val loginStateRepository: LoginStateRepository, private val conversationRepository: ConversationRepository, + private val swarmDirectory: SwarmDirectory, + private val pathManager: PathManager ) : ViewModel() { private val TAG = "TokenPageVM" @@ -238,13 +237,10 @@ class TokenPageViewModel @Inject constructor( withContext(Dispatchers.Default) { val myPublicKey = loginStateRepository.requireLocalNumber() - val getSwarmSetPromise: Promise, Exception> = - SnodeAPI.getSwarm(myPublicKey) - val numSessionNodesInOurSwarm = try { // Get the count of Session nodes in our swarm (technically in the range 1..10, but // even a new account seems to start with a nodes-in-swarm count of 4). - getSwarmSetPromise.await().size + swarmDirectory.getSwarm(myPublicKey).size } catch (e: Exception) { Log.w(TAG, "Couldn't get nodes in swarm count.", e) 5 // Pick a sane middle-ground should we error for any reason @@ -278,7 +274,7 @@ class TokenPageViewModel @Inject constructor( } // This is hard-coded to 2 on Android but may vary on other platforms - val pathCount = OnionRequestAPI.paths.value.size + val pathCount = pathManager.paths.value.size /* Note: Num session nodes securing you messages formula is: diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt index 041e64a1cb..1aea452f1e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt @@ -2,22 +2,12 @@ package org.thoughtcrime.securesms.tokenpage import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.Json -import network.loki.messenger.libsession_util.util.BlindKeyAPI -import okhttp3.Headers.Companion.toHeaders -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import org.session.libsession.database.StorageProtocol -import org.session.libsession.messaging.file_server.FileServerApi -import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.utilities.await -import org.session.libsignal.utilities.Base64 -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.api.server.ServerApiExecutor +import org.thoughtcrime.securesms.api.server.ServerApiRequest +import org.thoughtcrime.securesms.api.server.execute import javax.inject.Inject +import javax.inject.Provider import javax.inject.Singleton -import kotlin.time.Duration.Companion.milliseconds interface TokenRepository { suspend fun getInfoResponse(): InfoResponse? @@ -26,91 +16,21 @@ interface TokenRepository { @Singleton class TokenRepositoryImpl @Inject constructor( @param:ApplicationContext val context: Context, - private val storage: StorageProtocol, - private val json: Json, + private val serverApiExecutor: ServerApiExecutor, + private val getTokenApi: Provider, ): TokenRepository { - private val TAG = "TokenRepository" - - private val TOKEN_SERVER_URL = "http://networkv1.getsession.org" - private val TOKEN_SERVER_INFO_ENDPOINT = "$TOKEN_SERVER_URL/info" - private val SERVER_PUBLIC_KEY = "cbf461a4431dc9174dceef4421680d743a2a0e1a3131fc794240bcb0bc3dd449" - - private val secretKey by lazy { - storage.getUserED25519KeyPair()?.secretKey?.data - ?: throw (FileServerApi.Error.NoEd25519KeyPair) - } - - private val userBlindedKeys by lazy { - BlindKeyAPI.blindVersionKeyPair(secretKey) - } - - private fun defaultErrorHandling(e: Exception): T? { - Log.e("TokenRepo", "Server error getting data: $e") - return null - } - // Method to access the /info endpoint and retrieve a InfoResponse via onion-routing. - override suspend fun getInfoResponse(): InfoResponse? { - return sendOnionRequest( - path = "info", - url = TOKEN_SERVER_INFO_ENDPOINT - ) + override suspend fun getInfoResponse(): InfoResponse { + return serverApiExecutor.execute(ServerApiRequest( + serverBaseUrl = TOKEN_SERVER_URL, + serverX25519PubKeyHex = TOKEN_SERVER_PUBLIC_KEY, + api = getTokenApi.get(), + )) } - private suspend inline fun sendOnionRequest( - path: String, url: String, body: ByteArray? = null, - customCatch: (Exception) -> T? = { e -> defaultErrorHandling(e) } - ): T? { - val timestampSeconds = System.currentTimeMillis().milliseconds.inWholeSeconds - val signature = BlindKeyAPI.blindVersionSignRequest( - ed25519SecretKey = secretKey, // Important: Use the ED25519 secret key here and NOT the blinded secret key! - timestamp = timestampSeconds, - path = ("/$path"), - body = body, - method = if (body == null) "GET" else "POST" - ) - - val headersMap = mapOf( - "X-FS-Pubkey" to "07" + userBlindedKeys.pubKey.data.toHexString(), - "X-FS-Timestamp" to timestampSeconds.toString(), - "X-FS-Signature" to Base64.encodeBytes(signature) // Careful: Do NOT add `android.util.Base64.NO_WRAP` to this - it breaks it. - ) - - var requestBuilder = Request.Builder() - requestBuilder = if (body == null) { - requestBuilder.get() - } else { - requestBuilder.post(body.toRequestBody()) - } - val request = requestBuilder - .url(url) - .headers(headersMap.toHeaders()) - .build() - - var response: T? = null - try { - val rawResponse = OnionRequestAPI.sendOnionRequest( - request = request, - server = TOKEN_SERVER_URL, // Note: The `request` contains the actual endpoint we'll hit - x25519PublicKey = SERVER_PUBLIC_KEY - ).await() - - val resultJsonString = rawResponse.body?.decodeToString() - if (resultJsonString == null) { - Log.w(TAG, "${T::class.java} decoded to null") - } else { - response = json.decodeFromString(resultJsonString) - } - } - catch (se: SerializationException) { - Log.e(TAG, "Got a serialization exception attempting to decode ${T::class.java}", se) - } - catch (e: Exception) { - val catchResponse = customCatch(e) - Log.e(TAG, "Got an error: $catchResponse") - return catchResponse - } - - return response + companion object { + private const val TOKEN_SERVER_URL = "http://networkv1.getsession.org" + private const val TOKEN_SERVER_PUBLIC_KEY = "cbf461a4431dc9174dceef4421680d743a2a0e1a3131fc794240bcb0bc3dd449" } + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt index cb2be5960b..d949dc72ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt @@ -73,6 +73,8 @@ data class SimpleDialogData( val message: CharSequence, val positiveText: String? = null, val positiveStyleDanger: Boolean = true, + + val negativeStyleDanger: Boolean = false, val showXIcon: Boolean = false, val negativeText: String? = null, val positiveQaTag: String? = null, diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 31775fd2d3..e5911a54f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -4,8 +4,6 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.Crossfade -import androidx.compose.animation.SizeTransform import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.FastOutSlowInEasing @@ -36,14 +34,12 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn @@ -79,6 +75,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -86,6 +83,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.BlendMode @@ -103,6 +102,9 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics @@ -427,15 +429,7 @@ fun Cell( modifier = modifier .then( if (dropShadow) - Modifier.dropShadow( - shape = MaterialTheme.shapes.small, - shadow = Shadow( - radius = 4.dp, - color = LocalColors.current.text, - alpha = 0.25f, - offset = DpOffset(0.dp, 4.dp) - ) - ) + Modifier.sessionDropShadow() else Modifier ) .clip(MaterialTheme.shapes.small) @@ -773,6 +767,75 @@ fun SearchBar( ) } +/** + * Search with the close action for removing focus + */ + +@Composable +fun SearchBarWithClose( + query: String, + onValueChanged: (String) -> Unit, + onClear: () -> Unit, + isFocused: Boolean, + onFocusChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier, + placeholder: String? = null, + enabled: Boolean = true, + backgroundColor: Color = LocalColors.current.backgroundSecondary, +) { + + val focusManager = LocalFocusManager.current + val keyboard = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + + // When the parent toggles isFocused, request or clear focus accordingly + LaunchedEffect(isFocused) { + if (isFocused) { + focusRequester.requestFocus() + keyboard?.show() + } else { + focusManager.clearFocus(force = true) + keyboard?.hide() + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) + + ) { + SearchBar( + query = query, + onValueChanged = onValueChanged, + onClear = onClear, + placeholder = placeholder, + enabled = enabled, + backgroundColor = backgroundColor, + modifier = Modifier + .weight(1f) + .background(backgroundColor, MaterialTheme.shapes.small) + .onFocusChanged { onFocusChanged(it.isFocused) } + ) + + // Right-side Cancel (outside the search field) + AnimatedVisibility(visible = isFocused) { + Text( + text = LocalResources.current.getString(R.string.close), + style = LocalType.current.base, + color = LocalColors.current.text, + modifier = Modifier + .clickable { + focusManager.clearFocus(force = true) + } + .padding( + vertical = LocalDimensions.current.xxsSpacing + ) + ) + } + } +} + /** * CollapsibleFooterAction */ @@ -901,20 +964,20 @@ private fun CollapsibleFooterActions( val capDp = with(density) { capPx.toDp() } val single = items.size == 1 - val measuredMaxButtonWidthPx = remember(items, capPx) { mutableIntStateOf(1) } + var equalWidthPx by rememberSaveable(capPx) { mutableIntStateOf(-1) } // Only do the offscreen equal width computation when we have 2+ buttons. if (!single) { SubcomposeLayout { parentConstraints -> val measurables = subcompose("measureButtons") { items.forEach { item -> - SlimFillButtonRect(item.buttonLabel.string(), color = item.buttonColor) {} + SlimFillButtonRect(item.buttonLabel.string(), color = LocalColors.current.accent) {} } } val placeables = measurables.map { m -> m.measure( Constraints( - minWidth = 1, + minWidth = 0, maxWidth = capPx, minHeight = 0, maxHeight = parentConstraints.maxHeight @@ -922,13 +985,12 @@ private fun CollapsibleFooterActions( ) } val natural = placeables.maxOfOrNull { it.width } ?: 1 - measuredMaxButtonWidthPx.intValue = natural.coerceIn(1, capPx) + equalWidthPx = natural.coerceIn(0, capPx) + layout(0, 0) {} } } - val equalWidthDp = with(density) { measuredMaxButtonWidthPx.intValue.toDp() } - Column( modifier = Modifier .fillMaxWidth() @@ -949,13 +1011,22 @@ private fun CollapsibleFooterActions( }, qaTag = R.string.qa_collapsing_footer_action, endContent = { + val widthMod = + if (single) { + Modifier + .wrapContentWidth() + .widthIn(max = capDp) + } else if (equalWidthPx >= 0) { + Modifier.width(with(density) { equalWidthPx.toDp() }) + } else { + Modifier + .wrapContentWidth() + .widthIn(max = capDp) + } Box( modifier = Modifier .padding(start = LocalDimensions.current.smallSpacing) - .then( - if (single) Modifier.wrapContentWidth().widthIn(max = capDp) - else Modifier.width(equalWidthDp) - ) + .then(widthMod) ) { val buttonModifier = if (single) Modifier else Modifier.fillMaxWidth() SlimFillButtonRect( @@ -963,7 +1034,7 @@ private fun CollapsibleFooterActions( .qaTag(stringResource(R.string.qa_collapsing_footer_action)+"_"+item.buttonLabel.string().lowercase()) .clearAndSetSemantics{}, text = item.buttonLabel.string(), - color = item.buttonColor + color = if(item.isDanger) LocalColors.current.danger else LocalColors.current.accent ) { item.onClick() } @@ -985,7 +1056,7 @@ data class CollapsibleFooterActionData( data class CollapsibleFooterItemData( val label: GetString, val buttonLabel: GetString, - val buttonColor: Color, + val isDanger: Boolean, val onClick: () -> Unit ) @@ -1000,13 +1071,13 @@ fun PreviewCollapsibleActionTray( CollapsibleFooterItemData( label = GetString("Invite "), buttonLabel = GetString("Invite"), - buttonColor = LocalColors.current.accent, + isDanger = false, onClick = {} ), CollapsibleFooterItemData( label = GetString("Delete"), buttonLabel = GetString("2"), - buttonColor = LocalColors.current.danger, + isDanger = true, onClick = {} ) ) @@ -1032,13 +1103,13 @@ fun PreviewCollapsibleActionTrayLongText( CollapsibleFooterItemData( label = GetString("Looooooooooooooooooooooooooooooooooooooooooooooooooooooooong"), buttonLabel = GetString("Long Looooooooooooooooooooong"), - buttonColor = LocalColors.current.accent, + isDanger = false, onClick = {} ), CollapsibleFooterItemData( label = GetString("Delete"), buttonLabel = GetString("Delete"), - buttonColor = LocalColors.current.danger, + isDanger = true, onClick = {} ) ) @@ -1506,4 +1577,23 @@ fun PreviewActionRowItems() { ) } } +} + + +@Preview +@Composable +fun PreviewSearchWithCancel( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + SearchBarWithClose( + query = "Test Query", + onValueChanged = { }, + onClear = { }, + placeholder = "Search", + enabled = true, + isFocused = true, + onFocusChanged = {} + ) + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Modifiers.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Modifiers.kt index 4c76695858..bb7ecb5716 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Modifiers.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Modifiers.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size @@ -36,6 +37,7 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag @@ -44,6 +46,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import org.thoughtcrime.securesms.ui.theme.LocalColors @@ -318,3 +321,13 @@ private fun DrawScope.drawShimmerOverlay( ) } +@Composable +fun Modifier.sessionDropShadow() = this.dropShadow( + shape = MaterialTheme.shapes.small, + shadow = Shadow( + radius = 4.dp, + color = LocalColors.current.text, + alpha = 0.25f, + offset = DpOffset(0.dp, 4.dp) + ) +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt index 735c418938..3988fe5bca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt @@ -49,6 +49,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.retain.retain import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -253,8 +254,8 @@ fun SessionProCTA( // We should avoid internal state in a composable but having the bottom sheet // here avoids re-defining the sheet in multiple places in the app - var showDialog by remember { mutableStateOf(true) } - var showProSheet by remember { mutableStateOf(false) } + var showDialog by retain { mutableStateOf(true) } + var showProSheet by retain { mutableStateOf(false) } // default handling of the upgrade button val defaultUpgrade: () -> Unit = { @@ -640,10 +641,9 @@ fun LongMessageProCTA( // Reusable animated profile pic Pro CTA @Composable fun AnimatedProfilePicProCTA( - proSubscription: ProStatus, + expired: Boolean, onDismissRequest: () -> Unit, ){ - val expired = proSubscription is ProStatus.Expired val context = LocalContext.current AnimatedSessionProCTA( diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt index bc4c0330b0..cd71ed5c99 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt @@ -21,10 +21,11 @@ class UINavigator () { suspend fun navigate( destination: T, - navOptions: NavOptionsBuilder.() -> Unit = {} + navOptions: NavOptionsBuilder.() -> Unit = {}, + debounce : Boolean = true // For when intentionally chaining navigations ) { val currentTime = System.currentTimeMillis() - if (currentTime - lastNavigationTime > navigationDebounceTime) { + if (!debounce || currentTime - lastNavigationTime > navigationDebounceTime) { lastNavigationTime = currentTime _navigationActions.send(NavigationAction.Navigate( destination = destination, diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt index 95da4c7df5..aa3e8a80b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt @@ -1,10 +1,13 @@ package org.thoughtcrime.securesms.ui import android.app.Activity +import android.content.ActivityNotFoundException import android.content.Context import android.content.ContextWrapper import android.content.Intent import android.net.Uri +import android.os.PowerManager +import android.provider.Settings import android.view.View import android.view.ViewTreeObserver import android.widget.Toast @@ -89,6 +92,34 @@ fun Context.findActivity(): Activity { throw IllegalStateException("Permissions should be called in the context of an Activity") } +fun Context.isWhitelistedFromDoze(): Boolean { + val pm = getSystemService(Context.POWER_SERVICE) as PowerManager + return pm.isIgnoringBatteryOptimizations(packageName) +} + +fun Activity.requestDozeWhitelist() { + if (isWhitelistedFromDoze()) return + + val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:$packageName") + } + try { + startActivity(intent) // shows the system dialog for this specific app + } catch (_: ActivityNotFoundException) { + // Fallback to the general settings list + try { + val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) + startActivity(intent) + } catch (_: ActivityNotFoundException) { + try { + startActivity(Intent(Settings.ACTION_SETTINGS)) + } catch (_: ActivityNotFoundException) { + Toast.makeText(this, R.string.errorGeneric, Toast.LENGTH_LONG).show() + } + } + } +} + inline fun T.afterMeasured(crossinline block: T.() -> Unit) { viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { override fun onGlobalLayout() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AnnotatedString.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AnnotatedString.kt index af93a5ef4f..08f19c8fe5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AnnotatedString.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AnnotatedString.kt @@ -61,7 +61,7 @@ private fun resources(): Resources { @Composable fun annotatedStringResource( @StringRes id: Int, - highlightColor: Color = LocalColors.current.accent + highlightColor: Color = LocalColors.current.accentText ): AnnotatedString { val resources = resources() val density = LocalDensity.current @@ -74,7 +74,7 @@ fun annotatedStringResource( @Composable fun annotatedStringResource( text: CharSequence, - highlightColor: Color = LocalColors.current.accent + highlightColor: Color = LocalColors.current.accentText ): AnnotatedString { val density = LocalDensity.current return remember(text.hashCode()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Avatar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Avatar.kt index f1e49a1d50..a47c6e11f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Avatar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Avatar.kt @@ -134,15 +134,15 @@ fun Avatar( badge = when (badge) { AvatarBadge.None -> null - else -> { - { - Image( - painter = painterResource(id = badge.icon), - contentDescription = null, - ) - } - } - } + is AvatarBadge.ResourceBadge -> { { + Image( + painter = painterResource(id = badge.icon), + contentDescription = null, + ) + }} + + is AvatarBadge.ComposeBadge -> badge.content + } ) } @@ -296,7 +296,7 @@ fun PreviewAvatarSingleAdmin(){ color = primaryGreen, remoteFile = null ))), - badge = AvatarBadge.Admin + badge = AvatarBadge.ResourceBadge.Admin ) } } 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 e2fd221faa..af22fb21fa 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 @@ -161,7 +161,7 @@ data class OceanDark(override val accent: Color = primaryBlue) : ThemeColors { override val disabled = disabledDark override val background = oceanDark2 override val backgroundSecondary = oceanDark1 - override val backgroundTertiary = oceanDark0 + override val backgroundTertiary = oceanDark2 override val onInvertedBackgroundAccent = background override val text = oceanDark7 override val textSecondary = oceanDark5 diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AbstractCursorLoader.java b/app/src/main/java/org/thoughtcrime/securesms/util/AbstractCursorLoader.java index b8fbef3803..dbcaee23fe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AbstractCursorLoader.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AbstractCursorLoader.java @@ -2,14 +2,15 @@ import android.annotation.SuppressLint; import android.content.Context; -import android.database.Cursor; +import android.database.ContentObserver; + import androidx.loader.content.AsyncTaskLoader; /** * A Loader similar to CursorLoader that doesn't require queries to go through the ContentResolver * to get the benefits of reloading when content has changed. */ -public abstract class AbstractCursorLoader extends AsyncTaskLoader { +public abstract class AbstractCursorLoader extends AsyncTaskLoader { @SuppressWarnings("unused") private static final String TAG = AbstractCursorLoader.class.getSimpleName(); @@ -17,7 +18,7 @@ public abstract class AbstractCursorLoader extends AsyncTaskLoader { @SuppressLint("StaticFieldLeak") protected final Context context; private final ForceLoadContentObserver observer; - protected Cursor cursor; + protected T cursor; public AbstractCursorLoader(Context context) { super(context); @@ -25,17 +26,17 @@ public AbstractCursorLoader(Context context) { this.observer = new ForceLoadContentObserver(); } - public abstract Cursor getCursor(); + public abstract T getData(); @Override - public void deliverResult(Cursor newCursor) { + public void deliverResult(T newCursor) { if (isReset()) { if (newCursor != null) { newCursor.close(); } return; } - Cursor oldCursor = this.cursor; + T oldCursor = this.cursor; this.cursor = newCursor; @@ -63,15 +64,15 @@ protected void onStopLoading() { } @Override - public void onCanceled(Cursor cursor) { + public void onCanceled(T cursor) { if (cursor != null && !cursor.isClosed()) { cursor.close(); } } @Override - public Cursor loadInBackground() { - Cursor newCursor = getCursor(); + public T loadInBackground() { + T newCursor = getData(); if (newCursor != null) { newCursor.getCount(); newCursor.registerContentObserver(observer); @@ -91,4 +92,11 @@ protected void onReset() { cursor = null; } + public interface CursorLike { + void close(); + boolean isClosed(); + int getCount(); + void registerContentObserver(ContentObserver observer); + } + } 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/java/org/thoughtcrime/securesms/util/AvatarUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtils.kt index ff7c0d78ed..63a9fecbaa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtils.kt @@ -11,6 +11,7 @@ import android.graphics.drawable.BitmapDrawable import android.text.TextPaint import android.text.TextUtils import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.core.content.ContextCompat import androidx.core.graphics.createBitmap @@ -69,6 +70,11 @@ class AvatarUtils @Inject constructor( val elements = buildList { when { + // if we know we have a custom image, including for groups, use that + recipient.avatar != null -> { + add(getUIElementForRecipient(recipient)) + } + // The recipient is group like and have two members, use both images firstMember != null && secondMember != null -> { add(getUIElementForRecipient(firstMember)) @@ -229,10 +235,17 @@ data class AvatarUIElement( val freezeFrame: Boolean = true, ) -sealed class AvatarBadge(@DrawableRes val icon: Int){ - data object None: AvatarBadge(0) - data object Admin: AvatarBadge(R.drawable.ic_crown_custom_enlarged_no_padding) - data class Custom(@DrawableRes val iconRes: Int): AvatarBadge(iconRes) +sealed class AvatarBadge{ + data object None: AvatarBadge() + + sealed class ResourceBadge(@DrawableRes val icon: Int): AvatarBadge() { + data object Admin: ResourceBadge(R.drawable.ic_crown_custom_enlarged_no_padding) + data class Custom(@DrawableRes val iconRes: Int): ResourceBadge(iconRes) + } + + data class ComposeBadge( + val content: @Composable () -> Unit + ): AvatarBadge() } fun ImageRequest.Builder.avatarOptions( @@ -241,6 +254,7 @@ fun ImageRequest.Builder.avatarOptions( ): ImageRequest.Builder = this.size(sizePx, sizePx) .precision(Precision.INEXACT) .apply { + memoryCacheKeyExtra("freezeFrame", freezeFrame.toString()) if (freezeFrame) { decoderFactory(BitmapFactoryDecoder.Factory()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt index 030560f41f..d1ba385308 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt @@ -199,20 +199,19 @@ class DateUtils @Inject constructor( date1.hour == date2.hour } - fun getExpiryString(instant: Instant?): String { - if (instant == null) return context.getString(R.string.proExpired) - - val now = Instant.now() - val remaining = Duration.between(now, instant) - + fun getExpiryString(remaining: Duration): String { // Already expired - if (remaining.isNegative || remaining.isZero) { + if (remaining.isNegative) { return context.getString(R.string.proExpired) } val locale = context.resources.configuration.locales[0] val format = MeasureFormat.getInstance(locale, MeasureFormat.FormatWidth.WIDE) + if (remaining.isZero) { + return format.format(Measure(0, MeasureUnit.SECOND)) + } + // Round any fractional second up to the next whole second val totalSeconds = remaining.seconds + if (remaining.nano > 0) 1 else 0 val DAY: Long = 86_400 @@ -240,6 +239,13 @@ class DateUtils @Inject constructor( } } + fun getExpiryString(instant: Instant?, now: Instant = Instant.now()): String { + if (instant == null) return context.getString(R.string.proExpired) + return getExpiryString( + remaining = Duration.between(now, instant) + ) + } + // Helper methods private fun toLocalDate(timestamp: Long): LocalDate = Instant.ofEpochMilli(timestamp).atZone(ZoneId.systemDefault()).toLocalDate() @@ -291,7 +297,12 @@ class DateUtils @Inject constructor( fun getLocalisedTimeDuration(context: Context, amount: Int, unit: MeasureUnit): String { val locale = context.resources.configuration.locales[0] val format = MeasureFormat.getInstance(locale, MeasureFormat.FormatWidth.WIDE) - return format.format(Measure(amount, unit)) + val rawString = format.format(Measure(amount, unit)) + + // capitalise duration + return rawString.replace(Regex("""(\d+\p{Z}+)(\p{L})""")) { + it.groupValues[1] + it.groupValues[2].uppercase(locale) + } } // Format a given timestamp with a specific pattern diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt index a19f89dae3..ab74ef8d89 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.util import android.content.res.Resources +import android.util.Log import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import kotlin.math.roundToInt @@ -23,13 +24,23 @@ fun toDp(px: Float, resources: Resources): Float { return (px / scale) } -/** - * Returns true if the recyclerview is scrolled within 50dp of the bottom - */ val RecyclerView.isNearBottom: Boolean - get() = computeVerticalScrollOffset().coerceAtLeast(0) + - computeVerticalScrollExtent() + - toPx(50, resources) >= computeVerticalScrollRange() + get() { + val offset = computeVerticalScrollOffset().coerceAtLeast(0) + val extent = computeVerticalScrollExtent() + val range = computeVerticalScrollRange() + val thresholdPx = toPx(50, resources) + + // If there's no scrollable area, don't treat it as "near bottom" + if (range <= extent) { + return false + } + + val remaining = range - (offset + extent) // distance from bottom in px + + // true only when remaining distance to bottom <= 50dp + return remaining <= thresholdPx + } val RecyclerView.isFullyScrolled: Boolean get() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt b/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt index e9362d9d75..e4a85c1605 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch -import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsignal.utilities.Log import java.io.DataInputStream import java.io.InputStream @@ -73,8 +73,9 @@ class IP2Country internal constructor( if (isInitialized) { return; } shared = IP2Country(context.applicationContext) + //todo we should look into injecting this class and optimising GlobalScope.launch { - OnionRequestAPI.paths + MessagingModuleConfiguration.shared.pathManager.paths .filter { it.isNotEmpty() } .collectLatest { shared.populateCacheIfNeeded() @@ -104,7 +105,7 @@ class IP2Country internal constructor( private fun populateCacheIfNeeded() { val start = System.currentTimeMillis() - OnionRequestAPI.paths.value.iterator().forEach { path -> + MessagingModuleConfiguration.shared.pathManager.paths.value.iterator().forEach { path -> path.iterator().forEach { snode -> cacheCountryForIP(snode.ip) // Preload if needed } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Stopwatch.java b/app/src/main/java/org/thoughtcrime/securesms/util/Stopwatch.java deleted file mode 100644 index d92fc7546d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Stopwatch.java +++ /dev/null @@ -1,56 +0,0 @@ -package org.thoughtcrime.securesms.util; - -import androidx.annotation.NonNull; - -import org.session.libsignal.utilities.Log; - -import java.util.LinkedList; -import java.util.List; - -public class Stopwatch { - - private final long startTime; - private final String title; - private final List splits; - - public Stopwatch(@NonNull String title) { - this.startTime = System.currentTimeMillis(); - this.title = title; - this.splits = new LinkedList<>(); - } - - public void split(@NonNull String label) { - splits.add(new Split(System.currentTimeMillis(), label)); - } - - public void stop(@NonNull String tag) { - StringBuilder out = new StringBuilder(); - out.append("[").append(title).append("] "); - - if (splits.size() > 0) { - out.append(splits.get(0).label).append(": "); - out.append(splits.get(0).time - startTime); - out.append(" "); - } - - if (splits.size() > 1) { - for (int i = 1; i < splits.size(); i++) { - out.append(splits.get(i).label).append(": "); - out.append(splits.get(i).time - splits.get(i - 1).time); - out.append("ms "); - } - out.append("total: ").append(splits.get(splits.size() - 1).time - startTime).append("ms."); - } - Log.d(tag, out.toString()); - } - - private static class Split { - final long time; - final String label; - - Split(long time, String label) { - this.time = time; - this.label = label; - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ThrowableUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ThrowableUtil.kt index fb97eddd54..db14772241 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ThrowableUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ThrowableUtil.kt @@ -12,9 +12,9 @@ fun Throwable.causes(): Sequence = sequence { } /** - * Find out if this throwable as a root cause of the specified type, if so return it. + * Find out if this throwable or any of its causes is of type [E], returning the first one found or null. */ -inline fun Throwable.getRootCause(): E? { +inline fun Throwable.findCause(): E? { return causes() .filterIsInstance() .firstOrNull() diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/VersionDataFetcher.kt b/app/src/main/java/org/thoughtcrime/securesms/util/VersionDataFetcher.kt index 0c4035dbd2..6b99cfca16 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/VersionDataFetcher.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/VersionDataFetcher.kt @@ -5,11 +5,16 @@ import android.os.Looper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import org.session.libsession.messaging.file_server.FileServerApi +import org.session.libsession.messaging.file_server.FileServerApis +import org.session.libsession.messaging.file_server.GetClientVersionApi import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.api.server.ServerApiExecutor +import org.thoughtcrime.securesms.api.server.ServerApiRequest +import org.thoughtcrime.securesms.api.server.execute import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import javax.inject.Inject +import javax.inject.Provider import javax.inject.Singleton import kotlin.time.Duration.Companion.hours @@ -19,14 +24,20 @@ private val REFRESH_TIME_MS = 4.hours.inWholeMilliseconds @Singleton class VersionDataFetcher @Inject constructor( private val prefs: TextSecurePreferences, - private val fileServerApi: FileServerApi, + private val serverApiExecutor: ServerApiExecutor, + private val getClientVersionApi: Provider, ) : OnAppStartupComponent { private val handler = Handler(Looper.getMainLooper()) private val fetchVersionData = Runnable { scope.launch { try { // Perform the version check - val clientVersion = fileServerApi.getClientVersion() + val clientVersion = serverApiExecutor.execute( + ServerApiRequest( + fileServer = FileServerApis.DEFAULT_FILE_SERVER, + api = getClientVersionApi.get() + ) + ) Log.i(TAG, "Fetched version data: $clientVersion") prefs.setLastVersionCheck() startTimedVersionCheck() diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt index 168e46276d..61fe3b3f5a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -18,19 +18,17 @@ import kotlinx.serialization.json.boolean import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.functional.bind import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.calls.CallMessageType import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Debouncer import org.session.libsession.utilities.Util -import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.ICE_CANDIDATES import org.session.libsignal.utilities.Log +import org.session.protos.SessionProtos.CallMessage.Type.ICE_CANDIDATES import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioDeviceUpdate import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioEnabled @@ -75,6 +73,7 @@ class CallManager @Inject constructor( audioManager: AudioManagerCompat, private val storage: StorageProtocol, private val messageSender: MessageSender, + private val snodeClock: SnodeClock ): PeerConnection.Observer, SignalAudioManager.EventListener, CameraEventListener, DataChannel.Observer { @@ -691,7 +690,7 @@ class CallManager @Inject constructor( } } - fun insertCallMessage(threadPublicKey: String, callMessageType: CallMessageType, signal: Boolean = false, sentTimestamp: Long = SnodeAPI.nowWithOffset) { + fun insertCallMessage(threadPublicKey: String, callMessageType: CallMessageType, signal: Boolean = false, sentTimestamp: Long = snodeClock.currentTimeMillis()) { storage.insertCallMessage(threadPublicKey, callMessageType, sentTimestamp) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt index b3d83b6663..0f4e461a2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt @@ -2,29 +2,24 @@ package org.thoughtcrime.securesms.webrtc import android.Manifest import android.content.Context -import dagger.Lazy import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.calls.CallMessageType import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.utilities.WebRtcUtils -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.ANSWER -import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.END_CALL -import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.ICE_CANDIDATES -import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.OFFER -import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.PRE_OFFER -import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.PROVISIONAL_ANSWER import org.session.libsignal.utilities.Log +import org.session.protos.SessionProtos.CallMessage.Type.ANSWER +import org.session.protos.SessionProtos.CallMessage.Type.END_CALL +import org.session.protos.SessionProtos.CallMessage.Type.ICE_CANDIDATES +import org.session.protos.SessionProtos.CallMessage.Type.OFFER +import org.session.protos.SessionProtos.CallMessage.Type.PRE_OFFER +import org.session.protos.SessionProtos.CallMessage.Type.PROVISIONAL_ANSWER +import org.thoughtcrime.securesms.auth.AuthAwareComponent +import org.thoughtcrime.securesms.auth.LoggedInState import org.thoughtcrime.securesms.database.RecipientRepository -import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.permissions.Permissions import org.webrtc.IceCandidate import javax.inject.Inject @@ -36,51 +31,49 @@ class CallMessageProcessor @Inject constructor( private val textSecurePreferences: TextSecurePreferences, private val storage: StorageProtocol, private val webRtcBridge: WebRtcCallBridge, - private val recipientRepository: Lazy, - @ManagerScope scope: CoroutineScope -) : OnAppStartupComponent { + private val recipientRepository: RecipientRepository, + private val snodeClock: SnodeClock +) : AuthAwareComponent { companion object { private const val TAG = "CallMessageProcessor" private const val VERY_EXPIRED_TIME = 15 * 60 * 1000L } - init { - scope.launch(IO) { - while (isActive) { - val nextMessage = WebRtcUtils.SIGNAL_QUEUE.receive() - Log.d("Loki", nextMessage.type?.name ?: "CALL MESSAGE RECEIVED") - val sender = nextMessage.sender ?: continue - val approvedContact = recipientRepository.get().getRecipient(Address.fromSerialized(sender))?.approved == true - Log.i("Loki", "Contact is approved?: $approvedContact") - if (!approvedContact && storage.getUserPublicKey() != sender) continue - - // If the user has not enabled voice/video calls or if the user has not granted audio/microphone permissions - if ( - !textSecurePreferences.isCallNotificationsEnabled() || - !Permissions.hasAll(context, Manifest.permission.RECORD_AUDIO) - ) { - Log.d("Loki","Dropping call message if call notifications disabled") - if (nextMessage.type != PRE_OFFER) continue - val sentTimestamp = nextMessage.sentTimestamp ?: continue - insertMissedCall(sender, sentTimestamp) - continue - } - - val isVeryExpired = (nextMessage.sentTimestamp?:0) + VERY_EXPIRED_TIME < SnodeAPI.nowWithOffset - if (isVeryExpired) { - Log.e("Loki", "Dropping very expired call message") - continue - } - - when (nextMessage.type) { - OFFER -> incomingCall(nextMessage) - ANSWER -> incomingAnswer(nextMessage) - END_CALL -> incomingHangup(nextMessage) - ICE_CANDIDATES -> handleIceCandidates(nextMessage) - PRE_OFFER -> incomingPreOffer(nextMessage) - PROVISIONAL_ANSWER, null -> {} // TODO: if necessary - } + override suspend fun doWhileLoggedIn(loggedInState: LoggedInState) { + while (true) { + val nextMessage = WebRtcUtils.SIGNAL_QUEUE.receive() + Log.d("Loki", nextMessage.type?.name ?: "CALL MESSAGE RECEIVED") + val sender = nextMessage.sender ?: continue + val approvedContact = recipientRepository.getRecipient(Address.fromSerialized(sender))?.approved == true + Log.i("Loki", "Contact is approved?: $approvedContact") + if (!approvedContact && storage.getUserPublicKey() != sender) continue + + // If the user has not enabled voice/video calls or if the user has not granted audio/microphone permissions + if ( + !textSecurePreferences.isCallNotificationsEnabled() || + !Permissions.hasAll(context, Manifest.permission.RECORD_AUDIO) + ) { + Log.d("Loki","Dropping call message if call notifications disabled") + if (nextMessage.type != PRE_OFFER) continue + val sentTimestamp = nextMessage.sentTimestamp ?: continue + insertMissedCall(sender, sentTimestamp) + continue + } + + val isVeryExpired = (nextMessage.sentTimestamp?:0) + VERY_EXPIRED_TIME < snodeClock.currentTimeMillis() + if (isVeryExpired) { + Log.e("Loki", "Dropping very expired call message") + continue + } + + when (nextMessage.type) { + OFFER -> incomingCall(nextMessage) + ANSWER -> incomingAnswer(nextMessage) + END_CALL -> incomingHangup(nextMessage) + ICE_CANDIDATES -> handleIceCandidates(nextMessage) + PRE_OFFER -> incomingPreOffer(nextMessage) + PROVISIONAL_ANSWER, null -> {} // TODO: if necessary } } } diff --git a/app/src/main/proto/SignalService.proto b/app/src/main/proto/SignalService.proto deleted file mode 100644 index dab5289377..0000000000 --- a/app/src/main/proto/SignalService.proto +++ /dev/null @@ -1,332 +0,0 @@ -syntax = "proto2"; - -package signalservice; - -option java_package = "org.session.libsignal.protos"; -option java_outer_classname = "SignalServiceProtos"; - -message Envelope { - - enum Type { - SESSION_MESSAGE = 6; - CLOSED_GROUP_MESSAGE = 7; - } - - // @required - required Type type = 1; - optional string source = 2; - optional uint32 sourceDevice = 7; - // @required - required uint64 timestampMs = 5; - optional bytes content = 8; - optional uint64 serverTimestampMs = 10; -} - -message TypingMessage { - - enum Action { - STARTED = 0; - STOPPED = 1; - } - - // @required - required uint64 timestampMs = 1; - // @required - required Action action = 2; -} - -message UnsendRequest { - // @required - required uint64 timestampMs = 1; - // @required - required string author = 2; -} - -message Content { - enum ExpirationType { - UNKNOWN = 0; - DELETE_AFTER_READ = 1; - DELETE_AFTER_SEND = 2; - } - - optional DataMessage dataMessage = 1; - optional CallMessage callMessage = 3; - optional ReceiptMessage receiptMessage = 5; - optional TypingMessage typingMessage = 6; - optional DataExtractionNotification dataExtractionNotification = 8; - optional UnsendRequest unsendRequest = 9; - optional MessageRequestResponse messageRequestResponse = 10; - optional ExpirationType expirationType = 12; - optional uint32 expirationTimerSeconds = 13; - optional uint64 sigTimestampMs = 15; - optional ProMessage proMessage = 16; - optional bytes proSigForCommunityMessageOnly = 17; - - reserved 14; - reserved 11; // Used to be a "sharedConfigMessage" but no longer used - reserved 7; // Used to be a "configurationMessage" but it has been deleted -} - -message KeyPair { - // @required - required bytes publicKey = 1; - // @required - required bytes privateKey = 2; -} - -message DataExtractionNotification { - - enum Type { - SCREENSHOT = 1; - MEDIA_SAVED = 2; // timestamp - } - - // @required - required Type type = 1; - optional uint64 timestampMs = 2; -} - -message DataMessage { - - enum Flags { - EXPIRATION_TIMER_UPDATE = 2; - } - - message Quote { - - message QuotedAttachment { - - enum Flags { - VOICE_MESSAGE = 1; - } - - optional string contentType = 1; - optional string fileName = 2; - optional AttachmentPointer thumbnail = 3; - optional uint32 flags = 4; - } - - // @required - required uint64 id = 1; - // @required - required string author = 2; - optional string text = 3; - repeated QuotedAttachment attachments = 4; - } - - message Preview { - // @required - required string url = 1; - optional string title = 2; - optional AttachmentPointer image = 3; - } - - message LokiProfile { - optional string displayName = 1; - optional string profilePicture = 2; - optional uint64 lastProfileUpdateSeconds = 3; - } - - message OpenGroupInvitation { - // @required - required string url = 1; - // @required - required string name = 3; - } - - // New closed group update messages - message GroupUpdateMessage { - optional GroupUpdateInviteMessage inviteMessage = 1; - optional GroupUpdateInfoChangeMessage infoChangeMessage = 2; - optional GroupUpdateMemberChangeMessage memberChangeMessage = 3; - optional GroupUpdatePromoteMessage promoteMessage = 4; - optional GroupUpdateMemberLeftMessage memberLeftMessage = 5; - optional GroupUpdateInviteResponseMessage inviteResponse = 6; - optional GroupUpdateDeleteMemberContentMessage deleteMemberContent = 7; - optional GroupUpdateMemberLeftNotificationMessage memberLeftNotificationMessage = 8; - } - - // New closed groups - message GroupUpdateInviteMessage { - // @required - required string groupSessionId = 1; // The `groupIdentityPublicKey` with a `03` prefix - // @required - required string name = 2; - // @required - required bytes memberAuthData = 3; - // @required - required bytes adminSignature = 4; - } - - message GroupUpdateDeleteMessage { - repeated string memberSessionIds = 1; - // @required - // signature of "DELETE" || timestamp || sessionId[0] || ... || sessionId[n] - required bytes adminSignature = 2; - } - - message GroupUpdatePromoteMessage { - // @required - required bytes groupIdentitySeed = 1; - // @required - required string name = 2; - } - - message GroupUpdateInfoChangeMessage { - enum Type { - NAME = 1; - AVATAR = 2; - DISAPPEARING_MESSAGES = 3; - } - - // @required - required Type type = 1; - optional string updatedName = 2; - optional uint32 updatedExpirationSeconds = 3; - // @required - // "INFO_CHANGE" || type || timestamp - required bytes adminSignature = 4; - } - - message GroupUpdateMemberChangeMessage { - enum Type { - ADDED = 1; - REMOVED = 2; - PROMOTED = 3; - } - - // @required - required Type type = 1; - repeated string memberSessionIds = 2; - optional bool historyShared = 3; - // @required - // "MEMBER_CHANGE" || type || timestamp - required bytes adminSignature = 4; - } - - message GroupUpdateMemberLeftMessage { - // the pubkey of the member left is included as part of the closed group encryption logic (senderIdentity on desktop) - } - - message GroupUpdateInviteResponseMessage { - // @required - required bool isApproved = 1; // Whether the request was approved - } - - message GroupUpdateDeleteMemberContentMessage { - repeated string memberSessionIds = 1; - repeated string messageHashes = 2; - optional bytes adminSignature = 3; - } - - message GroupUpdateMemberLeftNotificationMessage { - // the pubkey of the member left is included as part of the closed group encryption logic (senderIdentity on desktop) - } - - message Reaction { - enum Action { - REACT = 0; - REMOVE = 1; - } - // @required - required uint64 id = 1; - // @required - required string author = 2; - optional string emoji = 3; - // @required - required Action action = 4; - } - - optional string body = 1; - repeated AttachmentPointer attachments = 2; - optional uint32 flags = 4; - optional uint32 expireTimerSeconds = 5; - optional bytes profileKey = 6; - optional uint64 timestamp = 7; - optional Quote quote = 8; - repeated Preview preview = 10; - optional Reaction reaction = 11; - optional LokiProfile profile = 101; - optional OpenGroupInvitation openGroupInvitation = 102; - optional string syncTarget = 105; - optional bool blocksCommunityMessageRequests = 106; - optional GroupUpdateMessage groupUpdateMessage = 120; - - reserved 104; // Used to be "closedGroupControlMessage" but it has been deleted -} - -message CallMessage { - - enum Type { - PRE_OFFER = 6; - OFFER = 1; - ANSWER = 2; - PROVISIONAL_ANSWER = 3; - ICE_CANDIDATES = 4; - END_CALL = 5; - } - - // Multiple ICE candidates may be batched together for performance - - // @required - required Type type = 1; - repeated string sdps = 2; - repeated uint32 sdpMLineIndexes = 3; - repeated string sdpMids = 4; - // @required - required string uuid = 5; -} - -message MessageRequestResponse { - // @required - required bool isApproved = 1; - optional bytes profileKey = 2; - optional DataMessage.LokiProfile profile = 3; -} - -message ReceiptMessage { - - enum Type { - DELIVERY = 0; - READ = 1; - } - - // @required - required Type type = 1; - repeated uint64 timestampMs = 2; -} - -message AttachmentPointer { - - enum Flags { - VOICE_MESSAGE = 1; - } - - // @required - required fixed64 id = 1; - optional string contentType = 2; - optional bytes key = 3; - optional uint32 size = 4; - optional bytes thumbnail = 5; - optional bytes digest = 6; - optional string fileName = 7; - optional uint32 flags = 8; - optional uint32 width = 9; - optional uint32 height = 10; - optional string caption = 11; - optional string url = 101; -} - -message ProMessage { - optional ProProof proof = 1; - optional uint64 profile_bitset = 2; - optional uint64 msg_bitset = 3; -} - -message ProProof { - optional uint32 version = 1; - optional bytes genIndexHash = 2; - optional bytes rotatingPublicKey = 3; - optional uint64 expiryUnixTs = 4; // Epoch timestamp in milliseconds - optional bytes sig = 5; -} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add_admin_custom.xml b/app/src/main/res/drawable/ic_add_admin_custom.xml new file mode 100644 index 0000000000..675fbac6e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_admin_custom.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ban.xml b/app/src/main/res/drawable/ic_ban.xml deleted file mode 100644 index 744936981f..0000000000 --- a/app/src/main/res/drawable/ic_ban.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_user_round_block.xml b/app/src/main/res/drawable/ic_user_round_block.xml new file mode 100644 index 0000000000..8130e38b89 --- /dev/null +++ b/app/src/main/res/drawable/ic_user_round_block.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_user_round_search.xml b/app/src/main/res/drawable/ic_user_round_search.xml new file mode 100644 index 0000000000..df48dae1b1 --- /dev/null +++ b/app/src/main/res/drawable/ic_user_round_search.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_user_round_x.xml b/app/src/main/res/drawable/ic_user_round_x.xml index 4bcbd0d2c1..b09d84fcca 100644 --- a/app/src/main/res/drawable/ic_user_round_x.xml +++ b/app/src/main/res/drawable/ic_user_round_x.xml @@ -1,17 +1,11 @@ - + - - - - - - - - - + + + diff --git a/app/src/main/res/drawable/paths_building_dot.xml b/app/src/main/res/drawable/paths_building_dot.xml deleted file mode 100644 index e5da8b82c0..0000000000 --- a/app/src/main/res/drawable/paths_building_dot.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index 202a67a80a..d7452053d4 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -88,7 +88,7 @@ android:id="@+id/attachmentOptionsContainer" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="12dp" + android:layout_marginStart="@dimen/normal_padding" android:elevation="8dp" app:layout_constraintStart_toStartOf="parent" app:layout_constraintBottom_toTopOf="@+id/inputBar" diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml index 4a9064f609..acb2cfe506 100644 --- a/app/src/main/res/layout/activity_home.xml +++ b/app/src/main/res/layout/activity_home.xml @@ -38,14 +38,6 @@ app:layout_constraintBottom_toBottomOf="parent" android:contentDescription="@string/AccessibilityId_profilePicture" /> - - - - + + + + + android:title="@string/resend" + android:id="@+id/menu_context_resend" + app:showAsAction="never" /> + + - - + + + diff --git a/app/src/main/res/menu/menu_message_request.xml b/app/src/main/res/menu/menu_message_request.xml index dd794551d2..d4a3729144 100644 --- a/app/src/main/res/menu/menu_message_request.xml +++ b/app/src/main/res/menu/menu_message_request.xml @@ -9,7 +9,7 @@ diff --git a/app/src/main/res/values-b+ar+SA/strings.xml b/app/src/main/res/values-b+ar+SA/strings.xml index 100b7af9fb..834b658827 100644 --- a/app/src/main/res/values-b+ar+SA/strings.xml +++ b/app/src/main/res/values-b+ar+SA/strings.xml @@ -15,6 +15,17 @@ هذا معرف الحساب الخاص بك. يمكن للمستخدمين الآخرين مسحه ضوئيا لبدء محادثة معك. الحجم الحقيقي َأَضف + + إضافة مشرفين + إضافة مشرف + إضافة مشرفين + إضافة بعض المشرفين + إضافة العديد من المشرفين + إضافة مشرفين + + إضافة مشرف + أدخل مُعرِّف الحساب للمستخدم الذي تريد ترقيته إلى مشرف.\n\nلإضافة عدة مستخدمين، أدخل كل مُعرِّف حساب مفصولًا بفاصلة. يمكن تحديد ما يصل إلى 20 مُعرِّف حساب في المرة الواحدة. + لا يمكن خفض رتبة المشرفين أو إزالتهم من المجموعة. لا يمكن إزاله المشرف. {name} و {count} آخرين تمت ترقيتهم إلى مشرف. ترقية المشرفين @@ -39,6 +50,14 @@ {name} تم إزالته كمشرف. تمت إزالة {name} و{count} آخرين من منصبهم كمشرفين. تمت إزالة {name} و{other_name} من منصبي المشرف. + + %1$d مشرفين محددين + %1$d مشرف محدد + %1$d مشرفان محددان + %1$d مشرفين محددين + %1$d مشرفين محددين + %1$d من المشرفين محددين + إرسال ترقيات المشرفين إرسال ترقية المشرف @@ -48,23 +67,31 @@ إرسال ترقيات المشرفين إعدادات المشرف + لا يمكنك تغيير حالتك كمشرف. لمغادرة المجموعة، افتح إعدادات المحادثة واختر \"مغادرة المجموعة\". {name} و {other_name} تم ترقيتهم إلى مشرف. + المشرفون + السماح +{count} مجهول أيقونة التطبيق تغيير اسم و أيقونة التطبيق يتطلب تغيير أيقونة التطبيق واسمه إغلاق {app_name}. سوف تستمر الإشعارات في استخدام أيقونة واسم {app_name} الافتراضي. + يتم عرض أيقونة التطبيق البديلة والاسم على الشاشة الرئيسية ودرج التطبيقات. يتم عرض أيقونة التطبيق المحدد والاسم على الشاشة الرئيسة و درج التطبيقات. الأيقونة والاسم + يتم عرض أيقونة التطبيق البديلة على الشاشة الرئيسية ومكتبة التطبيقات. سيظل اسم التطبيق يظهر باسم \"{app_name}\". استخدم أيقونة البديلة للتطبيق استخدام أيقونة واسم بديل للتطبيق حدد أيقونة بديلة للتطبيق أيقونة الآلة الحاسبة + MeetingSE الأخبار الملاحظات المخزون الطقس + شارة {app_pro} + الوضع الداكن التلقائي إخفاء شريط القائمة اللغة اختر إعدادات اللغة الخاصة بك لتطبيق {app_name}. سيعيد {app_name} التشغيل عند تغيير إعدادات اللغة. @@ -81,6 +108,7 @@ تكبير تصغير مرفق + المرفقات إضافة مرفق البوم بدون اسم تنزيل المرفقات تلقائيًا @@ -142,9 +170,12 @@ فشل المنع لقد فشل الغاء المنع الغاء منع المستخدم + أدخل معرف حساب المستخدم الذي تريد إلغاء حظره تم رفع المنع عن المستخدم منع المستخدم تم منع المستخدم + أدخل معرف حساب المستخدم الذي تريد حظره + معرف مخفي حظر إلغاء حظر جهة الإتصال لإرسال رسالة لا توجد جهات اتصال محظورة @@ -155,6 +186,8 @@ هل أنت متأكد من إلغاء حظر {name} و{count} آخرين؟ هل أنت متأكد من إلغاء حظر {name} وشخص آخر؟ تم الغاء الحظر عن {name} + عرض جهات الاتصال المحظورة وإدارتها. + لم يتم العثور على متصفح لفتح هذا الرابط، جرّب نسخ الرابط بدلاً من ذلك اتصال {name} اتصل بك لا يمكنك بدء مكالمة جديدة. أنهِ المكالمة الحالية أولًا. @@ -179,9 +212,14 @@ المكالمات (نسخة تجريبية) المكالمات الصوتية و المرئية المكالمات الصوتية والمرئية (تجريبي) + عنوان IP الخاص بك مرئي لشريك الاتصال وخادم {session_foundation} أثناء استخدام المكالمات التجريبية. تفعيل المكالمات الصوتية والمرئية من وإلى المستخدمين الآخرين. لقد اتصلت بـ {name} لقد فاتتك مكالمة من {name} لأنك لم تقم بتمكين المكالمات الصوتية والمرئية في إعدادات الخصوصية. + يحتاج {app_name} إلى الوصول إلى الكاميرا لتفعيل مكالمات الفيديو، ولكن تم رفض هذا الإذن. لا يمكنك تحديث أذونات الكاميرا أثناء المكالمة.\n\nهل ترغب في إنهاء المكالمة الآن وتفعيل الوصول إلى الكاميرا، أم تفضل أن يتم تذكيرك بذلك بعد المكالمة؟ + للسماح بالوصول إلى الكاميرا، افتح الإعدادات وقم بتفعيل صلاحية الكاميرا. + أثناء مكالمتك الأخيرة، حاولت استخدام الفيديو لكن لم تتمكن من ذلك لأنه تم رفض الوصول إلى الكاميرا من قبل. للسماح بالوصول، افتح الإعدادات وفعل صلاحية الكاميرا. + يتطلب الوصول إلى الكاميرا لا توجد كاميرا الكاميرا غير متوفرة. منح صلاحية الكاميرا @@ -189,7 +227,19 @@ {app_name} يحتاج إذن الوصول إلى الكاميرا لالتقاط الصور ومقاطع الفيديو، أو لمسح رموز QR. {app_name} يحتاج إذن الوصول إلى الكاميرا لمسح رموز QR إلغاء + إلغاء {pro} + قم بالإلغاء على موقع {platform} باستخدام الحساب {platform_account} الذي استخدمته للتسجيل في {pro}. + قم بالإلغاء على موقع {platform_store} باستخدام الحساب {platform_account} الذي استخدمته للتسجيل في {pro}. + تغيير فشل في تغيير كلمة المرور + قم بتغيير كلمة المرور لـ {app_name}. سيتم إعادة تشفير البيانات المخزنة محليًا باستخدام كلمة المرور الجديدة. + تغيير الإعداد + جاري التحقق من حالة {pro} + جارٍ التحقق من حالة {pro} الخاصة بك. يمكنك المتابعة بمجرد اكتمال هذا التحقق. + جارٍ التحقق من تفاصيل {pro} الخاصة بك. قد لا تتوفر بعض الإجراءات في هذه الصفحة حتى يكتمل هذا التحقق. + جارٍ التحقق من حالة {pro}... + جارٍ التحقق من تفاصيل {pro} الخاصة بك. لا يمكنك التجديد حتى يكتمل هذا التحقق. + جارٍ التحقق من حالة {pro} الخاصة بك. ستتمكن من الترقية إلى {pro} بمجرد اكتمال هذا التحقق. مسح مسح الكل مسح جميع البيانات @@ -209,20 +259,29 @@ هل متأكد من أنك تريد حذف بياناتك من الشبكة؟ إذا قمت بالمتابعة، فلن تتمكن من استعادة رسائلك أو جهات الاتصال. هل متأكد من رغبتك بمسح جهازك؟ مسح الجهاز فقط + مسح الجهاز وإعادة التشغيل + مسح الجهاز واستعادة مسح جميع الرسائل هل أنت متأكد من مسح جميع الرسائل من محادثتك مع {name} من جهازك؟ + هل أنت متأكد أنك تريد مسح جميع الرسائل من محادثتك مع {name} على هذا الجهاز؟ هل أنت متأكد من حذف كافة الرسائل {community_name}؟ من جهازك. + هل أنت متأكد أنك تريد مسح جميع الرسائل من {community_name} على هذا الجهاز؟ حذف للجميع حذف لي فقط هل أنت متأكد من حذف كافة الرسائل {group_name}؟ + هل أنت متأكد أنك تريد مسح جميع الرسائل من {group_name}؟ هل أنت متأكد من حذف كافة الرسائل {group_name}؟ من جهازك. + هل أنت متأكد أنك تريد مسح جميع الرسائل من {group_name} على هذا الجهاز؟ هل أنت متأكد من مسح كافة رسائل \"ملاحظة لنفسي\" من جهازك؟ + هل أنت متأكد أنك تريد مسح جميع رسائل ملاحظة لنفسي من هذا الجهاز؟ + المسح على هذا الجهاز غلق إغلاق التطبيق غلق النافذة إيداع التجزئة: {hash} سيؤدي هذا إلى حظر المستخدم المحدد من هذه المجتمع وحذف جميع رسائلهم. هل أنت متأكد أنك تريد المتابعة؟ سيؤدي هذا إلى حظر المستخدم المحدد من هذه المجتمع. هل أنت متأكد أنك تريد المتابعة؟ + أدخل وصفاً للمجتمع أدخل رابط المجتمع عنوان URL غير صحيح الرجاء التحقق من رابط المجتمع وحاول مرة أخرى. @@ -237,15 +296,27 @@ أنت متصل بالفعل بهذا المجتمع. مغادرة المجتمع فشل في مغادرة {community_name} + أدخل اسم المجتمع + يرجى إدخال اسم المجتمع مجتمع غير معروف رابط المجتمع نسخ رابط المجتمع تأكيد + تأكيد الترقية + هل أنت متأكد؟ لا يمكن خفض رتبة المشرفين أو إزالتهم من المجموعة. جهات الاتصال حذف جهة اتصال هل أنت متأكد من حذف {name}؟ من قائمة جهات إتصالك؟ ستصل أي رسائل جديدة من {name} كطلب رسالة. لا تملك اي جهات اتصال حتى الآن تحديد جهات الاتصال + + %1$d من جهات الاتصال محددة + %1$d جهة اتصال محددة + %1$d جهتا اتصال محددتان + %1$d جهات اتصال محددة + %1$d جهة اتصال محددة + %1$d من جهات الاتصال محددة + تفاصيل المستخدم كاميرا اختر إجراء لبدء المحادثة @@ -253,6 +324,7 @@ تكوين الرسالة الصورة المصغرة للصورة من الرسالة المقتبسة إنشاء محادثة مع جهة اتصال جديدة + اختر المحتوى المعروض في الإشعارات المحلية عند استلام رسالة واردة. أضِف إلى الشاشة الرئيسية تمت الإضافة للشاشة الرئيسية رسائل صوتية @@ -265,12 +337,18 @@ تم حذف المحادثة لا توجد رسائل في {conversation_name}. أدخل المفتاح + تحديد وظيفة مفاتيح Enter وShift+Enter في المحادثات. + SHIFT + ENTER يرسل الرسالة، ENTER يبدأ سطرًا جديدًا. + ENTER يرسل رسالة، SHIFT + ENTER يبدأ سطرًا جديدًا. مجموعات تقليم الرسالة تقليم المجتمعات + احذف الرسائل الأقدم من 6 أشهر تلقائيًا في المجتمعات ذات أكثر من 2000 رسالة. محادثة جديدة لا تملك أي محادثات حتى الآن + الإرسال باستخدام Enter النقر على Enter سوف يرسل الرسالة بدلاَ من بدء سطر جديد. + الإرسال باستخدام Shift+Enter جميع الوسائط التدقيق الإملائي تفعيل التحقق الإملائي عند كتابة الرسائل. @@ -279,7 +357,13 @@ نسخ إنشاء إنشاء مكالمة + الفوترة الحالية + كلمة المرور الحالية قص + الوضع الداكن + هل أنت متأكد أنك تريد حذف جميع الرسائل والمرفقات وبيانات الحساب من هذا الجهاز وإنشاء حساب جديد؟ + حدث خطأ في قاعدة البيانات.\n\nقم بتصدير سجلات التطبيق لمشاركتها لأغراض استكشاف الأخطاء وإصلاحها. إذا لم تنجح هذه العملية، أعد تثبيت {app_name} واستعد حسابك. + هل أنت متأكد أنك تريد حذف جميع الرسائل والمرفقات وبيانات الحساب من هذا الجهاز واستعادة حسابك من الشبكة؟ لقد لاحظنا أن {app_name} يستغرق وقتًا طويلاً لبدء.\n\nيمكنك مواصلة الانتظار، تصدير سجلات الجهاز للمشاركة في استكشاف الأخطاء وإصلاحها، أو محاولة إعادة تشغيل {app_name}. قاعدة بيانات تطبيقك غير متوافقة مع هذا الإصدار من {app_name}. أعد تثبيت التطبيق واستعد حسابك لإنشاء قاعدة بيانات جديدة ومتابعة استخدام {app_name}.\n\nتحذير: سيؤدي هذا إلى فقدان جميع الرسائل والمرفقات التي يزيد عمرها عن أسبوعين. تحسين قاعدة البيانات @@ -301,6 +385,24 @@ يرجى الانتظار أثناء إنشاء المجموعة... فشل في تحديث المجموعة ليس لديك صلاحيات حذف رسائل الاخرين + + حذف المرفقات المحددة + حذف المرفق المحدد + حذف المرفقين المحددين + حذف بعض المرفقات المحددة + حذف العديد من المرفقات المحددة + حذف المرفقات المحددة + + + هل أنت متأكد أنك تريد حذف المرفقات المحددة؟ سيتم أيضًا حذف الرسالة المرتبطة بهذه المرفقات. + هل أنت متأكد من أنك تريد حذف المرفق المحدد؟ سيتم أيضًا حذف الرسالة المرتبطة بهذا المرفق. + هل أنت متأكد من أنك تريد حذف المرفقين المحددين؟ سيتم أيضًا حذف الرسالة المرتبطة بهما. + هل أنت متأكد من أنك تريد حذف المرفقات المحددة؟ سيتم أيضًا حذف الرسالة المرتبطة بهذه المرفقات. + هل أنت متأكد من أنك تريد حذف المرفقات المحددة؟ سيتم أيضًا حذف الرسالة المرتبطة بهذه المرفقات. + هل أنت متأكد من أنك تريد حذف المرفقات المحددة؟ سيتم أيضًا حذف الرسالة المرتبطة بهذه المرفقات. + + هل أنت متأكد من أنك تريد حذف {name} من جهات الاتصال لديك؟\n\nسيؤدي هذا إلى حذف المحادثة، بما في ذلك جميع الرسائل والمرفقات. ستظهر الرسائل المستقبلية من {name} كطلب رسالة. + هل أنت متأكد من رغبتك في حذف محادثتك مع {name}؟\nسيؤدي هذا إلى حذف جميع الرسائل والمرفقات نهائيًا. حذف الرسائل حذف الرسالة @@ -366,6 +468,7 @@ هل أنت متأكد من حذف هذه الرسائل لدى الجميع؟ حذف تحويل أدوات المطور + إعدادات إشعارات الجهاز ابدأ الإملاء... الرسائل المختفية سيتم حذف الرسالة في {time_large} @@ -401,6 +504,7 @@ {admin_name} قام بتحديث إعدادات الرسائل المختفية. أنت قمت بتحديث إعدادات الرسائل المختفية. تجاهل + العرض يمكن أن يكون اسمك الحقيقي، أو لقب، أو أي شيء آخر تحبه — ويمكنك تغييره في أي وقت. ادخل اسم العرض الرجاء إدخال اسم العرض @@ -411,6 +515,9 @@ تعيين اسم العرض اسم العرض الخاص بك مرئي للمستخدمين والمجموعات والمجتمعات التي تتفاعل معها. وثيقة + تبرع + قوى قوية تحاول إضعاف الخصوصية، ولكن لا يمكننا متابعة هذه المعركة وحدنا.\n\nيساعد التبرع في إبقاء {app_name} آمناً، مستقلاً، ومتوفراً على الإنترنت. + يحتاج {app_name} إلى مساعدتك تم تنزيل جارٍ التنزيل... @@ -444,23 +551,51 @@ تفاعلت أنت و{name} مع {emoji_name} تفاعل مع رسالتك بـ {emoji} تفعيل + هل تريد تفعيل الوصول إلى الكاميرا؟ + عرض الإشعارات عند استلام رسائل جديدة. + إنهاء المكالمة للتفعيل + هل تستمتع بـ {app_name}؟ + يحتاج إلى تحسين {emoji} + رائع {emoji} + لقد كنت تستخدم {app_name} لفترة، كيف تسير الأمور؟ سنكون ممتنين لسماع رأيك. + دخول + أدخل كلمة المرور التي قمت بتعيينها لتطبيق {app_name} + أدخل كلمة المرور التي تستخدمها لفتح {app_name} عند بدء التشغيل، وليس كلمة مرور الاستعادة الخاصة بك + حدث خطأ أثناء التحقق من حالة {pro} الرجاء التحقق من اتصالك بالإنترنت وحاول مرة أخرى. نسخ الخطأ والخروج خطأ في قاعدة البيانات + حدث خطأ ما. يرجى المحاولة مرة أخرى لاحقًا. + خطأ في تحميل الوصول إلى {pro} + تعذر على {app_name} البحث عن هذا الـ ONS. يرجى التحقق من اتصال الشبكة والمحاولة مرة أخرى. حدث خطأ غير معروف. + هذا الـ ONS غير مسجَّل. يرجى التحقق من صحته والمحاولة مرة أخرى. + فشل في إعادة إرسال الدعوة إلى {name} في {group_name} + فشل في إعادة إرسال الدعوة إلى {name} و{count} آخرين في {group_name} + فشل في إعادة إرسال الدعوة إلى {name} و{other_name} في {group_name} + فشل في إعادة إرسال الترقية إلى {name} في {group_name} + فشل في إعادة إرسال الترقية إلى {name} و{count} آخرين في {group_name} + فشل في إعادة إرسال الترقية إلى {name} و{other_name} في {group_name} فشل التحميل إخفاقات + ملاحظات + شارك تجربتك مع {app_name} عن طريق إكمال استطلاع قصير. ملف ملفات + اتّبع إعدادات النظام + دائمًا مِن تحويل الشاشة كاملة GIF Giphy {app_name} سيتصل بمنصة Giphy لتقديم نتائج البحث. لن يكون لديك حماية كاملة للبيانات الوصفية عند إرسال الصور المتحركة (GIFs). + هل ترغب في إرسال ملاحظات؟ + نأسف لسماع أن تجربتك مع {app_name} لم تكن مثالية. سنكون شاكرين إذا خصصت لحظة لمشاركة آرائك في استطلاع قصير. تضم المجموعات بحد أقصى 100 عضو إنشاء مجموعة الرجاء إختيار عضو اخر على الأقل. حذف مجموعة + هل أنت متأكد من أنك تريد حذف {group_name}؟\n\nسيؤدي هذا إلى إزالة جميع الأعضاء وحذف كافة محتويات المجموعة. هل أنت متأكد أنك تريد حذف {group_name}؟ {group_name} تم حذفه بواسطة مشرف المجموعة. لن تتمكن من إرسال أي رسائل أخرى. أدخل وصف للمجموعة @@ -498,6 +633,9 @@ هل أنت متأكد من مغادرة {group_name}؟ هل أنت متأكد من مغادرة {group_name}?\n\nسيؤدي ذلك إلى إزالة جميع الأعضاء وحذف كافة محتويات المجموعة. فشل في مغادرة {group_name} + تمت دعوة {name} للانضمام إلى المجموعة. تمت مشاركة سجل الدردشة لآخر 14 يومًا. + تمت دعوة {name} و{count} آخرين للانضمام إلى المجموعة. تمت مشاركة سجل الدردشة لآخر 14 يومًا. + تمت دعوة {name} و{other_name} للانضمام إلى المجموعة. تمت مشاركة سجل الدردشة لآخر 14 يومًا. {name} غادر المجموعة. {name} و {count} آخرين غادروا المجموعة. {name} و {other_name} غادروا المجموعة. @@ -509,6 +647,9 @@ {name} و {other_name} تم دعوتهم للانضمام إلى المجموعة. أنت و{count} آخرين انضموا للمجموعة. تمت مشاركة سجل الدردشة. أنت و{other_name} انضموا للمجموعة. تمت مشاركة سجل الدردشة. + فشل في إزالة {name} من {group_name} + فشل في إزالة {name} و{count} آخرين من {group_name} + فشل في إزالة {name} و{other_name} من {group_name} أنت غادرت المجموعة. أعضاء المجموعة لا يوجد اعضاء اخرين في هذه المجموعة. @@ -520,8 +661,10 @@ تم تحديث اسم المجموعة. اسم المجموعة مرئي لجميع أعضاء المجموعة. ليس لديك رسائل من {group_name}. أرسل رسالة لبدء المحادثة! + لم يتم تحديث هذه المجموعة منذ أكثر من ٣٠ يومًا. قد تواجه مشاكل في إرسال الرسائل أو عرض معلومات المجموعة. أنت المشرف الوحيد في {group_name}.\n\nلا يمكن تغيير أعضاء المجموعة والإعدادات بدون المشرف. + أنت المشرف الوحيد في {group_name}.\n\nلا يمكن تغيير أعضاء المجموعة والإعدادات بدون وجود مشرف. لمغادرة المجموعة دون حذفها، يرجى أولاً إضافة مشرف جديد قيد الإزالة أنت تم ترقيتك إلى مشرف. أنت و {count} آخرين تمت ترقيتهم إلى مشرف. @@ -555,29 +698,76 @@ تعيين صورة مجموعة العرض مجموعة غير معروفة تم تحديث المجموعة + معالجة مرشحي الاتصال الأسئلة الأكثر طرحًا + تحقق من الأسئلة المتكررة لـ {app_name} للحصول على إجابات عن الأسئلة الشائعة. ساعدنا في ترجمة {app_name} + الإبلاغ عن خطأ شارك بعض التفاصيل لمساعدتنا في حل مشكلتك. صدّر السجلات الخاصة بك، ثم قم بتحميل الملف عبر مكتب المساعدة الخاص بـ {app_name}. تصدير السجلات إصدار السجلات الخاصة بك، ثم رفع الملف عبر مكتب المساعدة الخاص بـ{app_name}. حفظ على سطح المكتب + احفظ هذا الملف، ثم شاركه مع مطوري {app_name}. الدعم + ساعد في ترجمة {app_name} إلى أكثر من 80 لغة! نتطلع لقراءة أراءك إخفاء + تبديل ظهور شريط قائمة النظام + هل أنت متأكد أنك تريد إخفاء ملاحظة لنفسي من قائمة المحادثات؟ إخفاء الآخرين صورة صور + مهم لوحة المفاتيح في وضع التستّر طلب وضع التخفي إذا كان متاحًا. بناءً على لوحة المفاتيح التي تستخدمها، قد تتجاهل لوحة المفاتيح هذا الطلب. معلومات اختصار غير صالح + + دعوة جهات الاتصال + دعوة جهة اتصال + دعوة جهتي اتصال + دعوة جهات الاتصال + دعوة جهات الاتصال + دعوة جهات الاتصال + + + فشلت الدعوة + فشل الإرسال + فشل إرسال الدعوتين + فشلت بعض الدعوات + فشلت العديد من الدعوات + فشلت الدعوات + + + لا يمكن إرسال الدعوة هل تود المحاولة مرة أخرى؟ + لا يمكن إرسال الدعوة. هل تود المحاولة مرة أخرى؟ + لا يمكن إرسال الدعوتين. هل تود المحاولة مرة أخرى؟ + لا يمكن إرسال بعض الدعوات. هل تود المحاولة مرة أخرى؟ + لا يمكن إرسال العديد من الدعوات. هل تود المحاولة مرة أخرى؟ + لا يمكن إرسال الدعوات هل تود المحاولة مرة أخرى؟ + + + دعوة الأعضاء + دعوة عضو + دعوة عضوين + دعوة بعض الأعضاء + دعوة العديد من الأعضاء + دعوة الأعضاء + + ادعُ عضوًا جديدًا إلى المجموعة عن طريق إدخال معرف حساب صديقك أو ONS أو مسح رمزه QR {icon} + ادعُ عضوًا جديدًا إلى المجموعة عن طريق إدخال معرف حساب صديقك أو ONS أو مسح رمز QR الخاص به انضم لاحقاً + تشغيل {app_name} تلقائيًا عند بدء تشغيل الكمبيوتر. + تشغيل عند بدء التشغيل + يتم التحكم في هذا الإعداد من قبل النظام الخاص بك على لينُكس. لتمكين التشغيل التلقائي، أضف {app_name} إلى تطبيقات بدء التشغيل لديك في إعدادات النظام. معرفة المزيد مغادرة جاري المغادرة... هذه المجموعة الآن للقراءة فقط. أعد إنشاء هذه المجموعة لمواصلة الدردشة. هذه المجموعة الآن للقراءة فقط. اطلب من مشرف المجموعة إعادة إنشاء هذه المجموعة لمواصلة الدردشة. + تمت ترقية المجموعات! أعد إنشاء هذه المجموعة لتحسين الموثوقية. ستصبح هذه المجموعة للقراءة فقط في {date}. + تمت ترقية المجموعات! اطلب من مسؤول المجموعة إعادة إنشاء هذه المجموعة لتحسين الموثوقية. ستصبح هذه المجموعة للقراءة فقط في {date}. لن يتم نقل رسائل الدردشة إلى المجموعة الجديدة. لا يزال بإمكانك عرض كل رسائل الدردشة في المجموعة القديمة الخاصة بك. {name} انضم إلى المجموعة. انضم {name} و{count} آخرين إلى المجموعة. @@ -585,6 +775,8 @@ أنت و {other_name} انضممتما إلى المجموعة. {name} و {other_name} انضموا للمجموعة. أنت انضممت إلى المجموعة. + تقييد النشاط في الخلفية؟ + أنت تسمح حالياً لتطبيق {app_name} بالتشغيل في الخلفية لتحسين موثوقية الإشعارات. قد يؤدي تغيير هذا الإعداد إلى تقليل موثوقية الإشعارات. معاينات الرابط إظهار معاينات الروابط لعناوين URL المدعومة. تفعيل معاينة الروابط @@ -595,6 +787,7 @@ لن تكون لديك الحماية الكاملة للبيانات الوصفية عند إرسال معاينات الروابط. معاينات الرابط مغلقة {app_name} يجب الاتصال بالمواقع المرتبطة لإنشاء معاينات للروابط التي ترسلها وتستقبلها.\n\n يمكنك تفعيلها في إعدادات {app_name}. + روابط تحميل الحساب جاري تحميل حسابك جارٍ التحميل... @@ -607,8 +800,21 @@ حالة القفل انقر للفتح {app_name} مفتوح + السجلات + إدارة المسؤولين + إدارة الأعضاء + إدارة {pro} الأقصى + ربما لاحقاً الوسائط + + تم تحديد %1$d من الأعضاء + تم تحديد %1$d عضو + تم تحديد %1$d عضوين + تم تحديد %1$d أعضاء + تم تحديد %1$d من الأعضاء + تم تحديد %1$d من الأعضاء + %1$d عضو %1$d أعضاء @@ -626,7 +832,9 @@ %1$d عضو نشط أضف معرف الحساب أو ONS + لا يمكن ترقية الأعضاء إلا بعد قبولهم دعوة الانضمام إلى المجموعة. دعوة جهات الاتصال + ليس لديك أي جهات اتصال لدعوتها إلى هذه المجموعة.\nعد للخلف وادعُ أعضاء باستخدام معرف حسابهم أو ONS. إرسال دعوات إرسال دعوة @@ -639,9 +847,14 @@ هل تود مشاركة تاريخ الرسائل بالمجموعة مع {name} و{count} آخرين؟ هل تود مشاركة تاريخ الرسائل بالمجموعة مع {name} و{other_name}؟ مشاركة سجل الرسائل + مشاركة سجل الرسائل لآخر 14 يومًا مشاركة الرسائل الجديدة فقط دعوة + الأعضاء (غير المسؤولين) + شريط القائمة الرسالة + اقرأ المزيد + نسخ الرسالة هذه الرسالة فارغة. فشل توصيل الرسالة تم بلوغ حد الرسالة @@ -660,6 +873,7 @@ ابدأ محادثة جديدة عن طريق إدخال معرف حساب صديقك أو ONS. ابدأ محادثة جديدة عن طريق إدخال معرف حساب صديقك، ONS أو مسح رمزه QR. + ابدأ محادثة جديدة عن طريق إدخال معرف حساب صديقك، ONS أو مسح رمزه QR {icon} لديك %1$d رسائل جديدة. لديك رسالة جديدة. @@ -669,6 +883,7 @@ لديك %1$d رسائل جديدة. الرد على + لا يمكنك إرسال المرفقات حتى يتم قَبُول طلب الرسالة الخاص بك لا يمكنك إرسال رسائل صوتية حتى يتم قَبُول طلب الرسالة الخاص بك {name} دعاك للانضمام إلى {group_name}. بإرسال رسالة إلى هذه المجموعة سوف يقبل تلقائيًا دعوة المجموعة. @@ -680,6 +895,7 @@ هل أنت متأكد من مسح كافة طلبات الرسائل ودعوات المجموعات؟ طلبات رسائل المجتمع السماح بطلبات الرسائل من محادثات المجتمع. + هل أنت متأكد أنك تريد حذف طلب الرسالة وجهة الاتصال المرتبطة به؟ هل أنت متأكد من حذف طلب الرسالة؟ لديك طلب مراسلة جديدة لا توجد طلبات مراسلة معلقة @@ -697,20 +913,34 @@ {author}: {emoji} رسالة صوتية الرسائل تصغير + طول الرسالة + لقد تجاوزت الحد المسموح به لعدد الأحرف في هذه الرسالة. يرجى تقصير رسالتك إلى {limit} حرفًا أو أقل. + الرسالة طويلة جدًا + يرجى تقصير رسالتك إلى {limit} حرفًا أو أقل. + الرسالة طويلة جدًا + كلمة المرور الجديدة التالي + الخطوات التالية اختر اسم مستعار لـ {name}. سيظهر لك في محادثاتك الفردية والجماعية. أدخل اسم مستعار يرجى إدخال اسم مستعار أقصر إزالة الاسم المستعار تعيين الاسم المستعار لا + لا يوجد أعضاء غير مشرفين في هذه المجموعة. لا اقتراحات + أرسل رسائل تصل إلى 10,000 حرف في جميع المحادثات. + نظِّم المحادثات بعدد غير محدود من المحادثات المثبّتة. لا شيء ليس الآن ملاحظة لنفسي ليس لديك أي رسائل في ملاحظة لنفسي أو بمعنى آخر في الرسائل المحفوظة. إخفاء \"ملاحظة لنفسي\" هل أنت متأكد من إخفاء \"الملاحظة لنفسي\"؟ + يرجى الملاحظة: من خلال {action_type}، فإنك توافق على شروط الخدمة {icon} وسياسة الخصوصية {icon} الخاصة بـ {app_pro} + عرض الإشعارات + عرض اسم المُرسل ومعاينة لمحتوى الرسالة. + عرض اسم المُرسل فقط بدون أي محتوى للرسالة. جميع الرسائل محتوى الإشعارات المعلومات المعروضة في الإشعارات. @@ -722,6 +952,7 @@ سوف يتم إعلامك برسائل جديدة بشكل موثوق وفوري باستخدام خوادم إشعارات Huawei. سوف يتم إعلامك برسائل جديدة بشكل موثوق وفوري باستخدام خوادم إشعارات Apple. + عرض إشعار عام من {app_name} بدون اسم المُرسِل أو محتوى الرسالة. اذهب إلى إعدادات إشعارات الجهاز الإشعارات - الكل الإشعارات- الإشارات فقط @@ -729,6 +960,7 @@ {name} إلى {conversation_name} ربما تلقيت رسائل أثناء إعادة تشغيل {device} الخاص بك. لون ضوء التنبيه LED + تشغيل صوت عند استلام رسائل جديدة. فقط عندما يتم ذكر اسم إشعارات الرسالة الأحدث من: {name} @@ -736,6 +968,7 @@ صامت لمدة {time_large} إلغاء الكتم كتم + تم كتم الإشعارات لمدة {time_large} كتم حتى {date_time} الوضع البطيئ {app_name} سيتحقق بشكل دوري من وجود رسائل جديدة في الخلفية. @@ -749,6 +982,12 @@ مغلق حسناً يعمل + على جهاز {device_type} الخاص بك + افتح هذا الحساب في {app_name} على جهاز {device_type} مسجل الدخول إلى {platform_account} الذي استخدمته عند التسجيل لأول مرة. بعد ذلك، قم بإلغاء {pro} من إعدادات {app_pro}. + افتح حساب {app_name} هذا على جهاز {device_type} مُسجَّل الدخول فيه إلى {platform_account} الذي استخدمته عند التسجيل في البداية. بعد ذلك، قم بتحديث وصولك إلى {pro} من خلال إعدادات {app_pro}. + على جهاز مرتبط + على موقع {platform_store} + على موقع {platform} إنشاء حساب تم إنشاء الحساب لدي حساب @@ -773,20 +1012,44 @@ لم نتمكن من التعرف على هذا الـ ONS. يرجى التحقق منها والمحاولة مرة أخرى. لم نتمكن من البحث عن هذا ONS. الرجاء المحاولة مرة أخرى لاحقا. فتح + فتح موقع {platform_store} + فتح موقع {platform} الإلكتروني + افتح الإعدادات + افتح الاستبيان أخرى + كلمة السر تغيير كلمة السر + تغيير كلمة السر المطلوبة لفتح {app_name}. + تم تغيير كلمة المرور الخاصة بك. يُرجى الاحتفاظ بها في مكان آمن. أَكِد كلمة المرور + إنشاء كلمة مرور كلمة المرور الحالية غير صحيحة. أدخل كلمة السر الرجاء إدخال كلمة السر الحالية الرجاء إدخال كلمة السر الجديدة كلمة المرور يجب ان تحتوي فقط على الاحرف, الارقام و الرموز + يجب أن تتراوح كلمة المرور بين {min} و {max} حرفًا كلمتا المرور لا تتطابقان فشل تعيين كلمة المرور كلمة المرور خاطئة + تأكيد كلمة السر الجديدة إزالة كلمة السر + إزالة كلمة المرور المطلوبة لفتح {app_name} + تمت إزالة كلمة المرور الخاصة بك. تعيين كلمة المرور + تم تعيين كلمة المرور الخاصة بك. يُرجى الاحتفاظ بها في مكان آمن. + يتطلب إدخال كلمة مرور لفتح {app_name} عند بدء التشغيل. + أطول من 12 حرفًا + يتضمن رقمًا + يتضمن حرفًا صغيرًا + يتضمن رمزًا + يتضمن حرفًا كبيرًا + مؤشر قوة كلمة السر + يساعد تعيين كلمة سر قوية في حماية رسائلك ومرفقاتك في حال فقدان جهازك أو سرقته. + كلمات السر لصق + خطأ في الدفع + تمت معالجة دفعتك بنجاح، ولكن حدث خطأ أثناء {action_type} حالة {pro} الخاصة بك.\n\nيرجى التحقق من اتصال الشبكة والمحاولة مرة أخرى. تغيير الصَّلاحِيَة {app_name} يحتاج إلى الوصول إلى الموسيقى والصوت من أجل إرسال الملفات والموسيقى والصوت، ولكن تم رفض هذا الأذن بشكل دائم. انقر على الإعدادات → الأذونات، وقم بتشغيل \"الموسيقى والصوت\". {app_name} يحتاج استخدام Apple Music لتشغيل مرفقات الوسائط. @@ -798,7 +1061,11 @@ السماح بالوصول إلى الكاميرا لمكالمات الفيديو. ميزة قفل الشاشة على {app_name} تستخدم Face ID. إبقاء في قالب النظام + {app_name} يستمر في العمل في الخلفية عند إغلاق النافذة. {app_name} يحتاج إذن الوصول إلى مكتبة الصور لمواصلة العمل. يمكنك تفعيل الوصول من خلال إعدادات iOS. + يتطلب الوصول إلى الشبكة المحلية لتسهيل المكالمات. لتتمكن من المتابعة، فعّل إذن \"الشبكة المحلية\" من الإعدادات. + يتطلب {app_name} الوصول إلى الشبكة المحلية لإجراء المكالمات الصوتية ومكالمات الفيديو. + تم تمكين الوصول إلى الشبكة المحلية حالياً. لتعطيله، قم بإيقاف إذن \"الشبكة المحلية\" من الإعدادات. السماح بالوصول إلى الشبكة المحلية لتسهيل المكالمات الصوتية والفيديو. الشبكة المحلية ميكروفون @@ -816,11 +1083,193 @@ {app_name} يحتاج إذن الوصول إلى التخزين لحفظ المرفقات والوسائط. {app_name} يحتاج إذن الوصول إلى التخزين لحفظ الصور ومقاطع الفيديو، ولكن تم رفضه نهائيًا. يرجى الانتقال إلى إعدادات التطبيق، واختيار \"الأذونات\"، وتفعيل \"التخزين\". {app_name} يحتاج إذن الوصول إلى التخزين لإرسال الصور ومقاطع الفيديو. + ليس لديك صلاحيات الكتابة في هذه المجموعة تثبيت تثبيت المحادثة إلغاء التثبيت إلغاء تثبيت المحادثة + والمزيد قادم... + ميزات جديدة قادمة قريبًا إلى {pro}. اكتشف ما هو التالي في خارطة طريق {pro} {icon} + التفضيلات معاينة + معاينة الإشعار + وصولك إلى {pro} نشط!\n\nسيُجدد وصولك إلى {pro} تلقائيًا لمدة {current_plan_length} في {date}. + وصولك إلى {pro} نشط!\n\nسيُجدد وصولك إلى {pro} تلقائيًا لمدة أخرى قدرها\n{current_plan_length} في {date}. ستدخل أي تحديثات تجريها هنا حيز التنفيذ عند التجديد التالي. + خطأ في الوصول إلى {pro} + ستنتهي صلاحية وصولك إلى {pro} في {date}. + جارٍ تحميل وصول {pro} + يتم تحميل معلومات الوصول إلى {pro} الخاصة بك. لا يمكنك التحديث حتى تكتمل هذه العملية. + ...جارٍ تحميل وصول {pro} + تعذر الاتصال بالشبكة لتحميل معلومات الوصول إلى {pro} الخاصة بك. سيتم تعطيل تحديث {pro} عبر {app_name} حتى تتم استعادة الاتصال.\n\nيرجى التحقق من اتصال الشبكة والمحاولة مرة أخرى. + تعذر العثور على وصول {pro} + اكتشف {app_name} أن حسابك لا يحتوي على صلاحية وصول {pro}. إذا كنت تعتقد أن هذا خطأ، يُرجى التواصل مع دعم {app_name} للحصول على المساعدة. + استعادة الوصول إلى {pro} + تجديد الوصول إلى {pro} + في الوقت الحالي، يمكن شراء وتجديد وصول {pro} فقط عن طريق {platform_store} أو {platform_store_other}. وبما أنك تستخدم {app_name} على سطح المكتب، فلا يمكنك التجديد من هنا.\n\nيعمل مطورو {app_name} جاهدين على توفير خيارات دفع بديلة تُمكِّن المستخدمين من شراء وصول {pro} خارج {platform_store} و{platform_store_other}. خارطة طريق {pro} {icon} + قم بتجديد وصولك إلى {pro} على موقع {platform_store} باستخدام حساب {platform_account} الذي استخدمته للتسجيل في {pro}. + قم بالتجديد على موقع {platform} باستخدام {platform_account} الذي سجلت به اشتراك {pro}. + قم بتجديد صلاحية وصول {pro} لبدء استخدام ميزات النسخة التجريبية القوية من {app_pro} مرة أخرى. + تم استعادة الوصول إلى {pro} + اكتشف {app_name} واستعاد الوصول إلى {pro} لحسابك. تم استعادة حالة {pro} الخاصة بك! + نظرًا لأنك سجلت في الأصل في {app_pro} عبر {platform_store}، ستحتاج إلى استخدام حساب {platform_account} لتحديث وصولك إلى {pro}. + حالياً، يمكن شراء وصول {pro} فقط من خلال {platform_store} أو {platform_store_other}. بما أنك تستخدم {app_name} لسطح المكتب، لا يمكنك الترقية إلى {pro} من هنا.\n\nيعمل مطورو {app_name} بجد على توفير خيارات دفع بديلة تتيح للمستخدمين شراء وصول {pro} من خارج {platform_store} و{platform_store_other}. خارطة طريق {pro} {icon} + تم التفعيل + تفعيل + كل شيء جاهز! + تم تحديث وصولك إلى {app_pro}! سيتم إصدار فاتورة لك عند تجديد {pro} تلقائيًا في {date}. + أنت تمتلكه بالفعل + انطلق وقم بتحميل صور GIF وصور WebP المتحركة لصورة العرض الخاصة بك! + احصل على صور عرض متحركة وفعّل الميزات المميزة باستخدام {app_pro} Beta + صورة عرض متحركة + يمكن للمستخدمين تحميل صور GIF + صور عرض متحركة + قم بتعيين صور GIF المتحركة وصور WebP كصورة عرض لك. + حمّل صور GIF مع PRO + {pro} سيتم تجديده تلقائيًا خلال {time} + شارة {pro} + عرض شارة {app_pro} للمستخدمين الآخرين + شارات + اظهر دعمك لـ {app_name} من خلال شارة حصرية بجانب اسم العرض الخاص بك. + + تم إرسال %1$s شارة %2$s + تم إرسال %1$s شارة %2$s + تم إرسال شارتات %1$s %2$s + تم إرسال شارات %1$s %2$s + تم إرسال شارات %1$s %2$s + تم إرسال شارات %1$s %2$s + + ميزات {pro} التجريبية + {price} يتم إصدار الفاتورة سنويًا + {price} يتم إصدار الفاتورة شهريًا + {price} يتم إصدار الفاتورة كل ثلاثة أشهر + هل تريد إرسال رسائل أطول؟\nأرسل المزيد من النصوص وافتح الميزات الحصرية باستخدام {app_pro} Beta + هل تريد تثبيت المزيد؟\nنظّم محادثاتك وافتح الميزات الحصرية باستخدام {app_pro} Beta + هل تريد تثبيت أكثر من {limit} محادثة؟\nنظّم محادثاتك وافتح الميزات الحصرية باستخدام {app_pro} Beta + نأسف لرؤيتك تلغي {pro}. إليك ما تحتاج معرفته قبل إلغاء وصولك إلى {pro}. + الإلغاء + سيؤدي إلغاء الوصول إلى {pro} إلى منع التجديد التلقائي قبل انتهاء صلاحية وصول {pro}. لا يؤدي إلغاء {pro} إلى استرداد الأموال. ستتمكن من الاستمرار في استخدام ميزات {app_pro} حتى تنتهي صلاحية وصولك إلى {pro}.\n\nنظرًا لأنك قمت بالتسجيل في {app_pro} باستخدام {platform_account}، فستحتاج إلى استخدام نفس {platform_account} لإلغاء {pro}. + طريقتان لإلغاء وصولك إلى {pro}: + سيؤدي إلغاء وصول {pro} إلى منع التجديد التلقائي قبل انتهاء صلاحية {pro}.\n\nإلغاء {pro} لن يؤدي إلى استرداد المبلغ. ستتمكن من الاستمرار في استخدام ميزات {app_pro} حتى انتهاء صلاحية وصولك إلى {pro}. + اختر خيار الوصول إلى {pro} الأنسب لك.\nكلما كانت مدة الوصول أطول، كانت الخصومات أكبر. + هل أنت متأكد أنك تريد حذف بياناتك من هذا الجهاز؟\n\nلا يمكن نقل {app_pro} إلى حساب آخر. يرجى حفظ كلمة مرور الاستعادة لضمان إمكانية استعادة وصولك إلى {pro} لاحقًا. + هل أنت متأكد أنك تريد حذف بياناتك من الشبكة؟ إذا واصلت، فلن تتمكن من استعادة رسائلك أو جهات اتصالك.\n\nلا يمكن نقل {app_pro} إلى حساب آخر. يرجى حفظ كلمة مرور الاستعادة لضمان تمكنك من استعادة وصولك إلى {pro} لاحقًا. + تم تخفيض تكلفة وصولك إلى {pro} بنسبة {percent}% من السعر الكامل لـ {app_pro}. + حدث خطأ أثناء تحديث حالة {pro} + انتهت الصلاحية + للأسف، انتهت صلاحية وصولك إلى {pro}.\nقم بالتجديد لإعادة تفعيل الميزات والفوائد الحصرية في {app_pro} Beta. + سينتهي قريبًا + ستنتهي صلاحية وصولك إلى {pro} خلال {time}.\nقم بالتحديث الآن لتمكين الوصول المستمر إلى الميزات الحصرية والفوائد في {app_pro} Beta + ستنتهي صلاحية {pro} خلال {time} + الأسئلة الشائعة حول {pro} + اعثر على إجابات للأسئلة الشائعة في الأسئلة الشائعة لـ {app_pro}. + حمّل صور عرض بصيغة GIF وWebP + دردشات جماعية أكبر حتى 300 عضو + بالإضافة إلى الكثير من الميزات الحصرية الأخرى + رسائل حتى 10٬000 حرف + تثبيت عدد غير محدود من المحادثات + هل تريد الاستفادة الكاملة من {app_name}؟\nقم بالترقية إلى {app_pro} Beta للوصول إلى العديد من المزايا والميزات الحصرية. + تم تفعيل المجموعة + تمت زيادة سعة هذه المجموعة! يمكنها الآن دعم ما يصل إلى 300 عضو بفضل أحد مسؤولي المجموعة الذي يمتلك + + %1$s من المجموعات التي تمت ترقيتها + تم ترقية مجموعة واحدة %1$s + تم ترقية مجموعتين %1$s + تم ترقية %1$s مجموعات + تم ترقية %1$s مجموعة + تم ترقية %1$s مجموعة + + طلب استرداد الأموال نهائي. إذا تمت الموافقة، سيتم إلغاء وصولك إلى {pro} فورًا وستفقد إمكانية الوصول إلى جميع ميزات {pro}. + حجم المرفقات المُحسَّن + طول الرسالة المُحسَّن + مجموعات أكبر + يتم ترقية أي مجموعة تكون فيها مسؤولًا تلقائيًا لدعم 300 عضو. + الدردشات الجماعية الأكبر (حتى 300 عضو) ستتوفر قريبًا لجميع مستخدمي Pro Beta! + رسائل أطول + يمكنك إرسال رسائل تصل إلى 10,000 حرف في جميع المحادثات. + + %1$s من الرسائل الطويلة المُرسلة + تم إرسال رسالة طويلة %1$s + تم إرسال رسالتين طويلتين %1$s + تم إرسال %1$s رسائل طويلة + تم إرسال %1$s رسالة طويلة + تم إرسال %1$s رسالة طويلة + + تم استخدام الميزات التالية من {app_pro} في هذه الرسالة: + من خلال تثبيت جديد + أعد تثبيت {app_name} على هذا الجهاز عبر {platform_store}، واستعد حسابك باستخدام كلمة المرور للاسترداد، ثم جدد {pro} من إعدادات {app_pro}. + أعِد تثبيت {app_name} على هذا الجهاز عبر {platform_store}، واستعد حسابك باستخدام كلمة الاسترجاع، ثم قم بالترقية إلى {pro} من إعدادات {app_pro}. + في الوقت الحالي، هناك ثلاث طرق للتجديد: + في الوقت الحالي، هناك طريقتان للتجديد: + خصم {percent}% + + %1$s من المحادثات المثبتة + %1$s محادثة مثبتة + %1$s محادثتان مثبتتان + %1$s محادثات مثبتة + %1$s محادثة مثبتة + %1$s محادثة مثبتة + + نظرًا لأنك سجلت في الأصل في {app_pro} عبر {platform_store}، ستحتاج إلى استخدام حساب {platform_account} لطلب استرداد المبلغ. + نظرًا لأنك قمت بالتسجيل في {app_pro} في الأصل عبر {platform_store}، فسيتم معالجة طلب الاسترداد الخاص بك من قبل دعم {app_name}.\n\nلطلب استرداد، انقر على الزر أدناه واملأ نموذج طلب الاسترداد.\n\nيسعى دعم {app_name} لمعالجة طلبات الاسترداد خلال ٢٤ إلى ٧٢ ساعة، ولكن قد تستغرق المعالجة وقتًا أطول في فترات كثافة الطلبات. + تم تجديد صلاحية وصولك إلى {app_pro}! شكرًا لدعمك لـ {network_name}. + 1 شهر - {monthly_price} / شهريًا + 3 أشهر - {monthly_price} / شهريًا + 12 شهرًا - {monthly_price} / شهريًا + إعادة التفعيل + افتح هذا الحساب في {app_name} على جهاز {device_type} مُسجّل عليه الدخول بالحساب {platform_account} الذي سجلت به في البداية. بعد ذلك، اطلب استرداد المبلغ من خلال إعدادات {app_pro}. + نأسف لمغادرتك. إليك ما تحتاج إلى معرفته قبل طلب استرداد المبلغ. + يُعالج {platform} الآن طلب استرداد الأموال الخاص بك. عادةً ما يستغرق ذلك من 24 إلى 48 ساعة. اعتمادًا على قرارهم، قد تلاحظ تغيير حالة {pro} في {app_name}. + سيتم التعامل مع طلب استرداد المبلغ الخاص بك بواسطة دعم {app_name}.\n\nلطلب استرداد المبلغ، اضغط على الزر أدناه وأكمل نموذج طلب الاسترداد.\n\nبينما يسعى دعم {app_name} لمعالجة طلبات الاسترداد خلال ٢٤–٧٢ ساعة، قد تستغرق المعالجة وقتًا أطول في أوقات كثرة الطلبات. + سيتم التعامل مع طلب استرداد المبلغ الخاص بك حصريًا من خلال {platform} عبر موقع {platform}.\n\nبسبب سياسات استرداد {platform}، لا يستطيع مطورو {app_name} التأثير على نتيجة طلبات الاسترداد، سواء تمت الموافقة أو الرفض، أو إذا ما تم إصدار استرداد كامل أو جزئي. + يرجى التواصل مع {platform} للحصول على تحديثات إضافية بخصوص طلب استرداد المبلغ الخاص بك. وبسبب سياسات استرداد المبالغ المعتمدة من قِبل {platform}، لا يستطيع مطورو {app_name} التأثير على نتيجة طلبات الاسترداد.\n\nدعم استرداد {platform} + جارٍ استرداد {pro} + يتم التعامل مع طلبات استرداد الأموال لـ {app_pro} حصريًا من خلال {platform} عبر {platform_store}.\n\nوبسبب سياسات الاسترداد الخاصة بـ {platform}، لا يمتلك مطورو {app_name} أي قدرة على التأثير في نتيجة طلبات الاسترداد. ويتضمن ذلك ما إذا كان سيتم الموافقة على الطلب أو رفضه، وكذلك ما إذا كان سيتم إصدار استرداد كامل أو جزئي. + هل تريد استخدام صور العرض المتحركة مجددًا؟\nقم بتجديد وصول {pro} الخاص بك لفتح الميزات التي فاتتك. + تجديد {pro} Beta + قم بتجديد وصولك إلى {pro} من إعدادات {app_pro} على جهاز مرتبط يحتوي على تطبيق {app_name} تم تثبيته من خلال {platform_store} أو {platform_store_other}. + هل تريد إرسال رسائل أطول مجددًا؟\nقم بتجديد وصول {pro} الخاص بك لفتح الميزات التي فاتتك. + هل ترغب في استخدام {app_name} بكامل إمكانياته مرة أخرى؟\nقم بتجديد وصول {pro} لفتح الميزات التي فاتتك. + هل تريد تثبيت أكثر من {limit} محادثة مجددًا؟\nقم بتجديد وصول {pro} الخاص بك لفتح الميزات التي فاتتك. + هل تريد تثبيت المزيد من المحادثات مجددًا؟\nقم بتجديد وصول {pro} الخاص بك لفتح الميزات التي فاتتك. + من خلال التجديد، فإنك توافق على شروط الخدمة {icon} وسياسة الخصوصية {icon} الخاصة بـ {app_pro} + تجديد + في الوقت الحالي، يمكن شراء وتجديد الوصول إلى {pro} فقط عبر {platform_store} أو {platform_store_other}. نظرًا لأنك قمت بتثبيت {app_name} باستخدام {build_variant}، لا يمكنك التجديد من هنا.\n\nيعمل مطورو {app_name} بجهد على توفير خيارات دفع بديلة للسماح للمستخدمين بشراء الوصول إلى {pro} خارج {platform_store} و {platform_store_other}. خارطة طريق {pro} {icon} + تم طلب الاسترداد + أرسل المزيد من خلال + إعدادات {pro} + ابدأ استخدام {pro} + إحصائيات {pro} الخاصة بك + جارٍ تحميل إحصائيات {pro} + جاري تحميل إحصائيات {pro} الخاصة بك، يرجى الانتظار. + تعكس إحصاءات {pro} الاستخدام على هذا الجهاز وقد تظهر بشكل مختلف على الأجهزة المرتبطة. + خطأ في حالة {pro} + تعذر الاتصال بالشبكة للتحقق من حالة {pro} الخاصة بك. قد تكون المعلومات المعروضة في هذه الصفحة غير دقيقة حتى تتم استعادة الاتصال.\n\nيرجى التحقق من اتصال الشبكة والمحاولة مرة أخرى. + جارٍ تحميل حالة {pro} + يتم تحميل معلومات {pro} الخاصة بك. قد لا تتوفر بعض الإجراءات في هذه الصفحة حتى يكتمل التحميل. + جارٍ تحميل حالة {pro} + يتعذر الاتصال بالشبكة للتحقق من حالة {pro} الخاصة بك. لا يمكنك المتابعة حتى تتم استعادة الاتصال.\n\nيرجى التحقق من اتصالك بالشبكة ثم المحاولة مجددًا. + تعذر الاتصال بالشبكة للتحقق من حالة {pro} الخاصة بك. لا يمكنك الترقية إلى {pro} حتى تتم استعادة الاتصال.\n\nيرجى التحقق من اتصال الشبكة والمحاولة مرة أخرى. + تعذر الاتصال بالشبكة لتحديث حالة {pro} الخاصة بك. سيتم تعطيل بعض الإجراءات في هذه الصفحة حتى تتم استعادة الاتصال.\n\nيرجى التحقق من اتصال الشبكة والمحاولة مرة أخرى. + تعذر الاتصال بالشبكة لتحميل وصولك الحالي إلى {pro}. سيتم تعطيل تجديد {pro} عبر {app_name} حتى يتم استعادة الاتصال.\n\nيرجى التحقق من اتصالك بالشبكة والمحاولة مرة أخرى. + تحتاج مساعدة في {pro}؟ قدّم طلبًا إلى فريق الدعم. + من خلال {action_type}، أنت تقوم بـ {activation_type} {app_pro} عبر بروتوكول {app_name}. سيتولى {entity} تسهيل هذا التفعيل ولكنه ليس موفر خدمة {app_pro}. {entity} غير مسؤول عن أداء أو توفر أو وظيفة {app_pro}. + من خلال التحديث، فإنك توافق على شروط الخدمة {icon} و سياسة الخصوصية {icon} الخاصة بـ {app_pro} + تثبيتات غير محدودة + نظّم جميع محادثاتك باستخدام محادثات مثبّتة غير محدودة. + خيار الفوترة الحالي يمنحك {current_plan_length} من وصول {pro}. هل أنت متأكد أنك تريد التبديل إلى خيار الفوترة {selected_plan_length_singular}؟\n\nعند التحديث، سيتم تجديد وصول {pro} تلقائيًا في {date} لمدة إضافية قدرها {selected_plan_length} من وصول {pro}. + ستنتهي صلاحية وصول {pro} الخاص بك في {date}.\n\nعند التحديث، سيتم تجديد وصول {pro} تلقائيًا في {date} لمدة إضافية قدرها {selected_plan_length} من وصول {pro}. + تحديث + قم بالترقية إلى {app_pro} Beta للوصول إلى العديد من المزايا والميزات الحصرية. + قم بالترقية إلى {pro} من إعدادات {app_pro} على جهاز مرتبط تم تثبيت {app_name} عليه عبر {platform_store} أو {platform_store_other}. + حاليًا، يمكن شراء الوصول إلى {pro} فقط عبر {platform_store} أو {platform_store_other}. نظرًا لأنك قمتَ بتثبيت {app_name} باستخدام {build_variant}، فإنه لا يمكنك الترقية إلى {pro} هنا.\n\nيعمل مطورو {app_name} بجد على توفير خيارات دفع بديلة لتمكين المستخدمين من شراء الوصول إلى {pro} خارج {platform_store} و{platform_store_other}. خارطة طريق {pro} {icon} + حالياً، يوجد خيار واحد فقط للترقية: + حاليًا، هناك طريقتان للترقية: + لقد تمّت الترقية إلى {app_pro}!\nشكرًا لدعمك لـ {network_name}. + ترقية + جارٍ الترقية إلى {pro} + من خلال الترقية، فإنك توافق على شروط الخدمة {icon} وسياسة الخصوصية {icon} الخاصة بـ{app_pro} + هل ترغب بالاستفادة أكثر من {app_name}?\nقم بالترقية إلى {app_pro} Beta لتجربة مراسلة أقوى. + {platform} يعالج طلب استرداد المبلغ الخاص بك الملف الشخصي صورة العرض فشل في إزالة صورة العرض. @@ -828,6 +1277,31 @@ الرجاء اختيار ملف أصغر. فشل تحديث الملف الشخصي. ترقية + سيتمكن المشرفون من رؤية سجل الرسائل لآخر 14 يوماً ولا يمكن خفض رتبتهم أو إزالتهم من المجموعة. + + ترقية الأعضاء + ترقية عضو + ترقية عضوين + ترقية الأعضاء + ترقية الأعضاء + ترقية الأعضاء + + + فشلت الترقية + فشلت الترقية + فشلت الترقّيتان + فشلت بعض الترقيات + فشلت العديد من الترقيات + فشلت الترقيات + + + لا يمكن الترقية. أتود المحاولة مرة أخرى؟ + تعذر تطبيق الترقية. هل تود المحاولة مرة أخرى؟ + تعذر تطبيق الترقّيتين. هل تود المحاولة مرة أخرى؟ + تعذر تطبيق بعض الترقيات. هل تود المحاولة مرة أخرى؟ + تعذر تطبيق العديد من الترقيات. هل تود المحاولة مرة أخرى؟ + فشلت الترقيات. أتود المحاولة مرة أخرى؟ + رمز QR رمز QR هذا لا يحتوي على مُعرف حساب رمز QR هذا لا يحتوي على عبارة استرداد @@ -836,15 +1310,20 @@ يمكن للأصدقاء إرسال رسائل إليك عن طريق مسح رمز QR الخاص بك. انهاء {app_name} انهاء + تقييم {app_name}؟ + قيِّم التطبيق + يسعدنا أنك تستمتع بتجربة استخدام {app_name}. إذا كان لديك وقت، فإن تقييمك لنا على {storevariant} يساعد الآخرين على اكتشاف المراسلة الخاصة والآمنة! قراءة إيصالات القراءة إظهار إيصالات القراءة لجميع الرسائل التي ترسلها وتستلمها. استلمت: تلقي جواب استقبال طلب اتصال + استقبال عرض الاتصال مستحسن احفظ كلمة مرور الاسترداد الخاصة بك للتأكد من أنك لن تفقد الوصول إلى حسابك. احفظ كلمة مرور الاسترداد الخاصة بك + استخدم كلمة الاسترداد لتحميل حسابك على أجهزة جديدة.\n\nلا يمكن استرداد الحساب بدون كلمة الاسترداد. تأكد من حفظها في مكان آمن ومحمٍ — ولا تشاركها مع أي شخص. أدخل كلمة مرور الاسترجاع حدث خطأ عند محاولة تحميل كلمة المرور الاحتياطية الخاصة بك.\n\nيرجى تصدير السجلات الخاصة بك، ثم تحميل المِلَفّ عبر مكتب مساعدة {app_name} للمساعدة في حل هذه المشكلة. يرجى التحقق من كلمة مرور الاسترداد الخاصة بك وحاول مرة أخرى. @@ -854,20 +1333,94 @@ لتحميل حسابك، أدخل عبارة الاسترداد الخاصة بك. إخفاء كلمة مرور الاسترداد بشكل دائم بدون كلمة المرور الاستردادية، لا يمكنك تحميل حسابك على الأجهزة الجديدة. \n\nنوصيك بشدة بحفظ كلمة المرور الاستردادية في مكان آمن قبل المتابعة. + هل أنت متأكد من أنك تريد إخفاء كلمة مرور الاسترداد نهائيًا على هذا الجهاز؟\n\nلا يمكن التراجع عن هذا. إخفاء كلمة مرور الاسترداد قم بإخفاء كلمة مرور الاسترداد بشكل دائم على هذا الجهاز. أدخل كلمة مرور الاسترجاع لتحميل حسابك. إذا لم تقم بحفظها، يمكنك العثور عليها في إعدادات التطبيق. + عرض كلمة مرور الاسترداد + رؤية كلمة مرور الاسترداد هذه هي عبارة الاسترداد الخاصة بك. إذا قمت بإرساله إلى شخص ما ، فسيكون لديه حق الوصول الكامل إلى حسابك. إعادة إنشاء المجموعة إعادة + نظرًا لأنك قمت بالتسجيل في {app_pro} في الأصل من خلال حساب {platform_account} مختلف، فستحتاج إلى استخدام {platform_account} هذا لتحديث وصولك إلى {pro}. + طريقتان لطلب استرداد المبلغ: + قلّل طول الرسالة بمقدار {count} + {count} حرف متبقي + ذكرني لاحقًا إزالة + + إزالة الأعضاء + إزالة العضو + إزالة العضوين + إزالة بعض الأعضاء + إزالة العديد من الأعضاء + إزالة الأعضاء + + + إزالة الأعضاء ورسائلهم + إزالة العضو ورسائله + إزالة العضوين ورسائلهما + إزالة الأعضاء ورسائلهم + إزالة الأعضاء ورسائلهم + إزالة الأعضاء ورسائلهم + فشل في إزالة كلمة المرور + قم بإزالة كلمة المرور الحالية لـ {app_name}. سيتم إعادة تشفير البيانات المخزّنة محليًا باستخدام مفتاح يتم إنشاؤه عشوائيًا وتخزينه على جهازك. + + جارٍ إزالة الأعضاء + جارٍ إزالة العضو + جارٍ إزالة الأعضاء + جارٍ إزالة الأعضاء + جارٍ إزالة الأعضاء + جارٍ إزالة الأعضاء + + تجديد + تجديد {pro} رد + طلب استرداد + قدّم طلب استرداد على موقع {platform} باستخدام حساب {platform_account} الذي استخدمته للتسجيل في {pro}. إعادة الإرسال + + إعادة إرسال الدعوات + إعادة إرسال الدعوة + إعادة إرسال الدعوتين + إعادة إرسال بعض الدعوات + إعادة إرسال العديد من الدعوات + إعادة إرسال الدعوات + + + إعادة إرسال الترقيات + إعادة إرسال الترقية + إعادة إرسال الترقية + إعادة إرسال الترقيات + إعادة إرسال الترقيات + إعادة إرسال الترقيات + + + جارٍ إعادة إرسال الدعوات + جارٍ إعادة إرسال الدعوة + جارٍ إعادة إرسال الدعوتين + جارٍ إعادة إرسال بعض الدعوات + جارٍ إعادة إرسال العديد من الدعوات + جارٍ إعادة إرسال الدعوات + + + يتم إعادة إرسال الترقيات + يتم إعادة إرسال الترقية + يتم إعادة إرسال الترقية + يتم إعادة إرسال الترقيات + يتم إعادة إرسال الترقيات + يتم إعادة إرسال الترقيات + جاري تحميل معلومات الدولة... إعادة التشغيل إعادة المزامنة إعادة المحاولة + حد المراجعات + يبدو أنك قمت بمراجعة {app_name} مؤخرًا. نشكرك على ملاحظاتك! + تشغيل التطبيق في الخلفية + تشغيل {app_name} في الخلفية؟ + نظرًا لتمكين وضع البطء، نوصي بالسماح لتطبيق {app_name} بالتشغيل في الخلفية لتعزيز استلام الإشعارات. قد يحسن هذا من اتساق الإشعارات، ولكن قد يواصل النظام تقييد النشاط في الخلفية تلقائياً.\n\nيمكنك تغيير هذا لاحقاً من الإعدادات. حفظ تم الحفظ الرسائل المحفوظة @@ -876,6 +1429,8 @@ أمان الشاشة إشعارات لقطة الشاشة تطلب إشعار عندما يلتقط شخص آخر لقطة شاشة لمحادثة واحد لواحد. + إخفاء نافذة {app_name} في لقطات الشاشة الملتقطة على هذا الجهاز. + حماية اللقطات {name} قام بتصوير الشاشة. بحث ابحث في جهات الاتصال @@ -899,6 +1454,15 @@ إرسل إرسال إرسال طلب للمكالمة + إرسال مرشحي الاتصال + + جارٍ إرسال الترقيات + جارٍ إرسال الترقية + جارٍ إرسال الترقيات + جارٍ إرسال الترقيات + جارٍ إرسال الترقيات + جارٍ إرسال الترقيات + تم الإرسال: المظهر مسح البيانات @@ -906,58 +1470,117 @@ المساعدة دعوة صديق طلبات المُراسلة + سعر {token_name_short} الحالي يتم إرسال الرسائل باستخدام {network_name}. تتكون الشبكة من عقد محفزة مع {token_name_long}، الذي يحافظ على {app_name} لامركزي وآمن. اعرف المزيد {icon} + تعرّف على التجميد + القيمة السوقية + عُقد {app_name} التي تؤمن رسائلك + عُقد {app_name} في السرب الخاص بك + {token_name_long} أصبح نشطًا! استكشف قسم {network_name} الجديد في الإعدادات لتتعرف على كيفية دعم {token_name_long} لتطبيق Session. + الشبكة مؤمنة بواسطة + عندما تقوم بتجميد {token_name_long} لتأمين الشبكة، فإنك تكسب مكافآت بعملة {token_name_short} من {staking_reward_pool}. + جديد الإشعارات الصلاحيات الخصوصية + إصدار {app_pro} التجريبي كلمة مرور الاسترداد التعديلات تعيين + تعيين صورة عرض المجتمع + قم بتعيين كلمة مرور لـ {app_name}. سيتم تشفير البيانات المخزنة محليًا باستخدام هذه الكلمة. سيُطلب منك إدخال هذه الكلمة في كل مرة يبدأ فيها {app_name}. + تعذّر تحديث الإعداد يجب عليك إعادة تشغيل {app_name} لتطبيق الإعدادات الجديدة. أمان الشاشة + بدء التشغيل مشاركة ادعُ صديقك للدردشة معك على {app_name} بمشاركة معرف حسابك معهم. شارك مع أصدقائك حيثما تتحدث معهم عادةً — ثم نقل المحادثة هنا. هناك مشكلة في فتح قاعدة البيانات. يرجى إعادة تشغيل التطبيق والمحاولة مرة أخرى. + عذرًا! يبدو أنك لا تملك حسابًا على {app_name} بعد.\n\nستحتاج إلى إنشاء حساب في تطبيق {app_name} قبل أن تتمكن من المشاركة. + هل ترغب في مشاركة سجل رسائل المجموعة مع هذا المستخدم؟ مشاركة إلى {app_name} + عذراً، {app_name} يدعم فقط مشاركة صور وفيديوهات متعددة في آنٍ واحد + يدعم المشاركة للوسائط فقط. تم استثناء الملفات غير الوسائط إظهار إظهار الكل عرض أقل + عرض \"ملاحظة لنفسي\" + هل أنت متأكد أنك تريد عرض ملاحظة لنفسي في قائمة المحادثات؟ + مدقق الإملاء الملصقات + القوة + هل تواجه مشكلة؟ تصفح مقالات المساعدة أو افتح تذكرة دعم لدى {app_name}. الذهاب لصفحة الدعم معلومات النظام: {information} أنقر للمحاولة مرة أخرى استمرار افتراضي خطأ + رجوع + معاينة السمة + معرّف الحساب الخاص بـ {name} مرئي بناءً على تفاعلاتك السابقة + تُستخدم المعرفات المخفية في المجتمعات لتقليل الرسائل غير المرغوب بها وزيادة الخصوصية + ترجمة + علبة الرموز حاول مجدداً مؤشرات الكتابة شاهد وشارك مؤشرات الكتابة. + غير متاح تراجع غير معروف + وحدة المعالجة المركزية غير مدعومة + تحديث + تحديث وصول {pro} + طريقتان لتحديث وصولك إلى {pro}: تحديثات التطبيق + تحديث معلومات المجتمع + اسم المجتمع ووصفه مرئيان لجميع أعضاء المجتمع + يرجى إدخال وصف أقصر للمجتمع + يرجى إدخال اسم أقصر للمجتمع تم تثبيت التحديث، اضغط لإعادة التشغيل جارٍ تنزيل التحديث: {percent_loader}% لا يمكن التحديث {app_name} فشل في التحديث. يرجى الانتقال إلى {session_download_url} وتثبيت الإصدار الجديد يدويًا، ثم قم بالاتصال بمركز المساعدة لإعلامنا بالمشكلة. + تحديث معلومات المجموعة + اسم ووصف المجموعة مرئيان لجميع أعضاء المجموعة. + يرجى إدخال وصف أقصر للمجموعة إصدار جديد من {app_name} متاح، انقر للتحديث. يتوفر إصدار جديد ({version}) لتطبيق {app_name}. + تحديث معلومات الملف الشخصي + اسم العرض وصورة العرض الخاصة بك ظاهران في جميع المحادثات. اِذهب الى ملاحظات الاِصدار تحديث {app_name} النسخة {version} + آخر تحديث منذ {relative_time} + التحديثات + جارٍ التحديث... + ترقية + ترقية {app_name} + الترقية إلى جارٍ تحميل نسخ الرابط فتح الرابط سيتم فتح هذا في متصفحك. هل أنت متأكد من فتح هذا الرابط في متصفحك؟\n\n{url} + سيتم فتح الروابط في متصفحك. استخدم الوضع السريع + غيّر خطتك باستخدام {platform_account} الذي استخدمته عند التسجيل، عبر موقع {platform}. + عبر موقع {platform} الإلكتروني فيديو تعذر تشغيل الفيديو عرض + عرض أقل + عرض المزيد قد يستغرق ذلك بضع دقائق. لحظة واحدة من فضلك... تحذير + انتهى دعم نظام iOS 15. يرجى التحديث إلى iOS 16 أو إصدار أحدث للاستمرار في تلقي تحديثات التطبيق. نافذة نعم أنت + وحدة المعالجة المركزية الخاصة بك لا تدعم تعليمات SSE 4.2، وهي مطلوبة من قبل {app_name} على أنظمة التشغيل Linux x64 لمعالجة الصور. يرجى الترقية إلى وحدة معالجة مركزية متوافقة أو استخدام نظام تشغيل مختلف. + كلمة مرور الاسترداد الخاصة بك + عامل التكبير + ضبط حجم النص والعناصر المرئية. \ No newline at end of file diff --git a/app/src/main/res/values-b+az+AZ/strings.xml b/app/src/main/res/values-b+az+AZ/strings.xml index a6ad5d2716..7a767f66e1 100644 --- a/app/src/main/res/values-b+az+AZ/strings.xml +++ b/app/src/main/res/values-b+az+AZ/strings.xml @@ -978,6 +978,7 @@ {app_pro} erişiminiz güncəllənib! {pro} abunəliyiniz {date} tarixində avtomatik yeniləndiyi zaman ödəniş haqqı alınacaq. Artıq yüksəltdiniz Getdik və ekran şəkliniz üçün GIF-lər və animasiyalı WebP təsvirləri yükləyin! + {app_pro} Beta ilə animasiyalı ekran şəkilləri əldə edin və premium özəlliklərin kilidini açın Animasiyalı profil şəkli istifadəçiləri GIF-ləri yükləyə bilər Animasiyalı ekran şəkilləri @@ -996,12 +997,15 @@ {price} - illik haqq {price} - aylıq haqq {price} - rüblük haqq + Daha uzun mesajlar göndərmək istəyirsiniz?\n{app_pro} Beta ilə daha çox mesaj göndərin və premim özəlliklərin kilidini açın + Daha çoxunu sancmaq istəyirsiniz?\n{app_pro} Beta ilə söhbətlərinizi təşkil edin və premium özəlliklərin kilidini açın + {limit} danışıqdan çoxunu sancmaq istəyirsiniz?\n{app_pro} Beta ilə söhbətlərinizi təşkil edin və premium özəlliklərin kilidini açın Ləğv etmə {pro} erişiminizi ləğv etməyin iki yolu var: Tam {app_pro} qiymətinə sahib {pro} erişiminiz üçün artıq {percent}% endirim var. {pro} statusunu təzələmə xətası Müddəti bitib - Təəssüf ki, {pro} erişiminizin istifadə müddəti bitib. {app_pro} üzrə eksklüziv imtiyazları və özəllikləri təkrar aktivləşdirmək üçün yeniləyin. + Təəssüf ki, {pro} erişiminizin istifadə müddəti bitib.\n{app_pro} üzrə eksklüziv imtiyazları və özəllikləri təkrar aktivləşdirmək üçün yeniləyin. Tezliklə bitir {pro}, {time} vaxtında başa çatır {pro} TVS @@ -1053,7 +1057,6 @@ {pro} erişiminizi, {platform_store} və ya {platform_store_other} üzərindən {app_name} quraşdırılmış və əlaqələndirilmiş cihazın {app_pro} ayarlarında yeniləyin. {app_name} tətbiqini yenidən maksimum potensialda istifadə etmək istəyirsiniz?\nQaçırdığınız özəlliklərin kilidini açmaq üçün {pro} erişiminizi yeniləyin. Daha çox danışığı təkrar sancmaq istəyirsiniz?\nQaçırdığınız özəlliklərin kilidini açmaq üçün {pro} erişiminizi yeniləyin. - Pro yeniləməsi uğursuz oldu, tezliklə yenidən sınanacaq Geri ödəmə tələb edildi Daha çoxunu göndərmək üçün {pro} ayarları @@ -1072,6 +1075,7 @@ Güncəlləyərək, {app_pro} Xidmət Şərtləri {icon} və Məxfilik Siyasəti {icon} ilə razılaşırsınız Limitsiz sancma Limitsiz sancılmış danışıqla bütün söhbətlərinizi təşkil edin. + {app_name} tətbiqindən daha çox faydalanmaq istəyirsiniz?\nDaha güclü mesajlaşma təcrübəsi üçün {app_pro} Betaya yüksəldin. {platform} geri ödəmə tələbinizi emal edir Profil Ekran şəkli @@ -1314,7 +1318,6 @@ Keçidlər, brauzerinizdə açılacaq. Sürətli rejimi istifadə et {platform} veb saytı vasitəsilə - Qeydiyyatdan keçdiyiniz {platform_account} hesabını istifadə edərək {pro} erişiminizi {platform_store} veb saytı üzərindən güncəlləyin. Video Video oxudula bilmir. Göstər diff --git a/app/src/main/res/values-b+cs+CZ/strings.xml b/app/src/main/res/values-b+cs+CZ/strings.xml index dee81f75ac..7775725eb7 100644 --- a/app/src/main/res/values-b+cs+CZ/strings.xml +++ b/app/src/main/res/values-b+cs+CZ/strings.xml @@ -10,7 +10,6 @@ Toto ID účtu je neplatné. Zkontrolujte to prosím a zkuste to znovu. Zadejte ID účtu nebo ONS Pozvat ID účtu nebo ONS - Ahoj, používám {app_name} k rychlou komunikaci s úplným soukromím a bezpečností. Připoj se ke mě! Moje ID účtu je\n\n{account_id}\n\nAdresa pro stažení je {session_download_url} ID vašeho účtu Toto je vaše ID účtu. Ostatní uživatelé jej mohou naskenovat, aby s vámi mohli zahájit konverzaci. Skutečná velikost @@ -21,6 +20,7 @@ Přidat správce Přidat správce + Přidat správce Zadejte ID účtu uživatele, kterého povyšujete na správce.\n\nChcete-li přidat více uživatelů, zadejte každé ID účtu oddělené čárkou. Najednou lze zadat až 20 ID účtů. Správci nemohou být poníženi ani odebráni ze skupiny. Správce nelze odebrat. @@ -63,6 +63,7 @@ Nemůžete změnit svůj stav správce. Chcete-li skupinu opustit, otevřete nastavení konverzace a vyberte možnost Opustit skupinu. {name} a {other_name} byli povýšeni na správce. Správci + Povolit +{count} Anonymní Ikona aplikace @@ -179,6 +180,7 @@ Opravdu chcete odblokovat {name} a 1 další? {name} odblokován(a) Zobrazit a spravovat blokované kontakty. + Nenalezen žádný prohlížeč pro otevření této URL, zkuste místo toho zkopírovat URL Volat {name} vám volal(a) Nemůžete zahájit nový hovor. Dokončete nejprve svůj aktuální hovor. @@ -219,12 +221,12 @@ {app_name} potřebuje přístup k fotoaparátu ke skenování QR kódů Zrušit Zrušit {pro} - Zrušit tarif {pro} Zrušení proveďte na webu {platform} pomocí {platform_account}, kterým jste si zaregistrovali {pro}. Zrušení proveďte na webu {platform_store} pomocí {platform_account}, kterým jste si zaregistrovali {pro}. Změnit Změna hesla selhala Změňte své heslo pro {app_name}. Lokálně uložená data budou znovu zašifrována pomocí vašeho nového hesla. + Změnit nastavení Kontrola stavu {pro} Kontroluje se váš stav {pro}. Jakmile kontrola skončí, budete moci pokračovat. Kontrolují se vaše údaje {pro}. Některé akce na této stránce nemusí být dostupné, dokud kontrola nebude dokončena. @@ -437,6 +439,7 @@ Jste si jisti, že chcete smazat tyto zprávy pro všechny? Mazání Přepnout nástroje vývojáře + Nastavení zařízení pro upozornění Začít diktovat... Mizející zprávy Zpráva zmizí za {time_large} @@ -484,6 +487,8 @@ Vaše zobrazované jméno je viditelné pro uživatele, skupiny a komunity, se kterými jste ve spojení. Dokument Darovat + Mocné síly se snaží oslabit soukromí, ale tento boj nemůžeme vést sami.\n\nPříspěvky pomáhají udržet aplikaci {app_name} bezpečnou, nezávislou a online. + {app_name} potřebuje vaši pomoc Hotovo Stáhnout Stahování... @@ -537,6 +542,8 @@ Nepodařilo se znovu odeslat pozvánku pro {name} ve skupině {group_name} Nepodařilo se znovu odeslat pozvánku pro {name} a {count} dalších ve skupině {group_name} Nepodařilo se znovu odeslat pozvánku pro {name} a {other_name} ve skupině {group_name} + Nepodařilo se znovu odeslat povýšení pro {name} ve skupině {group_name} + Nepodařilo se znovu odeslat povýšení pro {name} a {count} dalších ve skupině {group_name} Nepodařilo se znovu odeslat pozvánku pro {name} a {other_name} ve skupině {group_name} Stahování selhalo Chyby @@ -702,6 +709,7 @@ Pozvat členy Pozvěte nového člena do skupiny zadáním ID účtu vašeho přítele, ONS nebo naskenováním jejich QR kódu {icon} + Pozvěte nového člena do skupiny zadáním ID účtu vašeho přítele, ONS nebo naskenováním jejich QR kódu Připojit Později Spustit {app_name} automaticky při spuštění počítače. @@ -721,6 +729,8 @@ Vy a {other_name} se připojili ke skupině. {name} a {other_name} se připojili ke skupině. Vy jste se připojili ke skupině. + Omezit aktivitu na pozadí? + Aktuálně povolujete {app_name} běžet na pozadí, aby bylo zajištěno spolehlivější doručování oznámení. Změna této volby může vést k méně spolehlivým oznámením. Náhledy odkazů Zobrazit náhledy odkazů pro podporované URL adresy. Zapnout náhledy odkazů @@ -749,6 +759,7 @@ Spravovat členy Spravovat {pro} Maximum + Možná později Média %1$d člen vybrán @@ -769,6 +780,7 @@ %1$d aktivních členů Přidat ID účtu nebo ONS + Členové mohou být povýšeni teprve poté, co přijmou pozvání ke vstupu do skupiny. Pozvat kontakty Nemáte žádné kontakty, které byste mohli pozvat do této skupiny.\nVraťte se zpět později a pozvěte členy pomocí jejich ID účtu nebo ONS. @@ -781,6 +793,7 @@ Chcete sdílet historii zpráv skupiny s {name} a {count} dalšími? Chcete sdílet historii zpráv skupiny s {name} a {other_name}? Sdílet historii zpráv + Sdílet historii zpráv za posledních 14 dní Sdílet pouze nové zprávy Pozvat Členové (ne správci) @@ -878,6 +891,7 @@ Nemáte žádné zprávy v Poznámka sobě. Skrýt Poznámku sobě Jste si jisti, že chcete skrýt Poznámku sobě? + UPOZORNĚNÍ: {action_type} znamená, že souhlasíte s Podmínkami služby {icon} a se Zásadami ochrany osobních údajů {icon} {app_pro} Zobrazení upozornění Zobrazit jméno odesílatele a náhled obsahu zprávy. Zobrazit pouze jméno odesílatele bez obsahu zprávy. @@ -987,6 +1001,8 @@ Nastavení silného hesla pomáhá chránit vaše zprávy a přílohy v případě ztráty nebo odcizení zařízení. Hesla Vložit + Platební chyba + Vaše platba byla úspěšně zpracována, ale došlo k chybě {action_type} vašeho stavu {pro}.\n\nZkontrolujte prosím vaše síťové připojení a zkuste to znovu. Změna oprávnění {app_name} potřebuje přístup k hudbě a zvuku, aby mohla posílat soubory, hudbu a zvuk. Přístup byl ale trvale odepřen. Klepněte na Nastavení → Oprávnění a zapněte \"Hudba a zvuk\". {app_name} potřebuje použít Apple Music pro přehrávání mediálních příloh. @@ -1052,10 +1068,12 @@ Protože jste si původně zaregistrovali {app_pro} přes {platform_store}, je třeba, abyste k aktualizaci svého přístupu k {pro} využili svůj {platform_account}. V současnosti lze přístup k {pro} zakoupit pouze prostřednictvím {platform_store} nebo {platform_store_other}. Protože používáte {app_name} Desktop, nemůžete zde provést navýšení na {pro}.\n\nVývojáři {app_name} intenzivně pracují na alternativních platebních možnostech, které by uživatelům umožnily zakoupit přístup k {pro} mimo {platform_store} a {platform_store_other}. Plán vývoje {pro} {icon} Aktivováno + aktivování Vše je nastaveno! Váš přístup k {app_pro} byl aktualizován! Účtování proběhne při automatickém prodloužení {pro} dne {date}. Už máte Jako váš zobrazovaný profilový obrázek nastavte animovaný GIF nebo WebP! + Získejte možnost nahrát animovaný zobrazovaný obrázek profilu a další prémiové funkce se {app_pro} Beta Animovaný zobrazovaný obrázek uživatelé mohou nahrávat GIFy Animované zobrazované obrázky @@ -1076,6 +1094,9 @@ {price} účtováno ročně {price} účtováno měsíčně {price} účtováno čtvrtletně + Chcete posílat delší zprávy?\nPosílejte více textu odemknutím prémiových funkcí {app_pro} Beta + Chcete více připnutí?\nOrganizujte své chaty a odemkněte prémiové funkce pomocí {app_pro} Beta + Chcete více než {limit} připnutí?\nOrganizujte své chaty a odemkněte prémiové funkce pomocí {app_pro} Beta Mrzí nás, že rušíte {pro}. Než zrušíte {pro}, přečtěte si, co byste měli vědět. Zrušit Zrušení přístupu k {pro} zabrání jeho automatickému prodloužení před vypršením platnosti přístupu k {pro}. Zrušením {pro} nedojde k vrácení peněz. Funkce {app_pro} budete moci využívat až do vypršení přístupu k {pro}.\n\nProtože jste si původně {app_pro} zařídili pomocí účtu {platform_account}, bude ke zrušení přístupu k {pro} potřeba použít stejný {platform_account}. @@ -1089,6 +1110,7 @@ Platnost vypršela Bohužel, váš tarif {pro} vypršel. Prodlužte jej, abyste znovu získali přístup k exkluzivním výhodám a funkcím {app_pro}. Brzy vyprší + Váš přístup k {pro} vyprší za {time}.\nProveďte nyní aktualizaci, abyste si zachovali přístup k exkluzivním výhodám a funkcím {app_pro} Beta {pro} vyprší za {time} {pro} FAQ Najděte odpovědi na časté dotazy v nápovědě {app_pro}. @@ -1139,6 +1161,7 @@ 1 měsíc – {monthly_price} / měsíc 3 měsíce – {monthly_price} / měsíc 12 měsíců – {monthly_price} / měsíc + opětovné aktivování Otevřete tento účet {app_name} na zařízení {device_type}, které je přihlášeno k účtu {platform_account}, se kterým jste se původně zaregistrovali. Poté požádejte o vrácení platby přes nastavení {app_pro}. Mrzí nás, že to rušíte. Než požádáte o vrácení peněz, přečtěte si informace, které byste měli vědět. {platform} nyní zpracovává vaši žádost o vrácení peněz. Obvykle to trvá 24-48 hodin. V závislosti na jejich rozhodnutí se může váš stav {pro} v aplikaci {app_name} změnit. @@ -1152,9 +1175,10 @@ Prodlužte svůj přístup k {pro} v nastavení {app_pro} na propojeném zařízení s nainstalovanou aplikací {app_name} prostřednictvím {platform_store} nebo {platform_store_other}. Chcete znovu posílat delší zprávy?\nObnovte si přístup k {pro} pro odemknutí funkcí, které vám chyběly. Chcete znovu využít {app_name} naplno?\nProdlužte si přístup k {pro} pro odemknutí funkcí, které vám chyběly. + Chcete si znovu připnout více než {limit} konverzací?\nProdlužte si přístup k {pro} pro odemknutí funkcí, které vám chyběly. Chcete si znovu připnout více konverzací?\nProdlužte si přístup k {pro} pro odemknutí funkcí, které vám chyběly. Prodloužením souhlasíte s Podmínkami služby {icon} a Zásadami ochrany osobních údajů {icon} {app_pro} - Prodloužení Pro selhalo, brzy proběhne další pokus + prodlužování V současnosti lze přístup k {pro} zakoupit a prodloužit pouze prostřednictvím {platform_store} nebo {platform_store_other}. Protože jste nainstalovali {app_name} pomocí {build_variant}, nemůžete zde prodloužit přístup.\n\nVývojáři {app_name} intenzivně pracují na alternativních platebních možnostech, které by uživatelům umožnily zakoupit přístup k {pro} mimo {platform_store} a {platform_store_other}. Plán vývoje {pro} {icon} Žádost o vrácení peněz Posílejte více se @@ -1174,19 +1198,23 @@ Nelze se připojit k síti, aby se obnovil váš stav {pro}. Některé akce na této stránce budou deaktivovány, dokud nebude obnoveno připojení.\n\nZkontrolujte připojení k síti a zkuste to znovu. Nelze se připojit k síti kvůli načtení vašeho aktuálního přístupu k {pro}. Obnovení {pro} prostřednictvím {app_name} bude deaktivováno, dokud nebude obnoveno připojení.\n\nZkontrolujte připojení k síti a zkuste to znovu. Potřebujete pomoc s {pro}? Pošlete žádost týmu podpory. + Tím, že provádíte {action_type}, {activation_type} {app_pro} prostřednictvím protokolu {app_name}. {entity} tuto aktivaci zprostředkuje, ale není poskytovatelem služby {app_pro}. {entity} nenese odpovědnost za výkon, dostupnost ani funkčnost služby {app_pro}. Aktualizací souhlasíte s Podmínkami služby {icon} a Zásadami ochrany osobních údajů {icon} {app_pro} Neomezený počet připnutí Organizujte si komunikaci pomocí neomezeného počtu připnutých konverzací. Vaše aktuální fakturační možnost zahrnuje {current_plan_length} přístupu k {pro}. Jste si jisti, že chcete přejít na fakturační možnost {selected_plan_length_singular}?\n\nPo aktualizaci se váš přístup k {pro} automaticky prodlouží {date} na dalších {selected_plan_length} přístupu k {pro}. Váš přístup k {pro} vyprší {date}.\n\nAktualizací se váš {pro} přístup automaticky prodlouží {date} na dalších {selected_plan_length} přístupu k {pro}. + aktualizování Navyšte na {app_pro} Beta a získejte přístup k mnoha exkluzivním výhodám a funkcím. Navyšte na {pro} v nastavení {app_pro} na propojeném zařízení s nainstalovanou {app_name} prostřednictvím {platform_store} nebo {platform_store_other}. V současnosti lze přístup k {pro} zakoupit pouze prostřednictvím {platform_store} nebo {platform_store_other}. Protože jste nainstalovali {app_name} pomocí {build_variant}, nemůžete zde navýšit na {pro}.\n\nVývojáři {app_name} intenzivně pracují na alternativních platebních možnostech, které by uživatelům umožnily zakoupit přístup k {pro} mimo {platform_store} a {platform_store_other}. Plán vývoje {pro} {icon} V tuto chvíli je k dispozici pouze jedna možnost navýšení: Nyní jsou k dispozici dva způsoby navýšení: Navýšili jste na {app_pro}!\n\nDěkujeme, že podporujete síť {network_name}. + navyšování Navýšení na {pro} Navýšením souhlasíte s Podmínkami služby {icon} a Zásadami ochrany osobních údajů {icon} {app_pro} + Chcete z {app_name} získat více?\nNavyštee na {app_pro} Beta pro výkonnější posílání zpráv. {platform} zpracovává vaši žádost o vrácení peněz Profil Zobrazovaný obrázek @@ -1321,6 +1349,9 @@ Opakovat Omezení hodnocení Zdá se, že jste nedávno hodnotili {app_name}. Děkujeme vám za vaši zpětnou vazbu! + Provozovat aplikaci na pozadí + Spustit {app_name} na pozadí? + Protože používáte pomalý režim, doporučujeme povolit aplikaci {app_name} běžet na pozadí, pro lepší upozorňování. Může to zajistit větší konzistentnost oznámení, i když váš systém může i tak automaticky omezit aktivitu na pozadí.\n\nToto nastavení můžete později změnit v Nastavení. Uložit Uloženo Uložené zprávy @@ -1353,6 +1384,12 @@ Odesílání Odesílání nabídky hovoru Odesílání kandidátů na připojení + + Odesílání povýšení + Odesílání povýšení + Odesílání povýšení + Odesílání povýšení + Odesláno: Vzhled Vyčistit data @@ -1388,7 +1425,10 @@ Sdílejte se svými přáteli tam, kde s nimi obvykle mluvíte — a pak konverzaci přesuňte sem. Při otevírání databáze se vyskytl problém. Prosím, restartujte aplikaci a zkuste to znovu. Ups! Vypadá to, že ještě nemáte účet {app_name}.\n\nPřed sdílením si ho budete muset v aplikaci {app_name} vytvořit. + Chcete sdílet historii zpráv skupiny s tímto uživatelem? Sdílet do {app_name} + Je nám líto, {app_name} podporuje pouze sdílení více obrázků a videí najednou + Sdílení podporuje pouze média. Soubory, které nejsou médii, byly vyloučeny Zobrazit Zobrazit všechny Zobrazit méně @@ -1454,7 +1494,6 @@ Použít rychlý režim Změňte svůj tarif pomocí {platform_account}, kterým jste se zaregistrovali, prostřednictvím webu {platform}. Přes webové stránky {platform} - Aktualizujte svůj přístup k {pro} pomocí {platform_account}, se kterým jste se zaregistrovali, prostřednictvím webu {platform_store}. Video Nelze přehrát video. Zobrazit diff --git a/app/src/main/res/values-b+da+DK/strings.xml b/app/src/main/res/values-b+da+DK/strings.xml index 609b56c225..c158a3c809 100644 --- a/app/src/main/res/values-b+da+DK/strings.xml +++ b/app/src/main/res/values-b+da+DK/strings.xml @@ -48,6 +48,7 @@ Anonym App-ikon Alternativ ikon og app-navn vises på hjemmeskærmen og i app-skuffen. + Det valgte app-ikon og navn vises på hjemmeskærmen og i app-skuffen. Ikon og navn Alternativ ikon vises på hjemmeskærmen og i biblioteket. App-navnet vil stadig være \'{app_name}\'. Brug alternativ app-ikon @@ -806,6 +807,7 @@ Venner kan sende beskeder til dig ved at scanne din QR-kode. Afslut {app_name} Afslut + Vurdér {app_name}? Læst Læsekvitteringer Vis læsekvitteringer for alle beskeder du sender og modtager. diff --git a/app/src/main/res/values-b+de+DE/strings.xml b/app/src/main/res/values-b+de+DE/strings.xml index bbe7a62045..dbee6f71d7 100644 --- a/app/src/main/res/values-b+de+DE/strings.xml +++ b/app/src/main/res/values-b+de+DE/strings.xml @@ -15,7 +15,13 @@ Dies ist deine Account-ID. Andere Benutzer können sie scannen, um eine Unterhaltung mit dir zu beginnen. Originalgröße Hinzufügen + + Administrator hinzufügen + Administratoren hinzufügen + + Admin hinzufügen Gib die Account-ID des Nutzers ein, den du zum Administrator ernennst.\n\nUm mehrere Nutzer hinzuzufügen, gib jede Account-ID durch ein Komma getrennt ein. Es können bis zu 20 Account-IDs gleichzeitig angegeben werden. + Administratoren können nicht herabgestuft oder aus der Gruppe entfernt werden. Admins können nicht entfernt werden. {name} und {count} andere wurden zu Admin befördert. Admins befördern @@ -40,12 +46,19 @@ {name} wurde als Admin entfernt. {name} und {count} andere wurden als Admin entfernt. {name} und {other_name} wurden als Administrator entfernt. + + %1$d Administrator ausgewählt + %1$d Administratoren ausgewählt + Admin Beförderung senden Admin Beförderungen senden Admin-Einstellungen + Du kannst deinen eigenen Administratorstatus nicht ändern. Um die Gruppe zu verlassen, öffne die Unterhaltungseinstellungen und wähle Gruppe verlassen. {name} und {other_name} wurden zu Admin befördert. + Administratoren + Zulassen +{count} Anonym App-Symbol @@ -162,6 +175,7 @@ Bist du sicher, dass du {name} und eine weitere Person entblocken möchtest? {name} entsperrt Blockierte Kontakte anzeigen und verwalten. + Kein Browser gefunden, um diese URL zu öffnen. Versuche stattdessen, die URL zu kopieren. Anrufen {name} hat angerufen Du kannst keinen neuen Anruf starten. Beende zuerst deinen aktuellen Anruf. @@ -190,6 +204,10 @@ Aktiviert Sprach- und Videoanrufe an und von anderen Benutzern. Du hast {name} angerufen Du hast einen Anruf von {name} verpasst, weil du Sprach- und Videoanrufe in den Datenschutzeinstellungen nicht aktiviert hast. + {app_name} benötigt Zugriff auf Ihre Kamera, um Videoanrufe zu ermöglichen, aber diese Berechtigung wurde verweigert. Während eines Anrufs können Sie die Kameraberechtigung nicht ändern.\n\nMöchten Sie den Anruf jetzt beenden und den Kamerazugriff aktivieren oder lieber nach dem Anruf daran erinnert werden? + Um den Kamerazugriff zu erlauben, öffnen Sie die Einstellungen und aktivieren Sie die Berechtigung für die Kamera. + Während Ihres letzten Anrufs haben Sie versucht, Video zu verwenden, konnten dies jedoch nicht, da der Kamerazugriff zuvor verweigert wurde. Um den Kamerazugriff zu erlauben, öffnen Sie die Einstellungen und aktivieren Sie die Kameraberechtigung. + Zugriff auf Kamera erforderlich Keine Kamera gefunden Kamera nicht verfügbar. Kamerazugriff gewähren @@ -197,9 +215,19 @@ {app_name} benötigt die Berechtigung »Kamera«, um Fotos oder Videos aufzunehmen oder QR-Codes zu scannen. {app_name} benötigt Kamera-Zugriff um QR-Codes zu scannen Abbrechen + {pro} kündigen + Kündigen Sie auf der {platform}-Website, indem Sie das {platform_account} verwenden, mit dem Sie sich für {pro} registriert haben. + Kündigen Sie auf der {platform_store}-Website, indem Sie das {platform_account} verwenden, mit dem Sie sich für {pro} registriert haben. Ändern Passwortänderung fehlgeschlagen Ändere dein Passwort für {app_name}. Lokal gespeicherte Dateien werden mit deinem neuen Passwort entschlüsselt. + Einstellung ändern + {pro}-Status wird überprüft + {pro}-Status wird überprüft. Du kannst fortfahren, sobald diese Prüfung abgeschlossen ist. + Deine {pro}-Details werden überprüft. Manche Aktionen auf dieser Seite sind erst verfügbar, wenn die Überprüfung abgeschlossen ist. + {pro}-Status wird geprüft... + Ihre {pro}-Details werden überprüft. Eine Verlängerung ist erst möglich, wenn die Überprüfung abgeschlossen ist. + Dein {pro}-Status wird überprüft. Du kannst auf {pro} upgraden, sobald die Überprüfung abgeschlossen ist. Löschen Alles löschen Alle Daten löschen @@ -258,11 +286,17 @@ Community-URL Community-URL kopieren Bestätigen + Beförderung bestätigen + Bist du sicher? Administratoren können nicht herabgestuft oder aus der Gruppe entfernt werden. Kontakte Kontakt löschen Bist du sicher, dass du {name} aus deinen Kontakten löschen möchtest? Neue Nachrichten von {name} werden als Nachrichtenanfrage eintreffen. Du hast noch keine Kontakte Kontakte auswählen + + %1$d Kontakt ausgewählt + %1$d Kontakte ausgewählt + Kontaktdetails ansehen Kamera Wähle eine Aktion, um eine Unterhaltung zu starten @@ -270,6 +304,7 @@ Nachricht verfassen Miniaturbild aus zitierter Nachricht Erstelle eine Unterhaltung mit einem neuen Kontakt + Wähle den Inhalt, der bei eingehenden Nachrichten in lokalen Benachrichtigungen angezeigt wird. Zum Startbildschirm hinzufügen Zum Startbildschirm hinzugefügt Audio-Nachrichten @@ -283,6 +318,8 @@ Es sind keine Nachrichten in {conversation_name}. Eingabetaste Definiere, wie Eingabe- und Umschalttaste in Konversationen funktionieren. + UMSCHALT + Eingabetaste sendet eine Nachricht, Eingabetaste beginnt eine neue Zeile. + Die Eingabetaste sendet eine Nachricht, Umschalttaste + Eingabetaste beginnt eine neue Zeile. Gruppen Nachrichtenkürzung Communities kürzen @@ -300,6 +337,7 @@ Kopieren Erstellen Anruf wird erstellt + Aktuelle Abrechnung Aktuelles Passwort Ausschneiden Dunkelmodus @@ -327,6 +365,14 @@ Bitte warte, während die Gruppe erstellt wird... Fehler beim Aktualisieren der Gruppe Du hast nicht die Berechtigung, Nachrichten anderer Teilnehmer zu löschen + + Ausgewählten Anhang löschen + Ausgewählte Anhänge löschen + + + Möchten Sie den ausgewählten Anhang wirklich löschen? Die zugehörige Nachricht wird ebenfalls gelöscht. + Möchten Sie die ausgewählten Anhänge wirklich löschen? Die zugehörige Nachricht wird ebenfalls gelöscht. + Möchtest du {name} wirklich aus deinen Kontakten löschen?\n\nDies wird deine Unterhaltung einschließlich aller Nachrichten und Anhänge löschen. Zukünftige Nachrichten von {name} erscheinen als Nachrichtenanfrage. Möchtest du deine Unterhaltung mit {name} wirklich löschen?\nDies wird alle Nachrichten und Anhänge dauerhaft löschen. @@ -366,6 +412,7 @@ Möchtest du diese Nachrichten wirklich für alle löschen? Wird gelöscht Entwicklertools ein-/ausblenden + Benachrichtigungseinstellungen des Geräts Diktat starten... Verschwindende Nachrichten Nachricht wird in {time_large} gelöscht @@ -413,6 +460,8 @@ Ihr Anzeigename ist für Benutzer, Gruppen und Gemeinschaften sichtbar, mit denen Sie interagieren. Dokument Spenden + Mächtige Kräfte versuchen, die Privatsphäre zu schwächen – aber wir können diesen Kampf nicht alleine führen.\n\nMit einer Spende hilfst du dabei, {app_name} sicher, unabhängig und online zu halten. + {app_name} braucht deine Hilfe Fertig Herunterladen Wird heruntergeladen... @@ -442,17 +491,31 @@ Du und {name} haben mit {emoji_name} reagiert Hat auf deine Nachricht mit {emoji} reagiert Aktivieren + Kamerazugriff aktivieren? Benachrichtigungen anzeigen, wenn du neue Nachrichten erhältst. + Anruf beenden, um zu aktivieren Gefällt dir {app_name}? Verbesserungswürdig {emoji} Großartig {emoji} Du nutzt {app_name} jetzt schon eine Weile – wie läuft’s? Wir würden uns sehr über dein Feedback freuen. Bestätigen + Geben Sie das Passwort ein, das Sie für {app_name} festgelegt haben + Geben Sie das Passwort ein, das Sie beim Starten zum Entsperren von {app_name} verwenden – nicht Ihr Wiederherstellungskennwort + Fehler bei der Überprüfung des {pro}-Status Bitte überprüfe deine Internetverbindung und versuche es erneut. Fehler kopieren und beenden Datenbankfehler Etwas ist schiefgelaufen. Bitte versuche es später erneut. + Fehler beim Laden des {pro}-Zugriffs + {app_name} konnte diese ONS nicht finden. Bitte überprüfe deine Netzwerkverbindung und versuche es erneut. Ein unbekannter Fehler ist aufgetreten. + Diese ONS ist nicht registriert. Bitte prüfe die Eingabe und versuche es erneut. + Einladung an {name} in {group_name} konnte nicht erneut gesendet werden + Einladung an {name} und {count} weitere in {group_name} konnte nicht erneut gesendet werden + Einladung an {name} und {other_name} in {group_name} konnte nicht erneut gesendet werden + Fehler beim erneuten Senden der Beförderung an {name} in {group_name} + Fehler beim erneuten Senden der Beförderung an {name} und {count} weitere in {group_name} + Fehler beim erneuten Senden der Beförderung an {name} und {other_name} in {group_name} Herunterladen fehlgeschlagen Fehler Feedback @@ -506,6 +569,9 @@ Bist du sicher, dass du {group_name} verlassen möchtest? Bist du sicher, dass du {group_name} verlassen möchtest?\n\nDadurch werden alle Mitglieder entfernt und alle Gruppendaten gelöscht. Fehler beim Verlassen von {group_name} + {name} wurde eingeladen, der Gruppe beizutreten. Der Nachrichtenverlauf der letzten 14 Tage wurde geteilt. + {name} und {count} weitere wurden eingeladen, der Gruppe beizutreten. Der Nachrichtenverlauf der letzten 14 Tage wurde geteilt. + {name} und {other_name} wurden eingeladen, der Gruppe beizutreten. Der Nachrichtenverlauf der letzten 14 Tage wurde geteilt. {name} hat die Gruppe verlassen. {name} und {count} andere haben die Gruppe verlassen. {name} und {other_name} haben die Gruppe verlassen. @@ -517,6 +583,9 @@ {name} und {other_name} wurden eingeladen, der Gruppe beizutreten. Du und {count} andere wurden eingeladen, der Gruppe beizutreten. Der Chatverlauf wurde freigegeben. Du und {other_name} wurden eingeladen der Gruppe beizutreten. Chat-Verlauf wurde geteilt. + {name} konnte nicht aus {group_name} entfernt werden + {name} und {count} weitere konnten nicht aus {group_name} entfernt werden + {name} und {other_name} konnten nicht aus {group_name} entfernt werden Du hast die Gruppe verlassen. Gruppenmitglieder Es gibt keine anderen Mitglieder in dieser Gruppe. @@ -530,6 +599,7 @@ Du hast keine Nachrichten von {group_name}. Sende eine Nachricht, um das Gespräch zu beginnen! Diese Gruppe wurde seit über 30 Tagen nicht aktualisiert. Beim Senden von Nachrichten oder beim Anzeigen der Gruppeninformationen können Probleme auftreten. Du bist der einzige Admin in {group_name}.\n\nGruppenmitglieder und -einstellungen können ohne einen Admin nicht geändert werden. + Du bist der einzige Administrator in {group_name}.\n\nGruppenmitglieder und Einstellungen können ohne Administrator nicht geändert werden. Um die Gruppe zu verlassen, ohne sie zu löschen, füge bitte zuerst einen neuen Administrator hinzu. Ausstehende Entfernung Du wurdest zum Admin befördert. Du und {count} andere wurden zu Admin befördert. @@ -569,14 +639,20 @@ Hilf mit, {app_name} in über 80 Sprachen zu übersetzen! Wir würden uns über dein Feedback freuen Ausblenden + Sichtbarkeit der Systemmenüleiste umschalten. Möchtest du Notiz an mich wirklich aus deiner Unterhaltungsliste ausblenden? Andere ausblenden Bild Bilder + Wichtig Inkognito-Tastatur Fordere den Inkognito-Modus an, wenn verfügbar. Abhängig von der Tastatur, die du verwendest, kann deine Tastatur diese Anfrage ignorieren. Info Ungültige Verknüpfung + + Kontakt einladen + Kontakte einladen + Einladung fehlgeschlagen Einladungen fehlgeschlagen @@ -585,8 +661,17 @@ Die Einladung konnte nicht gesendet werden. Möchtest du es erneut versuchen? Die Einladungen konnten nicht gesendet werden. Möchtest du es erneut versuchen? + + Mitglied einladen + Mitglieder einladen + + Lade ein neues Mitglied in die Gruppe ein, indem du die Account-ID oder ONS deines Freundes eingibst oder seinen QR-Code scannst {icon} + Lade ein neues Mitglied zur Gruppe ein, indem du die Account-ID, den ONS deines Freundes eingibst oder dessen QR-Code scannst Beitreten Später + Starten Sie {app_name} automatisch, wenn Ihr Computer hochfährt. + Beim Start starten + Diese Einstellung wird unter Linux von Ihrem System verwaltet. Um den automatischen Start zu aktivieren, fügen Sie {app_name} in den Systemeinstellungen zu Ihren Autostart-Anwendungen hinzu. Mehr erfahren Verlassen Verlassen... @@ -601,6 +686,8 @@ Du und {other_name} seid der Gruppe beigetreten. {name} und {other_name} sind der Gruppe beigetreten. Du bist der Gruppe beigetreten. + Hintergrundaktivität einschränken? + Du erlaubst derzeit, dass {app_name} im Hintergrund läuft, um die Zuverlässigkeit der Benachrichtigungen zu verbessern. Eine Änderung dieser Einstellung kann zu weniger zuverlässigen Benachrichtigungen führen. Link-Vorschauen Zeige Link-Vorschauen für unterstützte URLs. Link-Vorschau aktivieren @@ -625,9 +712,16 @@ Tippe zum Entsperren {app_name} ist entsperrt Protokolle + Admins verwalten Mitglieder verwalten + {pro} verwalten Maximal + Vielleicht später Medien + + %1$d Mitglied ausgewählt + %1$d Mitglieder ausgewählt + %1$d Mitglied %1$d Mitglieder @@ -637,7 +731,9 @@ %1$d aktive Mitglieder Account-ID oder ONS hinzufügen + Mitglieder können erst befördert werden, nachdem sie eine Einladung zur Gruppenmitgliedschaft angenommen haben. Kontakte einladen + Du hast keine Kontakte, die du in diese Gruppe einladen könntest.\nGehe zurück und lade Mitglieder über ihre Account-ID oder ONS ein. Einladung senden Einladungen senden @@ -646,8 +742,10 @@ Möchtest du den Nachrichtenverlauf der Gruppe mit {name} und {count} anderen teilen? Möchtest du den Nachrichtenverlauf der Gruppe mit {name} und {other_name} teilen? Nachrichtenverlauf teilen + Nachrichtenverlauf der letzten 14 Tage teilen Nur neue Nachrichten teilen Einladen + Mitglieder (keine Admins) Menüleiste Nachricht Weiterlesen @@ -666,6 +764,7 @@ Beginne eine neue Unterhaltung durch Eingabe der Account-ID oder des ONS deines Kontakts. Beginne eine neue Unterhaltung durch Eingabe der Account-ID, des ONS oder Scannen des QR-Codes deines Kontakts. + Beginne eine neue Unterhaltung, indem du die Account-ID oder ONS deines Freundes eingibst oder seinen QR-Code scannst {icon} Du hast eine neue Nachricht. Du hast %1$d neue Nachrichten. @@ -716,19 +815,24 @@ Nachricht zu lang Neues Passwort Weiter + Nächste Schritte Wähle einen Spitznamen für {name}. Dieser wird dir in deinen Einzel- und Gruppengesprächen angezeigt. Spitzname eingeben Bitte gib einen kürzeren Spitznamen ein Spitzname entfernen Spitzname festlegen Nein + Es gibt keine Mitglieder ohne Admin-Status in dieser Gruppe. Keine Vorschläge + Versenden Sie Nachrichten mit bis zu 10.000 Zeichen in allen Unterhaltungen. + Organisieren Sie Chats mit unbegrenzt vielen angehefteten Unterhaltungen. Keine Nicht jetzt Notiz an mich Du hast keine Nachrichten in »Notiz an mich«. »Notiz an mich« ausblenden Bist du sicher, dass du »Notiz an mich« ausblenden möchtest? + BITTE BEACHTEN: Durch {action_type} stimmst du den Nutzungsbedingungen {icon} und der Datenschutzrichtlinie {icon} von {app_pro} zu Benachrichtigungsanzeige Zeigt den Namen des Absenders und eine Vorschau des Nachrichteninhalts an. Nur den Namen des Absenders ohne Nachrichteninhalt anzeigen. @@ -772,6 +876,12 @@ Aus Okay Aktiv + Auf deinem {device_type}-Gerät + Öffnen Sie dieses {app_name}-Konto auf einem {device_type}-Gerät, das mit dem {platform_account} angemeldet ist, mit dem Sie sich ursprünglich registriert haben. Kündigen Sie dann {pro} über die {app_pro}-Einstellungen. + Öffne dieses {app_name}-Konto auf einem {device_type}-Gerät, das beim {platform_account} angemeldet ist, mit dem du dich ursprünglich registriert hast. Aktualisiere anschließend deinen {pro}-Zugang über die {app_pro}-Einstellungen. + Auf einem verknüpften Gerät + Auf der {platform_store}-Website + Auf der {platform}-Website Account erstellen Account erstellt Ich habe einen Account @@ -796,10 +906,14 @@ Wir konnten dieses ONS nicht erkennen. Bitte überprüfe es und versuche es erneut. Die Suche nach dieses ONS war nicht möglich. Bitte versuche es später noch einmal. Öffnen + {platform_store}-Website öffnen + {platform}-Website öffnen + Einstellungen öffnen Umfrage starten Sonstiges Passwort Passwort ändern + Ändere das Passwort, das zum Entsperren von {app_name} erforderlich ist. Dein Passwort wurde geändert. Bitte bewahre es sicher auf. Passwort bestätigen Passwort erstellen @@ -818,14 +932,18 @@ Dein Passwort wurde entfernt. Passwort festlegen Dein Passwort wurde festgelegt. Bitte bewahre es sicher auf. + Passwort zum Entsperren von {app_name} beim Start erforderlich. Länger als 12 Zeichen Enthält eine Zahl Enthält einen Kleinbuchstaben + Enthält ein Symbol Enthält einen Großbuchstaben Passwortstärke-Anzeige Ein schwieriges Passwort hilft deine Nachrichten und Anlagen zu schützen, wenn dein Gerät jemals verloren geht oder gestohlen wird. Passwörter Einfügen + Zahlungsfehler + Deine Zahlung wurde erfolgreich verarbeitet, aber es gab einen Fehler beim {action_type} deines {pro}-Status.\n\nBitte überprüfe deine Netzwerkverbindung und versuche es erneut. Berechtigungsänderung {app_name} benötigt Musik- und Audiozugriff, um Dateien, Musik und Audio zu senden, aber der Zugriff wurde dauerhaft verweigert. Bitte öffne die Einstellungen, wähle »Berechtigungen« und aktiviere »Musik und Audio«. {app_name} benötigt Zugriff auf Apple Music, um Medienanhänge abzuspielen. @@ -864,31 +982,173 @@ Unterhaltung anheften Lösen Unterhaltung lösen + Und vieles mehr... + Neue Funktionen kommen bald zu {pro}. Erfahre, was als Nächstes geplant ist auf der {pro} Roadmap {icon} Einstellungen Vorschau Benachrichtigungsvorschau + Dein {pro}-Zugang ist aktiv!\n\nDein {pro}-Zugang wird am {date} automatisch für weitere {current_plan_length} verlängert. + Dein {pro}-Zugang läuft am {date} ab.\n\nAktualisiere jetzt deinen {pro}-Zugang, damit er automatisch verlängert wird, bevor dein {pro}-Zugang abläuft. + Dein {pro}-Zugang ist aktiv!\n\nDein {pro}-Zugang wird am {date} automatisch für weitere\n{current_plan_length} verlängert. Alle hier vorgenommenen Änderungen treten bei der nächsten Verlängerung in Kraft. + {pro}-Zugriffsfehler + Dein {pro}-Zugang läuft am {date} ab. + {pro}-Zugriff wird geladen + Deine {pro}-Zugriffsdaten werden noch geladen. Du kannst erst ein Update durchführen, wenn dieser Vorgang abgeschlossen ist. + {pro}-Zugriff wird geladen... + Keine Verbindung zum Netzwerk möglich, um deine {pro}-Zugriffsinformationen zu laden. Die Aktualisierung von {pro} über {app_name} ist deaktiviert, bis die Verbindung wiederhergestellt ist.\n\nBitte überprüfe deine Netzwerkverbindung und versuche es erneut. + {pro}-Zugang nicht gefunden + {app_name} hat festgestellt, dass dein Konto keinen {pro}-Zugang besitzt. Wenn du glaubst, dass es sich um einen Fehler handelt, kontaktiere bitte den {app_name}-Support. + {pro}-Zugang wiederherstellen + {pro}-Zugang verlängern + Derzeit kann der Zugriff auf {pro} nur über den {platform_store} oder {platform_store_other} erworben und verlängert werden. Da Sie {app_name} Desktop verwenden, können Sie hier keine Verlängerung durchführen.\n\nDie Entwickler von {app_name} arbeiten intensiv an alternativen Zahlungsmöglichkeiten, damit Nutzer {pro}-Zugriff auch außerhalb des {platform_store} und {platform_store_other} erwerben können. {pro} Roadmap {icon} + Verlängern Sie Ihren {pro}-Zugang auf der {platform_store}-Website mit dem {platform_account}, mit dem Sie sich für {pro} registriert haben. + Verlängern Sie auf der {platform}-Website, indem Sie das {platform_account} verwenden, mit dem Sie sich für {pro} registriert haben. + Verlängere deinen {pro}-Zugang, um die leistungsstarken {app_pro} Beta-Funktionen wieder nutzen zu können. + {pro}-Zugang wiederhergestellt + {app_name} hat deinen {pro}-Zugang erkannt und wiederhergestellt. Dein {pro}-Status wurde zurückgesetzt! + Da Sie sich ursprünglich über den {platform_store} für {app_pro} angemeldet haben, müssen Sie Ihr {platform_account} verwenden, um Ihren {pro}-Zugang zu aktualisieren. + Derzeit kann der Zugriff auf {pro} nur über den {platform_store} oder {platform_store_other} erworben werden. Da du {app_name} Desktop verwendest, kannst du hier kein Upgrade auf {pro} durchführen.\n\nDie Entwickler von {app_name} arbeiten intensiv an alternativen Zahlungsmethoden, um den Erwerb des {pro}-Zugangs auch außerhalb des {platform_store} und {platform_store_other} zu ermöglichen. {pro} Roadmap {icon} Aktiviert + aktivieren + Alles erledigt! + Dein Zugang zu {app_pro} wurde aktualisiert! Die Abrechnung erfolgt, wenn {pro} am {date} automatisch verlängert wird. Du hast bereits Lade GIF- und animierte WebP-Bilder als Profilbild hoch! + Hole dir animierte Profilbilder und schalte Premium-Funktionen mit {app_pro} Beta frei Animiertes Profilbild Nutzer können GIFs hochladen + Animierte Profilbilder + Lege animierte GIF- und WebP-Bilder als dein Profilbild fest. GIFs hochladen mit Automatische {pro} Erneuerung in {time} + {pro}-Abzeichen + {app_pro}-Abzeichen anderen Nutzern anzeigen + Abzeichen + Zeige deine Unterstützung für {app_name} mit einem exklusiven Abzeichen neben deinem Anzeigenamen. + + %1$s %2$s-Abzeichen gesendet + %1$s %2$s-Abzeichen gesendet + + {pro} Beta-Funktionen + {price} jährlich abgerechnet + {price} monatlich abgerechnet + {price} vierteljährlich abgerechnet + Möchtest du längere Nachrichten senden?\nSende mehr Text und schalte Premium-Funktionen mit {app_pro} Beta frei + Mehr Anheftungen gewünscht?\nOrganisiere deine Chats und schalte Premium-Funktionen mit {app_pro} Beta frei + Mehr als {limit} Anheftungen gewünscht?\nOrganisiere deine Chats und schalte Premium-Funktionen mit {app_pro} Beta frei + Es ist schade, dass Sie {pro} kündigen möchten. Folgendes sollten Sie wissen, bevor Sie den {pro}-Zugriff kündigen. + Kündigung + Durch die Kündigung Ihres {pro}-Zugangs wird die automatische Verlängerung vor Ablauf Ihres {pro}-Zugangs verhindert. Die Kündigung von {pro} führt nicht zu einer Rückerstattung. Sie können die Funktionen von {app_pro} weiterhin nutzen, bis Ihr {pro}-Zugang abläuft.\n\nDa Sie sich ursprünglich mit Ihrem {platform_account} für {app_pro} angemeldet haben, müssen Sie dasselbe {platform_account} verwenden, um {pro} zu kündigen. + Zwei Möglichkeiten, den {pro}-Zugriff zu kündigen: + Die Kündigung Ihres {pro}-Zugriffs verhindert, dass dieser automatisch verlängert wird, bevor {pro} abläuft.\n\nDas Kündigen von {pro} führt nicht zu einer Rückerstattung. Sie können die {app_pro}-Funktionen weiterhin nutzen, bis Ihr {pro}-Zugriff abläuft. + Wählen Sie die {pro}-Zugriffsoption, die am besten zu Ihnen passt.\nLängerer Zugang bedeutet größere Rabatte. + Möchten Sie Ihre Daten wirklich von diesem Gerät löschen?\n\n{app_pro} kann nicht auf ein anderes Konto übertragen werden. Bitte speichern Sie Ihr Wiederherstellungskennwort, um sicherzustellen, dass Sie Ihren {pro}-Zugriff später wiederherstellen können. + Möchten Sie Ihre Daten wirklich aus dem Netzwerk löschen? Wenn Sie fortfahren, können Sie Ihre Nachrichten und Kontakte nicht wiederherstellen.\n\n{app_pro} kann nicht auf ein anderes Konto übertragen werden. Bitte speichern Sie Ihr Wiederherstellungskennwort, um sicherzustellen, dass Sie Ihren {pro}-Zugriff später wiederherstellen können. + Dein {pro}-Zugang ist bereits um {percent}% vom vollen {app_pro}-Preis reduziert. + Fehler beim Aktualisieren des {pro}-Status Abgelaufen + Leider ist dein {pro}-Zugang abgelaufen.\nVerlängere ihn, um die exklusiven Vorteile und Funktionen der {app_pro} Beta wieder zu nutzen. + Läuft bald ab + Dein {pro}-Zugang läuft in {time} ab.\nAktualisiere jetzt, um weiterhin die exklusiven Vorteile und Funktionen der {app_pro} Beta zu nutzen + {pro} läuft ab in {time} {pro} FAQ + Finde Antworten auf häufig gestellte Fragen in den {app_pro} FAQ. GIF- und WebP-Profilbilder hochladen Größere Gruppenchats mit bis zu 300 Mitgliedern Und viele weitere exklusive Funktionen Nachrichten mit bis zu 10.000 Zeichen Unbegrenzt viele Unterhaltungen anheften + Möchten Sie {app_name} in vollem Umfang nutzen?\nUpgrade auf {app_pro} Beta, um Zugriff auf zahlreiche exklusive Vorteile und Funktionen zu erhalten. Gruppe aktiviert Diese Gruppe hat mehr Kapazität! Sie kann bis zu 300 Mitglieder unterstützen, da ein Gruppen-Administrator + + %1$s Gruppe aktualisiert + %1$s Gruppen aktualisiert + + Das Anfordern einer Rückerstattung ist endgültig. Wenn sie genehmigt wird, wird dein {pro}-Zugang sofort storniert und du verlierst den Zugriff auf alle {pro}-Funktionen. Erhöhte Anhangsgröße Erhöhte Nachrichtenlänge + Größere Gruppen + Gruppen, in denen du Administrator bist, werden automatisch erweitert, um bis zu 300 Mitglieder zu unterstützen. + Größere Gruppenchats (mit bis zu 300 Mitgliedern) kommen bald für alle Pro-Beta-Nutzer! + Längere Nachrichten + Du kannst in allen Unterhaltungen Nachrichten mit bis zu 10.000 Zeichen senden. + + %1$s längere Nachricht gesendet + %1$s längere Nachrichten gesendet + Diese Nachricht verwendet die folgenden {app_pro}-Funktionen: + Mit einer Neuinstallation + Installieren Sie {app_name} über den {platform_store} auf diesem Gerät neu, stellen Sie Ihr Konto mit Ihrem Wiederherstellungskennwort wieder her, und verlängern Sie {pro} über die {app_pro}-Einstellungen. + Installieren Sie {app_name} auf diesem Gerät über den {platform_store} erneut, stellen Sie Ihr Konto mit Ihrem Wiederherstellungskennwort wieder her und führen Sie das Upgrade auf {pro} über die {app_pro}-Einstellungen durch. + Derzeit gibt es drei Möglichkeiten zur Verlängerung: + Momentan gibt es zwei Möglichkeiten zur Verlängerung: + {percent}% Rabatt + + %1$s angeheftete Unterhaltung + %1$s angeheftete Unterhaltungen + + Da Sie sich ursprünglich über den {platform_store} für {app_pro} registriert haben, müssen Sie Ihr {platform_account} verwenden, um eine Rückerstattung zu beantragen. + Da Sie sich ursprünglich über den {platform_store} für {app_pro} registriert haben, wird Ihre Rückerstattungsanfrage vom {app_name} Support bearbeitet.\n\nFordern Sie eine Rückerstattung an, indem Sie auf die Schaltfläche unten klicken und das Rückerstattungsformular ausfüllen.\n\nDer {app_name} Support bemüht sich, Rückerstattungen innerhalb von 24–72 Stunden zu bearbeiten. Bei hohem Anfrageaufkommen kann die Bearbeitung jedoch länger dauern. + Dein Zugang zu {app_pro} wurde verlängert! Vielen Dank für deine Unterstützung von {network_name}. + 1 Monat – {monthly_price} / Monat + 3 Monate – {monthly_price} / Monat + 12 Monate – {monthly_price} / Monat + erneut aktivieren + Öffnen Sie dieses {app_name}-Konto auf einem {device_type}-Gerät, bei dem Sie mit dem {platform_account} angemeldet sind, mit dem Sie sich ursprünglich registriert haben. Fordern Sie dann eine Rückerstattung über die {app_pro}-Einstellungen an. + Es ist schade, dass du gehst. Das solltest du wissen, bevor du eine Rückerstattung beantragst. + {platform} bearbeitet derzeit deine Rückerstattungsanfrage. Dies dauert in der Regel 24–48 Stunden. Abhängig von der Entscheidung kann sich dein {pro}-Status in {app_name} ändern. + Ihre Rückerstattungsanfrage wird vom {app_name} Support bearbeitet.\n\nFordern Sie eine Rückerstattung an, indem Sie auf die Schaltfläche unten klicken und das Rückerstattungsformular ausfüllen.\n\nDer {app_name} Support bemüht sich, Rückerstattungsanfragen innerhalb von 24–72 Stunden zu bearbeiten, jedoch kann es bei hohem Anfrageaufkommen zu Verzögerungen kommen. + Ihre Rückerstattungsanfrage wird ausschließlich von {platform} über die {platform}-Website bearbeitet.\n\nAufgrund der Rückerstattungsrichtlinien von {platform} haben die Entwickler von {app_name} keinen Einfluss auf das Ergebnis der Rückerstattungsanfrage. Dies betrifft sowohl die Genehmigung oder Ablehnung der Anfrage als auch die Entscheidung über eine vollständige oder teilweise Rückerstattung. + Bitte kontaktiere {platform}, um weitere Informationen zu deiner Rückerstattungsanfrage zu erhalten. Aufgrund der Rückerstattungsrichtlinien von {platform} haben die Entwickler von {app_name} keinerlei Einfluss auf das Ergebnis von Rückerstattungsanfragen.\n\n{platform}-Rückerstattungssupport + {pro} wird zurückerstattet + Rückerstattungen für {app_pro} werden ausschließlich von {platform} über den {platform_store} abgewickelt.\n\nAufgrund der Rückerstattungsrichtlinien von {platform} haben die Entwickler von {app_name} keinerlei Einfluss auf das Ergebnis von Rückerstattungsanfragen. Dies betrifft sowohl die Genehmigung oder Ablehnung der Anfrage als auch die Entscheidung über eine vollständige oder teilweise Rückerstattung. + Möchtest du wieder animierte Profilbilder verwenden?\nVerlängere deinen {pro}-Zugang, um die Funktionen freizuschalten, die dir gefehlt haben. + {pro} Beta verlängern + Verlängern Sie Ihren {pro}-Zugang in den Einstellungen von {app_pro} auf einem verknüpften Gerät mit installiertem {app_name} über den {platform_store} oder {platform_store_other}. + Möchtest du wieder längere Nachrichten senden?\nVerlängere deinen {pro}-Zugang, um die Funktionen freizuschalten, die dir gefehlt haben. + Möchtest du {app_name} wieder mit vollem Potenzial nutzen?\nVerlängere deinen {pro}-Zugang, um die Funktionen freizuschalten, die dir gefehlt haben. + Möchtest du wieder mehr als {limit} Unterhaltungen anpinnen?\nVerlängere deinen {pro}-Zugang, um die Funktionen freizuschalten, die dir gefehlt haben. + Möchtest du wieder mehr Unterhaltungen anpinnen?\nVerlängere deinen {pro}-Zugang, um die Funktionen freizuschalten, die dir gefehlt haben. + Durch die Verlängerung stimmst du den Nutzungsbedingungen {icon} und der Datenschutzerklärung {icon} von {app_pro} zu + verlängern + Derzeit kann der {pro}-Zugriff nur über den {platform_store} oder {platform_store_other} gekauft und verlängert werden. Da Sie {app_name} mit dem Build {build_variant} installiert haben, können Sie hier nicht verlängern.\n\nDie Entwickler von {app_name} arbeiten intensiv an alternativen Zahlungsmethoden, um es Nutzern zu ermöglichen, den {pro}-Zugriff außerhalb des {platform_store} und {platform_store_other} zu erwerben. {pro}-Roadmap {icon} + Rückerstattung beantragt Mehr senden mit + {pro}-Einstellungen + {pro} jetzt verwenden Deine {pro} Statistik + {pro}-Statistiken werden geladen + Deine {pro}-Statistiken werden geladen, bitte warten. + Die {pro}-Statistiken spiegeln die Nutzung auf diesem Gerät wider und können auf verknüpften Geräten abweichen + Fehler beim {pro}-Status + Keine Netzwerkverbindung zur Überprüfung deines {pro}-Status möglich. Die Informationen auf dieser Seite könnten ungenau sein, bis die Verbindung wiederhergestellt ist.\n\nBitte überprüfe deine Netzwerkverbindung und versuche es erneut. + {pro}-Status wird geladen + Deine {pro}-Informationen werden geladen. Einige Aktionen auf dieser Seite sind erst verfügbar, wenn das Laden abgeschlossen ist. + {pro}-Status wird geladen + Verbindung zum Netzwerk zur Überprüfung deines {pro}-Status nicht möglich. Du kannst erst fortfahren, wenn die Verbindung wiederhergestellt ist.\n\nBitte überprüfe deine Netzwerkverbindung und versuche es erneut. + Keine Netzwerkverbindung zur Überprüfung deines {pro}-Status möglich. Du kannst nicht auf {pro} upgraden, bis die Verbindung wiederhergestellt ist.\n\nBitte überprüfe deine Netzwerkverbindung und versuche es erneut. + Keine Verbindung zum Netzwerk möglich, um deinen {pro}-Status zu aktualisieren. Einige Aktionen auf dieser Seite sind deaktiviert, bis die Verbindung wiederhergestellt ist.\n\nBitte überprüfe deine Netzwerkverbindung und versuche es erneut. + Keine Verbindung zum Netzwerk, um Ihren aktuellen {pro}-Zugang zu laden. Die Verlängerung von {pro} über {app_name} ist deaktiviert, bis die Verbindung wiederhergestellt ist.\n\nBitte überprüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut. + Benötigst du Hilfe mit {pro}? Sende eine Anfrage an das Support-Team. + Durch {action_type} wird {app_pro} über das {app_name}-Protokoll {activation_type}. {entity} erleichtert diese Aktivierung, ist jedoch nicht der Anbieter von {app_pro}. {entity} ist nicht verantwortlich für Leistung, Verfügbarkeit oder Funktionalität von {app_pro}. Durch die Aktualisierung stimmst du den Nutzungsbedingungen {icon} und der Datenschutzerklärung {icon} von {app_pro} zu + Unbegrenzte Anheftungen + Organisiere all deine Chats mit unbegrenzt vielen angehefteten Konversationen. + Ihre aktuelle Abrechnungsoption gewährt Ihnen {current_plan_length} Zugriff auf {pro}. Möchten Sie wirklich zur {selected_plan_length_singular} Abrechnungsoption wechseln?\n\nDurch die Aktualisierung wird Ihr {pro}-Zugang am {date} automatisch um weitere {selected_plan_length} Zugriffe auf {pro} verlängert. + Ihr {pro}-Zugang läuft am {date} ab.\n\nDurch ein Update wird Ihr {pro}-Zugang am {date} automatisch um weitere {selected_plan_length} Jahre {pro}-Zugang verlängert. + aktualisieren + Upgrade auf {app_pro} Beta, um Zugriff auf zahlreiche exklusive Vorteile und Funktionen zu erhalten. + Upgrade auf {pro} über die {app_pro}-Einstellungen auf einem verknüpften Gerät mit {app_name}, installiert über den {platform_store} oder {platform_store_other}. + Derzeit kann der {pro}-Zugang nur über den {platform_store} oder den {platform_store_other} erworben werden. Da Sie {app_name} mit der {build_variant} installiert haben, ist ein Upgrade auf {pro} hier nicht möglich.\n\nDie Entwickler von {app_name} arbeiten intensiv an alternativen Zahlungsmöglichkeiten, um den Erwerb von {pro}-Zugang außerhalb des {platform_store} und {platform_store_other} zu ermöglichen. {pro}-Roadmap {icon} + Aktuell gibt es nur eine Möglichkeit zum Upgrade: + Momentan gibt es zwei Upgrade-Möglichkeiten: + Sie haben auf {app_pro} upgegradet!\nVielen Dank für Ihre Unterstützung des {network_name}. + upgraden + Upgrade auf {pro} wird durchgeführt + Durch das Upgrade stimmen Sie den Nutzungsbedingungen {icon} und der Datenschutzerklärung {icon} von {app_pro} zu + Willst du mehr aus {app_name} herausholen?\nUpgrade auf {app_pro} Beta für ein leistungsstärkeres Nachrichten-Erlebnis. + {platform} bearbeitet deine Rückerstattungsanfrage Profil Anzeigebild Fehler beim Entfernen des Profilbildes. @@ -896,6 +1156,11 @@ Bitte wähle eine kleinere Datei. Das Profil konnte nicht aktualisiert werden. Befördern + Administratoren können den Nachrichtenverlauf der letzten 14 Tage sehen und nicht herabgestuft oder aus der Gruppe entfernt werden. + + Mitglied befördern + Mitglieder befördern + Beförderung fehlgeschlagen Beförderungen fehlgeschlagen @@ -944,23 +1209,60 @@ Dies ist dein Wiederherstellungspasswort. Wenn du es jemandem sendest, wird diese Person vollen Zugriff auf deinen Account haben. Gruppe neu erstellen Wiederholen + Da du dich ursprünglich mit einem anderen {platform_account} für {app_pro} angemeldet hast, musst du dieses {platform_account} verwenden, um deinen {pro}-Zugang zu aktualisieren. + Zwei Möglichkeiten, eine Rückerstattung anzufordern: Nachricht um {count} Zeichen kürzen %1$d Zeichen verbleibt %1$d Zeichen verbleiben + Später erinnern Entfernen + + Mitglied entfernen + Mitglieder entfernen + + + Mitglied und dessen Nachrichten entfernen + Mitglieder und deren Nachrichten entfernen + Fehler beim Entfernen des Passworts Entferne dein aktuelles Passwort für {app_name}. Lokal gespeicherte Daten werden mit einem zufällig generierten Schlüssel, der auf deinem Gerät gespeichert wird, erneut verschlüsselt. + + Mitglied wird entfernt + Mitglieder werden entfernt + + Verlängern + {pro} wird verlängert Antworten Rückerstattung anfordern + Fordern Sie eine Rückerstattung über die {platform}-Website an, indem Sie das {platform_account} verwenden, mit dem Sie sich für {pro} registriert haben. Erneut senden + + Einladung erneut senden + Einladungen erneut senden + + + Beförderung erneut senden + Beförderungen erneut senden + + + Einladung wird erneut gesendet + Einladungen werden erneut gesendet + + + Beförderung wird erneut gesendet + Beförderungen werden erneut gesendet + Länder werden geladen ... Neustart Erneut synchronisieren Erneut versuchen Bewertungsgrenze Du hast {app_name} anscheinend kürzlich bewertet – danke für dein Feedback! + App im Hintergrund ausführen + {app_name} im Hintergrund ausführen? + Da du den Langsammodus verwendest, empfehlen wir, {app_name} im Hintergrund auszuführen, um die Benachrichtigungen zu verbessern. Dies kann die Konsistenz der Benachrichtigungen erhöhen, obwohl dein System die Hintergrundaktivität dennoch automatisch einschränken kann.\n\nDu kannst diese Einstellung später unter \"Einstellungen\" ändern. Speichern Gespeichert Gespeicherte Nachrichten @@ -969,6 +1271,8 @@ Bildschirmschutz Bildschirmfoto-Benachrichtigungen Erhalte eine Benachrichtigung, wenn ein Kontakt ein Bildschirmfoto in einer Unterhaltung macht. + Das {app_name}-Fenster in auf diesem Gerät aufgenommenen Screenshots verbergen. + Screenshot-Schutz {name} hat einen Screenshot gemacht. Suchen Kontakte durchsuchen @@ -989,6 +1293,10 @@ Wird gesendet ... Rufangebot wird gesendet Verbindungskandidaten werden gesendet + + Admin-Beförderung wird gesendet + Admin-Beförderungen werden gesendet + Gesendet: Darstellung Daten löschen @@ -1015,14 +1323,19 @@ Speichern Community-Anzeigebild festlegen Setze ein Passwort für {app_name}. Lokal gespeicherte Dateien werden mit diesem Passwort verschlüsselt. Du wirst jedes Mal nach diesem Passwort gefragt, wenn du {app_name} startest. + Einstellung kann nicht aktualisiert werden Du musst {app_name} neu starten, um die neuen Einstellungen zu übernehmen. Bildschirmschutz + Autostart Teilen Lade deine Kontakte ein, mit dir auf {app_name} zu chatten, indem du deine Account-ID mit ihnen teilst. Teile deine Account-ID mit Freunden und verlegt das Gespräch dann hierher. Ein Datenbankfehler ist aufgetreten. Bitte starte die App neu und versuche es erneut. Ups! Sieht so aus, als hättest du noch kein {app_name} Konto.\n\nDu musst eines in der {app_name} App erstellen, bevor du teilen kannst. + Möchtest du den Nachrichtenverlauf der Gruppe mit diesem Nutzer teilen? Teilen auf {app_name} + {app_name} unterstützt nur das gleichzeitige Teilen mehrerer Bilder und Videos. + Freigabe unterstützt nur Mediendateien. Nicht-Mediendateien wurden ausgeschlossen. Anzeigen Alle anzeigen Weniger anzeigen @@ -1038,16 +1351,22 @@ Weiter Standard Fehler + Zurück Design-Vorschau Die Account-ID von {name} ist basierend auf deinen vorherigen Interaktionen sichtbar Verschleierte IDs werden in Communities verwendet, um Spam zu reduzieren und die Privatsphäre zu erhöhen Übersetzen + Infobereich Erneut versuchen Tipp-Indikatoren Sehe und teile Tippindikatoren. Nicht verfügbar Rückgängig Unbekannt + Nicht unterstützte CPU + Aktualisieren + {pro}-Zugang aktualisieren + Zwei Möglichkeiten, deinen {pro}-Zugang zu aktualisieren: App-Aktualisierungen Community-Informationen aktualisieren Community-Name und -Beschreibung sind für alle Mitglieder der Community sichtbar. @@ -1070,13 +1389,18 @@ Zuletzt aktualisiert vor {relative_time} Aktualisierungen Wird aktualisiert... + Upgrade + {app_name} aktualisieren Upgrade auf Wird hochgeladen Link kopieren URL öffnen Dies wird in deinem Browser geöffnet. Bist du sicher, dass du diese URL in deinem Browser öffnen wollen?\n\n{url} + Links werden in deinem Browser geöffnet. Schnellen Modus verwenden + Ändern Sie Ihren Plan mit dem {platform_account}, mit dem Sie sich registriert haben, über die {platform}-Website. + Über die {platform}-Website Video Videowiedergabe fehlgeschlagen. Anzeigen @@ -1085,9 +1409,11 @@ Dies kann einige Minuten dauern. Einen Moment bitte... Warnung + Die Unterstützung für iOS 15 wurde beendet. Aktualisieren Sie auf iOS 16 oder neuer, um weiterhin App-Updates zu erhalten. Fenster Ja Du + Ihre CPU unterstützt keine SSE 4.2-Befehlssätze, die von {app_name} unter Linux x64-Betriebssystemen zur Bildverarbeitung benötigt werden. Bitte aktualisieren Sie auf eine kompatible CPU oder verwenden Sie ein anderes Betriebssystem. Dein Wiederherstellungspasswort Vergrößerungsfaktor Passe die Größe von Text und visuellen Elementen an. diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 71f181b145..fd67dcc2dc 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -15,7 +15,13 @@ Este es tu Account ID. Otros usuarios pueden escanearlo para iniciar una conversación contigo. Tamaño original Añadir + + Añadir administrador + Añadir administradores + + Añadir administrador Ingrese el Account ID del usuario que desea promover a administrador.\n\nPara agregar varios usuarios, ingrese cada Account ID separado por una coma. Puede especificar hasta 20 Account ID a la vez. + Los administradores no pueden ser degradados ni eliminados del grupo. Los administradores no pueden ser eliminados. {name} y {count} más fueron promovidos a Admin. Promover Administradores @@ -40,19 +46,26 @@ {name} fue removido como Admin. {name} y {count} más fueron removidos como Admins. {name} y {other_name} fueron removidos como Admins. + + %1$d administrador seleccionado + %1$d administradores seleccionados + - Enviando promoción de administrador + Enviando promoción a administrador Enviando promociones de administrador Configuración del administrador + No puedes cambiar tu estado de administrador. Para salir del grupo, abre los ajustes de la conversación y selecciona Salir del grupo. {name} y {other_name} fueron promovidos a Admin. + Administradores + Permitir +{count} Anónimo Ícono de la Aplicación Cambiar icono y nombre de la aplicación Cambiar el icono y el nombre de la aplicación requiere cerrar {app_name}. Las notificaciones seguirán utilizando el icono y nombre predeterminados de {app_name}. Ícono y nombre alternativos para la aplicación se muestran en la pantalla de inicio y en el menú de aplicaciones. - El icono y el nombre seleccionados de la aplicación se muestran en la pantalla de inicio y en el cajón de aplicaciones. + The selected app icon and name is displayed on the home screen and app drawer. Ícono y nombre El ícono alternativo para la aplicación se muestra en la pantalla de inicio y en el menú de aplicaciones. El nombre de la aplicación seguirá apareciendo como \'{app_name}\'. Usar un ícono alternativo para la aplicación @@ -65,6 +78,8 @@ Notas Acciones Clima + Insignia de {app_pro} + Modo oscuro automático Ocultar barra de menú Idioma Elige la configuración de idioma para {app_name}. {app_name} se reiniciará cuando cambies tu configuración de idioma. @@ -143,11 +158,11 @@ Bloqueo fallido ¡Desbloqueo fallido! Desbloquear usuario - Ingrese el Account ID del usuario al que va a quitar la prohibición. + Enter the Account ID of the user you are unbanning Usuario desbloqueado Bloquear usuario Usuario reportado - Ingrese el Account ID del usuario al que va a prohibir. + Enter the Account ID of the user you are banning ID cegado Bloquear Desbloquea este contacto para enviarle mensajes @@ -159,6 +174,8 @@ ¿Estás seguro de que deseas desbloquear a {name} y {count} otros? ¿Estás seguro de que deseas desbloquear a {name} y 1 más? Desbloqueado {name} + Ver y gestionar los contactos bloqueados. + No se encontró un navegador para abrir ese enlace, intenta copiar la URL en su lugar Llamar {name} te ha llamado No puedes iniciar una nueva llamada. Termina tu llamada actual primero. @@ -176,16 +193,21 @@ Las llamadas de voz y video requieren que las notificaciones estén habilitadas en la configuración de su dispositivo. Se requieren permisos de llamada Puedes activar el permiso de \"Llamadas de voz y video\" en los ajustes de Privacidad. - Puedes activar el permiso para \"Llamadas de voz y vídeo\" en los ajustes de Privacidad. + Puedes activar el permiso para \"Llamadas de voz y vídeo\" en los ajustes de permisos. Reconectando... Llamando... Llamada de {app_name} Llamadas (Beta) Llamadas de voz y video Llamadas de Voz y Video (Beta) + Tu IP es visible para tu socio de llamada y un servidor de {session_foundation} mientras usas las llamadas beta. Permite las llamadas de voz y video hacia y desde otros usuarios. Has llamado a {name} Perdiste una llamada de {name} porque no has habilitado Llamadas de Voz y Video en la Configuración de Privacidad. + {app_name} necesita acceso a tu cámara para habilitar las videollamadas, pero este permiso ha sido denegado. No puedes actualizar los permisos de cámara durante una llamada.\n\n¿Quieres finalizar la llamada ahora y habilitar el acceso a la cámara, o prefieres que se te recuerde después de la llamada? + Para permitir el acceso a la cámara, abre la configuración y activa el permiso de Cámara. + Durante tu última llamada, intentaste usar el video pero no fue posible porque se denegó previamente el acceso a la cámara. Para permitir el acceso a la cámara, abre la configuración y activa el permiso de Cámara. + Se requiere acceso a la cámara Cámara no encontrada Cámara no disponible. Permitir acceso a cámara @@ -193,7 +215,19 @@ {app_name} necesita acceso a la cámara para tomar fotos y videos, o escanear códigos QR. {app_name} necesita acceso a la cámara para escanear códigos QR Cancelar + Cancelar {pro} + Cancela en el sitio web de {platform} utilizando la cuenta {platform_account} con la que te registraste en {pro}. + Cancela en el sitio web de {platform_store} utilizando la cuenta {platform_account} con la que te registraste en {pro}. + Cambiar Error al cambiar la contraseña + Cambia tu contraseña de {app_name}. Los datos almacenados localmente se volverán a cifrar con tu nueva contraseña. + Cambiar configuración + Comprobando el estado de {pro} + Verificando tu estado {pro}. Podrás continuar una vez que se complete esta verificación. + Comprobando tus datos de {pro}. Algunas acciones en esta página pueden no estar disponibles hasta que finalice esta verificación. + Verificando estado de {pro}... + Verificando los detalles de tu {pro}. No podrás renovar hasta que esta verificación se complete. + Comprobando tu estado de {pro}. Podrás actualizar a {pro} una vez finalice esta verificación. Borrar Borrar todo Borrar todos los datos @@ -252,11 +286,17 @@ URL de Comunidad Copiar el URL de la comunidad Confirmar + Confirmar promoción + ¿Estás seguro? Los administradores no pueden ser degradados ni eliminados del grupo. Contactos Eliminar contacto ¿Estás seguro de que quieres eliminar a {name} de tus contactos? Los nuevos mensajes de {name} llegarán como una solicitud de mensaje. No tienes ningún contacto todavía Seleccionar contactos + + %1$d contacto seleccionado + %1$d contactos seleccionados + Detalles de usuario Cámara Selecciona una acción para iniciar una conversación @@ -264,6 +304,7 @@ Redactar mensaje Miniatura de una foto como cita de un mensaje Crear una conversación con un nuevo contacto + Elige el contenido que se mostrará en las notificaciones locales cuando recibas un mensaje. Añadir a la pantalla de inicio Agregado a la pantalla de inicio Mensajes de audio @@ -276,14 +317,18 @@ Conversación eliminada No hay mensajes en {conversation_name}. Introduce tecla + Define cómo funcionan las teclas Enter y Shift+Enter en las conversaciones. + SHIFT + ENTER envía un mensaje, ENTER inicia una nueva línea. ENTER envía un mensaje, SHIFT + ENTER inicia una nueva línea. Grupos Recorte de mensajes en chats Recortar Comunidades + Eliminar automáticamente los mensajes con más de 6 meses de antigüedad en comunidades con más de 2000 mensajes. Nueva conversación Aún no tienes conversaciones Enter para enviar Pulsar la tecla Intro enviará el mensaje en lugar de empezar una nueva línea. + Enviar con Shift+Enter Adjuntos Revisión ortográfica Activar el corrector ortográfico. @@ -292,7 +337,10 @@ Copiar Crear Creando llamada + Facturación actual + Contraseña actual Cortar + Modo oscuro ¿Estás seguro de que quieres eliminar todos los mensajes, archivos adjuntos y datos de la cuenta de este dispositivo y crear una cuenta nueva? Ocurrió un error en la base de datos.\n\nExporta tus registros de aplicación para compartirlos con fines de resolución de problemas. Si esto no funciona, reinstala {app_name} y restaura tu cuenta. ¿Estás seguro de que quieres eliminar todos los mensajes, archivos adjuntos y datos de la cuenta de este dispositivo y restaurar tu cuenta desde la red? @@ -317,6 +365,14 @@ Por favor, espera mientras se crea el grupo... Error al actualizar el grupo No tienes permiso para borrar los mensajes de otros + + Eliminar archivo adjunto seleccionado + Eliminar archivos adjuntos seleccionados + + + ¿Estás seguro de querer eliminar el archivo adjunto seleccionado? El mensaje asociado también se eliminará. + ¿Estás seguro de querer eliminar los archivos adjuntos seleccionados? El mensaje asociado también se eliminará. + ¿Estás seguro de que quieres eliminar a {name} de tus contactos?\n\nEsto eliminará tu conversación, incluidos todos los mensajes y archivos adjuntos. Los mensajes futuros de {name} aparecerán como una solicitud de mensaje. ¿Estás seguro de que quieres eliminar tu conversación con {name}?\nEsto eliminará permanentemente todos los mensajes y archivos adjuntos. @@ -324,8 +380,8 @@ Eliminar el mensaje - ¿Seguro que quieres eliminar este mensaje? - ¿Seguro que quieres eliminar estos mensajes? + ¿Estás seguro de que deseas eliminar este mensaje? + ¿Estás seguro de que deseas eliminar estos mensajes? Mensaje eliminado @@ -334,8 +390,8 @@ Este mensaje ha sido eliminado Este mensaje ha sido eliminado en este dispositivo - ¿Seguro que quieres eliminar el mensaje únicamente de este dispositivo? - ¿Seguro que quieres eliminar los mensajes únicamente de este dispositivo? + ¿Estás seguro de que deseas eliminar este mensaje solo de este dispositivo? + ¿Estás seguro de que deseas eliminar estos mensajes solo de este dispositivo? ¿Estás seguro de que deseas eliminar este mensaje para todos? Eliminar solo en este dispositivo @@ -356,6 +412,7 @@ ¿Estás seguro de que quieres eliminar estos mensajes para todos? Eliminando Activar herramientas de desarrollador + Ajustes de notificaciones del dispositivo Iniciar dictado... Desaparición de mensajes El mensaje se eliminará en {time_large} @@ -391,6 +448,7 @@ {admin_name} actualizó los ajustes de mensajes que desaparecen. actualizaste la configuración de los mensajes que desaparecen. Descartar + Pantalla Puede ser su nombre real, un alias o cualquier otra cosa - y puede cambiarlo cuando quiera. Ingresa un nombre para mostrar Por favor, ingresa un nombre para mostrar @@ -402,6 +460,8 @@ Tu nombre visible es visible para los usuarios, grupos y comunidades con los que interactúas. Documento Donar + Fuerzas poderosas intentan debilitar la privacidad, pero no podemos continuar esta lucha solos.\n\nDonar ayuda a mantener {app_name} seguro, independiente y en línea. + {app_name} necesita tu ayuda Hecho Descargar Descargando... @@ -431,19 +491,38 @@ Tú y {name} reaccionaron con {emoji_name} Ha reaccionado a tu mensaje {emoji} Activar + ¿Habilitar acceso a cámara? + Mostrar notificaciones cuando recibas mensajes nuevos. + Finalizar llamada para habilitar ¿Te está gustando {app_name}? Necesita mejoras {emoji} Está genial {emoji} Has estado usando {app_name} por un tiempo, ¿cómo va todo? Agradeceríamos mucho saber tu opinión. + Entrar + Introduce la contraseña que configuraste para {app_name} + Introduce la contraseña que usas para desbloquear {app_name} al iniciar, no tu Contraseña de recuperación + Error al comprobar el estado de {pro} Por favor, compruebe su conexión a internet e inténtelo de nuevo. Copiar error y salir Fallo en la base de datos Algo salió mal. Por favor, inténtalo de nuevo más tarde. + Error al cargar acceso a {pro} + {app_name} no pudo buscar este ONS. Por favor, verifica tu conexión de red e inténtalo de nuevo. Ocurrió un fallo desconocido. + Este ONS no está registrado. Por favor, verifica que sea correcto e inténtalo de nuevo. + Error al reenviar la invitación a {name} en {group_name} + Error al reenviar la invitación a {name} y a {count} más en {group_name} + Error al reenviar la invitación a {name} y {other_name} en {group_name} + No se pudo reenviar promoción a {name} en {group_name} + No se pudo reenviar promoción a {name} y {count} otros en {group_name} + No se pudo reenviar promoción a {name} y {other_name} en {group_name} Descarga fallida Fallos + Comentarios + Comparte tu experiencia con {app_name} completando una breve encuesta. Archivo Archivos + Coincidir ajustes del sistema. Para siempre De: Activar pantalla completa @@ -451,7 +530,7 @@ Giphy {app_name} se conectará a Giphy para proporcionar los resultados de búsqueda. No tendrás protección completa de metadatos al enviar GIFs. ¿Enviar comentarios? - Lamentamos saber que tu experiencia con {app_name} no ha sido ideal. Estaríamos agradecidos si pudieras tomarte un momento para compartir tus opiniones en una breve encuesta. + Sorry to hear your {app_name} experience hasn’t been ideal. We\'d be grateful if you could take a moment to share your thoughts in a brief survey El grupo tiene un máximo de 100 miembros Crear Grupo Por favor, elige al menos un otro miembro del grupo. @@ -490,6 +569,9 @@ ¿Estás seguro de que deseas abandonar {group_name}? ¿Estás seguro de que deseas abandonar {group_name}?\n\nEsto eliminará a todos los miembros y borrará todo el contenido del grupo. Falló al salir de {group_name} + {name} fue invitado a unirse al grupo. Se compartió el historial del chat de los últimos 14 días. + {name} y {count} más fueron invitados a unirse al grupo. Se compartió el historial del chat de los últimos 14 días. + {name} y {other_name} fueron invitados a unirse al grupo. Se compartió el historial del chat de los últimos 14 días. {name} ha abandonado el grupo. {name} y {count} más abandonaron el grupo. {name} y {other_name} abandonaron el grupo. @@ -501,6 +583,9 @@ {name} y {other_name} fueron invitados a unirse al grupo. y {count} más fueron invitados a unirse al grupo. El historial de chat fue compartido. y {other_name} fueron invitados a unirse al grupo. Se ha compartido el historial del chat. + No se pudo eliminar a {name} de {group_name} + No se pudo eliminar a {name} y {count} otros de {group_name} + No se pudo eliminar a {name} y {other_name} de {group_name} abandonaste el grupo. Miembros del grupo No hay otros miembros en este grupo. @@ -514,6 +599,7 @@ No tienes mensajes de {group_name}. ¡Envía un mensaje para iniciar la conversación! Este grupo no ha sido actualizado en más de 30 días. Puede que experimentes problemas al enviar mensajes o ver la información del grupo. Eres el único administrador en {group_name}.\n\nLos miembros del grupo y la configuración no se pueden cambiar sin un administrador. + You are the only admin in {group_name}.\n\nGroup members and settings cannot be changed without an admin. To leave the group without deleting it, please add a new admin first Pendiente de eliminación fuiste promovido a Admin. y {count} más fueron promovidos a Admin. @@ -541,22 +627,32 @@ Grupo actualizado Gestionando candidatos de conexión Preguntas frecuentes + Consulta las preguntas frecuentes de {app_name} para obtener respuestas a preguntas comunes. Ayúdanos a traducir {app_name} + Reportar un error Comparte algunos detalles para ayudarnos a resolver tu problema. Exporta tus registros, luego sube el archivo a través del Help Desk de {app_name}. Exportar registros Exporta tus registros, luego carga el archivo a través del Help Desk de {app_name}. Guardar en el escritorio + Guarde este archivo y luego compártalo con los desarrolladores de {app_name}. Soporte + ¡Ayuda a traducir {app_name} a más de 80 idiomas! Nos encantaría conocer tu opinión Ocultar + Alterna la visibilidad de la barra de menú del sistema. ¿Estás seguro de que quieres ocultar Nota Personal de tu lista de conversaciones? Ocultar Otros Imagen imágenes + Importante Teclado incógnito Solicitar modo incógnito si está disponible. Dependiendo del teclado que estés usando, tu teclado puede ignorar esta solicitud. Información Atajo no válido + + Invitar contacto + Invitar contactos + Invitación fallida Invitaciones fallidas @@ -565,8 +661,17 @@ No se ha podido enviar la invitación. ¿Quieres volver a intentarlo? No se han podido enviar las invitaciones. ¿Quieres volver a intentarlo? + + Invitar miembro + Invitar miembros + + Invita a un nuevo miembro al grupo ingresando el ID de cuenta, ONS o escaneando su código QR {icon} + Invita un nuevo miembro al grupo ingresando el ID de cuenta, ONS de tu amigo o escaneando su código QR Unirse Más tarde + Iniciar {app_name} automáticamente cuando el ordenador se encienda. + Iniciar al encender + Esta configuración está gestionada por tu sistema en Linux. Para habilitar el inicio automático, añade {app_name} a tus aplicaciones de inicio en la configuración del sistema. Saber más Abandonar Saliendo... @@ -581,6 +686,8 @@ y {other_name} se unieron al grupo. {name} y {other_name} se unieron al grupo. Te uniste al grupo. + ¿Limitar actividad en segundo plano? + Actualmente permites que {app_name} se ejecute en segundo plano para mejorar la confiabilidad de las notificaciones. Cambiar esta configuración podría resultar en notificaciones menos confiables. Previsualizaciones de enlaces Mostrar previsualizaciones de enlaces para las URL soportadas. Habilitar vista previa de enlaces @@ -591,6 +698,7 @@ No tendrás una privacidad completa de metadatos al enviar previsualizaciones de enlaces. Previsualizaciones de enlaces desactivadas {app_name} debe contactar con sitios web enlazados para generar vistas previas de los enlaces que envías y recibes.\n\nPuedes activarlas en los ajustes de {app_name}. + Enlaces Cargar cuenta Cargando su cuenta Cargando... @@ -603,9 +711,17 @@ Estado de bloqueo Toca para desbloquear {app_name} está desbloqueado + Registros + Administrar administradores Administrar miembros + Administrar {pro} Máximo + Tal vez después Multimedia + + %1$d miembro seleccionado + %1$d miembros seleccionados + %1$d miembro %1$d miembros @@ -615,7 +731,9 @@ %1$d miembros activos Añadir Account ID o ONS + Los miembros solo pueden ser promovidos una vez que hayan aceptado la invitación para unirse al grupo. Invitar Contactos + No tienes contactos para invitar a este grupo.\nRegresa e invita miembros usando su ID de cuenta o ONS. Enviar invitación Enviar invitaciones @@ -624,10 +742,14 @@ ¿Te gustaría compartir el historial de mensajes del grupo con {name} y {count} otros? ¿Te gustaría compartir el historial de mensajes del grupo con {name} y {other_name}? Compartir historial de mensajes + Compartir historial de mensajes de los últimos 14 días Compartir solo mensajes nuevos Invitar + Miembros (No administradores) + Barra de menú Mensaje Leer más + Copiar mensaje Este mensaje está vacío. Fallo al entregar el mensaje Límite de texto @@ -642,6 +764,7 @@ Comenzar una nueva conversación ingresando el Account ID o ONS de tu amigo. Comenzar una nueva conversación ingresando el Account ID, ONS o escaneando el código QR de tu amigo. + Inicia una nueva conversación ingresando el ID de la cuenta de tu amigo, su ONS o escaneando su código QR {icon} Tienes un mensaje nuevo. Tienes %1$d mensajes nuevos. @@ -652,7 +775,7 @@ Respondiendo a No puedes enviar archivos adjuntos hasta que se acepte tu solicitud de mensaje - No puedes enviar mensajes de voz hasta que se acepte tu solicitud de mensaje + You cannot send voice messages until your Message Request is accepted {name} te invitó a unirte a {group_name}. El envío de un mensaje a este grupo aceptará automáticamente la invitación al grupo. Tu solicitud de mensaje está actualmente pendiente. @@ -690,20 +813,29 @@ Mensaje demasiado largo Por favor, acorta tu mensaje a {limit} caracteres o menos. Mensaje demasiado largo + Nueva contraseña Siguiente + Próximos pasos Elige un nombre para {name}. Este aparecerá en tus conversaciones uno a uno y en grupos. Escriba un apodo Por favor, ingresa un apodo más corto Eliminar apodo Definir Apodo No + No hay miembros que no sean administradores en este grupo. Sin sugerencias + Envía mensajes de hasta 10,000 caracteres en todas las conversaciones. + Organiza los chats con conversaciones fijadas ilimitadas. Ninguno Ahora no Notas personales No tienes mensajes en Nota personal. Ocultar Notas personales ¿Estás seguro de que deseas ocultar Nota Personal? + AVISO: Al {action_type}, aceptas los Términos del servicio {icon} y la Política de privacidad {icon} de {app_pro} + Visualización de notificaciones + Mostrar el nombre del remitente y una vista previa del contenido del mensaje. + Mostrar solo el nombre del remitente sin ningún contenido del mensaje. Todos los mensajes Contenido de las notificaciones La información mostrada en las notificaciones. @@ -714,6 +846,7 @@ Se te notificará de nuevos mensajes de forma fiable e inmediata usando los servidores de notificaciones de Google. Se le notificará de los nuevos mensajes de forma fiable e inmediata usando los servidores de notificaciones de Huawei. Se le notificará de los nuevos mensajes de forma fiable e inmediata usando los servidores de notificaciones de Apple. + Mostrar una notificación genérica de {app_name} sin el nombre del remitente ni el contenido del mensaje. Ir a la configuración de notificaciones del dispositivo Notificaciones - Todas Notificaciones - Solo Menciones @@ -721,6 +854,7 @@ {name} a {conversation_name} Puede que hayas recibido mensajes mientras se reiniciaba tu {device}. Color del LED + Reproducir un sonido cuando recibas mensajes nuevos. Solo menciones Notificaciones de mensajes Más reciente de {name} @@ -742,6 +876,12 @@ Apagado Okay Activo + En tu dispositivo {device_type} + Abre esta cuenta de {app_name} en un dispositivo {device_type} con sesión iniciada en la cuenta {platform_account} con la que te registraste originalmente. Luego, cancela {pro} desde la configuración de {app_pro}. + Abre esta cuenta de {app_name} en un dispositivo {device_type} que haya iniciado sesión con la {platform_account} con la que te registraste originalmente. Luego, actualiza tu acceso {pro} desde los ajustes de {app_pro}. + En un dispositivo vinculado + En el sitio web de {platform_store} + En el sitio web de {platform} Crear cuenta Cuenta Creada Iniciar sesión @@ -766,11 +906,17 @@ No pudimos reconocer este ONS. Por favor, verifícalo e inténtalo de nuevo. No pudimos buscar este ONS. Por favor, inténtalo de nuevo más tarde. Abrir + Abrir sitio web de {platform_store} + Abrir el sitio web de {platform} + Abrir configuración Abrir encuesta Otro + Contraseña Cambiar Contraseña - Tu contraseña ha sido cambiada. Por favor, guárdala en un lugar seguro. + Cambiar la contraseña necesaria para desbloquear {app_name}. + Tu contraseña ha sido cambiada. Por favor, manténla segura. Confirmar contraseña + Crear contraseña Tu contraseña actual es incorrecta. Introducir contraseña Por favor, introduce tu contraseña actual @@ -780,9 +926,24 @@ Las contraseñas no coinciden Error al establecer la contraseña Contraseña Incorrecta + Confirmar nueva contraseña Eliminar Contraseña + Eliminar la contraseña requerida para desbloquear {app_name} + Tu contraseña ha sido eliminada. Establecer Contraseña + Tu contraseña ha sido establecida. Por favor, mantenla segura. + Requerir contraseña para desbloquear {app_name} al iniciar. + Más de 12 caracteres + Incluye un número + Incluye una letra minúscula + Incluye un símbolo + Incluye una letra mayúscula + Indicador de fortaleza de contraseña + Establecer una contraseña fuerte ayuda a proteger tus mensajes y archivos adjuntos si tu dispositivo se pierde o te lo roban. + Contraseñas Pegar + Error de pago + Tu pago se ha procesado correctamente, pero hubo un error al {action_type} tu estado {pro}.\n\nVerifica tu conexión de red e inténtalo de nuevo. Cambiar Permisos {app_name} necesita acceso a música y audio para enviar archivos, música y audio, pero ha sido denegado permanentemente. Toca Configuración → Permisos, y activa \"Música y audio\". {app_name} necesita usar Apple Music para reproducir archivos adjuntos multimedia. @@ -794,6 +955,7 @@ Permite el acceso a la cámara para las videollamadas. La función de pantalla bloqueada en {app_name} usa Face ID. Conservar en la bandeja del sistema + {app_name} continúa ejecutándose en segundo plano cuando cierras la ventana. {app_name} necesita acceso a la biblioteca de fotos para continuar. Puedes habilitar el acceso en los ajustes de iOS. Se requiere acceso a la Red Local para realizar llamadas. En Configuración, active el permiso de \"Red Local\" para continuar. {app_name} necesita acceso a la red local para realizar llamadas de voz y video. @@ -820,25 +982,172 @@ Fijar conversación Desfijar Desfijar conversación + Y mucho más... + Nuevas funciones llegarán pronto a {pro}. Descubre lo próximo en la Hoja de ruta de {pro} {icon} Preferencias Vista Previa + Vista previa de notificación + ¡Tu acceso a {pro} está activo!\n\nTu acceso a {pro} se renovará automáticamente por otro {current_plan_length} el {date}. + ¡Tu acceso a {pro} está activo!\n\nTu acceso a {pro} se renovará automáticamente por otro\n{current_plan_length} el {date}. Cualquier cambio que hagas aquí surtirá efecto en tu próxima renovación. + Error de acceso a {pro} + Tu acceso {pro} vencerá el {date}. + Cargando acceso a {pro} + La información de tu acceso a {pro} aún se está cargando. No puedes actualizar hasta que se complete este proceso. + cargando acceso a {pro}... + No se puede conectar a la red para cargar la información de acceso a {pro}. La actualización de {pro} a través de {app_name} estará deshabilitada hasta que se restablezca la conectividad.\n\nPor favor, revisa tu conexión de red e inténtalo de nuevo. + Acceso {pro} no encontrado + {app_name} detectó que tu cuenta no tiene acceso {pro}. Si crees que se trata de un error, contacta al soporte de {app_name} para obtener ayuda. + Recuperar acceso {pro} + Renovar acceso {pro} + Actualmente, el acceso a {pro} solo puede comprarse y renovarse a través de {platform_store} o {platform_store_other}. Como estás utilizando {app_name} Escritorio, no puedes renovar desde aquí.\n\nLos desarrolladores de {app_name} están trabajando arduamente en opciones de pago alternativas para permitir a los usuarios adquirir acceso a {pro} fuera de {platform_store} y {platform_store_other}. Hoja de ruta de {pro} {icon} + Renueva tu acceso {pro} en el sitio web de {platform_store} utilizando la cuenta {platform_account} con la que te registraste para {pro}. + Renueva en el sitio web de {platform} utilizando la cuenta {platform_account} con la que te registraste en {pro}. + Renueva tu acceso {pro} para volver a utilizar las potentes funciones beta de {app_pro}. + Acceso {pro} recuperado + {app_name} detectó y recuperó el acceso {pro} para tu cuenta. ¡Tu estado {pro} ha sido restaurado! + Como te registraste originalmente en {app_pro} a través de {platform_store}, deberás usar tu cuenta {platform_account} para actualizar tu acceso {pro}. + Actualmente, el acceso a {pro} solo se puede adquirir mediante {platform_store} o {platform_store_other}. Como estás usando {app_name} en Escritorio, no puedes actualizar a {pro} aquí.\n\nLos desarrolladores de {app_name} están trabajando arduamente en opciones de pago alternativas para permitir a los usuarios adquirir acceso a {pro} fuera de {platform_store} y {platform_store_other}. Hoja de ruta de {pro} {icon} Activado + activando + ¡Todo listo! + ¡Tu acceso a {app_pro} fue actualizado! Se te cobrará cuando {pro} se renueve automáticamente el {date}. Ya tienes ¡Adelante, sube GIFs e imágenes WebP animadas para tu imagen de perfil! + Consigue imágenes de perfil animadas y desbloquea funciones premium con {app_pro} Beta Imagen de perfil animada los usuarios pueden subir GIFs + Imágenes de perfil animadas + Establece GIF animados e imágenes WebP como tu foto de perfil. Sube GIFs con + {pro} se renovará automáticamente en {time} + Insignia {pro} + Mostrar la insignia {app_pro} a otros usuarios + Insignias + Demuestra tu apoyo a {app_name} con una insignia exclusiva junto a tu nombre de pantalla. + + %1$s insignia %2$s enviada + %1$s insignias %2$s enviadas + + Funciones beta de {pro} + {price} Facturado anualmente + {price} Facturado mensualmente + {price} Facturado trimestralmente + ¿Quieres enviar mensajes más largos?\nEnvía más texto y desbloquea funciones premium con {app_pro} Beta + ¿Quieres más conversaciones fijadas?\nOrganiza tus chats y desbloquea funciones premium con {app_pro} Beta + ¿Quieres más de {limit} conversaciones fijadas?\nOrganiza tus chats y desbloquea funciones premium con {app_pro} Beta + Lamentamos que canceles {pro}. Esto es lo que necesitas saber antes de cancelar tu acceso a {pro}. + Cancelación + Cancelar el acceso a {pro} impedirá la renovación automática antes de que expire el acceso a {pro}. Cancelar {pro} no conlleva un reembolso. Podrás seguir usando las funciones de {app_pro} hasta que expire tu acceso a {pro}.\n\nComo te registraste originalmente en {app_pro} con tu cuenta de {platform_account}, deberás usar esa misma cuenta {platform_account} para cancelar {pro}. + Dos formas de cancelar tu acceso a {pro}: + Cancelar el acceso a {pro} evitará la renovación automática antes de que expire {pro}.\n\nCancelar {pro} no genera un reembolso. Podrás seguir usando las funciones de {app_pro} hasta que expire tu acceso a {pro}. + Elige la opción de acceso a {pro} que más te convenga.\nMayor duración significa mayores descuentos. + ¿Estás seguro de que quieres eliminar tus datos de este dispositivo?\n\n{app_pro} no se puede transferir a otra cuenta. Guarda tu Contraseña de recuperación para asegurarte de que puedas restaurar tu acceso a {pro} más adelante. + ¿Estás seguro de que quieres eliminar tus datos de la red? Si continúas, no podrás restaurar tus mensajes ni tus contactos.\n\n{app_pro} no se puede transferir a otra cuenta. Guarda tu Contraseña de recuperación para asegurarte de que puedas restaurar tu acceso a {pro} más adelante. + Tu acceso {pro} ya tiene un descuento del {percent}% respecto al precio completo de {app_pro}. + Error al actualizar el estado de {pro} + Expirado + Lamentablemente, tu acceso {pro} ha vencido.\nRenuévalo para reactivar los beneficios y funciones exclusivas de {app_pro} Beta. + Vence pronto + Tu acceso {pro} vence en {time}.\nActualiza ahora para seguir disfrutando de los beneficios y funciones exclusivas de {app_pro} Beta + {pro} vence en {time} + Preguntas frecuentes de {pro} + Encuentra respuestas a preguntas frecuentes en las Preguntas Frecuentes de {app_pro}. Sube imágenes de perfil en formato GIF y WebP Chats grupales más grandes de hasta 300 miembros Y muchas funciones exclusivas más - Mensajes de hasta 10.000 caracteres + Messages up to 10,000 characters Fija conversaciones sin límite + ¿Quieres usar {app_name} al máximo?\nActualiza a {app_pro} Beta para acceder a muchas ventajas y funciones exclusivas. Grupo activado ¡Este grupo tiene capacidad ampliada! Puede admitir hasta 300 miembros porque un administrador del grupo tiene + + %1$s grupo actualizado + %1$s grupos actualizados + + Solicitar un reembolso es definitivo. Si se aprueba, tu acceso {pro} se cancelará de inmediato y perderás el acceso a todas las funciones de {pro}. Tamaño del archivo adjunto aumentado Mayor longitud de mensaje + Grupos más grandes + Los grupos en los que eres administrador se actualizan automáticamente para admitir hasta 300 miembros. + ¡Los chats grupales grandes (hasta 300 miembros) estarán disponibles pronto para todos los usuarios beta de Pro! + Mensajes más largos + You can send messages up to 10,000 characters in all conversations. + + %1$s mensaje más largo enviado + %1$s mensajes más largos enviados + Este mensaje utilizó las siguientes funciones de {app_pro}: + Con una nueva instalación + Reinstala {app_name} en este dispositivo a través de {platform_store}, restaura tu cuenta con tu Recovery password y renueva {pro} desde la configuración de {app_pro}. + Reinstala {app_name} en este dispositivo mediante {platform_store}, restaura tu cuenta con tu Contraseña de recuperación y actualiza a {pro} desde la configuración de {app_pro}. + Por ahora, existen tres formas de renovar: + Por ahora, hay dos formas de renovar: + {percent}% de descuento + + %1$s conversación fijada + %1$s conversaciones fijadas + + Como te registraste originalmente en {app_pro} a través de la {platform_store}, deberás usar tu cuenta {platform_account} para solicitar un reembolso. + Dado que te registraste originalmente en {app_pro} a través de la {platform_store}, tu solicitud de reembolso será procesada por el Soporte de {app_name}.\n\nSolicita un reembolso pulsando el botón de abajo y completando el formulario de solicitud de reembolso.\n\nAunque el Soporte de {app_name} se esfuerza por procesar las solicitudes de reembolso en un plazo de 24-72 horas, el procesamiento puede tardar más durante periodos de alto volumen de solicitudes. + ¡Tu acceso a {app_pro} ha sido renovado! Gracias por apoyar a {network_name}. + 1 mes - {monthly_price} / mes + 3 meses - {monthly_price} / mes + 12 meses - {monthly_price} / mes + reactivando + Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, request a refund via the {app_pro} settings. + Lamentamos que te vayas. Esto es lo que necesitas saber antes de solicitar un reembolso. + {platform} is now processing your refund request. This typically takes 24-48 hours. Depending on their decision, you may see your {pro} status change in {app_name}. + Your refund request will be handled by {app_name} Support.\n\nRequest a refund by hitting the button below and completing the refund request form.\n\nWhile {app_name} Support strives to process refund requests within 24-72 hours, processing may take longer during times of high request volume. + Tu solicitud de reembolso será gestionada exclusivamente por {platform} a través del sitio web de {platform}.\n\nDebido a las políticas de reembolso de {platform}, los desarrolladores de {app_name} no tienen la capacidad de influir en el resultado de las solicitudes de reembolso. Esto incluye si la solicitud es aprobada o rechazada, así como si se otorga un reembolso total o parcial. + Ponte en contacto con {platform} para obtener actualizaciones sobre tu solicitud de reembolso. Debido a las políticas de reembolso de {platform}, los desarrolladores de {app_name} no tienen la posibilidad de influir en el resultado de las solicitudes de reembolso.\n\nSoporte de reembolso de {platform} + Reembolso de {pro} + Los reembolsos de {app_pro} son gestionados exclusivamente por {platform} a través del {platform_store}.\n\nDebido a las políticas de reembolso de {platform}, los desarrolladores de {app_name} no tienen la posibilidad de influir en el resultado de las solicitudes de reembolso. Esto incluye si la solicitud es aprobada o rechazada, así como si se otorga un reembolso total o parcial. + ¿Quieres volver a usar fotos de perfil animadas?\nRenueva tu acceso a {pro} para desbloquear las funciones que te estás perdiendo. + Renovar {pro} Beta + Renueva tu acceso {pro} desde los ajustes de {app_pro} en un dispositivo vinculado con {app_name} instalado mediante {platform_store} o {platform_store_other}. + ¿Quieres volver a enviar mensajes más largos?\nRenueva tu acceso a {pro} para desbloquear las funciones que te estás perdiendo. + ¿Quieres volver a usar {app_name} al máximo de su potencial?\nRenueva tu acceso a {pro} para desbloquear las funciones que te has estado perdiendo. + ¿Quieres volver a fijar más de {limit} conversaciones?\nRenueva tu acceso a {pro} para desbloquear las funciones que te estás perdiendo. + ¿Quieres volver a fijar más conversaciones?\nRenueva tu acceso a {pro} para desbloquear las funciones que te estás perdiendo. + Al renovar, aceptas los Términos del servicio {icon} y la Política de privacidad {icon} de {app_pro} + renovando + Actualmente, el acceso a {pro} solo se puede comprar y renovar a través de {platform_store} o {platform_store_other}. Como instalaste {app_name} utilizando {build_variant}, no puedes renovar aquí.\n\nLos desarrolladores de {app_name} están trabajando arduamente en opciones de pago alternativas que permitan a los usuarios adquirir el acceso a {pro} fuera de {platform_store} y {platform_store_other}. Hoja de ruta de {pro} {icon} + Reembolso solicitado Envía más con + Configuración de {pro} + Comienza a usar {pro} + Tus estadísticas de {pro} + Cargando estadísticas de {pro} + Tus estadísticas de {pro} se están cargando, por favor espera. + {pro} stats reflect usage on this device and may appear differently on linked devices + Error de estado de {pro} + No se puede conectar a la red para comprobar tu estado de {pro}. La información mostrada en esta página podría ser inexacta hasta que se restablezca la conectividad.\n\nPor favor, revisa tu conexión de red e inténtalo de nuevo. + Cargando estado de {pro} + Tu información de {pro} se está cargando. Algunas acciones en esta página pueden no estar disponibles hasta que finalice la carga. + cargando estado de {pro} + No se pudo conectar a la red para verificar tu estado {pro}. No puedes continuar hasta que se restaure la conectividad.\n\nVerifica tu conexión de red e inténtalo de nuevo. + No se puede conectar a la red para comprobar tu estado de {pro}. No podrás actualizar a {pro} hasta que se restablezca la conectividad.\n\nPor favor, revisa tu conexión de red e inténtalo de nuevo. + No se puede conectar a la red para actualizar tu estado de {pro}. Algunas acciones en esta página se desactivarán hasta que se restablezca la conectividad.\n\nPor favor, revisa tu conexión de red e inténtalo de nuevo. + No se puede conectar a la red para cargar tu acceso actual a {pro}. Renovar {pro} desde {app_name} estará deshabilitado hasta que se restablezca la conexión.\n\nPor favor, verifica tu conexión a la red e inténtalo de nuevo. + ¿Necesitas ayuda con {pro}? Envía una solicitud al equipo de soporte. + Al {action_type}, estás {activation_type} {app_pro} mediante el protocolo {app_name}. {entity} facilitará dicha activación pero no es el proveedor de {app_pro}. {entity} no es responsable del rendimiento, disponibilidad o funcionalidad de {app_pro}. + Al actualizar, aceptas los Términos del servicio {icon} y la Política de privacidad {icon} de {app_pro} + Conversaciones fijadas ilimitadas + Organiza todos tus chats con conversaciones fijadas ilimitadas. + Tu opción de facturación actual otorga {current_plan_length} de acceso {pro}. ¿Seguro que deseas cambiar a la opción de facturación {selected_plan_length_singular}?\n\nAl actualizar, tu acceso {pro} se renovará automáticamente el {date} por {selected_plan_length} adicionales de acceso {pro}. + Tu acceso {pro} vencerá el {date}.\n\nAl actualizar, tu acceso {pro} se renovará automáticamente el {date} por {selected_plan_length} adicionales de acceso {pro}. + actualizando + Actualiza a {app_pro} Beta para acceder a muchas ventajas y funciones exclusivas. + Actualiza a {pro} desde la configuración de {app_pro} en un dispositivo vinculado con {app_name} instalado mediante {platform_store} o {platform_store_other}. + Actualmente, el acceso a {pro} solo se puede adquirir mediante {platform_store} o {platform_store_other}. Como instalaste {app_name} usando {build_variant}, no puedes actualizar a {pro} aquí.\n\nLos desarrolladores de {app_name} están trabajando arduamente en opciones de pago alternativas para permitir a los usuarios adquirir acceso a {pro} fuera de {platform_store} y {platform_store_other}. Hoja de ruta de {pro} {icon} + Por ahora, solo hay una forma de actualizar: + Por ahora, hay dos formas de actualizar: + ¡Has actualizado a {app_pro}!\nGracias por apoyar a {network_name}. + actualizando + Actualizando a {pro} + Al actualizar, aceptas los Términos del servicio {icon} y la Política de privacidad {icon} de {app_pro} + ¿Quieres sacarle más provecho a {app_name}?\nActualiza a {app_pro} Beta para una experiencia de mensajería más potente. + {platform} está procesando tu solicitud de reembolso Perfil Imagen de perfil Falló al remover foto de perfil. @@ -846,6 +1155,11 @@ Por favor, elija un archivo más pequeño. Fallo al actualizar el perfil. Promover + Los administradores podrán ver el historial de mensajes de los últimos 14 días y no podrán ser degradados ni eliminados del grupo. + + Promover miembro + Promover miembros + Promoción fallida Promociones fallidas @@ -864,7 +1178,7 @@ Salir ¿Calificar {app_name}? Calificar aplicación - Nos alegra que estés disfrutando de {app_name}. Si tienes un momento, calificarnos en {storevariant} ayuda a otros a descubrir la mensajería privada y segura. + We\'re glad you\'re enjoying {app_name}, if you have a moment, rating us in the {storevariant} helps others discover private, secure messaging! Leído Confirmaciones de lectura Mostrar confirmaciones de lectura para todos los mensajes que envíes y recibas. @@ -875,6 +1189,7 @@ Recomendado Guarde su clave de recuperación para asegurarse de no perder acceso a su cuenta. Guarde su clave de recuperación + Usa tu contraseña de recuperación para cargar tu cuenta en nuevos dispositivos.\n\nTu cuenta no se puede recuperar sin tu contraseña de recuperación. Asegúrate de que esté guardada en un lugar seguro y no la compartas con nadie. Escriba su clave de recuperación Ocurrió un error al intentar cargar tu contraseña de recuperación.\n\nPor favor exporta tus registros, y luego sube el archivo a través del Help Desk {app_name} para ayudar a resolver este problema. Por favor, comprueba tu clave de recuperación y vuelve a intentarlo. @@ -884,27 +1199,69 @@ Para cargar tu cuenta, ingresa tu recovery password. Ocultar Clave de Recuperación Permanentemente Sin su clave de recuperación no puede iniciar sesión en otros dispositivos.\n\nLe recomendamos que guarde su clave de recuperación en un lugar a salvo y seguro antes de seguir. + ¿Estás seguro de que deseas ocultar permanentemente tu contraseña de recuperación en este dispositivo?\n\nEsto no se puede deshacer. Ocultar Clave de Recuperación Ocultar permanentemente tu clave de recuperación en este dispositivo. Ingrese su clave de recuperación para cargar su cuenta. Si no la ha guardado, puede encontrarla en la configuración de su aplicación. + Ver contraseña de recuperación + Visibilidad de la contraseña de recuperación Esta es tu recovery password. Si la envías a alguien, tendrá acceso completo a tu cuenta. Volver a crear grupo Rehacer + Como te registraste originalmente en {app_pro} con otra {platform_account}, deberás usar esa {platform_account} para actualizar tu acceso a {pro}. + Dos formas de solicitar un reembolso: Reduce la longitud del mensaje en {count} %1$d carácter restante %1$d caracteres restantes + Recordar más tarde Eliminar + + Eliminar miembro + Eliminar miembros + + + Eliminar miembro y sus mensajes + Eliminar miembros y sus mensajes + Error al eliminar la contraseña + Elimina tu contraseña actual de {app_name}. Los datos almacenados localmente se volverán a cifrar con una clave generada aleatoriamente, almacenada en tu dispositivo. + + Eliminando miembro + Eliminando miembros + + Renovar + Renovando {pro} Responder + Solicitar reembolso + Solicita un reembolso en el sitio web de {platform}, utilizando la cuenta {platform_account} con la que te registraste en {pro}. Reenviar + + Reenviar invitación + Reenviar invitaciones + + + Reenviar promoción + Reenviar promociones + + + Reenviando invitación + Reenviando invitaciones + + + Reenviando promoción + Reenviando promociones + Cargando información del país... Reiniciar Reiniciar sincronización Reintentar Límite de reseñas Parece que ya has calificado {app_name} recientemente. ¡Gracias por tus comentarios! + Ejecutar la app en segundo plano + ¿Ejecutar {app_name} en segundo plano? + Como estás usando el modo lento, te recomendamos permitir que {app_name} se ejecute en segundo plano para mejorar las notificaciones. Esto puede mejorar la consistencia de las notificaciones, aunque tu sistema aún podría limitar la actividad en segundo plano automáticamente.\n\nPuedes cambiar esto más adelante en Ajustes. Guardar Guardado Mensajes guardados @@ -913,6 +1270,8 @@ Seguridad de pantalla Notificaciones de captura de pantalla Requerir una notificación cuando un contacto tome una captura de pantalla de un chat individual. + Ocultar la ventana de {app_name} en las capturas de pantalla tomadas en este dispositivo. + Protección contra capturas de pantalla {name} ha hecho una captura de pantalla. Buscar Buscar Contactos @@ -933,6 +1292,10 @@ Enviando Enviando oferta de llamada Enviando candidatos de conexión + + Enviando promoción + Enviando promociones + Enviado: Apariencia Borrar datos @@ -953,38 +1316,56 @@ Notificaciones Permisos Privacidad + {app_pro} Beta Clave de Recuperación Ajustes Definir Establecer imagen de perfil de la Comunidad + Establece una contraseña para {app_name}. Los datos almacenados localmente se cifrarán con esta contraseña. Se te pedirá que ingreses esta contraseña cada vez que se inicie {app_name}. + No se puede actualizar la configuración Debes reiniciar {app_name} para aplicar las nuevas configuraciones. Seguridad de pantalla + Inicio Compartir Invita a tu amigo a chatear contigo en {app_name} compartiendo tu ID de cuenta con ellos. Comparte con tus amigos dondequiera que hables con ellos — luego mueve la conversación aquí. Hay un problema al abrir la base de datos. Por favor reinicia la aplicación y vuelve a intentarlo. ¡Ups! Parece que no tienes una cuenta de {app_name} todavía.\n\nTendrás que crear una en la aplicación de {app_name} antes de que puedas compartir. + ¿Te gustaría compartir el historial de mensajes del grupo con este usuario? Compartir en {app_name} + Lo sentimos, {app_name} sólo admite compartir varias imágenes y vídeos a la vez + El uso compartido solo admite contenido multimedia. Los archivos que no son multimedia han sido excluidos Mostrar Mostrar todo Mostrar menos Mostrar Nota Personal ¿Estás seguro de que deseas mostrar Nota Personal en tu lista de conversaciones? + Corrector ortográfico Pegatinas (stickers) + Fortaleza + ¿Tienes problemas? Consulta los artículos de ayuda o abre un ticket con el soporte de {app_name}. Ir a la página de soporte técnico Información del Sistema: {information} Toque para reintentar Continuar Por defecto Error + Volver + Vista previa del tema El ID de cuenta de {name} es visible basándose en tus interacciones anteriores Los ID cegados se utilizan en las comunidades para reducir el spam y aumentar la privacidad + Traducir + Bandeja Intentar de nuevo Indicadores de escritura Ver y compartir indicadores de escritura. No disponible Deshacer Desconocido + CPU no compatible + Actualizar + Actualizar acceso {pro} + Dos formas de actualizar tu acceso {pro}: Actualizaciones de la aplicación Actualizar la información de la comunidad El nombre y la descripción de la comunidad son visibles para todos los miembros de la comunidad @@ -1000,17 +1381,25 @@ Hay una nueva versión de {app_name} disponible, toca para actualizar Hay disponible una nueva versión ({version}) de {app_name}. Actualizar información de perfil + Tu nombre visible y tu imagen de perfil son visibles en todas las conversaciones. Ir a las notas de versión Actualización de {app_name} Versión {version} Última actualización hace {relative_time} + Actualizaciones + Actualizando... + Actualizar + Actualizar {app_name} Actualizar a Cargando Copiar la dirección URL Abrir URL Esto se abrirá en tu navegador. ¿Estás seguro de que deseas abrir esta URL en tu navegador?\n\n{url} + Los enlaces se abrirán en tu navegador. Usar modo rápido + Cambia tu plan utilizando la cuenta {platform_account} con la que te registraste, a través del sitio web de {platform}. + A través del sitio web de {platform} Video No se puede reproducir el video. Ver @@ -1019,7 +1408,12 @@ Esto puede tomar unos minutos. Un momento, por favor... Advertencia + Support for iOS 15 has ended. Update to iOS 16 or later to continue receiving app updates. Ventana + Tu CPU no admite las instrucciones SSE 4.2, que son necesarias para que {app_name} procese imágenes en sistemas operativos Linux x64. Por favor, actualiza a una CPU compatible o utiliza un sistema operativo diferente. + Tu contraseña de recuperación + Factor de Zoom + Ajusta el tamaño del texto y los elementos visuales. \ No newline at end of file diff --git a/app/src/main/res/values-b+es+ES/strings.xml b/app/src/main/res/values-b+es+ES/strings.xml index 7dcd1c3202..e23d236507 100644 --- a/app/src/main/res/values-b+es+ES/strings.xml +++ b/app/src/main/res/values-b+es+ES/strings.xml @@ -15,7 +15,13 @@ Este es tu ID de cuenta. Otros usuarios pueden escanearlo para iniciar una conversación contigo. Tamaño original Añadir + + Añadir administrador + Añadir administradores + + Añadir administrador Ingrese el Account ID del usuario que desea promover a administrador.\n\nPara agregar varios usuarios, ingrese cada Account ID separado por una coma. Puede especificar hasta 20 Account ID a la vez. + Los administradores no pueden ser degradados ni eliminados del grupo. Los administradores no pueden ser eliminados. {name} y {count} más fueron promovidos a Administradores. Promover Administradores @@ -40,12 +46,19 @@ {name} fue destituido como Administrador. {name} y otros {count} fueron eliminados como moderadores. {name} y {other_name} fueron eliminados como moderadores. + + %1$d administrador seleccionado + %1$d administradores seleccionados + Enviando promoción de administrador Enviando promociones de administrador Configuración de administrador + No puedes cambiar tu estado de administrador. Para salir del grupo, abre los ajustes de la conversación y selecciona Salir del grupo. {name} y {other_name} fueron promovidos a Administradores. + Administradores + Permitir +{count} Anónimo Ícono de la app @@ -65,6 +78,8 @@ Notas Acciones Clima + Insignia de {app_pro} + Modo oscuro automático Esconder barra de menú Idioma Elija su configuración de idioma para {app_name}. {app_name} se reiniciará cuando cambie su configuración de idioma. @@ -159,6 +174,8 @@ ¿Estás seguro de que quieres desbloquear a {name} y {count} otros? ¿Estás seguro de que quieres desbloquear a {name} y 1 otro? Desbloqueado {name} + Ver y gestionar los contactos bloqueados. + No se encontró un navegador para abrir ese enlace, intenta copiar la URL en su lugar Llamar {name} te ha llamado No puedes iniciar una nueva llamada. Termina tu llamada actual primero. @@ -183,9 +200,14 @@ Llamadas (beta) Llamadas de voz y video Llamadas de Voz y Video (Beta) + Tu IP es visible para tu socio de llamada y un servidor de {session_foundation} mientras usas las llamadas beta. Permite llamadas de voz y vídeo de y hacia otros usuarios. Has llamado a {name} Perdiste una llamada de {name} porque no has habilitado Voice and Video Calls en configuraciones de privacidad. + {app_name} necesita acceso a tu cámara para habilitar las videollamadas, pero este permiso ha sido denegado. No puedes actualizar los permisos de cámara durante una llamada.\n\n¿Quieres finalizar la llamada ahora y habilitar el acceso a la cámara, o prefieres que se te recuerde después de la llamada? + Para permitir el acceso a la cámara, abre la configuración y activa el permiso de Cámara. + Durante tu última llamada, intentaste usar el video pero no fue posible porque se denegó previamente el acceso a la cámara. Para permitir el acceso a la cámara, abre la configuración y activa el permiso de Cámara. + Se requiere acceso a la cámara Cámara no encontrada La cámara no está disponible. Permitir acceso a cámara @@ -193,7 +215,19 @@ {app_name} necesita acceso a la cámara para tomar fotos y videos, o escanear códigos QR. {app_name} necesita acceso a la cámara para poder escanear códigos QR Cancelar + Cancelar {pro} + Cancela en el sitio web de {platform} utilizando la cuenta {platform_account} con la que te registraste en {pro}. + Cancela en el sitio web de {platform_store} utilizando la cuenta {platform_account} con la que te registraste en {pro}. + Cambiar Error al cambiar la contraseña + Cambia tu contraseña de {app_name}. Los datos almacenados localmente se volverán a cifrar con tu nueva contraseña. + Cambiar configuración + Comprobando el estado de {pro} + Verificando tu estado {pro}. Podrás continuar una vez que se complete esta verificación. + Comprobando tus datos de {pro}. Algunas acciones en esta página pueden no estar disponibles hasta que finalice esta verificación. + Verificando estado de {pro}... + Verificando los detalles de tu {pro}. No podrás renovar hasta que esta verificación se complete. + Comprobando tu estado de {pro}. Podrás actualizar a {pro} una vez finalice esta verificación. Borrar Borrar todo Borrar todos los datos @@ -252,11 +286,17 @@ URL de la comunidad Copiar URL de la comunidad Confirmar + Confirmar promoción + ¿Estás seguro? Los administradores no pueden ser degradados ni eliminados del grupo. Contactos Borrar Contacto ¿Estás seguro de querer eliminar a {name} de tus contactos? Los nuevos mensajes de {name} aparecerán como una solicitud de mensaje. Aún no tienes contactos Seleccionar contactos + + %1$d contacto seleccionado + %1$d contactos seleccionados + Detalles del usuario Cámara Elija una acción para iniciar una conversación @@ -264,6 +304,7 @@ Redactar mensaje Miniatura de una foto como cita de un mensaje Crear una conversación con un nuevo contacto + Elige el contenido que se mostrará en las notificaciones locales cuando recibas un mensaje. Añadir a la pantalla de inicio Agregado a la pantalla de inicio Mensajes de audio @@ -276,14 +317,18 @@ Conversación eliminada No hay mensajes en {conversation_name}. Introducir Clave + Define cómo funcionan las teclas Enter y Shift+Enter en las conversaciones. + SHIFT + ENTER envía un mensaje, ENTER inicia una nueva línea. ENTER envía un mensaje, SHIFT + ENTER inicia una nueva línea. Grupos Recorte de mensajes en chats Recortar Comunidades + Eliminar automáticamente los mensajes con más de 6 meses de antigüedad en comunidades con más de 2000 mensajes. Nueva conversación Aún no tienes conversaciones Enter para enviar Pulsar la tecla Intro enviará el mensaje en lugar de empezar una nueva línea. + Enviar con Shift+Enter Adjuntos Revisión ortográfica Activar corrección ortográfica al escribir mensajes. @@ -292,7 +337,10 @@ Copiar Crear Creando llamada + Facturación actual + Contraseña actual Cortar + Modo oscuro ¿Estás seguro de que quieres eliminar todos los mensajes, archivos adjuntos y datos de la cuenta de este dispositivo y crear una cuenta nueva? Ocurrió un error en la base de datos.\n\nExporta tus registros de aplicación para compartirlos con fines de resolución de problemas. Si esto no funciona, reinstala {app_name} y restaura tu cuenta. ¿Estás seguro de que quieres eliminar todos los mensajes, archivos adjuntos y datos de la cuenta de este dispositivo y restaurar tu cuenta desde la red? @@ -317,6 +365,14 @@ Por favor, espere mientras se crea el grupo... Error al actualizar el grupo No tienes permiso de borrar mensajes de otros + + Eliminar archivo adjunto seleccionado + Eliminar archivos adjuntos seleccionados + + + ¿Estás seguro de querer eliminar el archivo adjunto seleccionado? El mensaje asociado también se eliminará. + ¿Estás seguro de querer eliminar los archivos adjuntos seleccionados? El mensaje asociado también se eliminará. + ¿Estás seguro de que quieres eliminar a {name} de tus contactos?\n\nEsto eliminará tu conversación, incluidos todos los mensajes y archivos adjuntos. Los mensajes futuros de {name} aparecerán como una solicitud de mensaje. ¿Estás seguro de que quieres eliminar tu conversación con {name}?\nEsto eliminará permanentemente todos los mensajes y archivos adjuntos. @@ -356,6 +412,7 @@ ¿Estás seguro de querer eliminar estos mensajes para todos? Eliminando Activar herramientas de desarrollador + Ajustes de notificaciones del dispositivo Iniciar dictado... Desaparición de mensajes El mensaje se eliminará en {time_large} @@ -391,6 +448,7 @@ {admin_name} actualizó la configuración de los mensajes que desaparecen. has actualizado la configuración de desaparición de mensajes. Descartar + Pantalla Puede ser su nombre real, un alias o cualquier otra cosa — y puede cambiarlo cuando quiera. Ingresa un nombre para mostrar Por favor, elige un nombre para mostrar @@ -402,6 +460,8 @@ Tu nombre para mostrar es visible para los usuarios, grupos y comunidades con los que interactúas. Documento Donar + Fuerzas poderosas intentan debilitar la privacidad, pero no podemos continuar esta lucha solos.\n\nDonar ayuda a mantener {app_name} seguro, independiente y en línea. + {app_name} necesita tu ayuda Hecho Descargar Descargando... @@ -431,19 +491,38 @@ {name} y tú reaccionasteis con {emoji_name} Reaccionó a tu mensaje {emoji} Activar + ¿Habilitar acceso a cámara? + Mostrar notificaciones cuando recibas mensajes nuevos. + Finalizar llamada para habilitar ¿Te está gustando {app_name}? Necesita mejoras {emoji} Está genial {emoji} Has estado usando {app_name} por un tiempo, ¿cómo va todo? Agradeceríamos mucho saber tu opinión. + Entrar + Introduce la contraseña que configuraste para {app_name} + Introduce la contraseña que usas para desbloquear {app_name} al iniciar, no tu Contraseña de recuperación + Error al comprobar el estado de {pro} Por favor, comprueba su conexión a internet e inténtelo de nuevo. Copiar error y salir Error de base de datos Algo salió mal. Por favor, inténtalo de nuevo más tarde. + Error al cargar acceso a {pro} + {app_name} no pudo buscar este ONS. Por favor, verifica tu conexión de red e inténtalo de nuevo. Ocurrió un fallo desconocido. + Este ONS no está registrado. Por favor, verifica que sea correcto e inténtalo de nuevo. + Error al reenviar la invitación a {name} en {group_name} + Error al reenviar la invitación a {name} y a {count} más en {group_name} + Error al reenviar la invitación a {name} y {other_name} en {group_name} + No se pudo reenviar promoción a {name} en {group_name} + No se pudo reenviar promoción a {name} y {count} otros en {group_name} + No se pudo reenviar promoción a {name} y {other_name} en {group_name} Descarga fallida Fallos + Comentarios + Comparte tu experiencia con {app_name} completando una breve encuesta. Archivo Archivos + Coincidir ajustes del sistema. Para siempre De: Activar pantalla completa @@ -490,6 +569,9 @@ ¿Estás seguro de que quieres salir de {group_name}? ¿Estás seguro de que deseas abandonar el grupo {group_name}?\n\nEsto eliminará a todos los miembros y borrará todo el contenido del grupo. No se pudo salir de {group_name} + {name} fue invitado a unirse al grupo. Se compartió el historial del chat de los últimos 14 días. + {name} y {count} más fueron invitados a unirse al grupo. Se compartió el historial del chat de los últimos 14 días. + {name} y {other_name} fueron invitados a unirse al grupo. Se compartió el historial del chat de los últimos 14 días. {name} ha abandonado el grupo. {name} y {count} más abandonaron el grupo. {name} y {other_name} abandonaron el grupo. @@ -501,6 +583,9 @@ {name} y {other_name} fueron invitados a unirse al grupo. y {count} más habéis sido invitados a uniros al grupo. El historial de mensajes ha sido compartido. y {other_name} fueron invitados a unirse al grupo. Se ha compartido el historial del chat. + No se pudo eliminar a {name} de {group_name} + No se pudo eliminar a {name} y {count} otros de {group_name} + No se pudo eliminar a {name} y {other_name} de {group_name} has abandonado el grupo. Miembros del grupo No hay otros miembros en este grupo. @@ -514,6 +599,7 @@ No tienes mensajes de {group_name}. ¡Envía un mensaje para iniciar la conversación! Este grupo no ha sido actualizado en más de 30 días. Puede que experimentes problemas al enviar mensajes o ver la información del grupo. Eres el único admin en {group_name}.\n\nLos miembros y la configuración del grupo no pueden ser modificados sin un admin. + Eres el único administrador en {group_name}.\n\nNo se pueden cambiar los miembros ni la configuración del grupo sin un administrador. Para salir del grupo sin eliminarlo, añade primero un nuevo administrador. Pendiente de eliminación fuiste promovido a Administrador. y {count} más fueron promovidos a Administradores. @@ -541,22 +627,32 @@ Grupo actualizado Gestionando candidatos de conexión Preguntas Frecuentes + Consulta las preguntas frecuentes de {app_name} para obtener respuestas a preguntas comunes. Ayúdanos a traducir {app_name} + Reportar Error Comparte algunos detalles para ayudarnos a resolver tu problema. Exporta tus registros, luego sube el archivo a través del Help Desk de {app_name}. Exportar registros Exporte sus registros, luego cargue el archivo a través del Help Desk de {app_name}. Guardar en el escritorio + Guarde este archivo y luego compártalo con los desarrolladores de {app_name}. Soporte + ¡Ayuda a traducir {app_name} a más de 80 idiomas! Nos encantaría conocer tu opinión Ocultar + Cambiar visibilidad de la barra de menú del sistema. ¿Estás seguro de que quieres ocultar Nota Personal de tu lista de conversaciones? Ocultar otras Imagen imágenes + Importante Teclado incógnito Solicitar modo incógnito si está disponible. Dependiendo del teclado que estés usando, tu teclado puede ignorar esta solicitud. Información Atajo no válido + + Invitar contacto + Invitar contactos + Invitación fallida Invitaciones fallidas @@ -565,8 +661,17 @@ No se ha podido enviar la invitación. ¿Quieres volver a intentarlo? No se han podido enviar las invitaciones. ¿Quieres volver a intentarlo? + + Invitar miembro + Invitar miembros + + Invita a un nuevo miembro al grupo ingresando el ID de cuenta, ONS o escaneando su código QR {icon} + Invita un nuevo miembro al grupo ingresando el ID de cuenta, ONS de tu amigo o escaneando su código QR Unirse Más tarde + Iniciar {app_name} automáticamente cuando el ordenador se encienda. + Iniciar al encender + Esta configuración está gestionada por tu sistema en Linux. Para habilitar el inicio automático, añade {app_name} a tus aplicaciones de inicio en la configuración del sistema. Saber más Abandonar Saliendo... @@ -581,6 +686,8 @@ y {other_name} se unieron al grupo. {name} y {other_name} se unieron al grupo. Te uniste al grupo. + ¿Limitar actividad en segundo plano? + Actualmente permites que {app_name} se ejecute en segundo plano para mejorar la confiabilidad de las notificaciones. Cambiar esta configuración podría resultar en notificaciones menos confiables. Previsualizar enlaces Mostrar previsualización de enlaces para las URLs soportadas. Activar vistas previas de enlaces @@ -591,6 +698,7 @@ No tendrás una privacidad completa de metadatos al enviar previsualizaciones de enlaces. Las previsualizaciones de enlaces están desactivadas {app_name} debe contactar sitios web vinculados para generar vistas previas de los enlaces que envíes y recibas.\n\nPuedes activarlos en la configuración de {app_name}. + Enlaces Cargar cuenta Cargando su cuenta Cargando ... @@ -603,9 +711,17 @@ Estado de bloqueo Toca para Desbloquear {app_name} está desbloqueado + Registros + Administrar administradores Administrar miembros + Administrar {pro} Máximo + Tal vez después Multimedia + + %1$d miembro seleccionado + %1$d miembros seleccionados + %1$d miembro %1$d miembros @@ -615,7 +731,9 @@ %1$d miembros activos Añadir Account ID o ONS + Los miembros solo pueden ser promovidos una vez que hayan aceptado la invitación para unirse al grupo. Invitar Amigos + No tienes contactos para invitar a este grupo.\nRegresa e invita miembros usando su ID de cuenta o ONS. Enviar invitación Enviar invitaciones @@ -624,10 +742,14 @@ ¿Te gustaría compartir el historial de mensajes del grupo con {name} y {count} otros? ¿Te gustaría compartir el historial de mensajes del grupo con {name} y {other_name}? Compartir historial de mensajes + Compartir historial de mensajes de los últimos 14 días Compartir solo nuevos mensajes Invitar + Miembros (No administradores) + Barra de menú Mensaje Leer más + Copiar mensaje Este mensaje está vacío. Fallo al entregar el mensaje Límite de texto. @@ -642,6 +764,7 @@ Comienza una nueva conversación ingresando el ID de la cuenta o el ONS de tu amigo. Comienza una nueva conversación ingresando el ID de la cuenta, ONS o escaneando su código QR. + Inicia una nueva conversación ingresando el ID de la cuenta de tu amigo, su ONS o escaneando su código QR {icon} Tienes un mensaje nuevo. Tienes %1$d mensajes nuevos. @@ -690,20 +813,29 @@ Mensaje demasiado largo Por favor, acorta tu mensaje a {limit} caracteres o menos. Mensaje demasiado largo + Nueva contraseña Siguiente + Próximos pasos Elija un apodo para {name}. Esto aparecerá para ti en tus conversaciones individuales y de grupo. Escriba un apodo Por favor, introduce un apodo más corto Eliminar apodo Definir apodo No + No hay miembros que no sean administradores en este grupo. Sin sugerencias + Envía mensajes de hasta 10,000 caracteres en todas las conversaciones. + Organiza los chats con conversaciones fijadas ilimitadas. Ninguno Ahora no Notas personales No tienes mensajes en Nota personal. Ocultar notas personales ¿Estás seguro de que quieres ocultar Nota personal? + AVISO: Al {action_type}, aceptas los Términos del servicio {icon} y la Política de privacidad {icon} de {app_pro} + Visualización de notificaciones + Mostrar el nombre del remitente y una vista previa del contenido del mensaje. + Mostrar solo el nombre del remitente sin ningún contenido del mensaje. Todos los mensajes Contenido de notificaciones La información que se muestra en las notificaciones. @@ -714,6 +846,7 @@ Se te notificará de nuevos mensajes de forma fiable e inmediata usando los servidores de notificaciones de Google. Se le notificará de los nuevos mensajes de forma fiable e inmediata usando los servidores de notificaciones de Huawei. Se te notificará de nuevos mensajes de forma fiable e inmediata usando los servidores de notificaciones de Apple. + Mostrar una notificación genérica de {app_name} sin el nombre del remitente ni el contenido del mensaje. Ir a la configuración de notificaciones del dispositivo Notificaciones - Todas Notificaciones - Solo Menciones @@ -721,6 +854,7 @@ {name} a {conversation_name} Puede que hayas recibido mensajes mientras se reiniciaba tu {device}. Color del LED + Reproducir un sonido cuando recibas mensajes nuevos. Solo menciones Notificaciones de mensajes Más recientes desde: {name} @@ -742,6 +876,12 @@ Inactivo Okay Activo + En tu dispositivo {device_type} + Abre esta cuenta de {app_name} en un dispositivo {device_type} con sesión iniciada en la cuenta {platform_account} con la que te registraste originalmente. Luego, cancela {pro} desde la configuración de {app_pro}. + Abre esta cuenta de {app_name} en un dispositivo {device_type} que haya iniciado sesión con la {platform_account} con la que te registraste originalmente. Luego, actualiza tu acceso {pro} desde los ajustes de {app_pro}. + En un dispositivo vinculado + En el sitio web de {platform_store} + En el sitio web de {platform} Crear cuenta ¡Cuenta creada! Iniciar sesión @@ -766,11 +906,17 @@ No pudimos reconocer este ONS. Por favor, revisa y vuelve a intentarlo. No pudimos buscar este ONS. Por favor, inténtalo de nuevo más tarde. Abrir + Abrir sitio web de {platform_store} + Abrir el sitio web de {platform} + Abrir configuración Abrir encuesta Otro + Contraseña Cambiar Contraseña + Cambiar la contraseña requerida para desbloquear {app_name}. Tu contraseña ha sido cambiada. Por favor, guárdala en un lugar seguro. Confirmar contraseña + Crear contraseña Tu contraseña actual es incorrecta. Introducir contraseña Por favor, introduzca su antigua contraseña @@ -780,9 +926,24 @@ Las contraseñas no coinciden Error al establecer la contraseña Contraseña incorrecta + Confirmar nueva contraseña Eliminar Contraseña + Eliminar la contraseña requerida para desbloquear {app_name} + Tu contraseña ha sido eliminada. Establecer Contraseña + Tu contraseña ha sido establecida. Por favor, manténgala segura. + Requerir contraseña para desbloquear {app_name} al iniciar. + Más de 12 caracteres + Incluye un número + Incluye una letra minúscula + Incluye un símbolo + Incluye una letra mayúscula + Indicador de fortaleza de contraseña + Establecer una contraseña fuerte ayuda a proteger tus mensajes y archivos adjuntos si tu dispositivo se pierde o te lo roban. + Contraseñas Pegar + Error de pago + Tu pago se ha procesado correctamente, pero hubo un error al {action_type} tu estado {pro}.\n\nVerifica tu conexión de red e inténtalo de nuevo. Cambio de permiso {app_name} necesita acceso a música y audio para enviar archivos, música y audio, pero ha sido denegado permanentemente. Toca Configuración → Permisos, y activa \"Música y audio\". {app_name} necesita usar Apple Music para reproducir archivos adjuntos de medios. @@ -794,6 +955,7 @@ Permite el acceso a la cámara para las videollamadas. La función de bloqueo de pantalla en {app_name} usa Face ID. Ejecutar en Segundo Plano + {app_name} continúa ejecutándose en segundo plano cuando cierras la ventana. {app_name} necesita acceso a la biblioteca de fotos para continuar. Puedes habilitar acceso en los ajustes de iOS. Se requiere acceso a la red local para facilitar las llamadas. Activa el permiso de \"Red local\" en Configuración para continuar. {app_name} necesita acceso a la red local para realizar llamadas de voz y video. @@ -820,25 +982,170 @@ Anclar conversación Desfijar Desanclar conversación + Y mucho más... + Nuevas funciones llegarán pronto a {pro}. Descubre lo próximo en la Hoja de ruta de {pro} {icon} Preferencias Previsualizar + Vista previa de notificación + ¡Tu acceso a {pro} está activo!\n\nTu acceso a {pro} se renovará automáticamente por otro {current_plan_length} el {date}. + ¡Tu acceso a {pro} está activo!\n\nTu acceso a {pro} se renovará automáticamente por otro\n{current_plan_length} el {date}. Cualquier cambio que hagas aquí surtirá efecto en tu próxima renovación. + Error de acceso a {pro} + Tu acceso {pro} vencerá el {date}. + Cargando acceso a {pro} + La información de tu acceso a {pro} aún se está cargando. No puedes actualizar hasta que se complete este proceso. + cargando acceso a {pro}... + No se puede conectar a la red para cargar la información de acceso a {pro}. La actualización de {pro} a través de {app_name} estará deshabilitada hasta que se restablezca la conectividad.\n\nPor favor, revisa tu conexión de red e inténtalo de nuevo. + Acceso {pro} no encontrado + {app_name} detectó que tu cuenta no tiene acceso {pro}. Si crees que se trata de un error, contacta al soporte de {app_name} para obtener ayuda. + Recuperar acceso {pro} + Renovar acceso {pro} + Actualmente, el acceso a {pro} solo puede comprarse y renovarse a través de {platform_store} o {platform_store_other}. Como estás utilizando {app_name} Escritorio, no puedes renovar desde aquí.\n\nLos desarrolladores de {app_name} están trabajando arduamente en opciones de pago alternativas para permitir a los usuarios adquirir acceso a {pro} fuera de {platform_store} y {platform_store_other}. Hoja de ruta de {pro} {icon} + Renueva en el sitio web de {platform} utilizando la cuenta {platform_account} con la que te registraste en {pro}. + Renueva tu acceso {pro} para volver a utilizar las potentes funciones beta de {app_pro}. + Acceso {pro} recuperado + {app_name} detectó y recuperó el acceso {pro} para tu cuenta. ¡Tu estado {pro} ha sido restaurado! + Como te registraste originalmente en {app_pro} a través de {platform_store}, deberás usar tu cuenta {platform_account} para actualizar tu acceso {pro}. + Actualmente, el acceso a {pro} solo se puede adquirir mediante {platform_store} o {platform_store_other}. Como estás usando {app_name} en Escritorio, no puedes actualizar a {pro} aquí.\n\nLos desarrolladores de {app_name} están trabajando arduamente en opciones de pago alternativas para permitir a los usuarios adquirir acceso a {pro} fuera de {platform_store} y {platform_store_other}. Hoja de ruta de {pro} {icon} Activado + activando + ¡Todo listo! + ¡Tu acceso a {app_pro} fue actualizado! Se te cobrará cuando {pro} se renueve automáticamente el {date}. Ya tienes ¡Adelante, sube GIFs e imágenes WebP animadas para tu imagen de perfil! + Consigue imágenes de perfil animadas y desbloquea funciones premium con {app_pro} Beta Imagen de perfil animada los usuarios pueden subir GIFs + Imágenes de perfil animadas + Establece GIFs animados e imágenes WebP como tu foto de perfil. Sube GIFs con + {pro} se renovará automáticamente en {time} + Insignia {pro} + Mostrar la insignia {app_pro} a otros usuarios + Insignias + Demuestra tu apoyo a {app_name} con una insignia exclusiva junto a tu nombre de pantalla. + + %1$s insignia %2$s enviada + %1$s insignias %2$s enviadas + + Funciones beta de {pro} + {price} Facturado anualmente + {price} Facturado mensualmente + {price} Facturado trimestralmente + ¿Quieres enviar mensajes más largos?\nEnvía más texto y desbloquea funciones premium con {app_pro} Beta + ¿Quieres más conversaciones fijadas?\nOrganiza tus chats y desbloquea funciones premium con {app_pro} Beta + ¿Quieres más de {limit} conversaciones fijadas?\nOrganiza tus chats y desbloquea funciones premium con {app_pro} Beta + Lamentamos que canceles {pro}. Esto es lo que necesitas saber antes de cancelar tu acceso a {pro}. + Cancelación + Cancelar el acceso a {pro} impedirá la renovación automática antes de que expire el acceso a {pro}. Cancelar {pro} no conlleva un reembolso. Podrás seguir usando las funciones de {app_pro} hasta que expire tu acceso a {pro}.\n\nComo te registraste originalmente en {app_pro} con tu cuenta de {platform_account}, deberás usar esa misma cuenta {platform_account} para cancelar {pro}. + Dos formas de cancelar tu acceso a {pro}: + Elige la opción de acceso a {pro} que más te convenga.\nMayor duración significa mayores descuentos. + ¿Estás seguro de que quieres eliminar tus datos de este dispositivo?\n\n{app_pro} no se puede transferir a otra cuenta. Guarda tu Contraseña de recuperación para asegurarte de que puedas restaurar tu acceso a {pro} más adelante. + ¿Estás seguro de que quieres eliminar tus datos de la red? Si continúas, no podrás restaurar tus mensajes ni tus contactos.\n\n{app_pro} no se puede transferir a otra cuenta. Guarda tu Contraseña de recuperación para asegurarte de que puedas restaurar tu acceso a {pro} más adelante. + Tu acceso {pro} ya tiene un descuento del {percent}% respecto al precio completo de {app_pro}. + Error al actualizar el estado de {pro} + Expirado + Lamentablemente, tu acceso {pro} ha vencido.\nRenuévalo para reactivar los beneficios y funciones exclusivas de {app_pro} Beta. + Vence pronto + Tu acceso {pro} vence en {time}.\nActualiza ahora para seguir disfrutando de los beneficios y funciones exclusivas de {app_pro} Beta + {pro} vence en {time} + Preguntas frecuentes de {pro} + Encuentra respuestas a preguntas frecuentes en las Preguntas Frecuentes de {app_pro}. Sube imágenes de perfil en formato GIF y WebP Chats grupales más grandes de hasta 300 miembros Y muchas funciones exclusivas más Mensajes de hasta 10.000 caracteres Fija conversaciones sin límite + ¿Quieres usar {app_name} al máximo?\nActualiza a {app_pro} Beta para acceder a muchas ventajas y funciones exclusivas. Grupo activado ¡Este grupo tiene capacidad ampliada! Puede admitir hasta 300 miembros porque un administrador del grupo tiene + + %1$s grupo actualizado + %1$s grupos actualizados + + Solicitar un reembolso es definitivo. Si se aprueba, tu acceso {pro} se cancelará de inmediato y perderás el acceso a todas las funciones de {pro}. Tamaño del archivo adjunto aumentado Mayor longitud de mensaje + Grupos más grandes + Los grupos en los que eres administrador se actualizan automáticamente para admitir hasta 300 miembros. + ¡Los chats grupales grandes (hasta 300 miembros) estarán disponibles pronto para todos los usuarios beta de Pro! + Mensajes más largos + Puedes enviar mensajes de hasta 10.000 caracteres en todas las conversaciones. + + %1$s mensaje más largo enviado + %1$s mensajes más largos enviados + Este mensaje utilizó las siguientes funciones de {app_pro}: + Con una nueva instalación + Reinstala {app_name} en este dispositivo a través de {platform_store}, restaura tu cuenta con tu Contraseña de recuperación y renueva {pro} desde la configuración de {app_pro}. + Reinstala {app_name} en este dispositivo mediante {platform_store}, restaura tu cuenta con tu Contraseña de recuperación y actualiza a {pro} desde la configuración de {app_pro}. + Por ahora, existen tres formas de renovar: + Por ahora, hay dos formas de renovar: + {percent}% de descuento + + %1$s conversación fijada + %1$s conversaciones fijadas + + Como te registraste originalmente en {app_pro} a través de la {platform_store}, deberás usar tu cuenta {platform_account} para solicitar un reembolso. + Dado que te registraste originalmente en {app_pro} a través de la {platform_store}, tu solicitud de reembolso será procesada por el Soporte de {app_name}.\n\nSolicita un reembolso pulsando el botón de abajo y completando el formulario de solicitud de reembolso.\n\nAunque el Soporte de {app_name} se esfuerza por procesar las solicitudes de reembolso en un plazo de 24-72 horas, el procesamiento puede tardar más durante periodos de alto volumen de solicitudes. + ¡Tu acceso a {app_pro} ha sido renovado! Gracias por apoyar a {network_name}. + 1 mes - {monthly_price} / mes + 3 meses - {monthly_price} / mes + 12 meses - {monthly_price} / mes + reactivando + Abre esta cuenta de {app_name} en un dispositivo {device_type} con sesión iniciada en la cuenta {platform_account} con la que te registraste originalmente. Luego, solicita un reembolso desde la configuración de {app_pro}. + Lamentamos que te vayas. Esto es lo que necesitas saber antes de solicitar un reembolso. + {platform} está procesando tu solicitud de reembolso. Normalmente esto tarda entre 24 y 48 horas. Dependiendo de su decisión, puedes notar un cambio en tu estado {pro} en {app_name}. + Tu solicitud de reembolso será gestionada por Soporte de {app_name}.\n\nSolicita un reembolso pulsando el botón de abajo y completando el formulario correspondiente.\n\nAunque el Soporte de {app_name} se esfuerza por procesar las solicitudes de reembolso dentro de 24 a 72 horas, el procesamiento podría demorar más durante períodos de alta demanda. + Tu solicitud de reembolso será gestionada exclusivamente por {platform} a través del sitio web de {platform}.\n\nDebido a las políticas de reembolso de {platform}, los desarrolladores de {app_name} no tienen la capacidad de influir en el resultado de las solicitudes de reembolso. Esto incluye si la solicitud es aprobada o rechazada, así como si se otorga un reembolso total o parcial. + Ponte en contacto con {platform} para obtener actualizaciones sobre tu solicitud de reembolso. Debido a las políticas de reembolso de {platform}, los desarrolladores de {app_name} no tienen la posibilidad de influir en el resultado de las solicitudes de reembolso.\n\nSoporte de reembolso de {platform} + Reembolso de {pro} + Los reembolsos de {app_pro} son gestionados exclusivamente por {platform} a través de {platform_store}.\n\nDebido a las políticas de reembolso de {platform}, los desarrolladores de {app_name} no tienen la posibilidad de influir en el resultado de las solicitudes de reembolso. Esto incluye si la solicitud es aprobada o rechazada, así como si se otorga un reembolso total o parcial. + ¿Quieres volver a usar fotos de perfil animadas?\nRenueva tu acceso a {pro} para desbloquear las funciones que te estás perdiendo. + Renovar {pro} Beta + Renueva tu acceso {pro} desde los ajustes de {app_pro} en un dispositivo vinculado con {app_name} instalado mediante {platform_store} o {platform_store_other}. + ¿Quieres volver a enviar mensajes más largos?\nRenueva tu acceso a {pro} para desbloquear las funciones que te estás perdiendo. + ¿Quieres volver a usar {app_name} al máximo de su potencial?\nRenueva tu acceso a {pro} para desbloquear las funciones que te has estado perdiendo. + ¿Quieres volver a fijar más de {limit} conversaciones?\nRenueva tu acceso a {pro} para desbloquear las funciones que te estás perdiendo. + ¿Quieres volver a fijar más conversaciones?\nRenueva tu acceso a {pro} para desbloquear las funciones que te estás perdiendo. + Al renovar, aceptas los Términos del servicio {icon} y la Política de privacidad {icon} de {app_pro} + renovando + Actualmente, el acceso a {pro} solo se puede comprar y renovar a través de {platform_store} o {platform_store_other}. Como instalaste {app_name} utilizando {build_variant}, no puedes renovar aquí.\n\nLos desarrolladores de {app_name} están trabajando arduamente en opciones de pago alternativas que permitan a los usuarios adquirir el acceso a {pro} fuera de {platform_store} y {platform_store_other}. Hoja de ruta de {pro} {icon} + Reembolso solicitado Envía más con + Configuración de {pro} + Comienza a usar {pro} + Tus estadísticas de {pro} + Cargando estadísticas de {pro} + Tus estadísticas de {pro} se están cargando, por favor espera. + Las estadísticas de {pro} reflejan el uso en este dispositivo y pueden mostrarse de forma diferente en dispositivos vinculados + Error de estado de {pro} + No se puede conectar a la red para comprobar tu estado de {pro}. La información mostrada en esta página podría ser inexacta hasta que se restablezca la conectividad.\n\nPor favor, revisa tu conexión de red e inténtalo de nuevo. + Cargando estado de {pro} + Tu información de {pro} se está cargando. Algunas acciones en esta página pueden no estar disponibles hasta que finalice la carga. + cargando estado de {pro} + No se pudo conectar a la red para verificar tu estado {pro}. No puedes continuar hasta que se restaure la conectividad.\n\nVerifica tu conexión de red e inténtalo de nuevo. + No se puede conectar a la red para comprobar tu estado de {pro}. No podrás actualizar a {pro} hasta que se restablezca la conectividad.\n\nPor favor, revisa tu conexión de red e inténtalo de nuevo. + No se puede conectar a la red para actualizar tu estado de {pro}. Algunas acciones en esta página se desactivarán hasta que se restablezca la conectividad.\n\nPor favor, revisa tu conexión de red e inténtalo de nuevo. + No se puede conectar a la red para cargar tu acceso actual a {pro}. Renovar {pro} desde {app_name} estará deshabilitado hasta que se restablezca la conexión.\n\nPor favor, verifica tu conexión a la red e inténtalo de nuevo. + ¿Necesitas ayuda con {pro}? Envía una solicitud al equipo de soporte. + Al {action_type}, estás {activation_type} {app_pro} mediante el protocolo {app_name}. {entity} facilitará dicha activación pero no es el proveedor de {app_pro}. {entity} no es responsable del rendimiento, disponibilidad o funcionalidad de {app_pro}. + Al actualizar, aceptas los Términos del servicio {icon} y la Política de privacidad {icon} de {app_pro} + Conversaciones fijadas ilimitadas + Organiza todos tus chats con conversaciones fijadas ilimitadas. + Tu opción de facturación actual otorga {current_plan_length} de acceso {pro}. ¿Seguro que deseas cambiar a la opción de facturación {selected_plan_length_singular}?\n\nAl actualizar, tu acceso {pro} se renovará automáticamente el {date} por {selected_plan_length} adicionales de acceso {pro}. + Tu acceso {pro} vencerá el {date}.\n\nAl actualizar, tu acceso {pro} se renovará automáticamente el {date} por {selected_plan_length} adicionales de acceso {pro}. + actualizando + Actualiza a {app_pro} Beta para acceder a muchas ventajas y funciones exclusivas. + Actualiza a {pro} desde la configuración de {app_pro} en un dispositivo vinculado con {app_name} instalado mediante {platform_store} o {platform_store_other}. + Actualmente, el acceso a {pro} solo se puede adquirir mediante {platform_store} o {platform_store_other}. Como instalaste {app_name} usando {build_variant}, no puedes actualizar a {pro} aquí.\n\nLos desarrolladores de {app_name} están trabajando arduamente en opciones de pago alternativas para permitir a los usuarios adquirir acceso a {pro} fuera de {platform_store} y {platform_store_other}. Hoja de ruta de {pro} {icon} + Por ahora, solo hay una forma de actualizar: + Por ahora, hay dos formas de actualizar: + ¡Has actualizado a {app_pro}!\nGracias por apoyar a {network_name}. + actualizando + Actualizando a {pro} + Al actualizar, aceptas los Términos del servicio {icon} y la Política de privacidad {icon} de {app_pro} + ¿Quieres sacarle más provecho a {app_name}?\nActualiza a {app_pro} Beta para una experiencia de mensajería más potente. + {platform} está procesando tu solicitud de reembolso Perfil Imagen de perfil Fallo al eliminar la foto de perfil. @@ -846,6 +1153,11 @@ Por favor, elija un archivo más pequeño. Fallo al actualizar el perfil. Promover + Los administradores podrán ver el historial de mensajes de los últimos 14 días y no podrán ser degradados ni eliminados del grupo. + + Promover miembro + Promover miembros + Promoción fallida Promociones fallidas @@ -875,6 +1187,7 @@ Recomendado Guarde su clave de recuperación para asegurarse de que no perderá acceso a su cuenta. Guarde su clave de recuperación + Usa tu contraseña de recuperación para cargar tu cuenta en nuevos dispositivos.\n\nTu cuenta no se puede recuperar sin tu contraseña de recuperación. Asegúrate de que esté guardada en un lugar seguro y no la compartas con nadie. Ingrese su clave de recuperación Ocurrió un error al intentar cargar tu contraseña de recuperación.\n\nPor favor exporta tus registros, y luego sube el archivo a través del Help Desk {app_name} para ayudar a resolver este problema. Por favor, comprueba tu clave de recuperación y vuelve a intentarlo. @@ -884,27 +1197,69 @@ Para cargar tu cuenta, introduce tu contraseña de recuperación. Ocultar clave de recuperación permanentemente Sin su clave de recuperación no puede iniciar sesión en otros dispositivos.\n\nLe recomendamos que guarde su clave de recuperación en un lugar a salvo y seguro antes de seguir. + ¿Está seguro que desea ocultar permanentemente su contraseña de recuperación en este dispositivo?\n\nEsto no se puede deshacer. Ocultar Clave de Recuperación Oculta permanentemente tu clave de recuperación en este dispositivo. Ingrese su clave de recuperación para cargar su cuenta. Si no la ha guardado, puede encontrarla en la configuración de la aplicación. + Ver contraseña de recuperación + Visibilidad de la contraseña de recuperación Esta es tu contraseña de recuperación. Si la envías a alguien, tendrá acceso completo a tu cuenta. Volver a crear grupo Rehacer + Como te registraste originalmente en {app_pro} con otra {platform_account}, deberás usar esa {platform_account} para actualizar tu acceso a {pro}. + Dos formas de solicitar un reembolso: Reduce la longitud del mensaje en {count} %1$d carácter restante %1$d caracteres restantes + Recordar más tarde Eliminar + + Eliminar miembro + Eliminar miembros + + + Eliminar miembro y sus mensajes + Eliminar miembros y sus mensajes + Fallo al eliminar la contraseña + Elimina tu contraseña actual de {app_name}. Los datos almacenados localmente se volverán a cifrar con una clave generada aleatoriamente, almacenada en tu dispositivo. + + Eliminando miembro + Eliminando miembros + + Renovar + Renovando {pro} Responder + Solicitar reembolso + Solicita un reembolso en el sitio web de {platform}, utilizando la cuenta {platform_account} con la que te registraste en {pro}. Reenviar + + Reenviar invitación + Reenviar invitaciones + + + Reenviar promoción + Reenviar promociones + + + Reenviando invitación + Reenviando invitaciones + + + Reenviando promoción + Reenviando promociones + Cargando información del país... Reiniciar Resincronizar Reintentar Límite de reseñas Parece que ya has calificado {app_name} recientemente. ¡Gracias por tus comentarios! + Ejecutar la app en segundo plano + ¿Ejecutar {app_name} en segundo plano? + Como estás usando el modo lento, te recomendamos permitir que {app_name} se ejecute en segundo plano para mejorar las notificaciones. Esto puede mejorar la consistencia de las notificaciones, aunque tu sistema aún podría limitar la actividad en segundo plano automáticamente.\n\nPuedes cambiar esto más adelante en Ajustes. Guardar Guardado Mensajes guardados @@ -913,6 +1268,8 @@ Seguridad de pantalla Notificaciones de capturas de pantalla Requerir una notificación cuando un contacto haga una captura de pantalla de un chat individual. + Ocultar la ventana de {app_name} en las capturas de pantalla tomadas en este dispositivo. + Protección contra capturas de pantalla {name} ha hecho una captura de pantalla. Buscar Buscar contactos @@ -933,6 +1290,10 @@ Enviando Enviando oferta de llamada Enviando candidatos de conexión + + Enviando promoción + Enviando promociones + Enviado: Apariencia Borrar datos @@ -953,38 +1314,56 @@ Notificaciones Permisos Privacidad + {app_pro} Beta Clave de Recuperación Configuración Definir Establecer imagen de perfil de la Comunidad + Establece una contraseña para {app_name}. Los datos almacenados localmente se cifrarán con esta contraseña. Se te pedirá que ingreses esta contraseña cada vez que se inicie {app_name}. + No se puede actualizar la configuración Debes reiniciar {app_name} para aplicar las nuevas configuraciones. Seguridad de pantalla + Inicio Compartir Invita a tu amigo a hablar contigo en {app_name} compartiendo tu ID de Cuenta con él. Comparte con tus amigos dondequiera que hables con ellos — luego mueve la conversación aquí. Hay un problema al abrir la base de datos. Por favor, reinicia la aplicación e inténtalo de nuevo. ¡Ups! Parece que no tienes una cuenta de {app_name} todavía.\n\nTendrás que crear una en la aplicación de {app_name} antes de que puedas compartir. + ¿Te gustaría compartir el historial de mensajes del grupo con este usuario? Compartir en {app_name} + Lo sentimos, {app_name} sólo admite compartir varias imágenes y vídeos a la vez + El uso compartido solo admite contenido multimedia. Los archivos que no son multimedia han sido excluidos Mostrar Mostrar todas Mostrar menos Mostrar Nota Personal ¿Estás seguro de que deseas mostrar Nota Personal en tu lista de conversaciones? + Corrector ortográfico Pegatinas (stickers) + Fortaleza + ¿Tienes problemas? Consulta los artículos de ayuda o abre un ticket con el soporte de {app_name}. Ir a la página de soporte técnico Información del Sistema: {information} Toca para reintentar Continuar Por defecto Error + Volver + Vista previa del tema El ID de cuenta de {name} es visible basándose en tus interacciones anteriores Los ID cegados se utilizan en las comunidades para reducir el spam y aumentar la privacidad + Traducir + Bandeja Intentar de nuevo Indicadores de escribiendo Ver y compartir el indicador de escritura. No disponible Deshacer Desconocido + CPU no compatible + Actualizar + Actualizar acceso {pro} + Dos formas de actualizar tu acceso {pro}: Actualizaciones de la aplicación Actualizar la información de la comunidad El nombre y la descripción de la comunidad son visibles para todos los miembros de la comunidad @@ -1000,17 +1379,25 @@ Hay una nueva versión de {app_name} disponible, toca para actualizar Hay disponible una nueva versión ({version}) de {app_name}. Actualizar información de perfil + Tu nombre visible y tu imagen de perfil son visibles en todas las conversaciones. Ir a las notas de versión Actualización de {app_name} Versión {version} Última actualización hace {relative_time} + Actualizaciones + Actualizando... + Actualizar + Actualizar {app_name} Actualizar a Subiendo Copiar URL Abrir URL Esto se abrirá en tu navegador. ¿Estás seguro de que quieres abrir está URL en tu navegador? \n\n{url} + Los enlaces se abrirán en tu navegador. Usar Modo Rápido + Cambia tu plan utilizando la cuenta {platform_account} con la que te registraste, a través del sitio web de {platform}. + A través del sitio web de {platform} Vídeo No se puede reproducir el vídeo. Ver @@ -1019,7 +1406,12 @@ Esto puede tomar unos minutos. Un momento, por favor... Advertencia + La compatibilidad con iOS 15 ha finalizado. Actualiza a iOS 16 o una versión posterior para continuar recibiendo actualizaciones de la aplicación. Ventana + Tu CPU no admite las instrucciones SSE 4.2, que son necesarias para que {app_name} procese imágenes en sistemas operativos Linux x64. Por favor, actualiza a una CPU compatible o utiliza un sistema operativo diferente. + Tu contraseña de recuperación + Zoom general + Ajusta el tamaño del texto y los elementos visuales. \ No newline at end of file diff --git a/app/src/main/res/values-b+fi+FI/strings.xml b/app/src/main/res/values-b+fi+FI/strings.xml index fc40b33622..ae0d90cde7 100644 --- a/app/src/main/res/values-b+fi+FI/strings.xml +++ b/app/src/main/res/values-b+fi+FI/strings.xml @@ -3,6 +3,7 @@ Tietoja Hyväksy Kopioi Account ID + Tilin tunnus Tilin tunnus kopioitu Kopioi Account ID ja jaa se ystävillesi, jotta he voivat lähettää sinulle viestejä. Syötä Account ID @@ -14,6 +15,12 @@ Tämä on tilitunnuksesi. Muut käyttäjät voivat skannata sen aloittaakseen keskustelun kanssasi. Alkuperäinen koko Lisää + + Lisää ylläpitäjä + Lisää ylläpitäjiä + + Syötä sen käyttäjän tilitunnus (Account ID), jonka olet ylentämässä järjestelmänvalvojaksi.\n\nJos haluat lisätä useita käyttäjiä, syötä jokainen tilitunnus pilkulla erotettuna. Samanaikaisesti voidaan määrittää enintään 20 tilitunnusta. + Järjestelmänvalvojia ei voida alentaa tai poistaa ryhmästä. Ylläpitäjiä ei voida poistaa. {name} ja {count} muuta ylennettiin ylläpitäjiksi. Ylennä ylläpitäjäksi @@ -26,7 +33,9 @@ Käyttäjän {name} ylennys epäonnistui ryhmässä {group_name} Käyttäjien {name} ja {count} muun ylennykset epäonnistuivat ryhmässä {group_name} Käyttäjien {name} ja {other_name} ylennykset epäonnistuivat ryhmässä {group_name} + Kampanjaa ei lähetetty Ylläpitoylennys on lähetetty + Kampanjan tila tuntematon Poista valvoja Poista valvojana Tässä yhteisössä ei ole ylläpitäjiä. @@ -36,10 +45,39 @@ {name} poistettiin ylläpitäjänä. {name} ja {count} muuta poistettiin ylläpitäjästä. {name} ja {other_name} poistettiin ylläpitäjästä. + + %1$d Ylläpitäjä valittu + %1$d Ylläpitäjää valittu + + + Ylennystä lähetetään + Ylennyksiä lähetetään + Admin-asetukset + Et voi muuttaa ylläpitäjäasemasi. Poistuaksesi ryhmästä, avaa keskustelun asetukset ja valitse Poistu ryhmästä. {name} ja {other_name} ylennettiin ylläpitäjiksi. + Ylläpitäjät +{count} Anonyymi + Sovelluksen kuvake + Vaihda sovelluksen kuvake ja nimi + Sovelluksen kuvakkeen ja nimen muuttaminen edellyttää, että {app_name} suljetaan. Ilmoituksissa käytetään edelleen oletuskuvaketta ja -nimeä {app_name}. + Vaihtoehtoinen sovelluskuvake ja -nimi näkyvät aloitusnäytössä ja sovellusvalikossa. + Valittu sovelluskuvake ja -nimi näkyvät aloitusnäytössä ja sovellusvalikossa. + Kuvake ja nimi + Vaihtoehtoinen sovelluskuvake näkyy aloitusnäytössä ja sovelluskirjastossa. Sovelluksen nimenä näkyy silti \"{app_name}\". + Käytä vaihtoehtoista sovelluskuvaketta + Käytä vaihtoehtoista sovelluskuvaketta ja -nimeä + Valitse vaihtoehtoinen sovelluskuvake + Kuvake + Laskin + KokouksetSE + Uutiset + Merkinnät + Osakkeet + Sää + {app_pro} Merkki + Automaattinen tumma tila Piilota valikkopalkki Kieli Valitse kielen asetus {app_name} varten. {app_name} käynnistyy uudelleen, kun muutat kieliasetuksesi. @@ -56,6 +94,7 @@ Lähennä Loitonna Liite + Liitteet Lisää liite Nimetön albumi Lataa liitteet automaattisesti @@ -117,9 +156,12 @@ Esto epäonnistui Käyttäjän eston poisto epäonnistui Poista käyttäjän esto + Syötä sen käyttäjän tilitunnus (Account ID), jonka olet poistamassa porttikiellosta. Käyttäjän esto poistettu Estä käyttäjä Käyttäjä estettiin + Syötä sen käyttäjän tilitunnus (Account ID), jolle olet asettamassa porttikieltoa. + Sokea ID Estä Lähettääksesi viestin tälle yhteystiedolle sinun tulee ensin poistaa asettamasi esto. Ei estettyjä yhteystietoja @@ -130,6 +172,7 @@ Haluatko varmasti poistaa käyttäjän {name} ja {count} muuta eston? Haluatko varmasti poistaa käyttäjän {name} ja yhden muun eston? Esto poistettiin {name} + Näytä ja hallitse estettyjä yhteystietoja. Soita {name} soitti sinulle Et voi aloittaa uutta puhelua. Lopeta ensin nykyinen puhelusi. @@ -147,15 +190,21 @@ Ääni- ja videopuhelut vaativat ilmoitusten aktivoimisen laitteen järjestelmäasetuksissa. Puheluiden käyttöoikeus tarvitaan Voit aktivoida \"Ääni- ja videopuhelut\" -käyttöoikeuden yksityisyysasetuksista. + Voit ottaa käyttöön \"Ääni- ja videopuhelut\" -käyttöoikeuden käyttöoikeusasetuksista. Yhdistetään uudelleen… Soi... {app_name} Puhelu Puhelut (beta) Ääni- ja videopuhelut Ääni- ja videopuhelut (beta) + IP-osoitteesi on näkyvissä puhelukumppanillesi ja {session_foundation}-palvelimelle beta-puheluita käytettäessä. Mahdollistaa ääni- ja videopuheluiden soiton ja vastaanoton. Soitit käyttäjälle {name} Missasit puhelun käyttäjältä {name}, koska et ole ottanut käyttöön Ääni ja videopuheluita tietosuoja-asetuksissa. + Sovellus {app_name} tarvitsee pääsyn kameraasi videopuhelujen mahdollistamiseksi, mutta tämä lupa on evätty. Et voi muuttaa kameralupia puhelun aikana.\n\nHaluatko lopettaa puhelun nyt ja ottaa kameran käyttöön, vai haluatko muistutuksen puhelun jälkeen? + Kameran käytön sallimiseksi avaa asetukset ja ota käyttöön kameran käyttöoikeus. + Viimeisimmän puhelusi aikana yritit käyttää videota, mutta et voinut, koska kameran käyttöoikeus oli aiemmin evätty. Ota kameran käyttöoikeus käyttöön avaamalla asetukset ja sallimalla Kamera-oikeus. + Kameran käyttöoikeus vaaditaan Kameraa ei löytynyt Kamera ei käytettävissä. Myönnä kameran käyttöoikeus @@ -163,7 +212,18 @@ {app_name} tarvitsee kameran käyttöoikeuden kuvien ja videoiden ottamiseksi tai QR-koodien skannaamiseksi. {app_name} tarvitsee kameran käyttöoikeuden skannatakseen QR- koodeja Peruuta + Peruuta {pro} + Peru tilaamasi {pro}-palvelu {platform}-verkkosivustolla käyttämällä {platform_account}-tiliä, jolla rekisteröidyit. + Peru tilaamasi {pro}-palvelu {platform_store}-verkkosivustolla käyttämällä {platform_account}-tiliä, jolla rekisteröidyit. + Vaihda Salasanan vaihtaminen epäonnistui + Vaihda salasanasi sovellukselle {app_name}. Paikallisesti tallennetut tiedot salataan uudelleen uudella salasanallasi. + Tarkistetaan {pro}-tilaa + Tarkistetaan {pro}-tilaasi. Voit jatkaa, kun tämä tarkistus on valmis. + Tarkistetaan {pro}-tietojasi. Jotkin toiminnot tällä sivulla voivat olla poissa käytöstä, kunnes tarkistus on valmis. + Tarkistetaan {pro}-tilaa... + Tarkistetaan {pro}-tietojasi. Et voi uusia ennen kuin tämä tarkistus on valmis. + Tarkistetaan {pro}-tilaasi. Voit päivittää {pro}-versioon, kun tämä tarkistus on valmis. Tyhjennä Tyhjennä kaikki Tyhjennä kaikki tiedot @@ -179,19 +239,29 @@ Haluatko varmasti poistaa tietosi verkosta? Jos jatkat, et voi palauttaa viestejäsi etkä yhteystietojasi. Haluatko varmasti tyhjentää laitteesi? Tyhjennä laite + Tyhjennä laite ja käynnistä uudelleen + Tyhjennä laite ja palauta Tyhjennä kaikki viestit Haluatko varmasti tyhjentää kaikki viestit keskustelustasi käyttäjän {name} kanssa laitteestasi? + Haluatko varmasti tyhjentää kaikki viestit keskustelustasi käyttäjän {name} kanssa tältä laitteelta? Haluatko varmasti tyhjentää kaikki {community_name} viestit laitteestasi? + Haluatko varmasti poistaa kaikki viestit yhteisöstä {community_name} tältä laitteelta? Tyhjennä kaikilta Tyhjennä minulta Haluatko varmasti tyhjentää kaikki {group_name} viestit? + Haluatko varmasti poistaa kaikki viestit ryhmästä {group_name}? Haluatko varmasti tyhjentää kaikki {group_name} viestit laitteestasi? + Haluatko varmasti poistaa kaikki viestit ryhmästä {group_name} tältä laitteelta? Haluatko varmasti tyhjentää kaikki Omat muistiinpanot -viestit laitteestasi? + Haluatko varmasti tyhjentää kaikki Omat muistiinpanot -viestit tältä laitteelta? + Tyhjennä tältä laitteelta Sulje + Sulje sovellus Sulje ikkuna Commit Hash: {hash} Tämä estää valitun käyttäjän pääsyn tähän Community ja poistaa kaikki hänen viestinsä. Oletko varma, että haluat jatkaa? Tämä estää valitun käyttäjän pääsyn tähän Community. Oletko varma, että haluat jatkaa? + Syötä yhteisön kuvaus Syötä yhteisön URL-osoite Virheellinen URL-osoite Tarkista yhteisön URL ja yritä uudelleen. @@ -206,15 +276,23 @@ Olet jo jäsen tässä yhteisössä. Poistu yhteisöstä Poistuminen yhteisöstä {community_name} epäonnistui + Syötä yhteisön nimi + Syötä yhteisön nimi Tuntematon Community Yhteisön URL Kopioi yhteisön URL-osoite Vahvista + Vahvista ylennys + Oletko varma? Järjestelmänvalvojia ei voida alentaa tai poistaa ryhmästä. Yhteystiedot Poista yhteystieto Haluatko varmasti poistaa yhteystiedon {name}? Uudet viestit käyttäjältä {name} saapuvat viestipyyntönä. Sinulla ei ole vielä yhteystietoja Valitse yhteystiedot + + %1$d yhteystieto valittu + %1$d Yhteistietoa valittu + Käyttäjätiedot Kamera Valitse toiminto aloittaaksesi keskustelun @@ -222,6 +300,7 @@ Viestin kirjoitus Lainatun kuvaviestin pikkukuva Luo keskustelu uuden yhteystiedon kanssa + Valitse, mitä sisältöä näytetään paikallisissa ilmoituksissa saapuvan viestin yhteydessä. Lisää kotiruudulle Lisätty kotiruudulle Ääniviestit @@ -234,12 +313,18 @@ Keskustelu poistettu Keskustelussa {conversation_name} ei ole viestejä. Syötä avain + Määritä, miten Enter- ja Shift+Enter-näppäimet toimivat keskusteluissa. + SHIFT + ENTER lähettää viestin, ENTER aloittaa uuden rivin. + ENTER lähettää viestin, SHIFT + ENTER aloittaa uuden rivin. Ryhmät Keskustelujen tiivistys Tiivistä yhteisöt + Poista automaattisesti viestit, jotka ovat vanhempia kuin 6 kuukautta, yhteisöissä joissa on yli 2000 viestiä. Uusi keskustelu Sinulla ei ole vielä yhtään keskustelua + Lähetä Enter-näppäimellä Rivinvaihdon sijaan Enter-näppäimen painallus lähettää viestin. + Lähetä painamalla Shift+Enter Kaikki media Oikeinkirjoituksen tarkistus Käytä oikolukua kirjoitettaessa viestejä. @@ -247,7 +332,14 @@ Kopioitu Kopioi Luo + Luodaan puhelua + Nykyinen laskutus + Nykyinen salasana Leikkaa + Tumma tila + Haluatko varmasti poistaa kaikki viestit, liitteet ja tilitiedot tästä laitteesta ja luoda uuden tilin? + Tietokantavirhe tapahtui.\n\nVie sovelluksen lokitiedostot ja jaa ne ongelmanratkaisua varten. Jos tämä ei onnistu, asenna {app_name} uudelleen ja palauta tilisi. + Haluatko varmasti poistaa kaikki viestit, liitteet ja tilitiedot tältä laitteelta ja palauttaa tilisi verkosta? Huomasimme, että {app_name} käynnistyy hitaasti.\n\nVoit jatkaa odottamista, viedä laitteesi lokit jaettavaksi vianmäärityksessä tai yrittää käynnistää {app_name} uudelleen. Sovellustietokanta ei ole yhteensopiva tämän {app_name} version kanssa. Asenna sovellus uudelleen ja palauta tilisi, jotta voit luoda uuden tietokannan ja jatkaa {app_name} käyttöä.\n\nVaroitus: Tämä johtaa yli kaksi viikkoa vanhojen viestien ja liitteiden menetykseen. Optimoidaan tietokanta @@ -269,16 +361,34 @@ Odota kunnes ryhmä on luotu... Ryhmän päivitys epäonnistui Sinulla ei ole oikeutta poistaa muiden viestejä + + Poista valittu liite + Poista valitut liitteet + + + Oletko varma, että haluat poistaa valitun liitteen? Liitteeseen liittyvä viesti poistetaan myös. + Oletko varma, että haluat poistaa valitut liitteet? Liitteisiin liittyvä viesti poistetaan myös. + + Haluatko varmasti poistaa henkilön {name} yhteystiedoistasi?\n\nTämä poistaa keskustelun, mukaan lukien kaikki viestit ja liitteet. Tulevat viestit henkilöltä {name} näkyvät viestipyyntönä. + Haluatko varmasti poistaa keskustelusi käyttäjän {name} kanssa?\nTämä poistaa pysyvästi kaikki viestit ja liitteet. Poista viesti Poista viestit + + Oletko varma, että haluat poistaa tämän viestin? + Oletko varma, että haluat poistaa nämä viestit? + Viesti poistettu Viestit poistettu Tämä viesti on poistettu Tämä viesti on poistettu tästä laitteesta + + Oletko varma, että haluat poistaa tämän viestin vain tältä laitteelta? + Oletko varma, että haluat poistaa nämä viestit vain tältä laitteelta? + Haluatko varmasti poistaa tämän viestin kaikilta? Poista vain tältä laitteelta Poista kaikilta laitteiltani @@ -333,6 +443,7 @@ {admin_name} päivitti katoavien viestien asetukset. Sinä päivitit katoavien viestien asetukset. Hylkää + Näyttö Nimi voi olla oikea nimesi, salanimi tai mikä tahansa muu — ja voit vaihtaa sen milloin tahansa. Syötä näyttönimi Syötä näyttönimi @@ -343,6 +454,9 @@ Aseta näyttönimi Näyttönimesi on näkyvissä käyttäjille, ryhmille ja yhteisöille, joiden kanssa olet vuorovaikutuksessa. Dokumentti + Lahjoita + Voimakkaat tahot yrittävät heikentää yksityisyytt, mutta emme voi jatkaa tätä taistelua yksin.\n\nLahjoittaminen auttaa pitämään {app_name}-sovelluksen turvallisena, riippumattomana ja verkossa. + {app_name} tarvitsee apuasi Valmis Lataa Ladataan... @@ -372,22 +486,53 @@ Sinä ja {name} reagoivat emojilla {emoji_name} Reagoi viestiisi {emoji} Ota käyttöön + Ota kamera käyttöön? + Näytä ilmoitukset, kun saat uusia viestejä. + Lopeta puhelu ottaaksesi käyttöön + Nautitko {app_name}? + Vaatii parannusta {emoji} + Se on mahtavaa {emoji} + Olet käyttänyt {app_name} vähän aikaa, miten se menee? Olisimme todella arvostaneet ajatuksiasi kuulemista. + Siirry + Anna salasana, jonka loit sovellukselle {app_name} + Anna käynnistyksessä käyttämäsi salasana {app_name}:n avaamiseksi – älä käytä palautussalasanaa. + Virhe tarkistettaessa {pro}-tilaa Tarkista Internet-yhteytesi ja yritä uudelleen. Kopioi virhe ja lopeta Tietokantavirhe + Hups, tapahtui virhe. Yritä myöhemmin uudelleen. + Virhe ladattaessa {pro} käyttöoikeutta + Sovellus {app_name} ei pystynyt etsimään tätä ONS-tunnusta. Tarkista verkkoyhteytesi ja yritä uudelleen. Tapahtui tuntematon virhe. + Tätä ONS-tunnusta ei ole rekisteröity. Tarkista, että se on oikein, ja yritä uudelleen. + Kutsun lähettäminen uudelleen käyttäjälle {name} ryhmässä {group_name} epäonnistui. + Kutsun lähettäminen uudelleen käyttäjälle {name} ja {count} muulle ryhmässä {group_name} epäonnistui. + Kutsun lähettäminen uudelleen käyttäjille {name} ja {other_name} ryhmässä {group_name} epäonnistui. + Käyttäjän {name} ylennyksen uudelleenlähetys ryhmään {group_name} epäonnistui. + Ylentämisen lähettäminen uudelleen käyttäjälle {name} ja {count} muulle ryhmässä {group_name} epäonnistui + Ylentämisen uudelleenlähetys käyttäjille {name} ja {other_name} ryhmässä {group_name} epäonnistui + Lataaminen epäonnistui Viat + Palaute + Jaa kokemuksesi sovelluksesta {app_name} vastaamalla lyhyeen kyselyyn. Tiedosto Tiedostot + Käytä järjestelmän asetuksia + Ikuisesti Lähettäjä: Vaihda kokoruututila GIF Giphy {app_name} yhdistää Giphyyn tarjotakseen hakutulokset. GIF-lähetysten yhteydessä et saa täydellistä metatietosuojaa. + Anna palautetta + {app_name} -kokemuksesi ei valitettavasti ole ollut ihanteellinen. Olisimme kiitollisia, jos voisit käyttää hetken jakaaksesi ajatuksiasi lyhyessä tutkimuksessa Ryhmään voi lisätä maksimissaan 100 jäsentä Luo ryhmä Valitse vähintään 1 ryhmän jäsen. Poista ryhmä + Haluatko varmasti poistua ryhmästä {group_name}?\n\nTämä poistaa kaikki jäsenet ja poistaa kaiken ryhmäsisällön. + Haluatko varmasti poistaa yhteisön {group_name}? + Ryhmän ylläpitäjä on poistanut ryhmän {group_name}. Et voi enää lähettää viestejä. Syötä ryhmän kuvaus Ryhmän näyttökuva on vaihdettu. Muokkaa ryhmää @@ -400,6 +545,7 @@ Käyttäjien {name} ja {count} muun kutsuminen ryhmään {group_name} epäonnistui Käyttäjien {name} ja {other_name} kutsuminen ryhmään {group_name} epäonnistui Käyttäjän {name} kutsuminen ryhmään {group_name} epäonnistui + Kutsua ei lähetetty {name} kutsui sinut liittymään ryhmään {group_name}, jossa olet ylläpitäjä. Sinut kutsuttiin liittymään uudelleen ryhmään {group_name}, jossa olet ylläpitäjä. @@ -407,6 +553,7 @@ Lähettää kutsuja Kutsu on lähetetty + Kutsun tila tuntematon Ryhmän kutsu onnistui Käyttäjillä on oltava uusin versio vastaanottaakseen kutsuja Sinut kutsuttiin liittymään ryhmään. @@ -417,6 +564,9 @@ Haluatko varmasti poistua yhteisöstä {group_name}? Haluatko varmasti poistua ryhmästä {group_name}?\n\nTämä poistaa kaikki jäsenet ja poistaa kaiken ryhmäsisällön. Poistuminen ryhmästä {group_name} epäonnistui + {name} kutsuttiin liittymään ryhmään. Viimeisten 14 päivän chat-historia jaettiin. + {name} ja {count} muuta kutsuttiin liittymään ryhmään. Viimeisten 14 päivän chat-historia jaettiin. + {name} ja {other_name} kutsuttiin liittymään ryhmään. Viimeisten 14 päivän chat-historia jaettiin. {name} poistui ryhmästä. {name} ja {count} muuta poistui ryhmästä. {name} ja {other_name} poistui ryhmästä. @@ -427,6 +577,10 @@ {name} ja {count} muuta kutsuttiin ryhmään. {name} ja {other_name} kutsuttiin ryhmään. Sinä ja {count} muuta kutsuttiin ryhmään. Keskusteluhistoria jaetaan. + Sinut ja {other_name} kutsuttiin ryhmään. Keskusteluhistoria jaettiin. + {name}-käyttäjän poistaminen ryhmästä {group_name} epäonnistui + {name} ja {count} muuta poistaminen ryhmästä {group_name} epäonnistui + {name} ja {other_name} -jäsenten poistaminen ryhmästä {group_name} epäonnistui Sinä poistuit ryhmästä. Ryhmän jäsenet Ryhmässä ei ole muita jäseniä. @@ -438,9 +592,14 @@ Ryhmän nimi on vaihdettu. Ryhmän nimi näkyy ryhmän kaikille jäsenille. Sinulla ei ole viestejä käyttäjältä {group_name}. Lähetä viesti aloittaaksesi keskustelun! + Tätä ryhmää ei ole päivitetty yli 30 päivään. Viestien lähettämisessä tai ryhmän tietojen katsomisessa saattaa esiintyä ongelmia. Olet ainoa ylläpitäjä ryhmässä {group_name}.\n\nRyhmän jäseniä ja asetuksia ei voi muuttaa ilman ylläpitäjää. + Olet ainoa järjestelmänvalvoja ryhmässä {group_name}.\n +Ryhmän jäseniä ja asetuksia ei voida muuttaa ilman järjestelmänvalvojaa. Jos haluat poistua ryhmästä sitä poistamatta, lisää ensin uusi järjestelmänvalvoja. + Odottaa poistamista Sinut ylennettiin ylläpitäjäksi. Sinä ja {count} muuta ylennettiin ylläpitäjiksi. + Sinä ja {other_name} ylennettiin ylläpitäjiksi. Haluatko poistaa {name} ryhmästä {group_name}? Haluatko poistaa {name} ja {count} muuta ryhmästä {group_name}? Haluatko poistaa {name} ja {other_name} ryhmästä {group_name}? @@ -462,26 +621,61 @@ Aseta ryhmän näyttökuva Tuntematon ryhmä Ryhmä päivitetty + Yhdistettävien ehdokkaiden käsittely UKK + Katso {app_name}:n UKK saadaksesi vastauksia yleisiin kysymyksiin. Auta kääntämään {app_name} + Ilmoita virheestä Jaa meille tietoja, jotta voimme ratkaista ongelmasi. Vie lokeja ja lataa tiedosto sitten {app_name} Tukipisteeseen. Vie lokitiedot Vie lokitiedot ja lähetä tiedosto {app_name}\'s Help Desk -palvelusta. Tallenna työpöydälle + Tallenna tämä tiedosto ja jaa se sitten {app_name}in kehittäjille. Tue + Auta kääntämään sovellusta {app_name} yli 80 kielelle! Haluaisimme kuulla mielipiteesi Piilota + Vaihda järjestelmävalikkopalkin näkyvyyttä. + Haluatko varmasti piilottaa Muistiinpanot itselle keskustelulistastasi? Piilota muut Kuva + kuvat + Tärkeä Yksityinen näppäimistö Pyydä incognito-moodi, jos saatavilla. Riippuen käyttämästäsi näppäimistöstä, pyyntösi saatetaan ohittaa. Tietoja Virheellinen pikakuvake + + Kutsu yhteystieto + Kutsu yhteystietoja + + + Kutsu epäonnistui + Kutsut epäonnistuivat + + + Kutsua ei voitu lähettää. Haluaisitko yrittää uudelleen? + Kutsuja ei voitu lähettää. Haluaisitko yrittää uudelleen? + + + Kutsu jäsen + Kutsu jäseniä + + Kutsu uusi jäsen ryhmään syöttämällä ystäväsi tilitunnus (Account ID) tai ONS-tunnus tai skannaamalla heidän QR-koodinsa {icon} + Kutsu uusi jäsen ryhmään syöttämällä ystäväsi tilitunnus (Account ID) tai ONS-tunnus tai skannaamalla heidän QR-koodinsa Liity Myöhemmin + Käynnistä {app_name} automaattisesti, kun tietokoneesi käynnistyy. + Avaa käynnistettäessä + Tämä asetus on järjestelmän hallinnoima Linuxissa. Ota automaattinen käynnistys käyttöön lisäämällä {app_name} käynnistyssovelluksiin järjestelmäasetuksissa. Lisätietoja Poistu Poistutaan... + Tämä ryhmä on nyt vain luku -tilassa. Luo tämä ryhmä uudelleen jatkaaksesi keskustelua. + Tämä ryhmä on nyt vain luku -muodossa. Pyydä ryhmän ylläpitäjää luomaan tämä ryhmä uudelleen keskustelun jatkamiseksi. + Ryhmät on päivitetty! Luo tämä ryhmä uudelleen parannetun luotettavuuden takaamiseksi. Tämä ryhmä muuttuu vain luku -tilaan {date}. + Ryhmät on päivitetty! Pyydä ryhmän ylläpitäjää luomaan tämä ryhmä uudelleen paremman luotettavuuden takaamiseksi. Tämä ryhmä muuttuu vain luku -tilaan {date}. + Keskusteluhistoriaa ei siirretä uuteen ryhmään. Voit silti tarkastella koko keskusteluhistoriaa vanhassa ryhmässäsi. {name} liittyi ryhmään. {name} ja {count} muuta liittyi ryhmään. Sinä ja {count} muuta liittyi ryhmään. @@ -498,6 +692,7 @@ Sinulla ei ole täyttä metatietosuojaa lähetettäessä linkkien esikatseluita. Linkkien esikatselu on poistettu käytöstä {app_name} täytyy ottaa yhteyttä linkitettyihin verkkosivustoihin luodakseen linkkien esikatselut lähetetyistä ja vastaanotetuista linkeistä.\n\nVoit ottaa esikatselut käyttöön {app_name} asetuksista. + Linkit Lataa tili Ladataan tiliäsi Ladataan... @@ -510,8 +705,17 @@ Lukituksen tila Avaa napauttamalla {app_name} on avattu + Lokit + Hallinnoi järjestelmänvalvojia + Hallinnoi jäseniä + Hallitse {pro} Maksimi + Ehkä myöhemmin Media + + %1$d Jäsen valittu + %1$d Jäsentä valittu + %1$d jäsen %1$d jäsentä @@ -521,7 +725,9 @@ %1$d aktiivista jäsentä Lisää Account ID tai ONS + Jäseniä voidaan ylentää vain sen jälkeen, kun he ovat hyväksyneet kutsun liittyä ryhmään. Kutsu yhteystietoja + Sinulla ei ole kontakteja, joita kutsua tähän ryhmään. Palaa takaisin ja kutsu jäseniä heidän tilitunnuksellaan (Account ID) tai ONS-tunnuksellaan. Lähetä kutsu Lähetä kutsut @@ -530,9 +736,14 @@ Haluatko jakaa ryhmien viestihistorian {name} ja {count} muun kanssa? Haluatko jakaa ryhmien viestihistorian {name} ja {other_name} kanssa? Jaa viestihistoria + Jaa viestihistoria viimeisen 14 päivän ajalta Jaa vain uudet viestit Kutsu + Jäsenet (ei-ylläpitäjät) + Valikkopalkki Viesti + Lue lisää + Kopioi viesti Viesti on tyhjä. Viestin toimitus epäonnistui Viestin suurin sallittu pituus saavutettu @@ -547,11 +758,18 @@ Aloita uusi keskustelu syöttämällä ystäväsi Tilin ID tai ONS. Aloita uusi keskustelu syöttämällä ystäväsi Tilin ID, ONS tai skannaamalla heidän QR-koodinsa. + Aloita uusi keskustelu syöttämällä ystäväsi tilitunnus (Account ID) tai ONS-tunnus tai skannaamalla heidän QR-koodinsa {icon} Sinulla on uusi viesti. Sinulla on %1$d uutta viestiä. + + Sinulla on uusi viesti ryhmässä %1$s. + Sinulla on %1$d uutta viestiä ryhmässä %2$s. + Vastataan viestiin + Et voi lähettää liitteitä ennen kuin viestipyyntösi on hyväksytty + Et voi lähettää ääniviestejä ennen kuin Viestipyyntö hyväksytään {name} kutsui sinut ryhmään {group_name}. Viestin lähetys tähän ryhmään hyväksyy ryhmäkutsun automaattisesti. Viestipyyntösi odottaa. @@ -562,6 +780,7 @@ Haluatko varmasti tyhjentää kaikki viestipyynnöt ja ryhmäkutsut? Yhteisöjen viestipyynnöt Salli viestipyynnöt yhteisökeskusteluista. + Oletko varma, että haluat poistaa tämän viestipyynnön ja siihen liittyvän yhteystiedon? Haluatko varmasti poistaa viestipyynnön? Sinulla on uusi viestipyyntö Ei odottavia viestipyyntöjä @@ -579,20 +798,38 @@ {author}: {emoji} Ääniviesti Viestit Pienennä + + Viesteissä on %1$s merkin merkkirajoitus. Sinulla on %2$d merkkiä jäljellä. + Viesteissä on %1$s merkin merkkirajoitus. Sinulla on %2$d merkkiä jäljellä. + + Viestin pituus + Olet ylittänyt tämän viestin merkkirajan. Lyhennä viestiäsi niin, että se on enintään {limit} merkkiä pitkä. + Viesti on liian pitkä + Lyhennä viestisi enintään {limit} merkkiin. + Viesti on liian pitkä + Uusi salasana Seuraava + Seuraavat vaiheet Valitse lempinimi käyttäjälle {name}. Tämä näkyy sinulle kahdenkeskisissä ja ryhmäkeskusteluissa. Syötä lempinimi Anna lyhyempi lempinimi Poista lempinimi Aseta lempinimi Ei + Tässä ryhmässä ei ole muita kuin järjestelmänvalvojajäseniä. Ei ehdotuksia + Lähetä jopa 10 000 merkin pituisia viestejä kaikissa keskusteluissa. + Järjestä keskustelusi rajattomien kiinnitettyjen keskustelujen avulla. Ei mitään Ei nyt Viestit itselleni Omissa muistiinpanoissasi ei ole viestejä. Piilota Oma muistiinpano Haluatko varmasti piilottaa Oma muistiinpano? + HUOMIO: {action_type}-toiminnolla hyväksyt {app_pro} Käyttöehdot {icon} ja Tietosuojakäytännön {icon} + Ilmoituksen näyttö + Näytä lähettäjän nimi ja viesti-esikatselu. + Näytä vain lähettäjän nimi ilman viestisisältöä. Kaikki viestit Ilmoituksen sisältö Ilmoituksissa näytettävät tiedot. @@ -601,7 +838,9 @@ Ei nimeä tai sisältöä Fast Mode Uusista viesteistä ilmoitetaan luotettavasti ja viiveettä Googlen ilmoituspalvelinten avulla. + Saat ilmoituksen uusista viesteistä luotettavasti ja välittömästi Huawein ilmoituspalvelimien avulla. Uusista viesteistä ilmoitetaan luotettavasti ja viiveettä Applen ilmoituspalvelinten avulla. + Näytä yleinen {app_name}-ilmoitus ilman lähettäjän nimeä tai viestin sisältöä. Järjestelmän ilmoitusasetukset Ilmoitukset - Kaikki Ilmoitukset - Vain maininnat @@ -609,6 +848,7 @@ {name} to {conversation_name} Olet saattanut saada viesteja laitteesi {device} käynnistyessä uudelleen. LED:in väri + Toista ääni, kun saat uusia viestejä. Vain maininnat Viesti-ilmoitukset Uusin lähettäjältä {name} @@ -616,6 +856,8 @@ Mykistä ajaksi {time_large} Poista mykistys Mykistetty + Mykistetty {time_large} ajan + Mykistetty {date_time} asti Hidastila {app_name} tarkistaa ajoittain uudet viestit taustalla. Ääni @@ -628,6 +870,12 @@ Pois Okay Käytössä + {device_type}-laitteellasi + Avaa tämä {app_name}-tili {device_type}-laitteella, joka on kirjautunut siihen {platform_account}-tiliin, jolla alun perin rekisteröidyit. Peru sitten {pro} {app_pro}-asetusten kautta. + Avaa tämä {app_name}-tili {device_type}-laitteella, joka on kirjautunut siihen {platform_account}-tiliin, jolla alun perin rekisteröidyit. Päivitä sitten {pro}-käyttöoikeutesi {app_pro}-asetuksista. + Liitetty laitteeseen + {platform_store}-sivustolla + {platform}-sivustolla Luo tili Tili luotu Minulla on tili @@ -652,33 +900,70 @@ Emme tunnistaneet tätä ONS:a. Tarkista se ja yritä uudelleen. Emme pystyneet hakemaan tätä ONS:a. Yritä myöhemmin uudelleen. Avaa + Avaa {platform_store}-sivusto + Avaa {platform}-sivusto + Avaa asetukset + Avaa mielipidekysely Muu + Salasana Vaihda salasana + Vaihda salasana, jota tarvitaan {app_name}:n avaamiseen. + Salasanasi on vaihdettu. Pidä se turvassa. Vahvista salasana + Luo salasana Nykyinen salasanasi on virheellinen. Syötä salasana Syötä nykyinen salasanasi Syötä uusi salasana Salasana voi sisältää vain kirjaimia, numeroita tai symboleja + Salasanan tulee olla {min}-{max} merkkiä pitkä Salasanat eivät täsmää Salasanan asetus epäonnistui Virheellinen salasana + Vahvista uusi salasana Poista salasana + Poista {app_name} avaukseen tarvittava salasana + Salasanasi on poistettu. Aseta salasana + Salasanasi on asetettu. Pidä se turvassa. + Vaadi salasana, jotta {app_name} voidaan avata käynnistyksen yhteydessä. + Yli 12 merkkiä + Sisältää numeron + Sisältää pienen kirjaimen + Sisältää erikoismerkin + Sisältää ison kirjaimen + Salasanan vahvuuden osoitin + Vahvan salasanan asettaminen auttaa suojaamaan viestisi ja liitteesi, jos laitteesi katoaa tai varastetaan. + Salasanat Liitä + Maksuvirhe + Maksusi on käsitelty onnistuneesti, mutta tapahtui virhe {action_type} {pro}-tilastatusi kanssa.\n\nTarkista verkkoyhteytesi ja yritä uudelleen. + Käyttöoikeuksien Muutos {app_name} tarvitsee pääsyn musiikkiin ja ääniin, jotta se voi lähettää tiedostoja, musiikkia ja ääntä, mutta pääsy on pysyvästi estetty. Napauta Asetukset → Luvat ja salli \"Musiikki ja äänet\". {app_name} tarvitsee käyttää Apple Musiikkia mediasisältöjen toistamiseen. Automaattinen päivitys Tarkista päivitykset automaattisesti käynnistäessä + Kameran käyttöoikeus tarvitaan videopuheluiden soittamiseen. Jatka vaihtamalla \"Kamera\"-käyttöoikeus päälle Asetuksista. + Kameran käyttöoikeus on tällä hetkellä käytössä. Jos haluat poistaa sen käytöstä, vaihda \"Kamera\"-käyttöoikeus pois päältä Asetuksista. {app_name} tarvitsee kameran käyttöoikeuden kuvien ja videoiden ottamiseksi, mutta oikeus on evätty pysyvästi. Napauta Asetukset → Käyttöoikeudet ja kytke \"Kamera\" päälle. + Salli kameran käyttö videopuheluita varten. Näytön lukitusominaisuus {app_name} käyttää Face ID:tä. Säilytä tehtäväpalkin ilmoitusalueella + {app_name} pysyy käynnissä taustalla, kun suljet ikkunan. {app_name} tarvitsee pääsyn valokuvakirjastoon jatkaakseen. Voit sallia käyttöoikeuden iOS:n asetuksista. + Paikallisverkkoon pääsy on tarpeen puheluiden mahdollistamiseksi. Vaihda Asetuksista \"Läheverkko\"-käyttöoikeus päälle jatkaaksesi. + {app_name} tarvitsee pääsyn paikalliseen verkkoon soittaakseen ääni- ja videopuheluja. + Paikallisverkon käyttöoikeus on tällä hetkellä käytössä. Jos haluat poistaa sen käytöstä, vaihda \"Paikallisverkko\"-käyttöoikeus pois päältä Asetuksissa. + Salli pääsy paikalliseen verkkoon helpottamaan ääni- ja videopuheluita. + Paikallisverkko Mikrofoni {app_name} tarvitsee mikrofonin käyttöoikeuden puheluiden soittamiseen ja ääniviestien lähettämiseen, mutta käyttöoikeus on evätty pysyvästi. Napauta Asetukset → Käyttöoikeudet ja ota käyttöön \"Mikrofoni\". + Mikrofonin käyttöoikeus tarvitaan puheluiden soittamiseen ja ääniviestien tallentamiseen. Jatka vaihtamalla \"Mikrofoni\"-käyttöoikeus päälle Asetuksista. Voit antaa mikrofonin käyttöoikeuden {app_name}:n yksityisyysasetuksista {app_name} tarvitsee mikrofonin käyttöoikeuden puheluiden soittamiseen ja ääniviestien nauhoittamiseen. + Mikrofonin käyttöoikeus on tällä hetkellä käytössä. Jos haluat poistaa sen käytöstä, vaihda \"Mikrofoni\"-käyttöoikeus pois päältä Asetuksista. Myönnä mikrofonin käyttöoikeus. + Salli mikrofonin käyttö puheluita ja ääniviestejä varten. {app_name} tarvitsee musiikki- ja äänioikeudet voidakseen lähettää tiedostoja, musiikkia ja ääntä. Tarvitaan käyttöoikeus {app_name} tarvitsee pääsyn valokuvakirjastoon, jotta voit lähettää valokuvia ja videoita, mutta lupa on evätty pysyvästi. Napauta Asetukset → Luvat ja laita \"Valokuvat ja videot\" päälle. @@ -686,11 +971,177 @@ {app_name} tarvitsee tallennustilan käyttöoikeuden liitteiden ja median tallentamiseksi. {app_name} tarvitsee tallennustilan käyttöoikeuden kuvien ja videoiden tallentamiseksi, mutta käyttöoikeus on evätty pysyvästi. Jatka sovellusasetuksiin, valitse \"Käyttöoikeudet\" ja ota käyttöön \"Tallennustila\". {app_name} tarvitsee tallennustilan käyttöoikeuden kuvien ja videoiden lähettämiseksi. + Sinulla ei ole kirjoitusoikeuksia tässä yhteisössä Kiinnitä Kiinnitä keskustelu Irrota Irroita keskustelu + Ja paljon muuta... + Uusia ominaisuuksia tulossa pian {pro}:lle. Tutustu tulevaan {pro} Roadmap -osuuteen {icon} + Asetukset Esikatselu + Ilmoituksen esikatselu + {pro}-oikeutesi on aktiivinen!\n\n{pro}-oikeutesi uusiutuu automaattisesti toiseksi {current_plan_length} {date}. + {pro}-käyttöoikeutesi vanhenee {date}.\n\nPäivitä {pro}-käyttöoikeutesi nyt varmistaaksesi, että se uusitaan automaattisesti ennen {pro}-käyttöoikeutesi vanhenemista. + {pro}-käyttöoikeutesi on aktiivinen!\n\n{pro}-käyttöoikeutesi uusiutuu automaattisesti uudelle \n{current_plan_length}-jaksolle {date}. Kaikki tässä tekemäsi muutokset astuvat voimaan seuraavan uusimisen yhteydessä. + {pro} Pääsyvirhe + {pro} käyttöoikeutesi vanhenee kohteessa {date}. + {pro}-käyttöoikeutta ladataan + {pro}-käyttöoikeustietosi ovat yhä latautumassa. Et voi tehdä päivitystä ennen kuin prosessi on valmis. + {pro} käyttöoikeuden lataus... + Verkon kautta ei saada yhteyttä {pro}-käyttöoikeuden tietojen lataamiseksi. {pro}-päivitys {app_name}-sovelluksen kautta ei ole käytettävissä ennen kuin yhteys on palautettu.\n\nTarkista verkkoyhteys ja yritä uudelleen. + {pro}-käyttöoikeutta ei löytynyt + {app_name} havaitsi, että tililläsi ei ole {pro}-oikeuksia. Jos uskot tämän olevan virhe, ota yhteyttä {app_name}-tukeen saadaksesi apua. + Palauta {pro}-käyttöoikeus + Uudista {pro} Pääsy + Tällä hetkellä {pro}-käyttöoikeuden voi ostaa ja uusia vain {platform_store}:n tai {platform_store_other}:n kautta. Koska käytät {app_name} Desktop -sovellusta, et voi uusia tilausta täällä.\n\n{app_name}:n kehittäjät työskentelevät kovasti vaihtoehtoisten maksutapojen parissa, jotta {pro}-käyttöoikeuden voisi jatkossa ostaa myös ilman {platform_store}:a ja {platform_store_other}:a. {pro}-suunnitelma {icon} + Uudista {pro}-käyttöoikeutesi {platform_store}-verkkosivustolla käyttämällä {platform_account}-tiliä, jolla rekisteröidyit {pro}-palveluun. + Uusimalla {pro}-käyttöoikeutesi voit jälleen käyttää tehokkaita {app_pro}-betaominaisuuksia. + {pro} Käyttöoikeus palautettu + {app_name} havaitsi ja palautti {pro}-käyttöoikeuden tilillesi. {pro}-tilasi on palautettu! + Koska rekisteröidyit alun perin {app_pro}-palveluun {platform_store}-palvelun kautta, sinun on käytettävä {platform_account}-tiliäsi päivittääksesi {pro}-käyttöoikeutesi. + Tällä hetkellä {pro}-käyttöoikeuden voi ostaa ja uusia vain {platform_store}:n tai {platform_store_other}:n kautta. Koska käytät {app_name} Desktop -sovellusta, et voi uusia {pro} tilausta täällä.\n\n{app_name}:n kehittäjät työskentelevät kovasti vaihtoehtoisten maksutapojen parissa, jotta {pro}-käyttöoikeuden voisi jatkossa ostaa myös ilman {platform_store}:a ja {platform_store_other}:a. {pro}-suunnitelma {icon} + Aktivoitu + aktivointi + Kaikki on valmista! + {app_pro}-palvelusi on päivitetty! Maksu veloitetaan automaattisesti {pro}-tilauksesi uusimisen yhteydessä {date}. + Sinulla on jo PRO. + Jatka vain ja lataa GIF- ja animoituja WebP-kuvia näyttökuvaksi! + Hanki animoituja näyttökuvia ja avaa premium-ominaisuudet {app_pro} Betan avulla + Animoidut näyttökuvat + käyttäjät voivat ladata GIF-tiedostoja + Animoidut näyttökuvat + Aseta animoidut GIF- ja WebP-kuvat näyttökuvaksesi. + Lähetä GIF-tiedostoja PRO-ominaisuudella + {pro} uusitaan automaattisesti {time} + {pro} Merkki + Näytä {app_pro}-merkki muille käyttäjille + Merkit + Näytä tukesi sovellukselle {app_name} ainutlaatuisella tunnuksella näyttönimesi vieressä. + + %1$s %2$s-merkki lähetetty + %1$s %2$s-merkkiä lähetetty + + {pro} Betatoiminnot + {price} laskutetaan vuosittain + {price} laskutetaan kuukausittain + {price} laskutetaan neljännesvuosittain + Haluatko lähettää pidempiä viestejä?\nLähetä enemmän tekstiä ja avaa premium-ominaisuudet käyttämällä {app_pro} Betaa + Haluatko lisää kiinnitettyjä keskusteluja?\nJärjestä keskustelusi ja avaa käyttöösi premium-ominaisuuksia {app_pro} Betan avulla + Haluatko enemmän kuin {limit} kiinnitettyä keskustelua?\nJärjestä keskustelusi ja avaa käyttöösi premium-ominaisuudet {app_pro} Betassa + Ikävä kuulla, että peruutat {pro}-tilauksesi. Tässä on, mitä sinun tulee tietää ennen kuin peruutat pääsysi {pro}-palveluun. + Peruutus + {pro}-tilauksen peruminen estää automaattisen uudistamisen ennen kuin {pro}-käyttö päättyy. {pro}-tilauksen peruminen ei oikeuta hyvitykseen. Voit käyttää {app_pro}-ominaisuuksia siihen saakka, kunnes {pro}-käyttö päättyy.\n\nKoska olet alun perin rekisteröitynyt {app_pro}:n käyttäjäksi {platform_account}-tililläsi, tarvitset saman {platform_account}-tilin peruuttaaksesi {pro}-tilauksesi. + Kaksi tapaa peruuttaa {pro}-käyttöoikeutesi: + {pro}-käyttöoikeuden peruminen estää automaattisen uusinnan ennen kuin {pro} vanhenee.\n\n{pro}-käyttöoikeuden peruminen ei oikeuta hyvitykseen. Voit jatkaa {app_pro}-ominaisuuksien käyttöä siihen asti, kunnes {pro}-käyttöoikeutesi päättyy. + Valitse sinulle sopiva {pro}-käyttöoikeusvaihtoehto.\nPidempi käyttöoikeus tuo suuremmat alennukset. + Haluatko varmasti poistaa tietosi tältä laitteelta?\n\n{app_pro} ei voida siirtää toiselle tilille. Tallenna palautussalasana, jotta voit palauttaa {pro}-käyttöoikeutesi myöhemmin. + Haluatko varmasti poistaa tietosi verkosta? Jos jatkat, et voi palauttaa viestejäsi tai yhteystietojasi.\n\n{app_pro} ei ole siirrettävissä toiselle tilille. Tallenna Palautussalasana varmistaaksesi, että voit palauttaa {pro}-käyttöoikeutesi myöhemmin. + {pro}-käyttöoikeutesi on jo alennettu {percent}% täydestä {app_pro}-hinnasta. + Virhe päivitettäessä {pro}-tilaa + Vanhentunut + Valitettavasti {pro}-tilisi käyttöoikeus on vanhentunut.\nUusi tilaus aktivoidaksesi uudelleen {app_pro} Betan eksklusiiviset edut ja ominaisuudet. + Vanhentuu Pian + {pro}-käyttöoikeutesi vanhenee {time} kuluttua.\nPäivitä nyt säilyttääksesi pääsyn {app_pro} Betan ainutlaatuisiin etuihin ja ominaisuuksiin. + {pro} vanhenee {time} + {pro} UKK + Löydä vastauksia yleisiin kysymyksiin {app_pro}-osion usein kysytyistä kysymyksistä. + Lataa GIF- ja WebP-animaatiokuvia profiilikuvaksi + Suuremmat ryhmäkeskustelut jopa 300 jäsenelle + Lisäksi paljon muita eksklusiivisia ominaisuuksia + Viestit jopa 10 000 merkkiä pitkiä + Kiinnitä rajattomasti keskusteluja + Haluatko hyödyntää {app_name} sovellusta täysipainoisesti?\nPäivitä {app_pro} Beta -versioon saadaksesi käyttöösi runsaasti ainutlaatuisia etuja ja ominaisuuksia. + Ryhmä Aktivoitu + Tämä ryhmä on laajennettu! Se voi nyt sisältää jopa 300 jäsentä, koska ryhmän ylläpitäjä on aktivoinut PRO-ominaisuuden + + %1$s Ryhmä päivitetty + %1$s Päivitettyä ryhmää + + Hyvityksen pyytäminen on lopullista. Jos pyyntö hyväksytään, {pro}-käyttösi peruutetaan välittömästi ja menetät pääsyn kaikkiin {pro}-ominaisuuksiin. + Suurempi liitetiedoston koko + Viestin Pituuden Lisääminen + Suuremmat ryhmät + Ryhmäsi, joissa toimit ylläpitäjänä, päivitetään automaattisesti tukemaan 300 jäsentä. + Suuremmat ryhmäkeskustelut (jopa 300 jäsentä) ovat pian saatavilla kaikille Pro Beetä käyttäville! + Pidemmät viestit + Voit lähettää jopa 10 000 merkin pituisia viestejä kaikissa keskusteluissa. + + %1$s Pidempi viesti lähetetty + %1$s Pidempiä viestejä lähetetty + + Tämä viesti käytti seuraavia {app_pro} ominaisuuksia: + Uuden asennuksen avulla + Asenna {app_name} uudelleen tälle laitteelle {platform_store}-palvelun kautta, palauta tilisi käyttämällä Palautussalasanaa ja uudista {pro} {app_pro}-asetuksista. + Asenna {app_name} uudelleen tälle laitteelle {platform_store}-palvelun kautta, palauta tilisi käyttämällä Palautussalasanaa ja päivitä versioon {pro} {app_pro}-asetuksista. + Tällä hetkellä on kolme tapaa uusia tilaus. + Tällä hetkellä on kaksi tapaa uusia tilaus. + {percent}% alennus + + %1$s Kiinnitetty keskustelu + %1$s Kiinnitettyä keskustelua + + Koska rekisteröidyit alun perin palveluun {app_pro} {platform_store}-palvelun kautta, sinun tulee käyttää {platform_account}-tiliäsi pyytääksesi hyvitystä. + Koska rekisteröidyit alun perin palveluun {app_pro} {platform_store}-kautta, hyvityspyyntösi käsittelee {app_name}-tuki.\n\nPyydä hyvitystä napsauttamalla alla olevaa painiketta ja täyttämällä hyvityspyynnön lomake.\n\n{app_name}-tuki pyrkii käsittelemään hyvityspyynnöt 24–72 tunnin kuluessa, mutta käsittely voi kestää kauemmin suuren pyynnön määrän aikana. + Käyttöoikeutesi {app_pro} on uusittu! Kiitos, että tuet {network_name}. + 1 kuukausi – {monthly_price} / kuukausi + 3 kuukautta – {monthly_price} / kuukausi + 12 kuukautta – {monthly_price} / kuukausi + aktivoidaan uudelleen + Avaa tämä {app_name}-tili {device_type}-laitteella, joka on kirjautunut siihen {platform_account}-tiliin, jolla alun perin rekisteröidyit. Pyydä sitten hyvitystä {app_pro}-asetusten kautta. + Ikävää, että olet lähdössä. Tässä on, mitä sinun tulee tietää ennen hyvityspyynnön tekemistä. + {platform} käsittelee nyt hyvityspyyntöäsi. Tämä kestää yleensä 24–48 tuntia. Päätöksestä riippuen {pro}-tilasi voi muuttua sovelluksessa {app_name}. + {app_name}-tuki käsittelee hyvityspyyntösi.\n\nPyydä hyvitystä painamalla alla olevaa painiketta ja täyttämällä hyvityspyynnön lomake.\n\nVaikka {app_name}-tuki pyrkii käsittelemään hyvityspyynnöt 24–72 tunnin kuluessa, käsittelyajat voivat olla pidempiä ruuhka-aikoina. + Hyvityspyyntösi käsittelee yksinomaan {platform} {platform} -verkkosivuston kautta.\n\n{platform}:n hyvityskäytäntöjen vuoksi {app_name}-kehittäjät eivät voi vaikuttaa hyvityspyynnön lopputulokseen. Tämä koskee sekä pyynnön hyväksymistä tai hylkäämistä että sitä, myönnetäänkö hyvitys kokonaan vai osittain. + Ota yhteyttä tahoon {platform} saadaksesi lisätietoja hyvityspyynnöstäsi. Koska {platform} määrittää hyvityskäytännöt, {app_name}-kehittäjillä ei ole mahdollisuutta vaikuttaa hyvityspyyntöjen lopputulokseen.\n\n{platform} hyvitystuki + Hyvitetään {pro} + Hyvitysten käsittelystä sovellukselle {app_pro} vastaa yksinomaan {platform} {platform_store}-palvelun kautta.\n\n{platform}:n hyvityskäytäntöjen vuoksi {app_name}-sovelluksen kehittäjillä ei ole mahdollisuutta vaikuttaa hyvityspyyntöjen lopputulokseen. Tämä koskee sekä pyynnön hyväksymistä tai hylkäämistä että sitä, myönnetäänkö hyvitys kokonaan vai osittain. + Haluatko käyttää animoituja näyttökuvia uudelleen?\nUusi {pro}-käyttöoikeutesi avataksesi ominaisuudet, joista olet jäänyt paitsi. + Uudista {pro} Beta + Uudista {pro}-käyttöoikeutesi {app_pro}-asetuksista yhdistetyllä laitteella, johon on asennettu {app_name} {platform_store}- tai {platform_store_other}-kaupan kautta. + Haluatko lähettää jälleen pidempiä viestejä?\nUusi {pro}-käyttöoikeutesi avataksesi ne ominaisuudet, joista olet jäänyt paitsi. + Haluatko käyttää {app_name}-sovelluksen maksimaalista potentiaalia uudelleen?\nUusi {pro}-käyttöoikeutesi avataksesi ne ominaisuudet, joista olet jäänyt paitsi. + Haluatko kiinnittää enemmän kuin {limit} keskustelua uudelleen?\nUusi {pro}-käyttöoikeus avataksesi ne ominaisuudet, joista olet jäänyt paitsi. + Haluatko kiinnittää enemmän keskusteluja uudelleen?\nUusi {pro}-käyttöoikeutesi avataksesi ne ominaisuudet, joista olet jäänyt paitsi. + Uusimalla hyväksyt {app_pro} Käyttöehdot {icon} ja Tietosuojakäytännön {icon} + uusiminen + Tällä hetkellä {pro}-käyttöoikeuden voi ostaa ja uusia vain {platform_store}:n tai {platform_store_other}:n kautta. Koska asensit {app_name}-sovelluksen käyttäen versiota {build_variant}, et voi uusia käyttöoikeutta tässä.\n\n{app_name}:n kehittäjät työskentelevät kovasti vaihtoehtoisten maksutapojen parissa, jotta käyttäjät voivat ostaa {pro}-käyttöoikeuden myös ilman {platform_store}:a ja {platform_store_other}:a. {pro}-suunnitelma {icon} + Hyvitystä pyydetty + Lähetä enemmän Prolla + {pro}-asetukset + Aloita {pro}-version käyttö + {pro} -tilastosi + {pro}-tilastot latautuvat + {pro}-tilastojasi ladataan, odota hetki. + {pro} -tilastot heijastavat tämän laitteen käyttöä ja voivat näyttää erilaisilta liitetyissä laitteissa + {pro}-tilan virhe + Verkkoyhteyttä ei voida muodostaa {pro}-tilan tarkistamiseksi. Tällä sivulla näytetty tieto saattaa olla virheellistä, kunnes yhteys on palautettu.\n\nTarkista verkkoyhteys ja yritä uudelleen. + {pro}-tila latautuu + {pro}-tietojasi ladataan. Jotkin toiminnot tällä sivulla eivät ehkä ole käytettävissä ennen kuin lataus on valmis. + {pro}-tila latautuu + Verkkoyhteyttä ei voida muodostaa {pro}-tilan tarkistamiseksi. Et voi jatkaa ennen kuin yhteys on palautettu.\n\nTarkista verkkoyhteys ja yritä uudelleen. + Verkkoyhteyttä ei voida muodostaa {pro}-tilan tarkistamiseksi. Et voi päivittää {pro}-versioon ennen kuin yhteys on palautettu.\n\nTarkista verkkoyhteys ja yritä uudelleen. + Verkkoyhteyttä ei voida muodostaa {pro}-tilan päivittämiseksi. Jotkin tämän sivun toiminnoista poistetaan käytöstä, kunnes yhteys on palautettu.\n\nTarkista verkkoyhteys ja yritä uudelleen. + Verkon kautta ei saada yhteyttä nykyisen {pro} käyttöoikeuden lataamiseksi. {pro}-tilauksen uusiminen sovelluksen {app_name} kautta on poissa käytöstä, kunnes yhteys on palautettu.\n\nTarkista verkkoyhteys ja yritä uudelleen. + Tarvitsetko apua tuotteessa {pro}? Lähetä tukipyyntö tukitiimille. + Toimimalla muodossa {action_type}, {activation_type} {app_pro} käyttäen {app_name}-protokollaa. {entity} helpottaa tätä aktivointia, mutta ei ole {app_pro}:n tarjoaja. {entity} ei ole vastuussa {app_pro}:n suorituskyvystä, saatavuudesta tai toiminnallisuudesta. + Päivittämällä hyväksyt {app_pro} Käyttöehdot {icon} ja Tietosuojakäytännön {icon} + Rajattomasti kiinnityksiä + Järjestä kaikki keskustelusi rajattomien kiinnitettyjen keskustelujen avulla. + Nykyinen laskutusvaihtoehtosi sisältää {current_plan_length} {pro}-käyttöoikeutta. Haluatko varmasti vaihtaa {selected_plan_length_singular} laskutusvaihtoehtoon?\n\nPäivittämällä {pro}-käyttöoikeutesi uusiutuu automaattisesti {date} ja saat lisää {selected_plan_length} {pro}-käyttöoikeutta. + {pro}-käyttöoikeutesi vanhenee {date}.\n\nPäivittämällä {pro}-käyttöoikeutesi uusiutuu automaattisesti {date} ja jatkuu {pro} {selected_plan_length} ajan. + päivitetään + Päivitä {app_pro} Betaan päästäksesi käsiksi latauksiin eksklusiivisista eduista ja ominaisuuksista. + Päivitä {pro}-versioon {app_pro}-asetuksista yhdistetyllä laitteella, johon on asennettu {app_name} joko {platform_store}- tai {platform_store_other}-kaupan kautta. + Tällä hetkellä {pro}-käyttöoikeuden voi ostaa vain {platform_store}:n tai {platform_store_other}:n kautta. Koska asensit sovelluksen nimeltä {app_name} käyttäen versiota {build_variant}, et voi päivittää {pro}-käyttöoikeuteen tässä.\n\n{app_name}:n kehittäjät työskentelevät kovasti vaihtoehtoisten maksutapojen parissa, jotta käyttäjät voivat ostaa {pro}-käyttöoikeuden myös ilman {platform_store}:a ja {platform_store_other}:a. {pro}-suunnitelma {icon} + Tällä hetkellä on vain yksi tapa päivittää. + Tällä hetkellä on kaksi tapaa päivittää: + Olet päivittänyt kohteeseen {app_pro}!\nKiitos, että tuit sovellusta {network_name}. + päivitetään + Päivitetään versioon {pro} + Päivittämällä hyväksyt {app_pro} Käyttöehdot {icon} ja Tietosuojakäytännön {icon} + Haluatko saada enemmän irti sovelluksesta {app_name}?\nPäivitä {app_pro} Beta -versioon saadaksesi tehokkaamman viestintäkokemuksen. + {platform} käsittelee hyvityspyyntöäsi Profiili Näyttökuva Näyttökuvan poisto ei onnistunut. @@ -698,6 +1149,19 @@ Valitse pienempi tiedosto. Profiilia ei voitu päivittää. Ylennä + Ylläpitäjät voivat nähdä viimeiset 14 päivää viestihistoriaa eikä heitä voida alentaa tai poistaa ryhmästä. + + Ylennä jäsen + Ylennä jäseniä + + + Ylentäminen epäonnistui + Ylennykset epäonnistuivat + + + Ylennystä ei voitu toteuttaa/soveltaa. Haluaisitko yrittää uudelleen? + Ylennyksiä ei voitu toteuttaa/soveltaa. Haluaisitko yrittää uudelleen? + QR-koodi Tämä QR-koodi ei sisällä tilitunnusta Tämä QR-koodi ei sisällä palautussalasanaa @@ -706,14 +1170,22 @@ Ystävät voivat lähettää sinulle viestejä skannaamalla QR-koodisi. Lopeta {app_name} Lopeta + Arvostele {app_name}? + Arvostele Sovellus + Hienoa, että pidät sovelluksesta {app_name}. Jos sinulla on hetki aikaa, arvostelumme {storevariant}:ssa auttaa muita löytämään yksityisen ja turvallisen viestinnän! Luettu Lukukuittaukset Näytä lukukuittaukset kaikille lähettämillesi ja vastaanottamillesi viestille. Vastaanotettu: + Vastattu puheluun + Saapuva puhelutarjous + Vastaanotetaan esitarjousta Suositeltu Tallenna palautussalasanasi varmistaaksesi, ettet menetä pääsyä tilillesi. Tallenna palautussalasanasi + Käytä palautussalasanaasi ladataksesi tilisi uusiin laitteisiin.\n\nTiliäsi ei voida palauttaa ilman palautussalasanaa. Varmista, että se on tallessa turvallisessa paikassa — äläkä jaa sitä kenellekään. Syötä palautussalasana + Virhe ilmeni yritettäessä ladata palautussalasanaasi.\n\nOle hyvä ja vie lokitiedostosi ja lähetä tiedosto {app_name}-sovelluksen tukipalveluun tämän ongelman ratkaisemiseksi. Tarkista palautuslauseesi ja yritä uudelleen. Jotkin palautuslausekkeesi sanat ovat virheelliset. Tarkista ja yritä uudelleen. Syöttämäsi palautusavain on liian lyhyt. Tarkista ja yritä uudelleen. @@ -721,19 +1193,66 @@ Lataa tilisi syöttämällä palautussalasanasi. Piilota Recovery Password pysyvästi Ilman palautussalasanaasi et voi ladata tiliäsi uusille laitteille. \n\nSuosittelemme vahvasti, että tallennat palautussalasanasi turvalliseen paikkaan ennen jatkamista. + Haluatko varmasti piilottaa palautussalasanasi pysyvästi tässä laitteessa?\n\nTätä toimintoa ei voi perua. Piilota Recovery Password Piilota palautuslause pysyvästi tällä laitteella. Syötä palautussalasanasi ladataksesi tilisi. Jos et ole tallentanut sitä, löydät sen sovelluksen asetuksista. + Näytä palautussalasana + Palautussalasanan näkyvyys Tämä on palautussalasanasi. Jos lähetät sen jollekulle, he saavat täyden pääsyn tilillesi. + Luo ryhmä uudelleen Tee uudelleen + Koska rekisteröidyit alun perin {app_pro}-palveluun eri {platform_account}-tilin kautta, sinun on käytettävä kyseistä {platform_account}-tiliä päivittääksesi {pro}-käyttöoikeutesi. + Kaksi tapaa pyytää hyvitystä: + Lyhennä viestiä {count} merkkiä + + %1$d merkki jäljellä + %1$d merkkiä jäljellä + + Muistuta myöhemmin Poista + + Poista jäsen + Poista jäsenet + + + Poista jäsen ja hänen viestinsä + Poista jäseniä ja heidän viestejään + Salasanan poisto epäonnistui + Poista nykyinen salasanasi sovellukselle {app_name}. Paikallisesti tallennettu data salataan uudelleen satunnaisesti luodulla avaimella, joka tallennetaan laitteellesi. + + Jäsenen poistaminen + Jäsenten poistaminen + + Uusi tilaus + Uusitaan {pro} Vastaa + Pyydä hyvitystä + Pyydä hyvitystä {platform}in verkkosivustolta käyttämällä {platform_account}-tiliä, jolla rekisteröidyit {pro}-palveluun. Lähetä uudelleen + + Lähetä kutsu uudelleen + Lähetä kutsut uudelleen + + + Lähetä tarjous Uudelleen + Lähetä tarjoukset Uudelleen + + + Kutsua lähetetään uudelleen + Kutsuja lähetetään uudelleen + + + Tarjousta lähetetään + Tarjouksia lähetetään + Ladataan maatietoja... Käynnistä uudelleen Synkronoi uudelleen Yritä uudelleen + Arvosteluraja + Näyttää siltä, että olet jo arvostellut {app_name} viime aikoina, kiitos palautteestasi! Tallenna Tallennettu Tallennetut viestit @@ -742,6 +1261,8 @@ Näytön suojaus Ilmoita kuvankaappauksesta Vaadi ilmoitus, kun yhteystieto ottaa kuvankaappauksen kahdenkeskisestä keskustelusta. + Piilota {app_name}-ikkuna tämän laitteen kuvakaappauksissa. + Kuvakaappauksen suojaus {name} otti kuvakaappauksen. Hae Etsi yhteystietoja @@ -757,8 +1278,15 @@ Etsitään... Valitse Valitse kaikki + Valitse sovelluskuvake Lähetä Lähetetään + Lähetetään puhelutarjousta + Lähetetään yhteysehdoituksia + + Lähetetään tarjousta + Lähetetään tarjouksia + Lähetetty: Ulkoasu Tyhjennä tiedot @@ -766,55 +1294,115 @@ Tuki Kutsu ystäviä Viestipyynnöt + Nykyinen {token_name_short}-hinta + Viestit lähetetään käyttäen verkkoa {network_name}. Verkko koostuu solmuista, joita kannustetaan {token_name_long} avulla ja mikä pitää {app_name}-sovelluksen hajautettuna ja turvallisena. Lue lisää {icon} + Lisätietoja stakingista + Markkina-arvo + {app_name}-solmut suojaavat viestejäsi + {app_name}-solmut swarmissasi + {token_name_long} on nyt käytössä! Tutustu uuteen {network_name}-osioon Asetuksissa nähdäksesi, miten {token_name_long} toimii Sessionin taustalla. + Verkko suojattu tahon: + Kun panostat {token_name_long} -tokeneita verkon turvallisuuden varmistamiseksi, saat palkkioita {token_name_short}-tokeneina {staking_reward_pool}-palkkiopoolista. + Uusi Ilmoitukset Käyttöoikeudet Yksityisyys + {app_pro} Beta Recovery Password Asetukset Aseta + Aseta yhteisön profiilikuva + Aseta salasana sovellukselle {app_name}. Paikallisesti tallennetut tiedot salataan tällä salasanalla. Sinua pyydetään syöttämään tämä salasana aina, kun {app_name} käynnistyy. + Asetusta ei voi päivittää Sinun on käynnistettävä {app_name} uudelleen, jotta uudet asetuksesi tulevat voimaan. Näytön suojaus + Käynnistys Jaa Kutsu ystäväsi keskustelemaan kanssasi {app_name}-sovelluksessa jakamalla heille tilisi ID:n. Jaa ystävien kanssa missä yleensä puhut heidän kanssaan - siirrä sitten keskustelu tänne. Ongelma avattaessa tietokantaa. Käynnistä sovellus uudelleen ja yritä uudelleen. + Hups! Näyttää siltä, ettei sinulla vielä ole {app_name}-tiliä.\n\nSinun täytyy luoda tili {app_name}-sovelluksessa ennen kuin voit jakaa. + Haluatko jakaa ryhmän viestihistorian tämän käyttäjän kanssa? Jaa {app_name} Näytä Näytä kaikki Näytä vähemmän + Näytä Oma muistiinpano + Haluatko varmasti näyttää Muistiinpanot itselle keskustelulistassasi? + Oikeinkirjoituksen tarkistus Tarrat + Vahvuus + Ongelmia? Tutustu ohjeartikkeleihin tai ota yhteyttä {app_name} -tukeen. Siirry tukisivulle Järjestelmän tiedot: {information} + Napauta yrittääksesi uudelleen Jatka Oletus Virhe + Palaa + Teeman esikatselu + {name}:n Tilitunnus näkyy aiempien vuorovaikutustesi perusteella + Yhteisöissä käytetään sokeita tunnisteita roskapostin vähentämiseksi ja yksityisyyden lisäämiseksi + Käännä + Ilmaisinalue Yritä uudelleen Kirjoitusindikaattorit Näe ja jaa kirjoitusindikaattorit. + Ei saatavilla Kumoa Tuntematon + Suoritinta ei tueta + Päivitä + Päivitä {pro}-käyttöoikeus + Kaksi tapaa päivittää {pro}-käyttöoikeutesi: Sovellusten päivitykset + Päivitä yhteisön tiedot + Yhteisön nimi ja kuvaus ovat näkyvissä kaikille yhteisön jäsenille + Anna lyhyempi yhteisön kuvaus. + Syötä lyhyempi yhteisön nimi Päivitys asennettu, klikkaa käynnistääksesi uudelleen. Päivitetään latausta: {percent_loader}% Päivitys epäonnistui {app_name} päivitys epäonnistui. Mene osoitteeseen {session_download_url} ja asenna uusi versio manuaalisesti. Ota yhteyttä tukipalveluumme ja ilmoita tästä ongelmasta. + Päivitä ryhmän tiedot + Ryhmän nimi ja kuvaus ovat näkyvissä kaikille ryhmän jäsenille. + Anna lyhyempi ryhmäkuvaus. Uusi versio {app_name} on saatavilla, napauta päivittääksesi + Uusi versio ({version}) sovelluksesta {app_name} on saatavilla. + Päivitä profiilin tiedot + Näyttönimesi ja profiilikuvasi näkyvät kaikissa keskusteluissa. Siirry julkaisutietoihin {app_name} Päivitys Versio {version} + Viimeksi päivitetty {relative_time} sitten + Päivitykset + Päivitetään... + Päivitä + Päivitä {app_name} + Päivitä Pro-versioon Ladataan Kopioi URL Avataanko URL? Tämä avautuu selaimessasi. Haluatko varmasti avata tämän URL-osoitteen selaimessa?\n\n{url} + Linkit avautuvat selaimessasi. Käytä nopeaa tilaa + Vaihda suunnitelmasi käyttämällä {platform_account}-tiliä, jolla rekisteröidyin, {platform}-verkkosivuston kautta. + {platform}-sivuston kautta Video Videon toisto epäonnistui. Näytä + Näytä vähemmän + Näytä lisää Tämä saattaa viedä hetken. Hetkinen, ole hyvä... Varoitus + Tuki iOS 15:lle on päättynyt. Päivitä iOS 16:een tai uudempaan saadaksesi edelleen sovelluspäivityksiä. Ikkuna Kyllä Sinä + Suoritin ei tue SSE 4.2 -ohjeita, joita {app_name} vaatii Linux x64 -käyttöjärjestelmissä kuvien käsittelemiseksi. Päivitä yhteensopivaan suoritinmalliin tai käytä toista käyttöjärjestelmää. + Palautussalasanasi + Zoomauskerroin + Säädä tekstin ja visuaalisten elementtien kokoa. \ No newline at end of file diff --git a/app/src/main/res/values-b+fr+FR/strings.xml b/app/src/main/res/values-b+fr+FR/strings.xml index 65cafef92c..48397f3ddc 100644 --- a/app/src/main/res/values-b+fr+FR/strings.xml +++ b/app/src/main/res/values-b+fr+FR/strings.xml @@ -19,6 +19,7 @@ Ajouter un administrateur Ajouter des administrateurs + Ajouter un administrateur Entrez l\'identifiant du compte de l\'utilisateur que vous souhaitez promouvoir en administrateur.\n\nPour ajouter plusieurs utilisateurs, saisissez chaque identifiant de compte séparé par une virgule. Vous pouvez spécifier jusqu\'à 20 identifiants à la fois. Les admins ne peuvent pas être rétrogradés ou retirés du groupe. Les administrateurs ne peuvent pas être supprimés. @@ -57,6 +58,7 @@ Vous ne pouvez pas modifier votre statut d’administrateur. Pour quitter le groupe, ouvrez les paramètres de la conversation et sélectionnez Quitter le groupe. {name} et {other_name} ont été promus en tant qu\'administrateurs. Administrateurs + Autoriser +{count} Anonyme Icône de l’app @@ -173,6 +175,7 @@ Êtes-vous sûr de vouloir débloquer {name} et 1 autre ? Débloqué {name} Afficher et gérer les contacts bloqués. + Aucun navigateur trouvé pour ouvrir cette URL, essayez plutôt de copier l\'URL Appeler {name} vous a appelé·e Vous ne pouvez pas démarrer un nouvel appel. Terminez votre appel en cours d\'abord. @@ -213,12 +216,12 @@ {app_name} a besoin d\'accéder à l\'appareil photo pour scanner les codes QR Annuler Résilier l\'accès {pro} - Annuler l’abonnement {pro} Annulez sur le site Web de {platform}, en utilisant le compte {platform_account} avec lequel vous vous êtes inscrit à {pro}. Annulez sur le site Web de {platform_store}, en utilisant le compte {platform_account} avec lequel vous vous êtes inscrit à {pro}. Modifier Échec de changement de mot de passe Changez votre mot de passe pour {app_name}. Les données stockées localement seront re-chiffrées avec votre nouveau mot de passe. + Modifier le paramètre Vérification du statut {pro} Vérification de votre statut {pro}. Vous pourrez continuer une fois cette vérification terminée. Vérifications de vos informations {pro}. Certaines actions de cette page peuvent être indisponible jusqu\'à la fin de la vérification. @@ -409,6 +412,7 @@ Êtes-vous certain·e de vouloir supprimer ces messages pour tout le monde? Suppression Afficher/masquer les outils pour développeurs + Paramètres de notification de l\'appareil Démarrer la dictée... Messages éphémères Le message s\'effacera dans {time_large} @@ -456,6 +460,8 @@ Votre nom d\'affichage est visible par les utilisateurs, les groupes et les communautés avec lesquels vous interagissez. Document Faire un don + Des forces importantes cherchent à affaiblir la vie privée, mais nous ne pouvons poursuivre ce combat seuls.\n\nFaire un don permet à {app_name} de rester sécurisé, indépendant et accessible. + {app_name} a besoin de votre aide Terminé Télécharger Téléchargement… @@ -507,6 +513,8 @@ Échec du renvoi de l\'invitation à {name} dans {group_name} Échec du renvoi de {name} et de {count} autres dans {group_name} Échec du renvoi de l\'invitation à {name} et {other_name} dans {group_name} + Échec de l\'envoi de la promotion à {name} dans {group_name} + Échec de l\'envoi de la promotion à {name} et à {count} autres dans {group_name} Échec du renvoi de l\'invitation à {name} et {other_name} dans {group_name} Échec du téléchargement Échecs @@ -575,6 +583,9 @@ {name} et {other_name} ont été invités à rejoindre le groupe. Vous et {count} autres avez été invité·e·s à rejoindre le groupe. L\'historique de discussion a été partagé. Vous et {other_name} avez été invité·e·s à rejoindre le groupe. L\'historique de discussion a été partagé. + Échec de la suppression de {name} du groupe {group_name} + Échec de la suppression de {name} et de {count} autres du groupe {group_name} + Échec de la suppression de {name} et {other_name} du groupe {group_name} Vous avez quitté le groupe. Membres du groupe Il n\'y a pas d\'autres membres dans ce groupe. @@ -655,6 +666,7 @@ Inviter des membres Invitez un nouveau membre au groupe en entrant l\'ID de compte, l\'ONS ou en scannant le code QR {icon} de ce membre + Invitez un nouveau membre au groupe en entrant l\'ID de compte, l\'ONS ou en scannant le code QR de ce membre Rejoindre Plus tard Lancer automatiquement {app_name} au démarrage de votre ordinateur. @@ -674,6 +686,8 @@ Vous et {other_name} avez rejoint le groupe. {name} et {other_name} ont rejoint le groupe. Vous avez rejoint le groupe. + Limiter l\'activité en arrière-plan? + Vous autorisez actuellement {app_name} à s\'exécuter en arrière-plan pour améliorer la fiabilité des notifications. Modifier ce paramètre pourrait rendre les notifications moins fiables. Aperçus des liens Afficher les aperçus de lien pour les URL supportées. Activer les aperçus de lien @@ -702,6 +716,7 @@ Gérer Membres Gérer {pro} Maximale + Peut-être plus tard Médias %1$d Membre sélectionné @@ -716,6 +731,7 @@ %1$d membres actifs Ajouter un ID de compte ou ONS + Les membres ne peuvent être promus qu\'après avoir accepté une invitation à rejoindre le groupe. Inviter des amis Vous n’avez aucun contact à inviter dans ce groupe.\n Revenez en arrière et invitez des membres en utilisant leur identifiant de compte ou leur ONS. @@ -726,6 +742,7 @@ Voulez-vous partager l\'historique des messages de groupe avec {name} et {count} autres? Voulez-vous partager l\'historique des messages de groupe avec {name} et {other_name}? Partager l\'historique des messages + Partager l\'historique des messages des 14 derniers jours Partager seulement les nouveaux messages Inviter Membres (non-administrateurs) @@ -815,6 +832,7 @@ Vous n\'avez aucun message dans Note à mon intention. Cacher la Note à soi-même Êtes-vous sûr de vouloir masquer Note à moi-même ? + VEUILLEZ NOTER: En {action_type}, vous acceptez les Conditions d\'utilisation {icon} et la Politique de confidentialité {icon} de {app_pro} Affichage des notifications Afficher le nom de l\'expéditeur et un aperçu du contenu du message. Afficher uniquement le nom de l\'expéditeur sans aucun contenu du message. @@ -924,6 +942,8 @@ Définir un mot de passe robuste permet de protéger vos messages et pièces jointes en cas de perte ou de vol de votre appareil. Mots de passe Coller + Erreur de paiement + Votre paiement a été traité avec succès, mais une erreur est survenue lors de la {action_type} de votre statut {pro}.\n\nVeuillez vérifier votre connexion réseau et réessayer. Changement de permission {app_name} a besoin d\'un accès à la musique et à l\'audio pour envoyer des fichiers, de la musique et de l\'audio, mais il a été refusé définitivement. Appuyez sur Paramètres → Autorisations, puis activez \"Musique et audio\". {app_name} doit accéder à Apple Music pour lire les pièces jointes multimédias. @@ -989,10 +1009,12 @@ Comme vous vous êtes initialement inscrit à {app_pro} via {platform_store}, vous devez utiliser votre compte {platform_account} pour mettre à jour votre accès {pro}. Actuellement, les accès {pro} ne peuvent être achetés que via les boutiques {platform_store} et {platform_store_other}. Étant donné que vous utilisez {app_name} Bureau, vous ne pouvez pas effectuer de mise à niveau vers {pro} ici.\n\nLes développeurs de {app_name} travaillent activement sur des options de paiement alternatives pour permettre aux utilisateurs dʼacheter des abonnements {pro} en dehors des boutiques {platform_store} et {platform_store_other}. Feuille de route {pro} {icon} Activé + activation Tout est prêt ! Votre accès {app_pro} a été mis à jour ! Vous serez facturé lorsque {pro} sera automatiquement renouvelé le {date}. Vous avez déjà Téléchargez des GIF et des images WebP animées pour votre photo de profil ! + Obtenez des photos de profil animées et débloquez des fonctionnalités premium avec {app_pro} Beta Photo de profil animée les utilisateurs peuvent télécharger des GIFs Photos de profil animées @@ -1011,6 +1033,9 @@ {price} facturé annuellement {price} facturé mensuellement {price} facturé trimestriellement + Vous souhaitez envoyer des messages plus longs?\nEnvoyez plus de texte et débloquez des fonctionnalités premium avec {app_pro} Beta + Vous voulez plus de messages épinglés?\nOrganisez vos discussions et débloquez les fonctionnalités premium avec {app_pro} Beta + Vous voulez plus que {limit} messages épinglés?\nOrganisez vos discussions et débloquez les fonctionnalités premium avec {app_pro} Beta Nous sommes désolés de vous voir annuler {pro}. Voici ce que vous devez savoir avant d\'annuler votre accès {pro}. Annulation L\'annulation de l\'accès {pro} empêchera le renouvellement automatique avant l\'expiration de l\'accès {pro}. L\'annulation de {pro} n\'entraîne pas de remboursement. Vous pourrez continuer à utiliser les fonctionnalités {app_pro} jusqu\'à l\'expiration de votre accès {pro}.\n\nComme vous vous êtes initialement inscrit à {app_pro} avec votre {platform_account}, vous devrez utiliser le même compte {platform_account} pour annuler {pro}. @@ -1024,6 +1049,7 @@ Expiré Malheureusement, votre accès {pro} a expiré.\nRenouvelez-le pour réactiver les avantages et fonctionnalités exclusifs de {app_pro} Beta. Expiration imminente + Votre accès {pro} expire dans {time}.\nMettez à jour maintenant pour continuer à accéder aux avantages et fonctionnalités exclusifs de {app_pro} Beta {pro} expire dans {time} FAQ {pro} Trouvez des réponses aux questions fréquentes dans la FAQ de {app_pro}. @@ -1068,6 +1094,7 @@ 1 mois – {monthly_price} / mois 3 mois - {monthly_price} / mois 12 mois - {monthly_price} / mois + réactivation Ouvrez ce compte {app_name} sur un appareil {device_type} connecté au compte {platform_account} avec lequel vous vous êtes inscrit à l\'origine. Ensuite, demandez un remboursement via les paramètres {app_pro}. Nous sommes désolés de vous voir partir. Voici ce que vous devez savoir avant de demander un remboursement. {platform} traite actuellement votre demande de remboursement. Cela prend généralement 24-48 heures. Selon leur décision, votre statut {pro} peut changer dans {app_name}. @@ -1081,9 +1108,10 @@ Renouvelez votre accès {pro} dans les paramètres de {app_pro} sur un appareil lié avec {app_name} installé via {platform_store} ou {platform_store_other}. Envie d’envoyer à nouveau des messages plus longs ?\n Renouvelez votre accès {pro} pour débloquer les fonctionnalités qui vous manquent. Envie d’utiliser {app_name} à son plein potentiel à nouveau ?\n Renouvelez votre accès {pro} pour débloquer les fonctionnalités qui vous manquent. + Vous souhaitez à nouveau épingler plus de {limit} conversations?\nRenouvelez votre accès {pro} pour débloquer les fonctionnalités qui vous manquent. Envie d’épingler à nouveau plus de conversations ?\n Renouvelez votre accès {pro} pour débloquer les fonctionnalités qui vous manquent. En renouvelant, vous acceptez les Conditions d\'utilisation {icon} et la Politique de confidentialité {icon} de {app_pro} - Renouvellement Pro échoué, nouvelle tentative bientôt + renouvellement Actuellement, les accès {pro} ne peuvent être achetés et renouvelés que via les boutiques {platform_store} et {platform_store_other}. Étant donné que vous avez installé {app_name} à l\'aide de la version {build_variant}, vous ne pouvez pas renouveler votre abonnement ici.\n\nLes développeurs de {app_name} travaillent activement sur des options de paiement alternatives pour permettre aux utilisateurs d\'acheter des abonnements {pro} en dehors des boutiques {platform_store} et {platform_store_other}. Feuille de route {pro}{icon} Remboursement demandé Envoyez plus avec @@ -1103,19 +1131,23 @@ Impossible de se connecter au réseau pour actualiser votre statut {pro}. Certaines actions sur cette page seront désactivées jusqu\'à ce que la connexion soit rétablie.\n\n Veuillez vérifier votre connexion réseau et réessayer. Impossible de se connecter au réseau pour charger votre accès {pro} actuel. Le renouvellement de {pro} via {app_name} sera désactivé jusqu\'à ce que la connexion soit rétablie.\n\n Veuillez vérifier votre connexion réseau et réessayer. Besoin d\'aide avec {pro} ? Envoyez une demande au support. + En {action_type}, vous êtes en train de {activation_type} {app_pro} via le protocole {app_name}. {entity} facilitera cette activation, mais n\'est pas le fournisseur de {app_pro}. {entity} n\'est pas responsable des performances, de la disponibilité ou de la fonctionnalité de {app_pro}. En mettant à jour, vous acceptez les Conditions d\'utilisation {icon} et la Politique de confidentialité {icon} de {app_pro} Épingles illimitées Organisez toutes vos discussions avec un nombre illimité de conversations épinglées. Votre option de facturation actuelle vous donne droit à {current_plan_length} d\'accès {pro}. Voulez-vous vraiment passer à l\'option de facturation {selected_plan_length_singular}?\n\nEn mettant à jour, votre accès {pro} sera automatiquement renouvelé le {date} pour {selected_plan_length} supplémentaire d\'accès {pro}. Votre accès {pro} expirera le {date}.\n\nEn le mettant à jour, votre accès {pro} sera automatiquement renouvelé le {date} pour {selected_plan_length} supplémentaires d’accès {pro}. + mise à jour Passez à {app_pro} Bêta pour accéder à de nombreux avantages et fonctionnalités exclusifs. Passez à {pro} depuis les paramètres de {app_pro} sur un appareil lié avec {app_name} installé via {platform_store} ou {platform_store_other}. Actuellement, les accès {pro} ne peuvent être achetés que via les boutiques {platform_store} et {platform_store_other}. Étant donné que vous avez installé {app_name} à l\'aide de la version {build_variant}, vous ne pouvez pas effectuer de mise à niveau vers {pro} ici.\n\nLes développeurs de {app_name} travaillent activement sur des options de paiement alternatives pour permettre aux utilisateurs d\'acheter des abonnements {pro} en dehors des boutiques {platform_store} et {platform_store_other}. Feuille de route {pro}{icon} Pour l\'instant, il y a un seul moyen de mettre à niveau : Pour l\'instant, il y a deux moyens de mettre à niveau : Vous êtes passé à {app_pro} !\nMerci de soutenir {network_name}. + mise à niveau Mise à niveau vers {pro} En mettant à niveau, vous acceptez les Conditions d\'utilisation {icon} et la Politique de confidentialité {icon} de {app_pro} + Vous voulez tirer le meilleur parti de {app_name}?\nPassez à {app_pro} Beta pour une expérience de messagerie améliorée. {platform} traite votre demande de remboursement Profil Définir une photo de profil @@ -1190,8 +1222,16 @@ Retirer un membre Supprimer les membres + + Supprimer le membre et ses messages + Supprimer les membres et leurs messages + Échec de supprimer le mot de passe Supprimez votre mot de passe actuel pour {app_name}. Les données stockées localement seront à nouveau chiffrées à l\'aide d\'une clé générée aléatoirement, stockée sur votre appareil. + + Suppression du membre + Suppression des membres + Renouveler Renouvellement de {pro} Répondre @@ -1220,6 +1260,9 @@ Réessayer Limite d’évaluations Il semble que vous ayez déjà évalué {app_name} récemment. Merci pour vos retours ! + Exécuter l\'application en arrière-plan + Exécuter {app_name} en arrière-plan? + Puisque vous utilisez le mode lent, nous vous recommandons d\'autoriser {app_name} à s\'exécuter en arrière-plan pour améliorer la fiabilité des notifications. Cela peut améliorer la cohérence des notifications, bien que votre système puisse toujours limiter automatiquement l\'activité en arrière-plan.\n\nVous pouvez modifier ce paramètre ultérieurement dans les paramètres. Enregistrer Enregistré Messages enregistrés @@ -1250,6 +1293,10 @@ Envoi Appel en cours d’envoi Envoi des candidats à la connexion + + Envoi de la promotion + Envoi des promotions + Envoyé : Apparence Effacer les données @@ -1285,7 +1332,10 @@ Partagez avec vos amis où vous communiquez habituellement avec eux - puis déplacez la conversation ici. Il y a un problème pour ouvrir la base de données. Veuillez redémarrer l\'application et réessayer. Oups ! Il semble que vous n\'ayez pas encore de compte {app_name} .\n\nVous devrez en créer un dans l\'application {app_name} avant de pouvoir le partager. + Souhaitez-vous partager l\'historique des messages du groupe avec cet utilisateur? Partager sur {app_name} + Désolé, {app_name} prend uniquement en charge le partage de plusieurs images et vidéos à la fois + Le partage prend uniquement en charge les fichiers multimédias. Les fichiers non multimédias ont été exclus Afficher Tout afficher Afficher moins @@ -1351,7 +1401,6 @@ Utiliser le mode rapide Modifiez votre abonnement en utilisant le compte {platform_account} avec lequel vous vous êtes inscrit, via le site web de {platform}. Via le site Web {platform} - Modifiez votre accès {pro} en utilisant le compte {platform_account} avec lequel vous vous êtes inscrit, via le site web de {platform_store}. Vidéo Impossible de lire la vidéo. Afficher diff --git a/app/src/main/res/values-b+hi+IN/strings.xml b/app/src/main/res/values-b+hi+IN/strings.xml index d6cefc4556..805207a425 100644 --- a/app/src/main/res/values-b+hi+IN/strings.xml +++ b/app/src/main/res/values-b+hi+IN/strings.xml @@ -15,7 +15,13 @@ यह आपका Account ID है। अन्य उपयोगकर्ता आपके साथ बातचीत शुरू करने के लिए इसे स्कैन कर सकते हैं। वास्तविक आकार जोड़ें + + प्रशासक जोड़ें + प्रशासक जोड़ें + + एडमिन जोड़ें उस उपयोगकर्ता का Account ID दर्ज करें जिसे आप एडमिन बना रहे हैं।\n\nएक से अधिक उपयोगकर्ताओं को जोड़ने के लिए, प्रत्येक Account ID को कॉमा से अलग करके दर्ज करें। एक बार में अधिकतम 20 Account ID दर्ज किए जा सकते हैं। + प्रशासकों को पदावनत नहीं किया जा सकता या समूह से हटाया नहीं जा सकता। एडमिन हटाए नहीं जा सकते। {name} और {count} अन्य को Admin बनाया गया। एडमिन को पदोन्नत करें @@ -40,12 +46,19 @@ {name} को एडमिन से हटा दिया गया। {name} और {count} अन्य को व्यवस्थापक पद से हटा दिया गया | {name} and {other_name} को व्यवस्थापक पद से हटा दिया गया | + + %1$d प्रशासक चयनित + %1$d प्रशासक चयनित + एडमिन प्रमोशन भेजा जा रहा है एडमिन प्रमोशन भेजे जा रहे हैं एडमिन सेटिंग्स + आप अपना एडमिन स्टेटस नहीं बदल सकते। समूह छोड़ने के लिए, बातचीत सेटिंग्स खोलें और समूह छोड़ें चुनें। {name} और {other_name} को Admin बनाया गया। + एडमिन + अनुमति दें +{count} गुमनाम ऐप आइकॉन @@ -65,6 +78,8 @@ नोट्स शेयरों मौसम + {app_pro} बैज + स्वचालित डार्क मोड मेनू बार छुपाएं भाषा {app_name} के लिए अपनी भाषा सेटिंग चुनें. जब आप अपनी भाषा सेटिंग बदलेंगे तो {app_name} पुनः प्रारंभ हो जाएगा। @@ -159,6 +174,8 @@ क्या आप वाकई {name} और {count} अन्य को अनब्लॉक करना चाहते हैं? क्या आप वाकई {name} और 1 अन्य को अनब्लॉक करना चाहते हैं? अनब्लॉक किया {name} + ब्लॉक किए गए संपर्कों को देखें और प्रबंधित करें। + उस URL को खोलने के लिए कोई ब्राउज़र नहीं मिला, कृपया इसके बजाय URL को कॉपी करने का प्रयास करें कॉल {name} ने आपको कॉल किया आप एक नई कॉल शुरू नहीं कर सकते। पहले अपनी वर्तमान कॉल समाप्त करें। @@ -183,9 +200,14 @@ कॉल्स (बेटा) वॉइस और वीडियो कॉल वॉइस और वीडियो कॉल (बीटा) + बीटा कॉल का उपयोग करते समय आपका IP पता आपके कॉल पार्टनर और एक {session_foundation} सर्वर को दिखाई देता है। अन्य उपयोगकर्ताओं से वॉयस और वीडियो कॉल सक्षम करता है। आपने {name} को कॉल किया आपको प्राइवेसी सेटिंग्स में वॉइस और वीडियो कॉल्स सक्षम नहीं करने के कारण {name} से कॉल छूट गया। + वीडियो कॉल सक्षम करने के लिए {app_name} को आपके कैमरा तक पहुँच की आवश्यकता है, लेकिन यह अनुमति अस्वीकृत कर दी गई है। आप कॉल के दौरान कैमरा अनुमति को अपडेट नहीं कर सकते।\n\nक्या आप अभी कॉल समाप्त कर कैमरा एक्सेस सक्षम करना चाहेंगे, या कॉल के बाद याद दिलाया जाना चाहेंगे? + कैमरा एक्सेस की अनुमति देने के लिए, सेटिंग्स खोलें और कैमरा अनुमति चालू करें। + आपकी पिछली कॉल के दौरान, आपने वीडियो का उपयोग करने की कोशिश की थी लेकिन कैमरा एक्सेस पहले से अस्वीकृत होने के कारण ऐसा नहीं कर सके। कैमरा एक्सेस की अनुमति देने के लिए, सेटिंग्स खोलें और कैमरा अनुमति चालू करें। + कैमरा एक्सेस आवश्यक है कोई कैमरा नहीं मिला कैमरा अनुपलब्ध. कैमरा के उपयोग को प्रदान करें @@ -193,7 +215,19 @@ फ़ोटो और वीडियो लेने या क्यूआर कोड स्कैन करने के लिए {app_name} को कैमरा एक्सेस की आवश्यकता है। क्यूआर कोड स्कैन करने के लिए {app_name} को कैमरा एक्सेस की आवश्यकता है रद्द करना | + {pro} रद्द करें + {platform} वेबसाइट पर जाकर {pro} रद्द करें, उस {platform_account} का उपयोग करके जिससे आपने साइन अप किया था। + {platform_store} वेबसाइट पर जाकर {pro} रद्द करें, उस {platform_account} का उपयोग करके जिससे आपने साइन अप किया था। + बदलें पासवर्ड बदलने में विफल रहा + {app_name} के लिए अपना पासवर्ड बदलें। स्थानीय रूप से संग्रहीत डेटा फिर से आपके नए पासवर्ड से एन्क्रिप्ट किया जाएगा। + सेटिंग बदलें + {pro} स्थिति जांची जा रही है + आपकी {pro} स्थिति की जाँच की जा रही है। यह जाँच पूर्ण होते ही आप आगे बढ़ सकेंगे। + आपकी {pro} जानकारी की जांच की जा रही है। जब तक यह जांच पूरी नहीं होती, इस पृष्ठ पर कुछ क्रियाएं उपलब्ध नहीं होंगी। + {pro} स्थिति जांची जा रही है... + आपका {pro} विवरण जांचा जा रहा है। जब तक यह जांच पूरी नहीं हो जाती, आप नवीनीकरण नहीं कर सकते। + आपकी {pro} स्थिति की जांच की जा रही है। आप जाँच पूर्ण होने के पश्चात {pro} में उन्नत कर सकते हैं। साफ़ सभ साफ करें सभी डेटा हटाएं @@ -252,11 +286,17 @@ सामुदायिक यूआरएल सामुदायिक यूआरएल कॉपी करें पुष्टि करें + पदोन्नति की पुष्टि करें + क्या आप वाकई सुनिश्चित हैं? एडमिन को डिमोट या समूह से हटाया नहीं जा सकता। संपर्क संपर्क हटाएँ क्या आप वाकई अपने संपर्कों से {name} को हटाना चाहते हैं? {name} से आने वाले नए संदेश एक संदेश अनुरोध के रूप में आएंगे। अभी तक आपके पास कोई संपर्क नहीं हैं कांटेक्ट चुनें + + %1$d संपर्क चयनित + %1$d संपर्क चयनित + उपयोगकर्ता विवरण कैमरा एक संवादी शुरू करने के लिए एक क्रिया चुनें @@ -264,6 +304,7 @@ संदेश संरचना उद्धृत संदेश से छवि का थंबनेल नए संपर्क के साथ बातचीत बनाएं + जब कोई नया संदेश प्राप्त होता है, तो स्थानीय सूचनाओं में प्रदर्शित सामग्री चुनें। होम स्क्रीन में शामिल करें होम स्क्रीन में जोड़ा गया ऑडियो संदेश @@ -276,12 +317,18 @@ बातचीत हटाई गई {conversation_name} में कोई संदेश नहीं हैं। कुंजी दर्ज करें + कन्वर्सेशन में Enter और Shift+Enter कुंजियों की कार्यप्रणाली को परिभाषित करें। + SHIFT + ENTER एक संदेश भेजता है, ENTER एक नई पंक्ति शुरू करता है + ENTER संदेश भेजता है, SHIFT + ENTER नई लाइन शुरू करता है। समूह संदेश ट्रिमिंग समुदायों को ट्रिम करें + ऐसी समुदायों में जहाँ 2000 से अधिक संदेश हों, वहाँ 6 महीने से पुराने संदेशों को स्वचालित रूप से हटा दिया जाएगा। नई बातचीत आपके पास अभी तक कोई वार्तालाप नहीं है + एन्टर के साथ भेजें एन्टर कुंजी को दबाने से संदेश भेजा जाएगा नई लाइन शुरू करने से बजाय। + Shift+Enter के साथ भेजें सभी मीडिया वर्तनी की जाँच संदेश टाइप करते समय वर्तनी जांच सक्षम करें। @@ -290,7 +337,10 @@ कॉपी करें बनाएं कॉल बनाया जा रहा है + वर्तमान बिलिंग + वर्तमान पासवर्ड कट + डार्क मोड क्या आप वाकई इस डिवाइस से सभी संदेश, अटैचमेंट और खाता डेटा हटाना चाहते हैं और एक नया खाता बनाना चाहते हैं? डेटाबेस त्रुटि हुई है।\n\nसमस्या निवारण के लिए अपने एप्लिकेशन लॉग्स को शेयर करने के लिए निर्यात करें। यदि यह असफल रहता है, तो {app_name} को फिर से इंस्टॉल करें और अपना खाता पुनः प्राप्त करें। क्या आप वाकई इस डिवाइस से सभी संदेश, अटैचमेंट और खाता डेटा हटाना चाहते हैं और नेटवर्क से अपना खाता पुनः प्राप्त करना चाहते हैं? @@ -315,6 +365,14 @@ कृपया समूह बनने तक प्रतीक्षा करें ... समूह अपडेट करने में विफल आपको दूसरों के संदेशों को हटाने की अनुमति नहीं है + + चयनित अटैचमेंट हटाएं + चयनित अटैचमेंट्स हटाएं + + + क्या आप वाकई चयनित अटैचमेंट हटाना चाहते हैं? अटैचमेंट से संबद्ध संदेश भी हटाया जाएगा। + क्या आप वाकई चयनित अटैचमेंट्स हटाना चाहते हैं? अटैचमेंट्स से जुड़ा संदेश भी हटा दिया जाएगा। + क्या आप वाकई अपने संपर्कों से {name} को हटाना चाहते हैं?\n\nयह आपके वार्तालाप को हटा देगा, जिसमें सभी संदेश और अटैचमेंट्स शामिल हैं। {name} से भविष्य के संदेश Message request के रूप में दिखाई देंगे। क्या आप वाकई {name} के साथ अपना वार्तालाप हटाना चाहते हैं?\nयह सभी संदेशों और अटैचमेंट्स को स्थायी रूप से हटा देगा। @@ -354,6 +412,7 @@ क्या आप वाकई सभी के लिए इन संदेशों को हटाना चाहते हैं? हटाया जा रहा है डेवलपर टूल टॉगल करें + डिवाइस अधिसूचना सेटिंग्स डिक्टेशन शुरू करें... गायब होने वाले संदेश संदेश {time_large} में हटा दिया जाएगा @@ -389,6 +448,7 @@ {admin_name} ने गायब होने वाले संदेश सेटिंग्स को अपडेट किया है। आप ने गायब संदेश सेटिंग्स को अपडेट किया है। खारिज करें + प्रदर्शन यह आपका असली नाम, उपनाम, या कुछ और हो सकता है - और आप इसे कभी भी बदल सकते हैं। डिस्प्ले नाम डालें कृपया एक डिस्प्ले नाम चुनें @@ -400,6 +460,8 @@ आपका डिस्प्ले नाम उन उपयोगकर्ताओं, समूहों और समुदायों को दिखाई देता है जिनके साथ आप संपर्क करते हैं। दस्तावेज़ दान करें + शक्तिशाली ताकतें गोपनीयता को कमजोर करने का प्रयास कर रही हैं, लेकिन हम यह लड़ाई अकेले नहीं जारी रख सकते।\n\nदान करने से {app_name} को सुरक्षित, स्वतंत्र और ऑनलाइन बनाए रखने में मदद मिलती है। + {app_name} को आपकी सहायता चाहिए पूरा हुआ डाउनलोड डाउनलोड हो रहा है... @@ -429,19 +491,38 @@ आपने और {name} ने {emoji_name} के साथ प्रतिक्रिया व्यक्त की आपके संदेश पर प्रतिक्रिया दी {emoji} सक्षम करें + क्या कैमरा एक्सेस सक्षम करें? + जब आपको नए संदेश प्राप्त हों तो सूचनाएं दिखाएं। + सक्षम करने के लिए कॉल समाप्त करें क्या आप {app_name} का आनंद ले रहे हैं? सुधार की आवश्यकता है {emoji} बहुत बढ़िया {emoji} आप कुछ समय से {app_name} का उपयोग कर रहे हैं, सब कैसा चल रहा है? हम आपके विचार जानकर बहुत आभारी होंगे। + प्रवেশ करें + {app_name} के लिए आपने जो पासवर्ड सेट किया है उसे दर्ज करें + {app_name} को स्टार्टअप पर अनलॉक करने के लिए जो पासवर्ड आप उपयोग करते हैं वह दर्ज करें, न कि आपका रिकवरी पासवर्ड + {pro} स्थिति की जांच में त्रुटि कृपया अपनी इंटरनेट कनेक्शन और फिरसे प्रयास करें। त्रुटि कॉपी करें और छोड़ दें डेटाबेस त्रुटि कुछ गलत हो गया। कृपया बाद में पुनः प्रयास करें। + {pro} एक्सेस लोड करने में त्रुटि + {app_name} इस ONS को खोजने में असमर्थ है। कृपया अपना नेटवर्क कनेक्शन जांचें और पुनः प्रयास करें। एक अज्ञात त्रुटि हुई। + यह ONS पंजीकृत नहीं है। कृपया जांचें कि यह सही है और पुनः प्रयास करें। + {group_name} में {name} को निमंत्रण पुनः भेजने में विफल रहा + {group_name} में {name} और {count} अन्य को निमंत्रण पुनः भेजने में विफल रहा + {group_name} में {name} और {other_name} को निमंत्रण पुनः भेजने में विफल रहा + {group_name} में {name} को प्रमोशन फिर से भेजने में विफल + {group_name} में {name} और {count} अन्य को प्रमोशन फिर से भेजने में विफल + {group_name} में {name} और {other_name} को प्रमोशन फिर से भेजने में विफल डाउनलोड विफल विफलतायें + प्रतिक्रिया + {app_name} के साथ अपने अनुभव को एक संक्षिप्त सर्वेक्षण भरकर साझा करें। फ़ाइल फ़ाइलें + सिस्टम सेटिंग्स का पालन करें हमेशा के लिए तरफ से: पूर्णस्क्रीन में जाएं @@ -488,6 +569,9 @@ क्या आप वाकई {group_name} छोड़ना चाहते हैं? क्या आप वाकई {group_name} छोड़ना चाहते हैं?\n\nइससे सभी सदस्यों को हटा दिया जाएगा और सभी समूह सामग्री को हटा दिया जाएगा। {group_name} छोड़ने में विफल + {name} को समूह में शामिल होने के लिए आमंत्रित किया गया। पिछले 14 दिनों का चैट इतिहास साझा किया गया। + {name} और {count} अन्य को समूह में शामिल होने के लिए आमंत्रित किया गया। पिछले 14 दिनों का चैट इतिहास साझा किया गया। + {name} और {other_name} को समूह में शामिल होने के लिए आमंत्रित किया गया। पिछले 14 दिनों का चैट इतिहास साझा किया गया। {name} ने समूह छोड़ दिया। {name} और {count} अन्य समूह से निकल गए। {name} और {other_name} समूह से निकल गए। @@ -499,6 +583,9 @@ {name} और {other_name} को समूह में शामिल होने के लिए आमंत्रित किया गया था। आप और {count} अन्य को समूह में शामिल होने के लिए आमंत्रित किया गया। चैट इतिहास साझा किया गया। आप और {other_name} को समूह में शामिल होने के लिए आमंत्रित किया गया। चैट इतिहास साझा किया गया। + {name} को {group_name} से हटाने में विफल + {name} और {count} अन्य को {group_name} से हटाने में विफल + {name} और {other_name} को {group_name} से हटाने में विफल आप ने समूह छोड़ दिया। समूह के सदस्य इस समूह में कोई अन्य सदस्य नहीं है। @@ -512,6 +599,7 @@ आपके पास {group_name} से कोई संदेश नहीं हैं। वार्तालाप शुरू करने के लिए एक संदेश भेजें! इस समूह को 30 दिनों से अधिक समय से अपडेट नहीं किया गया है। आपको संदेश भेजने या समूह की जानकारी देखने में समस्या आ सकती है। आप {group_name} में अकेले व्यवस्थापक हैं।\n\nसमूह सदस्य और सेटिंग्स बिना व्यवस्थापक के बदले नहीं जा सकते। + आप {group_name} में अकेले व्यवस्थापक हैं।\n\nप्रशासक के बिना समूह सदस्यों और सेटिंग्स को बदला नहीं जा सकता। समूह को हटाए बिना छोड़ने के लिए, कृपया पहले एक नया व्यवस्थापक जोड़ें लंबित हटाना आप को Admin बनाया गया। आप और {count} अन्य को Admin बनाया गया। @@ -539,22 +627,32 @@ समूह अपडेट किया गया कनेक्शन उम्मीदवारों को संभाला जा रहा है अकसर किये गए सवाल + सामान्य प्रश्नों के उत्तर पाने के लिए {app_name} FAQ देखें। हमें {app_name} का अनुवाद करने में मदद करें + बग सूचित करें अपने मुद्दे को हल करने में हमारी मदद करने के लिए कुछ विवरण साझा करें। अपने लॉग्स को निर्यात करें, फिर फ़ाइल को {app_name} के हेल्प डेस्क के माध्यम से अपलोड करें। लॉग निर्यात करें अपने लॉग निर्यात करें, फिर फ़ाइल को {app_name} के हेल्प डेस्क के माध्यम से अपलोड करें। डेस्कटॉप पर सहेजें + इस फ़ाइल को सहेजें, फिर इसे {app_name} डेवलपर्स के साथ साझा करें। सहायता + {app_name} को 80 से अधिक भाषाओं में अनुवाद करने में मदद करें! हमें आपकी प्रतिक्रिया पसंद आएगी छिपाएँ + सिस्टम मेनू बार दृश्यता टॉगल करें। क्या आप वाकई अपनी बातचीत सूची से अपने लिए नोट छुपाना चाहते हैं? बाकियों को छुपाएं तस्वीर इमेजिस + महत्वपूर्ण गुप्त कीबोर्ड संवेदनशील मोड उपलब्ध होने पर अनुरोध करें। आप जिस कीबोर्ड का उपयोग कर रहे हैं, उसके आधार पर आपका कीबोर्ड इस अनुरोध को अनदेखा कर सकता है। जानकारी अमान्य शॉर्टकट + + संपर्क को आमंत्रित करें + मित्रों को आमंत्रित करें + निमंत्रण विफल निमंत्रण विफल @@ -563,8 +661,17 @@ आमंत्रण नहीं भेजा जा सका। क्या आप फिर से प्रयास करना चाहेंगे? आमंत्रण नहीं भेजे जा सके। क्या आप फिर से प्रयास करना चाहेंगे? + + सदस्य को आमंत्रित करें + सदस्यों को आमंत्रित करें + + अपने मित्र की खाता आईडी, ONS दर्ज करके या उनके QR कोड को स्कैन करके समूह में एक नया सदस्य आमंत्रित करें {icon} + अपने मित्र के खाता आईडी, ONS दर्ज करके या उनका QR कोड स्कैन करके समूह में एक नया सदस्य आमंत्रित करें से जुड़ें बाद में + जब आपका कंप्यूटर चालू हो तब {app_name} को स्वचालित रूप से प्रारंभ करें। + स्टार्टअप पर प्रारंभ करें + यह सेटिंग लिनक्स पर आपके सिस्टम द्वारा प्रबंधित की जाती है। स्वचालित स्टार्टअप सक्षम करने के लिए, {app_name} को अपने सिस्टम सेटिंग्स में स्टार्टअप एप्लिकेशन में जोड़ें। अधिक जानें छोड़ें छोड़ना... @@ -579,6 +686,8 @@ आप और {other_name} समूह में शामिल हुए {name} और {other_name} समूह में शामिल हुए। आप समूह में शामिल हुए + क्या पृष्ठभूमि गतिविधि सीमित करें? + वर्तमान में आप अधिसूचना की विश्वसनीयता को बेहतर बनाने के लिए {app_name} को पृष्ठभूमि में चलाने की अनुमति दे रहे हैं। इस सेटिंग को बदलने से अधिसूचनाएं कम भरोसेमंद हो सकती हैं। लिंक पूर्वावलोकन समर्थित URL के लिए लिंक प्रीव्यू दिखाएं। लिंक पूर्वावलोकन सक्षम करें @@ -589,6 +698,7 @@ लिंक प्रीव्यू भेजते समय आपके पास पूर्ण मेटाडेटा सुरक्षा नहीं होगी। लिंक पूर्वावलोकन बंद हैं {app_name} को आपके द्वारा भेजे और प्राप्त किए गए लिंक के पूर्वावलोकन उत्पन्न करने के लिए लिंक की गई वेबसाइटों से संपर्क करना होगा।\n\nआप इन्हें {app_name} की सेटिंग्स में चालू कर सकते हैं। + लिंक अकाउंट लोड करें आपका अकाउंट लोड हो रहा है लोड हो रहा है... @@ -601,9 +711,17 @@ लॉक स्थिति अनलॉक करने के लिए टैप करें {app_name} अनलॉक है + लॉग्स + प्रबंधकों का प्रबंधन करें सदस्यों का प्रबंधन करें + {pro} प्रबंधित करें अधिकतम + शायद बाद में मीडिया + + %1$d सदस्य चयनित + %1$d सदस्य चयनित + %1$d सदस्य %1$dसदस्य @@ -613,7 +731,9 @@ %1$d सक्रिय सदस्य खाता आईडी या ONS जोड़ें + सदस्यों को केवल तभी प्रचारित किया जा सकता है जब वे समूह में शामिल होने के निमंत्रण को स्वीकार कर लें। मित्रों को आमंत्रित करें + आपके पास इस समूह में आमंत्रित करने के लिए कोई संपर्क नहीं है।\nवापस जाएँ और सदस्यों को उनकी खाता आईडी या ONS का उपयोग करके आमंत्रित करें। आमंत्रण भेजे आमंत्रण सेंड करें @@ -622,10 +742,14 @@ क्या आप {name} और {count} अन्य के साथ समूह संदेश इतिहास साझा करना चाहेंगे? क्या आप {name} और {other_name} के साथ समूह संदेश इतिहास साझा करना चाहेंगे? संदेश इतिहास साझा करें + पिछले 14 दिनों का संदेश इतिहास साझा करें केवल नए संदेश साझा करें आमंत्रण + सदस्य (गैर-प्रशासक) + मेनू बार संदेश और पढ़ें + संदेश कॉपी करें यह संदेश खाली है। संदेश प्रेषण असफल हुआ संदेश सीमा तक पहुंच गया @@ -640,6 +764,7 @@ अपने मित्र के खाता आईडी या ओएनएस दर्ज करके नई वार्तालाप शुरू करें। अपने मित्र के खाता आईडी, ओएनएस या उनके QR कोड को स्कैन करके नई वार्तालाप शुरू करें। + अपने मित्र की खाता आईडी, ONS दर्ज करके या उनके QR कोड को स्कैन करके नई बातचीत शुरू करें {icon} आपको एक नया संदेश मिला है। आपको %1$d नए संदेश मिले हैं। @@ -688,20 +813,29 @@ संदेश बहुत लंबा है कृपया अपने संदेश को {limit} वर्णों या कम में छोटा करें। संदेश बहुत लंबा है + नया पासवर्ड अगला + अगले चरण {name} के लिए एक उपनाम चुनें। यह आपके एक-से-एक और समूह वार्तालापों में आपको दिखाई देगा। उपनाम दर्ज करें कृपया एक छोटा उपनाम दर्ज करें उपनाम हटाएं उपनाम सेट करें नहीं + इस समूह में कोई गैर-प्रशासक सदस्य नहीं है। कोई सुझाव नहीं हैं + सभी वार्तालापों में 10,000 वर्णों तक के संदेश भेजें। + असीमित पिन की गई वार्तालापों के साथ चैट व्यवस्थित करें। कुछ नहीं अभी नहीं खुद पर ध्यान दें आपके पास Note to Self में कोई संदेश नहीं हैं। अपने लिए नोट छुपाएं क्या आप वाकई Note to Self को छिपाना चाहते हैं? + कृपया ध्यान दें: {action_type} द्वारा, आप {app_pro} के सेवा की शर्तों {icon} और गोपनीयता नीति {icon} से सहमत होते हैं + अधिसूचना प्रदर्शन + प्रेषक का नाम और संदेश सामग्री का पूर्वावलोकन दिखाएं। + केवल प्रेषक का नाम दिखाएं, संदेश सामग्री नहीं। सभी संदेश सूचना सामग्री सूचनाओं में दिखाई गई जानकारी। @@ -712,6 +846,7 @@ आपको नई सूचनाओं के बारे में Google के नोटीफिकेशन servers से तत्काल सूचित किया जाएगा। Huawei के अधिसूचना सर्वर का उपयोग करके आपको नए संदेशों की विश्वसनीय और तुरंत सूचना दी जाएगी। आपको नई सूचनाओं के बारे में Apple के नोटीफिकेशन servers से तत्काल सूचित किया जाएगा। + {app_name} की एक सामान्य सूचना दिखाएं, जिसमें प्रेषक का नाम या संदेश सामग्री शामिल न हो। डिवाइस अधिसूचना सेटिंग्स पर जाएं सूचनाएं - सभी सूचनाएं - केवल उल्लेख @@ -719,6 +854,7 @@ {name} ने {conversation_name} को हो सकता है कि आपका {device} पुनरारंभ होने के दौरान आपको संदेश प्राप्त हुए हों। LED रंग + जब आपको नए संदेश प्राप्त हों तो ध्वनि चलाएँ। केवल उल्लेख संदेश सूचनाएं {name} से हाल ही में @@ -740,6 +876,12 @@ बंद ठीक है ऑन + आपके {device_type} डिवाइस पर + यह {app_name} खाता उस {device_type} डिवाइस पर खोलें जिसमें वही {platform_account} लॉग इन है जिससे आपने साइन अप किया था। फिर, {app_pro} सेटिंग्स में जाकर {pro} रद्द करें। + इस {app_name} खाते को एक {device_type} डिवाइस पर खोलें जो उस {platform_account} में लॉग इन है जिससे आपने मूल रूप से साइन अप किया था। इसके बाद, {app_pro} सेटिंग्स के माध्यम से अपनी {pro} एक्सेस अपडेट करें। + लिंक किए गए डिवाइस पर + {platform_store} वेबसाइट पर + {platform} वेबसाइट पर खाता बनाएं खाता बनाया गया मेरा खाता है @@ -764,10 +906,17 @@ हम इस ONS को पहचान नहीं सके। कृपया इसे जाँचें और पुनः प्रयास करें। हम इस ONS को खोजने में असमर्थ थे। कृपया बाद में पुनः प्रयास करें। खोलें + {platform_store} वेबसाइट खोलें + {platform} वेबसाइट खोलें + सेटिंग्स खोलें सर्वेक्षण खोलें अन्य + पासवर्ड पासवर्ड बदलें + {app_name} को अनलॉक करने के लिए आवश्यक पासवर्ड बदलें। + आपका पासवर्ड बदल दिया गया है। कृपया इसे सुरक्षित रखें। पासवर्ड की पुष्टि करें + पासवर्ड बनाएँ आपका वर्तमान पासवर्ड गलत है। पासवर्ड दर्ज करें कृपया अपना वर्तमान पासवर्ड दर्ज करें @@ -777,9 +926,24 @@ पासवर्ड्स मेल नहीं खाते पासवर्ड सेट करने में विफल गलत पासवर्ड + नया पासवर्ड पुष्टि करें पासवर्ड हटाएं + {app_name} को अनलॉक करने के लिए आवश्यक पासवर्ड हटाएं + आपका पासवर्ड हटा दिया गया है। पासवर्ड सेट करें + आपका पासवर्ड सेट कर दिया गया है। कृपया इसे सुरक्षित रखें। + स्टार्टअप पर {app_name} को अनलॉक करने के लिए पासवर्ड आवश्यक है। + 12 अक्षरों से लंबा + एक संख्या शामिल है + एक छोटे अक्षर को शामिल करता है + एक चिह्न शामिल है + एक बड़े अक्षर को शामिल करता है + पासवर्ड शक्ति सूचक + एक मजबूत पासवर्ड सेट करना आपके संदेशों और संलग्नकों को सुरक्षित रखने में मदद करता है अगर आपका डिवाइस कभी खो जाए या चोरी हो जाए। + पासवर्ड पेस्ट करें + भुगतान त्रुटि + आपका भुगतान सफलतापूर्वक संसाधित हो गया है, लेकिन आपके {pro} स्टेटस को {action_type} करते समय एक त्रुटि हुई।\n\nकृपया अपने नेटवर्क कनेक्शन की जाँच करें और पुनः प्रयास करें। अनुमति परिवर्तन {app_name} को संगीत और ऑडियो एक्सेस की आवश्यकता है ताकि फ़ाइलें, संगीत और ऑडियो भेजी जा सकें, लेकिन इसे स्थायी रूप से अस्वीकार कर दिया गया है। सेटिंग्स पर टैप करें → अनुमतियां, और \"संगीत और ऑडियो\" चालू करें। मीडिया संलग्नक बजाने के लिए {app_name} को Apple Music के उपयोग की आवश्यकता है। @@ -791,6 +955,7 @@ वीडियो कॉल के लिए कैमरे तक पहुंच की अनुमति दें. {app_name} पर स्क्रीन लॉक फीचर Face ID का उपयोग करता है। सिस्टम ट्रे में रखें + जब आप विंडो बंद करते हैं तो {app_name} पृष्ठभूमि में चलता रहता है. {app_name} को जारी रखने के लिए फ़ोटो लाइब्रेरी पहुंच की आवश्यकता है। आप iOS सेटिंग्स में पहुंच सक्षम कर सकते हैं। कॉल की सुविधा के लिए स्थानीय नेटवर्क एक्सेस की आवश्यकता होती है। जारी रखने के लिए सेटिंग्स में \"स्थानीय नेटवर्क\" अनुमति टॉगल करें। {app_name} को वॉयस और वीडियो कॉल करने के लिए स्थानीय नेटवर्क तक पहुंच की आवश्यकता है। @@ -817,24 +982,172 @@ पिन वार्तालाप अनपिन करें वार्तालाप अनपिन करें + और भी बहुत कुछ... + {pro} में जल्द ही नई सुविधाएं आ रही हैं। {pro} रोडमैप {icon} में जानें आगे क्या है + वरीयताएँ Preview + अधिसूचना पूर्वावलोकन + आपकी {pro} पहुँच सक्रिय है!\n\nआपकी {pro} पहुँच {current_plan_length} के लिए {date} को स्वचालित रूप से नवीनीकृत हो जाएगी। + आपकी {pro} पहुँच सक्रिय है!\n\nआपकी {pro} पहुँच {date} को स्वचालित रूप से \n{current_plan_length} के लिए नवीनीकृत कर दी जाएगी। यहाँ किए गए कोई भी बदलाव आपकी अगली नवीनीकरण तिथि पर प्रभावी होंगे। + {pro} एक्सेस त्रुटि + आपकी {pro} पहुँच {date} को समाप्त हो जाएगी। + {pro} एक्सेस लोड हो रहा है + आपकी {pro} एक्सेस जानकारी अभी भी लोड हो रही है। आप इस प्रक्रिया के पूरी होने तक अपडेट नहीं कर सकते। + {pro} एक्सेस लोड हो रहा है... + आपकी {pro} एक्सेस जानकारी को लोड करने के लिए नेटवर्क से कनेक्ट नहीं हो सका। जब तक कनेक्टिविटी पुनः स्थापित नहीं होती, {app_name} के माध्यम से {pro} को अपडेट करना अक्षम रहेगा।\n\nकृपया अपने नेटवर्क कनेक्शन की जांच करें और पुनः प्रयास करें। + {pro} एक्सेस नहीं मिला + {app_name} ने पाया कि आपके खाते में {pro} एक्सेस नहीं है। यदि आपको लगता है कि यह एक त्रुटि है, तो कृपया सहायता के लिए {app_name} समर्थन से संपर्क करें। + {pro} पहुँच पुनर्प्राप्त करें + {pro} पहुँच को नवीनीकृत करें + वर्तमान में, {pro} एक्सेस केवल {platform_store} या {platform_store_other} के माध्यम से ही खरीदी और नवीनीकृत की जा सकती है। चूंकि आप {app_name} डेस्कटॉप का उपयोग कर रहे हैं, इसलिए आप इसे यहां नवीनीकृत नहीं कर सकते।\n\n{app_name} के डेवलपर्स {platform_store} और {platform_store_other} के बाहर उपयोगकर्ताओं को {pro} एक्सेस खरीदने की अनुमति देने के लिए वैकल्पिक भुगतान विकल्पों पर काम कर रहे हैं। {pro} रोडमैप {icon} + {platform_store} वेबसाइट पर जाकर उस {platform_account} से अपनी {pro} एक्सेस नवीनीकृत करें जिससे आपने {pro} के लिए साइन अप किया था। + {platform} वेबसाइट पर जाकर उस {platform_account} के साथ नवीनीकरण करें जिससे आपने {pro} के लिए साइन अप किया था। + {app_pro} के शक्तिशाली Beta फ़ीचर्स का फिर से उपयोग शुरू करने के लिए अपनी {pro} एक्सेस नवीनीकृत करें। + {pro} पहुँच पुनर्प्राप्त की गई + {app_name} ने आपके खाते के लिए {pro} एक्सेस का पता लगाया और उसे पुनर्प्राप्त किया। आपकी {pro} स्थिति पुनः स्थापित कर दी गई है! + चूंकि आपने मूल रूप से {platform_store} के माध्यम से {app_pro} के लिए साइन अप किया था, इसलिए आपको अपने {platform_account} का उपयोग करके अपनी {pro} पहुँच को अपडेट करना होगा। + फिलहाल, {pro} एक्सेस केवल {platform_store} या {platform_store_other} के माध्यम से ही खरीदा जा सकता है। चूंकि आप {app_name} डेस्कटॉप का उपयोग कर रहे हैं, आप यहाँ {pro} में अपग्रेड नहीं कर सकते।\n\n{app_name} के डेवलपर वैकल्पिक भुगतान विकल्पों पर काम कर रहे हैं ताकि उपयोगकर्ता {platform_store} और {platform_store_other} के बाहर से {pro} एक्सेस खरीद सकें। {pro} रोडमैप {icon} सक्रिय किया गया + सक्रिय कर रहे हैं + आप पूरी तरह से तैयार हैं! + आपकी {app_pro} पहुँच अपडेट कर दी गई है! आपको {date} को {pro} के स्वचालित रूप से नवीनीकरण के समय बिल भेजा जाएगा। आपके पास पहले से ही है आगे बढ़ें और अपनी डिस्प्ले तस्वीर के लिए GIF और एनिमेटेड WebP इमेज अपलोड करें! + एनीमेटेड डिस्प्ले तस्वीरें प्राप्त करें और {app_pro} Beta के साथ प्रीमियम सुविधाओं का अनलॉक करें एनिमेटेड डिस्प्ले तस्वीर उपयोगकर्ता GIF अपलोड कर सकते हैं + एनिमेटेड डिस्प्ले तस्वीरें + एनिमेटेड GIF और WebP छवियों को अपनी डिस्प्ले तस्वीर के रूप में सेट करें। GIF अपलोड करें + {time} में {pro} स्वतः नवीनीकरण होगा + {pro} बैज + अन्य उपयोगकर्ताओं को {app_pro} बैज दिखाएं + बैज + अपने डिस्प्ले नाम के पास एक विशेष बैज के साथ {app_name} के लिए अपना समर्थन दिखाएं। + + %1$s %2$s बैज भेजा गया + %1$s %2$s बैज भेजे गए + + {pro} बीटा फीचर्स + {price} प्रति वर्ष बिल किया जाएगा + {price} प्रति माह बिल किया जाएगा + {price} प्रति तिमाही बिल किया जाएगा + लंबे संदेश भेजना चाहते हैं?\n{app_pro} Beta के साथ अधिक टेक्स्ट भेजें और प्रीमियम सुविधाओं का अनलॉक करें + अधिक पिन करना चाहते हैं?\n{app_pro} Beta के साथ अपनी चैट व्यवस्थित करें और प्रीमियम सुविधाओं का अनलॉक करें + {limit} से अधिक पिन करना चाहते हैं?\n{app_pro} Beta के साथ अपनी चैट व्यवस्थित करें और प्रीमियम सुविधाओं का अनलॉक करें + हमें खेद है कि आप {pro} रद्द कर रहे हैं। अपना {pro} एक्सेस रद्द करने से पहले जानने के लिए यहां कुछ बातें दी गई हैं। + रद्दीकरण + {pro} एक्सेस रद्द करने से {pro} एक्सेस समाप्त होने से पहले अपने आप नवीनीकरण नहीं होगा। {pro} को रद्द करने से रिफ़ंड नहीं होता है। आप तब तक {app_pro} फ़ीचर्स का उपयोग कर सकेंगे जब तक आपकी {pro} एक्सेस समाप्त नहीं हो जाती।\n\nचूंकि आपने मूलतः {platform_account} का उपयोग करके {app_pro} के लिए साइन अप किया था, इसलिए {pro} को रद्द करने के लिए आपको उसी {platform_account} का उपयोग करना होगा। + {pro} एक्सेस रद्द करने के दो तरीके: + {pro} एक्सेस रद्द करने से {pro} समाप्त होने से पहले स्वतः नवीनीकरण नहीं होगा।\n\n{pro} रद्द करने से रिफ़ंड नहीं होता है। आप तब तक {app_pro} सुविधाओं का उपयोग कर सकेंगे जब तक आपकी {pro} एक्सेस वैध है। + अपने लिए सही {pro} एक्सेस विकल्प चुनें।\nलंबी अवधि की एक्सेस का मतलब है अधिक छूट। + क्या आप वाकई इस डिवाइस से अपने डेटा को हटाना चाहते हैं?\n\n{app_pro} को किसी अन्य खाते में स्थानांतरित नहीं किया जा सकता। कृपया अपना रिकवरी पासवर्ड सहेजें ताकि आप बाद में अपनी {pro} एक्सेस को पुनर्स्थापित कर सकें। + क्या आप वाकई नेटवर्क से अपने डेटा को हटाना चाहते हैं? यदि आप जारी रखते हैं, तो आप अपने संदेश या संपर्क पुनःस्थापित नहीं कर सकेंगे।\n\n{app_pro} को किसी अन्य खाते में स्थानांतरित नहीं किया जा सकता। कृपया अपना रिकवरी पासवर्ड सहेजें ताकि आप बाद में अपनी {pro} एक्सेस को पुनर्स्थापित कर सकें। + आपकी {pro} पहुँच पहले से ही पूर्ण {app_pro} मूल्य का {percent}% छूट के साथ है। + {pro} स्थिति को ताज़ा करने में त्रुटि + समय समाप्त + दुर्भाग्यवश, आपकी {pro} पहुँच समाप्त हो गई है।\n{app_pro} बीटा के विशेष लाभों और सुविधाओं को पुनः सक्रिय करने के लिए नवीनीकृत करें। + जल्द समाप्त हो रहा है + आपकी {pro} पहुँच {time} में समाप्त हो रही है।\nअब अपडेट करें ताकि आप {app_pro} बीटा के विशेष लाभों और सुविधाओं का उपयोग जारी रख सकें + {pro} {time} में समाप्त हो रहा है + {pro} सामान्य प्रश्न + {app_pro} सामान्य प्रश्न अनुभाग में सामान्य प्रश्नों के उत्तर खोजें। GIF और WebP डिस्प्ले तस्वीरें अपलोड करें 300 सदस्यों तक बड़े समूह चैट साथ में कई और विशेष सुविधाएं 10,000 वर्णों तक संदेश अनंत वार्तालाप पिन करें + क्या आप {app_name} का पूरा लाभ उठाना चाहते हैं?\n{app_pro} बीटा में अपग्रेड करें और अनेक विशिष्ट फ़ायदों और सुविधाओं का अनुभव प्राप्त करें। समूह सक्रिय किया गया इस समूह की क्षमता बढ़ाई गई है! यह अब 300 सदस्यों तक का समर्थन कर सकता है क्योंकि एक समूह व्यवस्थापक के पास + + %1$s समूह उन्नत किया गया + %1$s समूहों को उन्नत किया गया + + रिफंड का अनुरोध अंतिम होता है। यदि स्वीकृत हो जाता है, तो आपकी {pro} पहुँच तुरंत रद्द कर दी जाएगी और आप सभी {pro} सुविधाओं की पहुँच खो देंगे। बढ़ाया गया अटैचमेंट आकार बढ़ाई गई संदेश लंबाई + बड़े समूह + जिन समूहों में आप व्यवस्थापक हैं, वे स्वचालित रूप से 300 सदस्यों का समर्थन करने के लिए अपग्रेड किए जाते हैं। + बड़े समूह चैट (300 तक सदस्यों के साथ) जल्द ही सभी Pro बीटा उपयोगकर्ताओं के लिए आ रहे हैं! + लंबे संदेश + आप सभी संवादों में 10,000 अक्षरों तक के संदेश भेज सकते हैं। + + %1$s लंबा संदेश भेजा गया + %1$s लंबे संदेश भेजे गए + इस संदेश में निम्नलिखित {app_pro} विशेषताएँ उपयोग की गईं हैं: + नई स्थापना के साथ + {platform_store} के माध्यम से इस डिवाइस पर {app_name} को पुनःइंस्टॉल करें, अपने रिकवरी पासवर्ड का उपयोग करके अपना खाता पुनःस्थापित करें, और {app_pro} सेटिंग्स से {pro} का नवीनीकरण करें। + इस डिवाइस पर {platform_store} के माध्यम से {app_name} को पुनः इंस्टॉल करें, अपनी रिकवरी पासवर्ड से खाता पुनर्स्थापित करें, और {app_pro} सेटिंग्स से {pro} में अपग्रेड करें। + अभी के लिए, नवीनीकरण के तीन तरीके हैं: + अभी के लिए, नवीनीकरण के दो तरीके हैं: + {percent}% छूट + + %1$s पिन की गई बातचीत + %1$s पिन की गई बातचीत + + चूंकि आपने मूल रूप से {platform_store} के माध्यम से {app_pro} के लिए साइन अप किया था, इसलिए आपको धनवापसी का अनुरोध करने के लिए अपने {platform_account} का उपयोग करना होगा। + चूंकि आपने प्रारंभ में {platform_store} के माध्यम से {app_pro} के लिए साइन अप किया था, इसलिए आपका धनवापसी अनुरोध {app_name} सहायता द्वारा संसाधित किया जाएगा।\n\nनीचे दिए गए बटन पर क्लिक करें और धनवापसी अनुरोध फ़ॉर्म भरें ताकि आप धनवापसी का अनुरोध कर सकें।\n\n{app_name} सहायता 24-72 घंटों के भीतर अनुरोधों को संसाधित करने का प्रयास करती है, लेकिन अनुरोधों की उच्च मात्रा के समय इसमें अधिक समय लग सकता है। + आपकी {app_pro} एक्सेस नवीनीकृत कर दी गई है! {network_name} का समर्थन करने के लिए धन्यवाद। + 1 महीना - {monthly_price} / माह + 3 महीने - {monthly_price} / माह + 12 महीने - {monthly_price} / माह + पुनः सक्रिय कर रहे हैं + यह {app_name} खाता उस {device_type} डिवाइस पर खोलें जिसमें वही {platform_account} लॉग इन है जिससे आपने साइन अप किया था। फिर, {app_pro} सेटिंग्स में जाकर रिफ़ंड का अनुरोध करें। + हमें आपके जाने का दुख है। रिफंड का अनुरोध करने से पहले आपको ये बात जाननी चाहिए। + {platform} अभी आपके रिफंड अनुरोध को प्रोसेस कर रहा है। यह सामान्यतः 24-48 घंटे लेता है। उनके निर्णय के आधार पर, आप {app_name} में अपनी {pro} स्थिति को बदलते हुए देख सकते हैं। + आपका धनवापसी अनुरोध {app_name} सहायता द्वारा संभाला जाएगा।\n\nनीचे दिए गए बटन को दबाकर और धनवापसी अनुरोध फ़ॉर्म पूरा करके धनवापसी का अनुरोध करें।\n\n{app_name} सहायता 24-72 घंटों के भीतर धनवापसी अनुरोधों को संसाधित करने का प्रयास करती है, लेकिन उच्च अनुरोध मात्रा के समय इसमें अधिक समय लग सकता है। + आपका धनवापसी अनुरोध विशेष रूप से {platform} वेबसाइट के माध्यम से {platform} द्वारा संभाला जाएगा।\n\n{platform} की धनवापसी नीतियों के कारण, {app_name} के डेवलपर धनवापसी अनुरोध के परिणाम को प्रभावित नहीं कर सकते। इसमें यह भी शामिल है कि अनुरोध को स्वीकृत या अस्वीकार किया जाए, साथ ही पूरी या आंशिक धनवापसी दी जाए या नहीं। + कृपया अपने रिफंड अनुरोध पर आगे की जानकारी के लिए {platform} से संपर्क करें। {platform} की रिफंड नीतियों के कारण, {app_name} डेवलपर्स के पास रिफंड परिणामों को प्रभावित करने की कोई क्षमता नहीं है।\n\n{platform} रिफंड समर्थन + {pro} की धनवापसी की जा रही है + {app_pro} के लिए रिफंड केवल {platform} द्वारा {platform_store} के माध्यम से नियंत्रित किए जाते हैं।\n\n{platform} की रिफंड नीतियों के कारण, {app_name} डेवलपर्स रिफंड अनुरोधों के परिणाम को प्रभावित नहीं कर सकते। इसमें यह शामिल है कि अनुरोध को स्वीकृत या अस्वीकृत किया जाए, और पूर्ण या आंशिक रिफंड जारी किया जाए या नहीं। + क्या आप फिर से एनिमेटेड डिस्प्ले पिक्चर्स का उपयोग करना चाहते हैं?\nजिन फ़ीचर्स को आप मिस कर रहे थे उन्हें अनलॉक करने के लिए अपना {pro} एक्सेस नवीनीकरण करें। + {pro} बीटा को नवीनीकृत करें + {app_name} वाले लिंक किए गए डिवाइस के {app_pro} सेटिंग्स से, जो {platform_store} या {platform_store_other} के माध्यम से इंस्टॉल किया गया है, अपनी {pro} एक्सेस को नवीनीकृत करें। + क्या आप फिर से लंबे संदेश भेजना चाहते हैं?\nवह सुविधाएँ अनलॉक करने के लिए अपना {pro} एक्सेस नवीनीकरण करें जिन्हें आप मिस कर रहे थे। + क्या आप {app_name} का अधिकतम उपयोग फिर से करना चाहते हैं?\nअपनी {pro} एक्सेस को नवीनीकृत करें ताकि आप जिन सुविधाओं से चूक रहे थे उन्हें अनलॉक कर सकें। + क्या आप फिर से {limit} से अधिक बातचीत को पिन करना चाहते हैं?\nअपनी {pro} एक्सेस को नवीनीकृत करें ताकि आप जिन सुविधाओं से चूक रहे थे उन्हें अनलॉक कर सकें। + क्या आप फिर से अधिक बातचीत को पिन करना चाहते हैं?\nअपनी {pro} एक्सेस को नवीनीकृत करें ताकि आप जिन सुविधाओं से चूक रहे थे उन्हें अनलॉक कर सकें। + नवीनीकरण करके, आप {app_pro} की सेवा की शर्तों {icon} और गोपनीयता नीति {icon} से सहमत होते हैं + नवीनीकरण कर रहे हैं + वर्तमान में, {pro} एक्सेस केवल {platform_store} या {platform_store_other} के माध्यम से ही खरीदी और नवीनीकृत की जा सकती है। चूंकि आपने {build_variant} का उपयोग करके {app_name} इंस्टॉल किया है, आप यहां नवीनीकरण नहीं कर सकते।\n\n{app_name} के डेवलपर्स वैकल्पिक भुगतान विकल्पों पर काम कर रहे हैं ताकि उपयोगकर्ता {platform_store} और {platform_store_other} से बाहर {pro} एक्सेस खरीद सकें। {pro} रोडमैप {icon} + रिफंड का अनुरोध किया गया इसके साथ और भेजें + {pro} सेटिंग्स + {pro} का उपयोग प्रारंभ करें + आपकी {pro} आंकड़े + {pro} आँकड़े लोड हो रहे हैं + आपके {pro} आँकड़े लोड हो रहे हैं, कृपया प्रतीक्षा करें। + {pro} आंकड़े इस डिवाइस पर उपयोग को दर्शाते हैं और लिंक किए गए डिवाइसों पर अलग दिख सकते हैं + {pro} स्थिति त्रुटि + आपकी {pro} स्थिति जांचने के लिए नेटवर्क से कनेक्ट नहीं हो सका। जब तक कनेक्टिविटी पुनः स्थापित नहीं होती, इस पृष्ठ पर दिखाई गई जानकारी गलत हो सकती है।\n\nकृपया अपने नेटवर्क कनेक्शन की जांच करें और पुनः प्रयास करें। + {pro} स्थिति लोड हो रही है + आपकी {pro} जानकारी लोड हो रही है। जब तक लोडिंग पूरी नहीं होती तब तक इस पृष्ठ पर कुछ क्रियाएं उपलब्ध नहीं होंगी। + {pro} स्थिति लोड हो रही है + आपकी {pro} स्थिति की जाँच के लिए नेटवर्क से कनेक्ट नहीं हो सका। जब तक कनेक्टिविटी पुनः स्थापित नहीं होती, आप आगे नहीं बढ़ सकते।\n\nकृपया अपनी नेटवर्क कनेक्शन की जाँच करें और पुनः प्रयास करें। + आपकी {pro} स्थिति जांचने के लिए नेटवर्क से कनेक्ट नहीं हो सका। जब तक कनेक्टिविटी पुनः स्थापित नहीं होती, तब तक आप {pro} में उन्नत नहीं कर पाएंगे।\n\nकृपया अपने नेटवर्क कनेक्शन की जांच करें और पुनः प्रयास करें। + आपकी {pro} स्थिति को ताज़ा करने के लिए नेटवर्क से कनेक्ट नहीं हो सका। जब तक कनेक्टिविटी पुनः स्थापित नहीं होती, तब तक इस पृष्ठ पर कुछ क्रियाएं अक्षम रहेंगी।\n\nकृपया अपने नेटवर्क कनेक्शन की जांच करें और पुनः प्रयास करें। + आपके वर्तमान {pro} एक्सेस को लोड करने के लिए नेटवर्क से कनेक्ट नहीं हो सका। {app_name} के माध्यम से {pro} का नवीनीकरण तब तक अक्षम रहेगा जब तक कनेक्टिविटी बहाल नहीं हो जाती।\n\nकृपया अपना नेटवर्क कनेक्शन जाँचे और पुनः प्रयास करें। + क्या आपको {pro} में सहायता चाहिए? सहायता टीम को अनुरोध भेजें। + {action_type} करने पर, आप {app_pro} को {app_name} प्रोटोकॉल के माध्यम से {activation_type} कर रहे हैं। {entity} इस सक्रियण की सुविधा प्रदान करेगा लेकिन {app_pro} का प्रदाता नहीं है। {entity} {app_pro} के प्रदर्शन, उपलब्धता या कार्यक्षमता के लिए जिम्मेदार नहीं है। + अपडेट करके, आप {app_pro} की सेवा की शर्तों {icon} और गोपनीयता नीति {icon} से सहमत होते हैं + अनलिमिटेड पिन्स + अनलिमिटेड पिन की गई बातचीत के साथ अपनी सभी चैट को व्यवस्थित करें। + आपका वर्तमान बिलिंग विकल्प {pro} पहुँच की {current_plan_length} प्रदान करता है। क्या आप वाकई {selected_plan_length_singular} बिलिंग विकल्प में स्विच करना चाहते हैं?\n\nअपडेट करने पर, आपकी {pro} पहुँच {date} को स्वचालित रूप से नवीनीकृत हो जाएगी, जो अतिरिक्त {selected_plan_length} की {pro} पहुँच प्रदान करेगी। + आपकी {pro} पहुँच {date} को समाप्त हो जाएगी।\n\nअपडेट करने पर, आपकी {pro} पहुँच {date} को स्वचालित रूप से नवीनीकृत हो जाएगी जो अतिरिक्त {selected_plan_length} की {pro} पहुँच प्रदान करेगी। + अद्यतन कर रहे हैं + कई विशिष्ट फ़ायदों और सुविधाओं तक पहुँच के लिए {app_pro} बीटा में अपग्रेड करें। + {platform_store} या {platform_store_other} के माध्यम से {app_name} इंस्टॉल किए गए लिंक किए गए डिवाइस पर {app_pro} सेटिंग्स से {pro} में अपग्रेड करें। + फिलहाल, {pro} एक्सेस केवल {platform_store} या {platform_store_other} के माध्यम से ही खरीदी जा सकती है। चूंकि आपने {app_name} को {build_variant} का उपयोग करके इंस्टॉल किया है, आप यहाँ {pro} में अपग्रेड नहीं कर सकते।\n\n{app_name} के डेवलपर वैकल्पिक भुगतान विकल्पों पर काम कर रहे हैं ताकि उपयोगकर्ता {platform_store} और {platform_store_other} के बाहर से {pro} एक्सेस खरीद सकें। {pro} रोडमैप {icon} + अभी के लिए, अपग्रेड करने का केवल एक ही तरीका है: + अभी के लिए, अपग्रेड करने के दो तरीके हैं: + आपने {app_pro} में अपग्रेड कर लिया है!\n{network_name} का समर्थन करने के लिए धन्यवाद। + अपग्रेड कर रहे हैं + {pro} में अपग्रेड किया जा रहा है + अपग्रेड करके, आप {app_pro} की सेवा की शर्तों {icon} और गोपनीयता नीति {icon} से सहमत होते हैं + क्या आप {app_name} से अधिक प्राप्त करना चाहते हैं?\n{app_pro} Beta में अपग्रेड करें एक अधिक शक्तिशाली संदेश अनुभव के लिए। + {platform} आपके रिफंड अनुरोध को प्रोसेस कर रहा है प्रोफ़ाइल प्रदर्शन चित्र डिस्प्ले तस्वीर हटाने में विफल। @@ -842,6 +1155,11 @@ Please pick a smaller file. प्रोफ़ाइल अपडेट करने में विफल। पदोन्नत करें + एडमिन पिछले 14 दिनों के संदेश इतिहास को देख सकेंगे और उन्हें डिमोट या समूह से हटाया नहीं जा सकता। + + सदस्य को पदोन्नत करें + सदस्यों को पदोन्नत करें + प्रमोशन विफल प्रमोशन विफल @@ -871,6 +1189,7 @@ संस्तुत अपना रिकवरी पासवर्ड सहेजें ताकि आप अपने खाते तक पहुँच न खोएं। अपना रिकवरी पासवर्ड सहेजें + अपने खाते को नए डिवाइस पर लोड करने के लिए अपने रिकवरी पासवर्ड का उपयोग करें।\n\nआपका खाता बिना रिकवरी पासवर्ड के पुनर्प्राप्त नहीं किया जा सकता है। सुनिश्चित करें कि यह किसी सुरक्षित और संरक्षित स्थान पर संग्रहीत हो — और इसे किसी के साथ साझा न करें अपना पुनर्प्राप्ति पासवर्ड दर्ज करें आपका पुनर्प्राप्ति पासवर्ड लोड करने का प्रयास करते समय एक त्रुटि हुई।\n\nकृपया अपने लॉग निर्यात करें, फिर इस समस्या को हल करने में सहायता के लिए {app_name} सहायता डेस्क के माध्यम से फ़ाइल अपलोड करें। Please check your recovery password and try again. @@ -880,27 +1199,69 @@ अपना खाता लोड करने के लिए, अपना recovery password दर्ज करें। Recovery Password स्थायी रूप से छुपाएं बिना अपने रिकवरी पासवर्ड के, आप अपने खाते को नए उपकरणों पर लोड नहीं कर सकते। \n\nहम दृढ़ता से अनुशंसा करते हैं कि आप जारी रखने से पहले अपने रिकवरी पासवर्ड को सुरक्षित और सुरक्षित स्थान पर सहेज लें। + क्या आप वाकई इस डिवाइस पर अपने रिकवरी पासवर्ड को स्थायी रूप से छिपाना चाहते हैं?\n\nइसे वापस नहीं लिया जा सकता है। Recovery Password छुपाएं इस डिवाइस पर अपने पुनर्प्राप्ति पासवर्ड को स्थायी रूप से छिपाएँ। अपना खाता लोड करने के लिए अपना पुनर्प्राप्ति पासवर्ड दर्ज करें। यदि आपने इसे सहेजा नहीं है, तो आप इसे अपनी ऐप सेटिंग में पा सकते हैं। + रिकवरी पासवर्ड देखें + रिकवरी पासवर्ड दृश्यता यह आपका पुनर्प्राप्ति वाक्यांश है। अगर आप इसे किसी को भेजते हैं तो उनके पास आपके खाते की पूरी पहुंच होगी। समूह पुनः बनाएँ फिर से करें + चूंकि आपने मूल रूप से {app_pro} के लिए पंजीकरण करते समय एक अलग {platform_account} का उपयोग किया था, इसलिए आपको अपनी {pro} एक्सेस को अपडेट करने के लिए उसी {platform_account} का उपयोग करना होगा। + रिफ़ंड का अनुरोध करने के दो तरीके: संदेश की लंबाई {count} तक घटाएं %1$d वर्ण शेष %1$d वर्ण शेष + बाद में याद दिलाएँ हटा दें + + सदस्य हटाएँ + सदस्यों को हटाएँ + + + सदस्य और उनके संदेशों को हटाएं + सदस्यों और उनके संदेशों को हटाएं + पासवर्ड हटाने में विफल + {app_name} के लिए अपना मौजूदा पासवर्ड हटाएं। स्थानीय रूप से संग्रहीत डेटा को एक यादृच्छिक रूप से उत्पन्न कुंजी से फिर से एन्क्रिप्ट किया जाएगा, जो आपके डिवाइस पर संग्रहीत होगी। + + सदस्य को हटाया जा रहा है + सदस्यों को हटाया जा रहा है + + नवीनीकरण करें + {pro} का नवीनीकरण जवाब + रिफंड का अनुरोध करें + {platform} वेबसाइट पर रिफ़ंड का अनुरोध करें, उस {platform_account} का उपयोग करते हुए जिससे आपने {pro} के लिए साइन अप किया था। फिर से भेजें + + निमंत्रण को पुनः भेजें + निमंत्रणों को पुनः भेजें + + + प्रमोशन को फिर से भेजें + प्रमोशनों को फिर से भेजें + + + निमंत्रण दोबारा भेजा जा रहा है + निमंत्रण दोबारा भेजे जा रहे हैं + + + प्रमोशन फिर से भेजा जा रहा है + प्रमोशनों को फिर से भेजा जा रहा है + देश की जानकारी लोड हो रही है... पुनः आरंभ करें पुनः सिंक करें पुनः प्रयास करें समीक्षा सीमा ऐसा लगता है कि आपने हाल ही में {app_name} की समीक्षा पहले ही कर दी है, आपकी प्रतिक्रिया के लिए धन्यवाद! + ऐप को पृष्ठभूमि में चलाएं + क्या आप {app_name} को पृष्ठभूमि में चलाना चाहते हैं? + चूंकि आप स्लो मोड का उपयोग कर रहे हैं, हम अनुशंसा करते हैं कि अधिसूचना सुधार के लिए {app_name} को पृष्ठभूमि में चलाने की अनुमति दें। इससे अधिसूचना की स्थिरता में सुधार हो सकता है, हालांकि आपका सिस्टम फिर भी पृष्ठभूमि गतिविधियों को स्वचालित रूप से सीमित कर सकता है।\n\nआप बाद में सेटिंग्स में इसे बदल सकते हैं। संरक्षित करें सेव किया गया सेव किए गए संदेश @@ -909,6 +1270,8 @@ स्क्रीन सुरक्षा स्क्रीनशॉट सूचनाएं जब कोई संपर्क एक-से-एक चैट का स्क्रीनशॉट लेता है तो अधिसूचना की आवश्यकता होती है। + इस डिवाइस पर लिए गए स्क्रीनशॉट में {app_name} विंडो को छुपाएं। + स्क्रीनशॉट सुरक्षा {name} ने स्क्रीनशॉट लिया। सर्च संपर्क खोजें @@ -929,6 +1292,10 @@ भेजा जा रहा है कॉल ऑफर भेजा जा रहा है कनेक्शन उम्मीदवार भेजे जा रहे हैं + + एडमिन प्रमोशन भेजा जा रहा है + एडमिन प्रमोशन भेजे जा रहे हैं + भेजा गया: दिखावट डेटा हटाएं @@ -949,38 +1316,56 @@ सूचनाएं अनुमतियाँ गोपनियता + {app_pro} बीटा रिपवरी पासवर्ड सेटिंग्स सेट करें Community की डिस्प्ले तस्वीर सेट करें + {app_name} के लिए एक पासवर्ड सेट करें। इस पासवर्ड से स्थानीय रूप से संग्रहीत डेटा एन्क्रिप्ट किया जाएगा। हर बार {app_name} शुरू करने पर आपसे यह पासवर्ड माँगा जाएगा। + सेटिंग अपडेट नहीं हो सकती अपनी नई सेटिंग्स लागू करने के लिए आपको {app_name} पुनः प्रारंभ करना होगा। स्क्रीन सुरक्षा + स्टार्टअप शेयर करें अपने मित्र को अपने साथ {app_name} पर चैट करने के लिए Account ID साझा करके आमंत्रित करें। अपने दोस्तों के साथ वहां साझा करें जहां आप आमतौर पर उनसे बात करते हैं - फिर यहां बातचीत को स्थानांतरित करें। डेटाबेस खोलने में समस्या हो रही है। कृपया एप्लिकेशन को पुनः प्रारंभ करें और फिर से कोशिश करें। ओह! ऐसा लगता है कि आपके पास अभी तक {app_name} खाता नहीं है।\n\nशेयर करने से पहले आपको {app_name} ऐप में एक खाता बनाना होगा। + क्या आप इस उपयोगकर्ता के साथ समूह संदेश इतिहास साझा करना चाहेंगे? {app_name} को साझा करें + माफ़ कीजिए, {app_name} एक साथ कई छवियों और वीडियो को साझा करने का ही समर्थन करता है + साझाकरण केवल मीडिया का समर्थन करता है। गैर-मीडिया फ़ाइलों को शामिल नहीं किया गया है दिखाएं सभी को दिखाएं कम दिखाएं अपने लिए नोट दिखाएं क्या आप वाकई अपनी वार्तालाप सूची में अपने लिए नोट दिखाना चाहते हैं? + स्पेल चेकर स्टिकर + शक्ति + कोई समस्या है? सहायता लेख देखिए या {app_name} सहायता से संपर्क करें। सहायता पेज पर जाएँ सिस्टम सूचना: {information} पुनः प्रयास करने के लिए दबाएँ जारी रखें डिफ़ॉल्ट त्रुटि + वापसी + थीम पूर्वावलोकन {name} का Account ID आपकी पिछली इंटरैक्शन के आधार पर दृश्यमान है ब्लाइंडेड ID समुदायों में स्पैम को कम करने और गोपनीयता बढ़ाने के लिए उपयोग की जाती हैं + अनुवाद करें + ट्रे फिर से कोशिश करो टाइपिंग सूचक टाइपिंग सूचक देखें और साझा करें। उपलब्ध नहीं है वापस लाएं अनजान + असमर्थित CPU + अपडेट + {pro} एक्सेस अपडेट करें + अपनी {pro} पहुँच को अपडेट करने के दो तरीके: ऐप अपडेट सामुदायिक जानकारी अपडेट करें सामुदायिक नाम और विवरण सभी सामुदायिक सदस्यों को दिखाई देता है @@ -995,17 +1380,26 @@ कृपया एक छोटा समूह विवरण दर्ज करें {app_name} का एक नया संस्करण उपलब्ध है, अपडेट करने के लिए टैप करें {app_name} का एक नया संस्करण ({version}) उपलब्ध है। + प्रोफ़ाइल जानकारी अपडेट करें + आपका प्रदर्शन नाम और डिस्प्ले तस्वीर सभी बातचीत में दिखाई देती हैं। रिलीज़ नोट्स पे जाइए {app_name} अपडेट वर्ज़न {version} अंतिम अद्यतन {relative_time} पहले + अपडेट्स + अद्यतन किया जा रहा है... + अपग्रेड + {app_name} को अपग्रेड करें अपग्रेड करें अपलोड हो रहा है यूआरएल कॉपी करें यूआरएल खोलें यह आपके ब्राउज़र में खुलेगा। क्या आप वाकई इस यूआरएल को अपने ब्राउज़र में खोलना चाहते हैं?\n\n{url} + लिंक आपके ब्राउज़र में खुलेंगे। तीव्र मोड इस्तेमाल करे + {platform} वेबसाइट के माध्यम से उस {platform_account} का उपयोग करके अपनी योजना बदलें जिससे आपने साइन अप किया था। + {platform} वेबसाइट के माध्यम से वीडियो वीडियो चलाने में असमर्थ राय @@ -1014,7 +1408,12 @@ इसमें कुछ मिनटों का समय लगेगा । एक पल कृपया... चेतावनी + iOS 15 के लिए समर्थन समाप्त हो गया है। ऐप अपडेट प्राप्त करना जारी रखने के लिए iOS 16 या बाद के संस्करण में अपडेट करें। विंडो हाँ आप + आपका CPU SSE 4.2 निर्देशों का समर्थन नहीं करता है, जो कि Linux x64 ऑपरेटिंग सिस्टम पर {app_name} द्वारा छवियों को प्रोसेस करने के लिए आवश्यक हैं। कृपया किसी संगत CPU में अपग्रेड करें या किसी अन्य ऑपरेटिंग सिस्टम का उपयोग करें। + आपका रिकवरी पासवर्ड + ज़ूम फ़ैक्टर + टेक्स्ट और दृश्य तत्वों का आकार समायोजित करें। \ No newline at end of file diff --git a/app/src/main/res/values-b+hu+HU/strings.xml b/app/src/main/res/values-b+hu+HU/strings.xml index ae6f1f5f2a..b6671a0716 100644 --- a/app/src/main/res/values-b+hu+HU/strings.xml +++ b/app/src/main/res/values-b+hu+HU/strings.xml @@ -15,7 +15,13 @@ Ez a te Felhasználó ID-d. Más felhasználók beszkennelhetik, hogy egy beszélgetést indítsanak el veled. Eredeti méret Hozzáadás + + Adminisztrátor hozzáadása + Adminisztrátorok hozzáadása + + Adminisztrátor hozzáadása Adja meg a felhasználó fiókazonosítóját, akit adminisztrátorrá kíván kinevezni.\n\nEgyszerre több felhasználó hozzáadásához adja meg az egyes fiókazonosítókat vesszővel elválasztva. Egyszerre legfeljebb 20 fiókazonosító adható meg. + Az adminisztrátorokat nem lehet lefokozni vagy eltávolítani a csoportból. Adminokat nem lehet eltávolítani. {name} és {count} másik személy adminisztrátorrá lettek előléptetve. Adminisztrátorok előléptetése @@ -40,12 +46,19 @@ {name} el lett távolítva mint adminisztrátor. {name} és {count} másik személy el lettek távolítva adminisztrátorként. {name} és {other_name} el lettek távolítva adminisztrátorként. + + %1$d adminisztrátor kiválasztva + %1$d adminisztrátor kiválasztva + Adminisztrátori kinevezés küldése Adminisztrátori kinevezés küldése Adminisztrátor beállítások + Nem módosíthatod az adminisztrátori státuszodat. A csoport elhagyásához nyisd meg a beszélgetés beállításait, és válaszd a Csoport elhagyása lehetőséget. {name} és {other_name} adminisztrátorrá lett előléptetve. + Adminisztrátorok + Engedélyezés +{count} Névtelen Alkalmazásikon @@ -65,6 +78,7 @@ Jegyzetek Részvények Időjárás + {app_pro} kitűző Automatikus sötét mód Menüsor elrejtése Nyelv @@ -149,6 +163,7 @@ Felhasználó kitiltása Felhasználó kitiltva Adja meg annak a felhasználónak a fiókazonosítóját, amelyiket ki akarja tiltani + Elrejtett azonosító Letiltás Üzenet küldéséhez oldd fel a kontakt letiltását. Nincsenek blokkolt kontaktok @@ -159,6 +174,8 @@ Biztos, hogy fel szeretnéd oldani {name} és {count} másik személy letiltását? Biztos, hogy fel szeretnéd oldani {name} és egy másik személy letiltását? Blokkolás feloldva: {name} + Letiltott partnerek megtekintése és kezelése. + Nem található böngésző az URL megnyitásához, próbáld meg inkább kimásolni az URL-t Hívás {name} hívott téged Nem tudsz új hívást kezdeni. Először fejezd be a jelenlegi hívást. @@ -187,6 +204,10 @@ Lehetővé teszi a hang- és videohívásokat más felhasználókkal. Felhívtad őt: {name} Elmulasztottad {name} hívását, mert a hang- és videó hívások funkció nincs engedélyezve az adatvédelmi beállításokban. + A(z) {app_name} alkalmazásnak szüksége van kamerádhoz való hozzáférésre a videóhívások engedélyezéséhez, azonban ez az engedély megtagadásra került. A hívás alatt nem módosíthatod a kamerához való hozzáférést.\n\nSzeretnéd most befejezni a hívást és engedélyezni a kamerához való hozzáférést, vagy inkább emlékeztetőt kérsz a hívás után? + A kamera-hozzáférés engedélyezéséhez nyisd meg a beállításokat, és kapcsold be a Kamera engedélyt. + Az előző hívás során megpróbáltad használni a videót, de nem sikerült, mert a kamera-hozzáférés korábban megtagadásra került. A hozzáférés engedélyezéséhez nyisd meg a beállításokat, és kapcsold be a Kamera engedélyt. + Kamera-hozzáférés szükséges Nem található kamera A kamera nem érhető el. Kamera-hozzáférés megadása @@ -194,7 +215,19 @@ {app_name} alkalmazásnak kamera-hozzáférésre van szüksége fotók és videók készítéséhez, illetve QR-kódok beolvasásához. {app_name} alkalmazásnak kamera-hozzáférésre van szüksége a QR-kódok beolvasásához Mégsem + {pro} megszakítása + Szüntesse meg a {pro} előfizetést a {platform} weboldalon, azzal a {platform_account} fiókkal, amellyel regisztrált. + Szüntesse meg a {pro} előfizetést a {platform_store} weboldalon, azzal a {platform_account} fiókkal, amellyel regisztrált. + Módosítás Jelszó módosítása sikertelen + Módosítsa a(z) {app_name} jelszavát. A helyileg tárolt adatokat az alkalmazás újratitkosítja az új jelszavával. + Beállítás módosítása + {pro} állapot ellenőrzése + A(z) {pro} állapot ellenőrzése folyamatban. Amint ez a folyamat befejeződött, folytathatja. + A(z) {pro} adataid ellenőrzése folyamatban van. Egyes műveletek ezen az oldalon nem lesznek elérhetők, amíg az ellenőrzés be nem fejeződik. + {pro} állapotának ellenőrzése... + A(z) {pro} részleteinek ellenőrzése folyamatban. A megújítás nem hajtható végre, amíg ez az ellenőrzés be nem fejeződik. + A(z) {pro} állapotod ellenőrzése zajlik. A vizsgálat befejezése után lehetőséged lesz frissíteni a(z) {pro} verzióra. Törlés Összes törlése Összes adat törlése @@ -232,6 +265,7 @@ Commit Hash: {hash} Ez ki fogja tiltani a kiválasztott felhasználót ebből a közösségből és törölni fogja az összes üzenetét. Biztosan folytatni akarod? Ez ki fogja tiltani a kiválasztott felhasználót ebből a közösségből. Biztosan folytatni akarod? + Adja meg a közösség leírását Add meg a közösség URL-jét Érvénytelen URL Ellenőrizd a közösség URL-jét és próbáld újra. @@ -246,15 +280,23 @@ Te már csatlakoztál ehhez a közösséghez. Kilépés a közösségből Nem sikerült kilépni a {community_name} közösségből + Adja meg a közösség nevét + Adja meg a közösség nevét Ismeretlen közösség Közösségi URL Közösségi URL másolása Megerősítés + Előléptetés megerősítése + Biztos vagy benne? Az adminisztrátorokat nem lehet lefokozni vagy eltávolítani a csoportból. Kontaktok Névjegy törlése Biztos, hogy törölni szeretnéd {name} a névjegyeid közül? {name} új üzenetei üzenetkérelemként fognak megérkezni. Még nincsenek kontaktjaid Kontaktok kiválasztása + + %1$d partner kiválasztva + %1$d partner kiválasztva + Felhasználó adatai Kamera Válasszon egy műveletet a beszélgetés megkezdéséhez @@ -262,6 +304,7 @@ Üzenet írása Az idézett üzenetben megjelenített fotó előnézeti képe Beszélgetés indítása új kontakttal + Válassza ki, hogy milyen tartalom jelenjen meg a helyi értesítésekben, amikor bejövő üzenet érkezik. Parancsikon hozzáadása Parancsikon hozzáadva Hangüzenetek @@ -274,6 +317,7 @@ Beszélgetés törölve A {conversation_name} beszélgetésben nincsenek további üzenetek. Enter billentyű + Határozd meg, hogyan működnek az Enter és a Shift+Enter billentyűk a beszélgetésekben. SHIFT + ENTER elküldi az üzenetet, ENTER új sort kezd. ENTER elküldi az üzenetet, SHIFT + ENTER új sort kezd. Csoportok @@ -284,6 +328,7 @@ Még nincsenek beszélgetéseid Küldés az enter billentyűvel Az Enter billentyű lenyomása elküldi az üzenetet új sor kezdése helyett. + Küldés Shift+Enter-rel Összes médiafájl Helyesírás ellenőrzése A helyesírás-ellenőrzés engedélyezése üzenetek írásakor. @@ -292,7 +337,10 @@ Másolás Létrehozás Hívás készítése + Jelenlegi számlázás + Jelenlegi jelszó Kivágás + Sötét mód Biztosan törli az összes üzenetet, mellékletet és fiókadatot erről az eszközről, és új fiókot hoz létre? Adatbázishiba történt.\n\nExportálja az alkalmazás naplóit, hogy megoszhassa azokat a hibaelhárításhoz. Ha ez nem sikerül, telepítse újra a(z) {app_name} alkalmazást és állítsa vissza a fiókját. Biztosan törli az összes üzenetet, mellékletet és fiókadatot erről az eszközről, és vissza állítja a fiókját a hálózatról? @@ -317,6 +365,14 @@ Várj amíg a csoport elkészül... Nem sikerült frissíteni a csoportot Nincs jogod mások üzeneteit törölni + + Kiválasztott melléklet törlése + Kiválasztott mellékletek törlése + + + Biztosan törölni szeretné a kijelölt mellékletet? Az ahhoz tartozó üzenet is törlésre kerül. + Biztosan törölni szeretné a kijelölt mellékleteket? Az ahhoz tartozó üzenetek is törlésre kerülnek. + Biztosan törli a névjegyek közül a következőt: {name}?\n\nEzzel törli a beszélgetést, beleértve az összes üzenetet és mellékletet. A jövőben a(z) {name} nevű partnerétől érkező üzenetek üzenetkérésként fognak megjelenni. Biztosan törli a következő partnerével folytatott beszélgetést: {name}?\nEz véglegesen törli az összes üzenetet és mellékletet. @@ -356,6 +412,7 @@ Biztos, hogy törölni szeretnéd ezeket az üzeneteket mindenki számára? Törlés Fejlesztői eszközök be-/kikapcsolása + Eszköz értesítési beállításai Diktálás indítása... Eltűnő üzenetek Az üzenet {time_large} múlva törlődik @@ -391,6 +448,7 @@ {admin_name} frissítette az eltűnő üzenetek beállításait. Te frissítetted az eltűnő üzenetek beállításait. Elvetés + Megjelenítés Ez lehet a valódi neved, egy álnév, vagy bármi más, amit szeretnél — és bármikor megváltoztathatod. Add meg a felhasználónevedet Adj meg egy felhasználónevet @@ -402,6 +460,8 @@ Az Ön megjelenítendő neve látható a felhaszálók, a csoportok és a közösségek számára, amelyekkel interakcióba lép. Dokumentum Adományozás + Hatalmas erők próbálják gyengíteni az adatvédelmet, de ezt a harcot nem tudjuk egyedül folytatni.\n\nA támogatásod segít megőrizni a(z) {app_name} biztonságát, függetlenségét és elérhetőségét. + A(z) {app_name} a segítségedet kéri Kész Letöltés Letöltés... @@ -431,17 +491,35 @@ Te és {name} ezzel reagáltatok: {emoji_name} Reagált az üzenetedre {emoji} Engedélyezés + Engedélyezed a Kamera-hozzáférést? + Értesítések megjelenítése új üzenetek fogadásakor. + Hívás befejezése az engedélyezéshez Tetszik a {app_name} alkalmazás? Még van min javítani {emoji} Fantasztikus {emoji} Már egy ideje használod a {app_name}-t, hogy tetszik? Nagyon örülnénk, ha megosztanád velünk a véleményedet. + Bejelentkezés + Adja meg azt a jelszót, amit a {app_name} alkalmazáshoz állított be. + Adja meg azt a jelszót, amellyel a(z) {app_name} alkalmazást indításkor feloldja – ne a Helyreállítási jelszót. + Hiba történt a(z) {pro} állapot ellenőrzésekor Ellenőrizd az internetkapcsolatot, majd próbáld újra. Hiba másolása és kilépés Adatbázishiba Valami hiba történt. Próbálja meg később újra. + Hiba történt a(z) {pro} hozzáférés betöltésekor + A(z) {app_name} nem tudta megkeresni ezt az ONS-t. Kérjük, ellenőrizze hálózati kapcsolatát, és próbálja meg újra. Ismeretlen hiba történt. + Ez az ONS nincs regisztrálva. Kérjük, ellenőrizze, hogy helyes-e, és próbálja újra. + Nem sikerült újra elküldeni a meghívót {name} részére a {group_name} csoportban. + Nem sikerült újra elküldeni a meghívót {name} és {count} másik személy részére a {group_name} csoportban. + Nem sikerült újra elküldeni a meghívót {name} és {other_name} részére a {group_name} csoportban. + Nem sikerült újraküldeni a kinevezést {name} részére a {group_name} csoportban + Nem sikerült újraküldeni a kinevezést {name} és további {count} fő részére a {group_name} csoportban + Nem sikerült újraküldeni a kinevezést {name} és {other_name} részére a {group_name} csoportban Sikertelen letöltés Hibák + Visszajelzés + Oszd meg a(z) {app_name} használatával kapcsolatos tapasztalataidat egy rövid kérdőív kitöltésével. Fájl Fájlok Rendszerbeállítások használata. @@ -491,6 +569,9 @@ Biztos, hogy ki akarsz lépni a(z) {group_name} csoportból? Biztos, hogy ki akarsz lépni a {group_name} csoportból?\n\nEz az összes tag eltávolításával és a csoport teljes tartalmának törlésével jár. Nem sikerült kilépni a {group_name} csoportból + {name} meghívást kapott a csoportba. Az elmúlt 14 nap üzenetelőzménye megosztásra került. + {name} és {count} másik személy meghívást kaptak a csoportba. Az elmúlt 14 nap üzenetelőzménye megosztásra került. + {name} és {other_name} meghívást kaptak a csoportba. Az elmúlt 14 nap üzenetelőzménye megosztásra került. {name} elhagyta a csoportot. {name} és {count} másik személy kiléptek a csoportból. {name} és {other_name} kilépett a csoportból. @@ -502,6 +583,9 @@ {name} és {other_name} meghívást kaptak a csoportba. Te és {count} másik személy meg lettetek hívva a csoportba. A beszélgetési előzményeket megosztottuk. Önt és {other_name}-t meghívták a csoportba. A csevegési előzmények meg lettek osztva. + {name} eltávolítása sikertelen innen: {group_name} + {name} és további {count} fő eltávolítása sikertelen innen: {group_name} + {name} és {other_name} eltávolítása sikertelen innen: {group_name} Ön elhagyta a csoportot. Csoporttagok Nincsenek más tagok ebben a csoportban. @@ -515,6 +599,7 @@ Nincsenek üzenetek a {group_name} csoportban. Küldj egy üzenetet a beszélgetés megkezdéséhez! A csoportot több mint 30 napja nem frissítették. Előfordulhat, hogy problémák merülnek fel az üzenetek küldésével vagy a csoportinformációk megtekintésével kapcsolatban. Te vagy az egyetlen adminisztrátor a {group_name} csoportban.\n\nA csoporttagok és beállítások nem változtathatók adminisztrátor nélkül. + Te vagy az egyetlen adminisztrátor a {group_name} csoportban.\n\nA csoporttagok és a beállítások nem módosíthatók adminisztrátor nélkül. Ha törlés nélkül szeretnéd elhagyni a csoportot, kérlek előbb adj hozzá egy új adminisztrátort. Eltávolításra vár Te adminisztrátorrá lettél előléptetve. Te és {count} másik személy adminisztrátorrá lettetek előléptetve. @@ -542,6 +627,7 @@ Csoport frissítve Kapcsolat jelöltek kezelése GYIK + Nézd meg a(z) {app_name} GYIK-et a gyakori kérdésekre adott válaszokért. Segíts lefordítani {app_name}-t Hiba jelentése Ossza meg velünk a problémája részleteit. Exportálja a naplóit, majd töltse fel a fájlt a(z) {app_name} Help Desk-en keresztül. @@ -550,6 +636,7 @@ Mentés az asztalra Mentse ezt a fájlt, majd ossza meg a {app_name} fejlesztőivel. Támogatás + Segíts lefordítani a(z) {app_name} alkalmazást több mint 80 nyelvre! Örülnénk a visszajelzésednek Elrejtés A rendszer menüsáv láthatóságának be/kikapcsolása. @@ -557,10 +644,15 @@ Többi elrejtése Kép képek + Fontos Inkognitó billentyűzet Inkognitó mód igénylése, ha elérhető. A használt billentyűzettől függően előfordulhat, hogy a billentyűzet figyelmen kívül hagyja ezt a kérést. Üzenet adatai Érvénytelen parancsikon + + Partner meghívása + Partnerek meghívása + Sikertelen meghívás Sikertelen meghívások @@ -569,8 +661,17 @@ A meghívót nem lehetett elküldeni. Szeretné újra megpróbálni? A meghívókat nem lehetett elküldeni. Szeretné újra megpróbálni? + + Tag meghívása + Tagok meghívása + + Hívj meg egy új tagot a csoportba a barátod Felhasználóazonosítójának (Account ID), ONS azonosítójának megadásával, vagy a QR-kód beolvasásával {icon} + Új tag meghívása a csoportba a barátod Felhasználóazonosítójának (Account ID), ONS azonosítójának megadásával vagy a QR-kódjuk beolvasásával Csatlakozás Később + A(z) {app_name} automatikus elindítása a számítógép indításakor. + Indítás rendszerindításkor + Ez a beállítás Linux rendszereken a rendszer által van kezelve. Az automatikus indítás engedélyezéséhez adja hozzá a(z) {app_name} alkalmazást az indítási alkalmazásaihoz a rendszerbeállításokban. További információ Kilépés Kilépés... @@ -585,6 +686,8 @@ Te és {other_name} csatlakoztatok a csoporthoz. {name} és {other_name} csatlakozott a csoporthoz. Te csatlakoztál a csoporthoz. + Korlátozni szeretnéd a háttértevékenységet? + Jelenleg engedélyezted, hogy a(z) {app_name} a háttérben fusson az értesítések megbízhatóságának javítása érdekében. Ennek a beállításnak a módosítása kevésbé megbízható értesítéseket eredményezhet. Linkelőnézetek Hivatkozás előnézet készítése támogatott URL-ekhez. Linkelőnézetek engedélyezése @@ -595,6 +698,7 @@ A linkelőnézetének elküldésekor nem lesz teljes metaadat-védelmed. Linkelőnézetek letiltva {app_name} kapcsolatba kell lépjen a hivatkozott webhelyekkel, hogy előnézeteket készíthessen az általad küldött és fogadott linkekről.\n\n Ezt a {app_name} beállításaiban bekapcsolhatod. + Hivatkozások Felhasználó betöltése Felhasználó betöltése Betöltés... @@ -607,9 +711,17 @@ Zár státusza Koppintson a feloldáshoz {app_name} feloldva + Naplók + Adminok kezelése Tagok kezelése + {pro} kezelése Maximum + Talán később Média + + %1$d tag kiválasztva + %1$d tag kiválasztva + %1$d tag %1$d tag @@ -619,7 +731,9 @@ %1$d aktív tag Felhasználó ID vagy ONS hozzáadása + A tagok csak akkor nevezhetők ki, ha elfogadták a csoportmeghívást. Kontaktok meghívása + Nincs kapcsolatod, akit meghívhatnál ebbe a csoportba.\nLépj vissza, és hívj meg tagokat a Felhasználóazonosítójuk vagy ONS segítségével. Meghívó küldése Meghívók küldése @@ -628,10 +742,14 @@ Meg szeretnéd osztani a csoport korábbi üzeneteit velük: {name} és {count} másik személy? Meg szeretnéd osztani a csoport korábbi üzeneteit velük: {name} és {other_name}? Üzenettörténet megosztása + Üzenetelőzmények megosztása az elmúlt 14 napból Csak új üzenetek megosztása Meghívás + Tagok (nem adminok) + Menüsor Üzenet Tudjon meg többet + Üzenet másolása Ez az üzenet üres. Üzenet kézbesítése sikertelen Üzenethossz-korlát elérve @@ -646,6 +764,7 @@ Új beszélgetés indítása az ismerősöd Felhasználó ID-jának megadásával. Új beszélgetés indítása úgy, hogy beírja ismerősöd Felhasználó ID-ját, ONS-t vagy beolvassa a QR kódját. + Indítson új beszélgetést, írja be barátja fiókazonosítóját (Account ID), ONS azonosítóját, vagy olvassa be a QR-kódját {icon} Új üzenete érkezett %1$d új üzenete érkezett. @@ -694,20 +813,29 @@ Az üzenet túl hosszú Rövidítse le az üzenetét {limit} karakterekre vagy kevesebbre. Az üzenet túl hosszú + Új jelszó Tovább + Következő lépések Válasszon becenevet {name} számára. Ez fog megjelenni az egyéni és csoportos beszélgetésekben. Add meg a becenevet Adjon meg egy rövidebb becenevet Becenév eltávolítása Becenév beállítása Nem + Nincsenek nem adminisztrátor tagok ebben a csoportban. Nincsenek javaslatok + Legfeljebb 10 000 karakteres üzeneteket küldhetsz minden beszélgetésben. + Szervezd a csevegéseidet korlátlan számú kitűzött beszélgetéssel. Nincs Most nem Privát feljegyzés A Privát feljegyzésed üres. Privát feljegyzés elrejtése Biztos, hogy el akarod rejteni a Privát feljegyzést? + FIGYELEM: A(z) {action_type} művelettel elfogadod a(z) {app_pro} Felhasználási feltételeit {icon} és Adatvédelmi szabályzatát {icon} + Értesítés megjelenítése + Jelenjen meg a feladó neve és az üzenet tartalmának előnézete. + Csak a feladó neve jelenjen meg, az üzenet tartalma nélkül. Minden üzenet Értesítések tartalma Az értesítésekben megjelenő információ. @@ -718,6 +846,7 @@ A Google értesítési szerverein keresztül megbízhatóan és azonnal értesítést kapsz az új üzenetekről. A Huawei értesítési kiszolgálóinak segítségével megbízhatóan és azonnal értesítést fog kapni az új üzenetekről. Az Apple értesítési szerverein keresztül megbízhatóan és azonnal értesítést kapsz az új üzenetekről. + Jelenjen meg egy általános {app_name} értesítés a feladó neve és az üzenet tartalma nélkül. Rendszerbeállítások megnyitása Értesítések - Összes Értesítések - Csak említések @@ -725,6 +854,7 @@ {name} - {conversation_name} Előfordulhat, hogy üzeneteket kaptál, miközben a {device} újraindult. LED színe + Hang lejátszása új üzenetek fogadásakor. Csak említések Üzenet értesítések Legutóbb tőle: {name} @@ -746,6 +876,12 @@ Ki Rendben Be + Az Ön {device_type} készülékén + Nyisd meg ezt a {app_name} fiókot egy {device_type} eszközön, amely be van jelentkezve az eredetileg a regisztrációhoz használt {platform_account} fiókba. Ezután a(z) {pro} előfizetést törölheted a {app_pro} beállításain keresztül. + Nyissa meg ezt a(z) {app_name} fiókot egy olyan {device_type} készüléken, amely be van jelentkezve az eredetileg használt {platform_account} fiókkal. Ezután frissítse a(z) {pro} hozzáférést a(z) {app_pro} beállításain keresztül. + Kapcsolt eszközön + A(z) {platform_store} weboldalon + A(z) {platform} weboldalon Fiók létrehozása Fiók létrehozva Már van felhasználóm @@ -770,8 +906,12 @@ Nem sikerült felismernünk ezt az ONS-t. Ellenőrizd, és próbáld újra. Az ONS keresése nem sikerült. Próbáld újra később. Megnyitás + {platform_store} weboldal megnyitása + {platform} weboldal megnyitása + Beállítások megnyitása Kérdőív megnyitása Egyéb + Jelszó Jelszó megváltoztatása A jelszó megváltoztatásához szükséges a {app_name} feloldása. A jelszó meg lett változtatva. Tartsd biztonságos helyen! @@ -786,12 +926,24 @@ A megadott jelszavak nem egyeznek Jelszó frissítése sikertelen Hibás jelszó + Új jelszó megerősítése Jelszó eltávolítása + A(z) {app_name} feloldásához szükséges jelszó eltávolítása A jelszavad el lett távolítva. Jelszó beállítása A jelszavad be lett állítva. Tartsd biztonságos helyen! Kérjen jelszót a {app_name} feloldásához. + 12 karakternél hosszabb + Tartalmaz egy számot + Tartalmaz kisbetűt + Tartalmaz egy szimbólumot + Tartalmaz nagybetűt + Jelszóerősség-jelző + Egy erős jelszó beállítása segít megvédeni üzeneteidet és mellékleteidet, ha az eszközöd elveszik vagy ellopják. + Jelszavak Beillesztés + Fizetési hiba + A fizetésed sikeresen megtörtént, de hiba történt a(z) {pro} státuszod {action_type} közben.\n\nKérjük, ellenőrizd a hálózati kapcsolatodat, és próbáld újra. Engedélyváltozás {app_name} alkalmazásnak zene és hang-hozzáférésre van szüksége a fájlok és zenék küldéséhez, de ez nem lett megadva. Kérlek, lépj tovább az alkalmazás beállításokhoz, válaszd az \"Engedélyek\" lehetőséget, majd engedélyezd a \"Zene és hang\" hozzáférést. {app_name}-nak szüksége van az Apple Music használatára a média mellékletek lejátszásához. @@ -830,20 +982,173 @@ Beszélgetés kitűzése Kitűzés eltávolítása Beszélgetés kitűzésének eltávolítása + És még sok más... + Hamarosan új funkciók érkeznek a {pro} előfizetésbe. Tudja meg, mi várható legközelebb a(z) {pro} ütemtervben {icon} + Beállítások Előnézet + Értesítés előnézete + A(z) {pro} hozzáférése aktív!\n\nA(z) {pro} hozzáférése automatikusan megújul még {current_plan_length} időtartamra {date} dátummal. + A(z) {pro} hozzáférése {date} dátummal lejár.\n\nFrissítse most a(z) {pro} hozzáférését, hogy automatikusan megújuljon, mielőtt a(z) {pro} hozzáférése lejár. + A(z) {pro} hozzáférése aktív!\n\nA(z) {pro} hozzáférése automatikusan megújul még \n{current_plan_length} időtartamra {date} dátummal. A módosítások, amelyeket itt végrehajt, a következő megújítás alkalmával lépnek életbe. + Hozzáférési hiba: {pro} + A(z) {pro} hozzáférésed {date} napján lejár. + {pro} hozzáférés betöltése + {pro} hozzáférésed adatai még betöltés alatt állnak. A frissítés csak a folyamat befejezése után lehetséges. + {pro} hozzáférés betöltése... + Nem sikerült csatlakozni a hálózathoz a(z) {pro} hozzáférési adatok betöltéséhez. A(z) {pro} frissítése a(z) {app_name} alkalmazáson keresztül le lesz tiltva, amíg a kapcsolat helyre nem áll.\n\nKérjük, ellenőrizze a hálózati kapcsolatot, majd próbálja újra. + {pro} hozzáférés nem található + A(z) {app_name} észlelte, hogy fiókja nem rendelkezik {pro} hozzáféréssel. Ha úgy gondolja, hogy ez tévedés, kérjük, forduljon a(z) {app_name} ügyfélszolgálatához segítségért. + {pro} hozzáférés visszaállítása + {pro} hozzáférés megújítása + A(z) {pro} hozzáférés jelenleg kizárólag a(z) {platform_store} vagy a(z) {platform_store_other} áruházon keresztül vásárolható meg és újítható meg. Mivel Ön a(z) {app_name} Asztali alkalmazást használja, itt nem tudja frissíteni.\n\nA(z) {app_name} fejlesztői elkötelezetten dolgoznak olyan alternatív fizetési lehetőségeken, amelyek lehetővé teszik a(z) {pro} hozzáférés megvásárlását a(z) {platform_store} és a(z) {platform_store_other} áruházakon kívül is. {pro} ütemterv {icon} + Újítsd meg a {pro} hozzáférésed a {platform_store} weboldalán, azzal a {platform_account} fiókkal, amellyel eredetileg regisztráltál a {pro}-ra. + Újítsa meg a {platform} weboldalon, azzal a {platform_account} fiókkal, amellyel a {pro}-ra regisztrált. + Újítsa meg {pro} hozzáférését, hogy ismét használhassa a nagy teljesítményű {app_pro} béta funkciókat. + {pro} hozzáférés visszaállítva + A(z) {app_name} felismerte és visszaállította a(z) {pro} hozzáférést a fiókodhoz. A(z) {pro} státuszod vissza lett állítva! + Mivel eredetileg a {platform_store} áruházon keresztül iratkoztál fel a {app_pro}-ra, a {platform_account} fiókodat kell használnod a {pro} hozzáférésed frissítéséhez. + Jelenleg a {pro} hozzáférés csak a(z) {platform_store} vagy a(z) {platform_store_other} áruházon keresztül vásárolható meg. Mivel a(z) {app_name} asztali verzióját használod, itt nem tudsz {pro} verzióra frissíteni.\n\nA {app_name} fejlesztői keményen dolgoznak alternatív fizetési lehetőségeken, hogy a felhasználók a(z) {platform_store} és {platform_store_other} áruházakon kívül is megvásárolhassák a {pro} hozzáférést. {pro} ütemterv {icon} aktiválva + aktiválás + Minden készen áll! + A(z) {app_pro} hozzáférésed frissítve lett! A számlázás akkor történik majd, amikor a(z) {pro} automatikusan megújul {date} napján. + Már rendelkezel vele Tölts fel GIF-eket és animált WebP képeket a profilképedhez! + Szerezz animált megjelenítési képeket, és oldd fel a prémium funkciókat a {app_pro} Beta használatával + Animált megjelenítendő kép felhasználók feltölthetnek GIF-eket + Animált megjelenítendő képek + Állítson be animált GIF-eket és WebP képeket megjelenítendő képként. + GIF-ek feltöltése ezzel + A(z) {pro} előfizetés automatikusan megújul {time} múlva + {pro} kitűző + {app_pro} kitűző megjelenítése más felhasználók számára + Kitűzők + Mutassa ki a támogatását a(z) {app_name} iránt egy exkluzív kitűzővel a megjelenítendő neve mellett. + + %1$s %2$s kitűző elküldve + %1$s %2$s kitűző elküldve + + {pro} béta funkciók + {price} évente számlázva + {price} havonta számlázva + {price} negyedévente számlázva + Szeretne hosszabb üzeneteket küldeni?\nKüldjön több szöveget és oldja fel a prémium funkciókat a(z) {app_pro} szolgáltatással + További beszélgetéseket szeretnél rögzíteni?\nRendszerezd a csevegéseidet, és oldd fel a prémium funkciókat a {app_pro} Beta használatával + Több mint {limit} beszélgetést szeretnél rögzíteni?\nRendszerezd a csevegéseidet, és oldd fel a prémium funkciókat a {app_pro} Beta használatával + Sajnáljuk, hogy lemondod a(z) {pro} előfizetést. A következőket érdemes tudnod, mielőtt lemondod a {pro} hozzáférésedet. + Lemondás + {pro} hozzáférés lemondásával megakadályozod az automatikus megújítást, mielőtt a {pro} hozzáférés lejárna. A {pro} lemondása nem jár visszatérítéssel. A {pro} hozzáférés lejártáig továbbra is használhatod a {app_pro} funkcióit.\n\nMivel eredetileg a(z) {platform_account} fiókoddal regisztráltál a(z) {app_pro} használatára, a {pro} lemondásához ugyanazt a(z) {platform_account} fiókot kell használnod. + A {pro} hozzáférés lemondásának két módja van: + A {pro} hozzáférés lemondása megakadályozza az automatikus megújítást, mielőtt a {pro} lejárna.\n\nA {pro} lemondása nem jár visszatérítéssel. A {app_pro} funkciókat továbbra is használhatod, amíg a {pro} hozzáférésed érvényes. + Válaszd ki a számodra legmegfelelőbb {pro} hozzáférési opciót.\nA hosszabb hozzáférés nagyobb kedvezményt jelent. + Biztosan törölni szeretné az adatait erről az eszközről?\n\n{app_pro} nem vihető át másik fiókba. Kérjük, mentse el a Helyreállítási jelszavát, hogy később vissza tudja állítani a {pro} hozzáférését. + Biztosan törölni szeretnéd adataidat a hálózatról? Ha folytatod, akkor nem lesz lehetőséged visszaállítani az üzeneteidet vagy a névjegyeidet.\n\n{app_pro} nem vihető át másik fiókra. Kérjük, mentsd el a Helyreállítási jelszavadat, hogy később vissza tudd állítani a {pro} hozzáférésedet. + A(z) {pro} hozzáférése már {percent}% kedvezménnyel érhető el a teljes {app_pro} árhoz képest. + Hiba történt a(z) {pro} állapot frissítésekor + Lejárt + Sajnáljuk, a(z) {pro} hozzáférésed lejárt.\nMegújítással újra elérheted a(z) {app_pro} Beta exkluzív előnyeit és funkcióit. + Hamarosan lejár + A(z) {pro} hozzáférésed {time} múlva lejár.\nFrissíts most, hogy továbbra is elérhesd a(z) {app_pro} Beta exkluzív előnyeit és funkcióit + A(z) {pro} előfizetése lejár {time} múlva + {pro} GYIK + Találja meg a válaszokat a gyakori kérdésekre a {app_pro} GYIK oldalán. GIF és WebP formátumú profilképek feltöltése Nagyobb csoportos beszélgetések akár 300 taggal Plusz még több exkluzív funkció Legfeljebb 10 000 karakteres üzenetek Tűzz ki korlátlan számú beszélgetést + Szeretnéd teljes mértékben kihasználni a(z) {app_name} lehetőségeit?\nFrissíts a(z) {app_pro} Bétára, és szerezz hozzáférést rengeteg exkluzív előnyhöz és funkcióhoz. + Csoport aktiválva + Ez a csoport kibővített kapacitással rendelkezik! Akár 300 tagot is képes befogadni, mert egy csoportadminisztrátor + + %1$s csoport frissítve + %1$s csoport frissítve + + A visszatérítés kérése végleges. Ha jóváhagyják, a(z) {pro} hozzáférése azonnal megszűnik, és elveszíti a(z) {pro} funkciókhoz való hozzáférést. + Megnövelt mellékletméret + Megnövelt üzenethossz + Nagyobb csoportok + Azok a csoportok, amelyeknek Ön az adminisztrátora, automatikusan frissítve lesznek, és akár 300 tagot is támogathatnak. + Hamarosan minden Pro Beta felhasználó számára elérhetővé válnak a nagyobb (akár 300 fős) csoportos csevegések! + Hosszabb üzenetek + Legfeljebb 10 000 karakteres üzeneteket küldhet minden beszélgetésben. %1$s hosszabb üzenet elküldve %1$s hosszabb üzenet elküldve + Ez az üzenet a következő {app_pro} funkciókat használta: + Újratelepítéssel + Telepítse újra a(z) {app_name} alkalmazást erre az eszközre a(z) {platform_store} segítségével, állítsa vissza fiókját a Helyreállítási jelszóval, majd újítsa meg a(z) {pro} előfizetést a(z) {app_pro} beállításain keresztül. + Telepítsd újra a(z) {app_name} alkalmazást erre az eszközre a(z) {platform_store} áruházon keresztül, állítsd vissza fiókodat a Helyreállítási jelszó segítségével, majd frissítsd {pro} verzióra a(z) {app_pro} beállításai között. + Jelenleg három módon lehet megújítani: + Jelenleg két módja van a megújításnak: + {percent}% kedvezmény + + %1$s kitűzött beszélgetés + %1$s kitűzött beszélgetés + + Mivel eredetileg a {platform_store} áruházon keresztül iratkoztál fel a {app_pro}-ra, ugyanazt a {platform_account} fiókot kell használnod a visszatérítés kéréséhez. + Mivel eredetileg a {platform_store} áruházon keresztül iratkoztál fel a(z) {app_pro} szolgáltatásra, a visszatérítési kérelmedet a(z) {app_name} súgócsapata dolgozza fel.\n\nKattints az alábbi gombra, és töltsd ki a visszatérítési űrlapot a kérés benyújtásához.\n\nA(z) {app_name} támogatási csapata arra törekszik, hogy a visszatérítési kérelmeket 24–72 órán belül feldolgozza, de nagy forgalom esetén ez hosszabb időt vehet igénybe. + A(z) {app_pro} hozzáférése megújult! Köszönjük, hogy támogatja a(z) {network_name} hálózatot. + 1 hónap - {monthly_price} / hónap + 3 hónap - {monthly_price} / hónap + 12 hónap - {monthly_price} / hónap + újraaktiválás + Nyissa meg ezt a(z) {app_name} fiókot egy olyan {device_type} eszközön, amely be van jelentkezve az eredetileg használt {platform_account} fiókkal. Ezután kérjen visszatérítést a(z) {app_pro} beállításain keresztül. + Sajnáljuk, hogy elmegy. Íme, amit tudnia kell, mielőtt visszatérítést kér. + A(z) {platform} jelenleg feldolgozza visszatérítési kérelmét. Ez általában 24–48 órát vesz igénybe. A döntésük alapján a(z) {pro} státusza megváltozhat a(z) {app_name} alkalmazásban. + A visszatérítési kérelmedet a(z) {app_name} ügyfélszolgálata kezeli.\n\nA visszatérítés igényléséhez kattints az alábbi gombra, és töltsd ki a visszatérítési űrlapot.\n\nBár a(z) {app_name} ügyfélszolgálat törekszik arra, hogy a visszatérítési kérelmeket 24–72 órán belül feldolgozza, a feldolgozás tovább is tarthat nagy mennyiségű kérés esetén. + A visszatérítési kérelmedet kizárólag a(z) {platform} kezeli a {platform} weboldalán keresztül.\n\nA(z) {platform} visszatérítési szabályzatai miatt a(z) {app_name} fejlesztőinek nincs lehetőségük befolyásolni a visszatérítési kérelmek kimenetelét. Ez vonatkozik arra, hogy a kérelmet elfogadják vagy elutasítják, valamint arra is, hogy teljes vagy részleges visszatérítést kapsz-e. + Kérjük, lépjen kapcsolatba a(z) {platform} ügyfélszolgálatával a visszatérítési kérelemmel kapcsolatos további frissítésekért. A(z) {platform} visszatérítési irányelvei miatt a(z) {app_name} fejlesztőinek nincs lehetőségük befolyásolni a visszatérítési kérelmek eredményét.\n\n{platform} visszatérítési ügyfélszolgálat + {pro} visszatérítése + A(z) {app_pro} visszatérítési kérelmeket kizárólag a(z) {platform} kezeli a(z) {platform_store} szolgáltatáson keresztül.\n\nA(z) {platform} visszatérítési irányelvei miatt a(z) {app_name} fejlesztőinek nincs lehetőségük befolyásolni a visszatérítési kérelmek kimenetelét. Ez magában foglalja a kérelem elfogadását vagy elutasítását, valamint a teljes vagy részleges visszatérítés jóváhagyását. + Szeretné ismét használni az animált profilképeket?\nMeglévő {pro} hozzáférésének megújításával feloldhatja a kihagyott funkciókat. + {pro} Beta megújítása + A {pro} hozzáférésed megújíthatod a {app_pro} beállításainál egy olyan kapcsolt eszközön, amelyre a {app_name} az {platform_store} vagy a {platform_store_other} áruházon keresztül van telepítve. + Szeretnél ismét hosszabb üzeneteket küldeni?\nÚjítsd meg {pro} hozzáférésed, és férj hozzá a hiányzó funkciókhoz. + Szeretné ismét a maximumon kihasználni a(z) {app_name} lehetőségeit?\nMeglévő {pro} hozzáférésének megújításával feloldhatja a kihagyott funkciókat. + Szeretne ismét több mint {limit} beszélgetést rögzíteni?\nMeglévő {pro} hozzáférésének megújításával feloldhatja a kihagyott funkciókat. + Szeretne ismét több beszélgetést rögzíteni?\nMeglévő {pro} hozzáférésének megújításával feloldhatja a kihagyott funkciókat. + A megújítással elfogadja a(z) {app_pro} Felhasználási feltételeit {icon} és az Adatvédelmi szabályzatát {icon} + megújítás + Jelenleg a {pro} hozzáférés csak a(z) {platform_store} vagy a(z) {platform_store_other} áruházon keresztül vásárolható meg és újítható meg. Mivel a(z) {app_name} alkalmazást a(z) {build_variant} használatával telepítetted, itt nem tudod megújítani.\n\nA {app_name} fejlesztői keményen dolgoznak alternatív fizetési lehetőségeken, hogy a felhasználók a(z) {platform_store} és {platform_store_other} áruházakon kívül is megvásárolhassák a {pro} hozzáférést. {pro} ütemterv {icon} + Visszatérítés kérve Küldjön többet ezzel: + {pro} beállítások + A {pro} használatának megkezdése + Saját {pro} statisztika + {pro} statisztikák betöltése + A(z) {pro} statisztikáid betöltése folyamatban van, kérlek várj. + A(z) {pro} statisztikák ezt az eszközt tükrözik, és eltérően jelenhetnek meg a csatlakoztatott eszközökön + {pro} állapot hiba + Nem sikerült csatlakozni a hálózathoz a(z) {pro} állapot ellenőrzése érdekében. Az ezen az oldalon megjelenő információk pontatlanok lehetnek, amíg a kapcsolat helyre nem áll.\n\nKérjük, ellenőrizze a hálózati kapcsolatot, majd próbálja újra. + {pro} állapot betöltése + A(z) {pro} adatok betöltése folyamatban van. Néhány művelet ezen az oldalon nem lesz elérhető, amíg a betöltés be nem fejeződik. + {pro} állapot betöltése + Nem sikerült csatlakozni a hálózathoz a(z) {pro} állapot ellenőrzéséhez. A kapcsolat helyreállításáig nem folytathatja.\n\nKérjük, ellenőrizze a hálózati kapcsolatot, majd próbálja újra. + Nem sikerült csatlakozni a hálózathoz a(z) {pro} állapot ellenőrzéséhez. A kapcsolat helyreállításáig nem lehet frissíteni {pro} verzióra.\n\nKérjük, ellenőrizze a hálózati kapcsolatot, majd próbálja újra. + Nem sikerült csatlakozni a hálózathoz a(z) {pro} állapot frissítéséhez. Amíg a kapcsolat nem áll helyre, az oldal egyes funkciói le lesznek tiltva.\n\nKérjük, ellenőrizze a hálózati kapcsolatot, majd próbálja újra. + Nem sikerült csatlakozni a hálózathoz a jelenlegi {pro} hozzáférés betöltéséhez. A {pro} megújítása a(z) {app_name} alkalmazáson keresztül le van tiltva, amíg a kapcsolat helyre nem áll.\n\nKérlek, ellenőrizd a hálózati kapcsolatodat, és próbáld újra. + Segítségre van szüksége a(z) {pro} használatához? Küldjön egy kérést az ügyfélszolgálatnak. + A(z) {action_type} művelettel a(z) {app_pro} szolgáltatást {activation_type} a(z) {app_name} protokollon keresztül. Ezt az aktiválást a(z) {entity} segíti elő, azonban nem ő a(z) {app_pro} szolgáltatója. A(z) {entity} nem vállal felelősséget a(z) {app_pro} teljesítményéért, elérhetőségéért vagy működéséért. + A frissítéssel elfogadja a(z) {app_pro} Felhasználási feltételeit {icon} és az Adatvédelmi szabályzatát {icon} + Korlátlan kitűzhető üzenet + Rendezze az összes csevegését korlátlan számú kitűzött beszélgetéssel. + A jelenlegi számlázási opciód {current_plan_length} {pro} hozzáférést biztosít. Biztosan át akarsz váltani a(z) {selected_plan_length_singular} számlázási opcióra?\n\nA frissítéssel {pro} hozzáférésed automatikusan meg fog újulni {date} napján további {selected_plan_length} {pro} hozzáférési időszakra. + A(z) {pro} hozzáférése lejár ekkor: {date}.\n\nA frissítéssel a(z) {pro} hozzáférése automatikusan meg fog újulni ekkor: {date}, további {selected_plan_length} időtartamra a(z) {pro} használatához. + frissítés + Frissítsd a(z) {app_pro} Beta verzióra, hogy exkluzív előnyökhöz és funkciókhoz juss hozzá. + Frissíts {pro} szintre a {app_pro} beállításain belül egy olyan csatlakoztatott eszközön, amelyre a(z) {app_name} a(z) {platform_store} vagy a(z) {platform_store_other} áruházon keresztül lett telepítve. + Jelenleg a {pro} hozzáférés csak a(z) {platform_store} vagy a(z) {platform_store_other} áruházon keresztül vásárolható meg. Mivel a(z) {app_name} alkalmazást a(z) {build_variant} segítségével telepítetted, itt nem tudsz {pro} verzióra váltani.\n\nA {app_name} fejlesztői keményen dolgoznak alternatív fizetési lehetőségeken, hogy a felhasználók a(z) {platform_store} és {platform_store_other} áruházakon kívül is megvásárolhassák a {pro} hozzáférést. {pro} ütemterv {icon} + Jelenleg csak egy lehetőség van a frissítésre: + Jelenleg két lehetőség áll rendelkezésre a frissítéshez: + Frissítettél a(z) {app_pro} verzióra!\nKöszönjük, hogy támogatod a(z) {network_name} közösséget. + frissítés + Frissítés folyamatban: {pro} + A frissítéssel elfogadod a(z) {app_pro} Felhasználási feltételeit {icon} és az Adatvédelmi szabályzatát {icon} + Többet szeretnél kihozni a {app_name} alkalmazásból?\nFrissíts a {app_pro} Beta verzióra a hatékonyabb üzenetküldési élmény érdekében. + A(z) {platform} éppen feldolgozza az Ön visszatérítési kérelmét Profil Profilkép Nem sikerült törölni a profilképet. @@ -851,6 +1156,11 @@ Válassz egy kisebb fájlt. Nem sikerült frissíteni a profilt. Előléptetés + Az adminisztrátorok hozzáférnek az elmúlt 14 nap üzenettörténetéhez, és nem lehet őket lefokozni vagy eltávolítani a csoportból. + + Tag előléptetése + Tagok előléptetése + Sikertelen előléptetés Sikertelen előléptetések @@ -894,22 +1204,65 @@ Visszaállítási jelszó elrejtése A visszaállítási jelszó végleges elrejtése ezen az eszközön. Írd be a visszaállítási jelszavad a fiókod betöltéséhez. Ha nem mentetted el, az alkalmazás beállításai között találhatod meg. + Visszaállítási jelszó megtekintése + A visszaállítási jelszó láthatósága Ez a visszaállítási jelszavad. Ha elküldöd valakinek, teljes hozzáférést kap a fiókodhoz. Csoport újra-létrehozása Újra + Mivel eredetileg egy másik {platform_account} fiókkal regisztrált a(z) {app_pro} szolgáltatásra, ezt a {platform_account} fiókot kell használnia a(z) {pro} hozzáférés frissítéséhez. + A visszatérítés kérésének két módja: Az üzenet hosszának csökkentése ennyivel: {count} %1$d karakter maradt %1$d karakter maradt + Emlékeztess később Eltávolítás + + Tag eltávolítása + Tagok eltávolítása + + + Tag és üzeneteinek eltávolítása + Tagok és üzeneteik eltávolítása + Jelszó eltávolítása sikertelen + Távolítsa el a jelenlegi jelszavát a(z) {app_name} alkalmazáshoz. A helyileg tárolt adatokat az alkalmazás újratitkosítja egy véletlenszerűen előállított kulccsal, amely az eszközön lesz tárolva. + + Tag eltávolítása + Tagok eltávolítása + + Megújítás + {pro} megújítása Válasz + Visszatérítés kérése + Kérjen visszatérítést a {platform} weboldalon, azzal a {platform_account} fiókkal, amellyel a {pro}-ra regisztrált. Újraküldés + + Meghívó újraküldése + Meghívók újraküldése + + + Előléptetés újraküldése + Előléptetések újraküldése + + + Meghívó újraküldése + Meghívók újraküldése + + + Előléptetés újraküldése folyamatban + Előléptetések újraküldése folyamatban + Országinformációk betöltése... Újraindítás Újraszinkronizálás Újra + Értékelési korlát + Úgy tűnik, nemrég már értékelted a(z) {app_name} alkalmazást, köszönjük a visszajelzésedet! + Alkalmazás futtatása a háttérben + Futtatni a(z) {app_name} alkalmazást a háttérben? + Mivel bekapcsoltad a lassú módot, javasoljuk, hogy engedélyezd a(z) {app_name} háttérben való futását az értesítések javítása érdekében. Ez javíthatja az értesítések megbízhatóságát, bár a rendszered továbbra is korlátozhatja a háttértevékenységet.\n\nEzt később megváltoztathatod a Beállításokban. Mentés Elmentve Elmentett üzenetek @@ -918,6 +1271,8 @@ Képernyőbiztonság Képernyőkép értesítések Értesítés igénylése, ha egy ismerős képernyőképet készít az egyéni csevegésről. + Elrejti a(z) {app_name} ablakát az ezen az eszközön készített képernyőképeken. + Képernyőkép-készítés megakadályozása {name} készített egy képernyőképet. Keresés Kapcsolatok keresése @@ -938,6 +1293,10 @@ Küldés Hívás ajánlás küldése Kapcsolat jelöltek küldése + + Adminisztrátori kinevezés küldése + Adminisztrátori kinevezés küldése + Elküldve: Megjelenés Adataid törlése @@ -958,37 +1317,61 @@ Értesítések Engedélyek Adatvédelem + {app_pro} béta Visszaállítási Jelszó Beállítások Beállít Közösség megjelenítendő képének beállítása + Állítson be egy jelszót a(z) {app_name} alkalmazáshoz. A helyileg tárolt adatok ezzel a jelszóval lesznek titkosítva. Minden alkalommal, amikor elindítja a(z) {app_name} alkalmazást, meg kell adnia ezt a jelszót. + A beállítás nem frissíthető Újra kell indítanod a {app_name}-t a beállítások érvényesítéséhez. Képernyőbiztonság + Rendszerindítás Megosztás Oszd meg a Felhasználó ID-dat ismerőseiddel, hogy a {app_name} alkalmazáson csevegjetek. Ossza meg barátaival ott, ahol általában beszélgetni szokott velük - majd helyezze át a beszélgetést ide. Hiba történt az adatbázis megnyitásakor. Indítsd újra az alkalmazást, majd próbáld újra. Úgy tűnik, még nincs {app_name} fiókja.\n\nA megosztás előtt létre kell hoznia egyet a(z) {app_name} alkalmazásban. + Szeretnéd megosztani a csoport üzenetelőzményeit ezzel a felhasználóval? Megosztás {app_name}-en + Sajnáljuk, a(z) {app_name} csak több kép és videó egyidejű megosztását támogatja + A megosztás csak médiatartalmakat támogat. A nem médiatípusú fájlok ki lettek zárva Megjelenítés Összes megjelenítése Kevesebb mutatása „Jegyzet magamnak” megjelenítése Biztosan meg akarja jeleníteni a Jegyzet magamnak jegyzetet a beszélgetési listában? + Helyesírás-ellenőrző Matricák + Erősség + Problémákba ütközött? Fedezze fel a súgócikkeket vagy nyisson hibajegyet a(z) {app_name} ügyfélszolgálatán. Támogatási oldal megnyitása Rendszerinformáció: {information} Érintse meg az újrapróbálkozáshoz Folytatás Alapértelmezett Hiba + Vissza + Téma előnézete + {name} fiókazonosítója korábbi interakcióid alapján látható + Az elrejtett azonosítók közösségekben használatosak a spam csökkentése és az adatvédelem növelése érdekében + Fordítás + Tálca Próbáld újra Gépelés-indikátorok Gépelés-indikátorok küldése és fogadása. Nem elérhető Visszavonás Ismeretlen + Nem támogatott CPU + Frissítés + {pro} hozzáférés frissítése + Két módja is van a(z) {pro} hozzáférés frissítésének: App frissítések + Közösségi információk frissítése + A közösség neve és leírása minden közösségi tag számára látható + Adjon meg egy rövidebb közösségleírást + Adjon meg egy rövidebb nevet a közösségnek Frissítés telepítve, kattints az újraindításhoz Frissítés letöltése: {percent_loader}% A frissítés sikertelen @@ -998,17 +1381,26 @@ Adjon meg egy rövidebb csoportleírást A(z) {app_name} új verziója elérhető, koppints a frissítéshez Elérhető a(z) {app_name} új, {version} verziója. + Profilinformációk frissítése + A megjelenítendő neve és profilképe minden beszélgetésben látható. Verzióinformáció megnyitása {app_name} Frissítés Verzió {version} Utoljára frissítve {relative_time} + Frissítések + Frissítés... + Frissítés + {app_name} frissítése Frissítés erre: Feltöltés URL másolása URL megnyitása Ez a böngésződben fog megnyílni. Biztos, hogy meg szeretnéd nyitni a böngésződben a következő linket?\n\n{url} + A hivatkozások a böngészőjében fognak megnyílni. Gyors mód használata + Változtasd meg az előfizetésedet a regisztrációhoz használt {platform_account} fiókkal, a {platform} weboldalon keresztül. + A(z) {platform} weboldalán keresztül Videó Videó lejátszása sikertelen. Megtekintés @@ -1017,7 +1409,12 @@ Ez néhány percig eltarthat. Egy pillanat... Figyelmeztetés + Az iOS 15 támogatása megszűnt. Frissítsen iOS 16-ra vagy újabb verzióra az alkalmazásfrissítések fogadásának folytatásához. Ablak Igen Te + A CPU nem támogatja az SSE 4.2 utasításkészletet, amelyre a(z) {app_name} alkalmazásnak szüksége van a képfeldolgozáshoz Linux x64 operációs rendszereken. Kérjük, frissítsen kompatibilis CPU-ra, vagy használjon más operációs rendszert. + A te visszaállítási jelszavad + Nagyítás mértéke + Állítsd be a szöveg és a vizuális elemek méretét. \ No newline at end of file diff --git a/app/src/main/res/values-b+it+IT/strings.xml b/app/src/main/res/values-b+it+IT/strings.xml index ca3b6e7119..d68bd54015 100644 --- a/app/src/main/res/values-b+it+IT/strings.xml +++ b/app/src/main/res/values-b+it+IT/strings.xml @@ -15,7 +15,13 @@ Questo è il tuo ID utente. Altri utenti possono scansionarlo per iniziare una conversazione con te. Dimensione attuale Aggiungi + + Aggiungi amministratore + Aggiungi amministratori + + Aggiungi amministratore Inserisci l\'Account ID dell\'utente che vuoi promuovere ad amministratore.\n\nPer aggiungere più utenti, inserisci ogni Account ID separato da una virgola. È possibile specificare fino a 20 Account ID alla volta. + Gli amministratori non possono essere retrocessi o rimossi dal gruppo. Gli amministratori non possono essere rimossi. {name} e altri {count} sono ora amministratori. Promuovi amministratori @@ -40,12 +46,19 @@ {name} è stato rimosso come amministratore. {name} e altri {count} sono stati rimossi come Amministratori. {name} e {other_name} sono stati rimossi come amministratori. + + %1$d amministratore selezionato + %1$d amministratori selezionati + Invio promozione ad amministratore Invio promozioni ad amministratore Impostazioni amministratore + Non puoi modificare il tuo stato di amministratore. Per uscire dal gruppo, apri le impostazioni della conversazione e seleziona Esci dal gruppo. {name} e {other_name} sono ora amministratori. + Amministratori + Consenti +{count} Anonimo Icona dell\'app @@ -65,6 +78,8 @@ Note Borsa Meteo + Badge {app_pro} + Modalità scura automatica Nascondi la barra dei menu Lingua Scegli la lingua per {app_name}. {app_name} si riavvierà ogni volta che la cambierai. @@ -159,6 +174,8 @@ Sei sicuro di voler sbloccare {name} e altri {count}? Sei sicuro di voler sbloccare {name} e un altro? Sbloccato {name} + Visualizza e gestisci i contatti bloccati. + Nessun browser trovato per aprire l\'URL, prova invece a copiare l\'URL Chiama {name} ti ha chiamato Al momento non puoi avviare una nuova chiamata. Termina la chiamata in corso e riprova. @@ -183,9 +200,14 @@ Chiamate (Beta) Chiamate vocali e video Chiamate vocali e video (Beta) + Il tuo IP è visibile all\'utente con cui stai parlando e a un server {session_foundation} durante l\'utilizzo delle chiamate beta. Abilita chiamate e videochiamate verso e da altri utenti. Hai chiamato {name} Hai perso una chiamata da {name} perché non hai abilitato Chiamate vocali e video nelle impostazioni della privacy. + {app_name} ha bisogno dell\'accesso alla tua fotocamera per abilitare le videochiamate, ma l\'autorizzazione è stata negata. Non puoi modificare i permessi della fotocamera durante una chiamata.\n\nVuoi terminare la chiamata ora e abilitare l\'accesso alla fotocamera oppure preferisci ricevere un promemoria al termine della chiamata? + Per consentire l\'accesso alla fotocamera, apri le impostazioni e attiva l\'autorizzazione Fotocamera. + Durante la tua ultima chiamata hai provato a usare il video ma non è stato possibile perché l\'accesso alla fotocamera era stato precedentemente negato. Per consentirlo, apri le impostazioni e attiva l\'autorizzazione Fotocamera. + Accesso alla fotocamera richiesto Nessuna fotocamera è stata trovata Fotocamera non disponibile. Consenti l\'accesso alla fotocamera @@ -193,7 +215,19 @@ {app_name} richiede l\'accesso alla fotocamera per scattare foto e video, o scansionare i codici QR. {app_name} richiede l\'accesso alla fotocamera per scansionare i codici QR Annulla + Annulla {pro} + Annulla sul sito web {platform} usando il {platform_account} con cui ti sei registrato a {pro}. + Annulla sul sito web {platform_store} usando il {platform_account} con cui ti sei registrato a {pro}. + Modifica Impossibile cambiare la password + Modifica la tua password per {app_name}. I dati memorizzati localmente verranno nuovamente crittografati con la nuova password. + Modifica impostazione + Verifica dello stato {pro} + Verifica dello stato {pro} in corso. Potrai continuare non appena il controllo sarà completato. + Verifica dei dettagli {pro}. Alcune azioni di questa pagina potrebbero non essere disponibili finché il controllo non sarà completato. + Verifica dello stato {pro} in corso... + Verifica dei tuoi dettagli {pro}. Non puoi rinnovare finché questo controllo non sarà completato. + Verifica dello stato {pro} in corso. Potrai eseguire l\'upgrade a {pro} una volta completata la verifica. Cancella Cancella tutto Elimina tutti i dati @@ -252,11 +286,17 @@ Link della Comunità Copia link Comunità Conferma + Conferma promozione + Sei sicuro? Gli amministratori non possono essere retrocessi né rimossi dal gruppo. Contatti Elimina contatto Sei sicuro di voler eliminare {name} dai tuoi contatti? I nuovi messaggi da {name} arriveranno come una richiesta di messaggio. Non hai ancora nessun contatto Seleziona contatti + + %1$d contatto selezionato + %1$d contatti selezionati + Dettagli utente Fotocamera Scegli un\'azione per iniziare una chat @@ -264,6 +304,7 @@ Composizione messaggio Anteprima dell\'immagine dal messaggio citato Inizia una chat con un nuovo contatto + Scegli i contenuti da mostrare nelle notifiche locali quando ricevi un messaggio. Aggiungi alla schermata principale Aggiunto alla schermata principale Messaggio vocale @@ -276,12 +317,18 @@ Conversazione eliminata Non ci sono messaggi in {conversation_name}. Inserisci chiave + Definisci il comportamento dei tasti Invio e Maiusc+Invio nelle conversazioni. + MAIUSC + INVIO invia un messaggio, INVIO inizia una nuova riga. + INVIO invia un messaggio, MAIUSC + INVIO inizia una nuova riga. Gruppi Sfoltitura dei messaggi Sfoltisci Comunità + Elimina automaticamente i messaggi più vecchi di 6 mesi nelle community con oltre 2000 messaggi. Nuova chat Non hai ancora nessuna chat + Invia con il tasto Invio Premere il tasto Invio invierà il messaggio invece d\'iniziare una nuova riga. + Invia con Shift+Enter Tutti i contenuti multimediali Controllo ortografico Abilita i suggerimenti da tastiera. @@ -290,7 +337,10 @@ Copia Crea Creazione chiamata + Fatturazione attuale + Password attuale Taglia + Modalità scura Sei sicuro di voler eliminare tutti i messaggi, gli allegati e i dati dell\'account da questo dispositivo e creare un nuovo account? Si è verificato un errore nel database.\n\nEsporta i log dell\'applicazione per condividerli e facilitare la risoluzione del problema. Se non funziona, reinstalla {app_name} e ripristina il tuo account. Sei sicuro di voler eliminare tutti i messaggi, gli allegati e i dati dell\'account da questo dispositivo e ripristinare il tuo account dalla rete? @@ -315,6 +365,14 @@ Attendi, la creazione del gruppo è in corso... C\'è stato un problema con l\'aggiornamento del gruppo Non hai il permesso di eliminare i messaggi altrui + + Elimina l\'allegato selezionato + Elimina gli allegati selezionati + + + Sei sicuro di voler eliminare l\'allegato selezionato? Anche il messaggio associato all\'allegato sarà eliminato. + Sei sicuro di voler eliminare gli allegati selezionati? Anche il messaggio associato agli allegati verrà eliminato. + Sei sicuro di voler eliminare {name} dai tuoi contatti?\n\nQuesto eliminerà la conversazione, inclusi tutti i messaggi e gli allegati. I messaggi futuri da parte di {name} verranno visualizzati come richiesta di messaggio. Sei sicuro di voler eliminare la tua conversazione con {name}?\nQuesta azione eliminerà in modo permanente tutti i messaggi e gli allegati. @@ -354,6 +412,7 @@ Sei sicuro di voler eliminare questi messaggi per tutti? Eliminazione Apri gli strumenti di sviluppo + Impostazioni notifiche del dispositivo Inizia dettatura... Messaggi effimeri Il messaggio verrà eliminato in {time_large} @@ -389,6 +448,7 @@ {admin_name} ha aggiornato le impostazioni dei messaggi effimeri. Hai aggiornato le impostazioni dei messaggi effimeri. Chiudi + Visualizzazione Può essere il tuo vero nome, un alias, o qualsiasi cosa ti piaccia — e puoi cambiarlo quando vuoi. Inserisci il nome da visualizzare Scegli il nome da visualizzare @@ -400,6 +460,8 @@ Il tuo Nome Pubblico è visibile agli utenti, ai gruppi e alle comunità con cui interagisci. Documento Dona + Forze potenti stanno cercando di indebolire la privacy, ma non possiamo continuare questa battaglia da soli.\n\nDonare aiuta a mantenere {app_name} sicuro, indipendente e online. + {app_name} ha bisogno del tuo aiuto Fatto Scarica Download in corso... @@ -429,19 +491,38 @@ Tu e {name} avete reagito con {emoji_name} Ha reagito al tuo messaggio {emoji} Attiva + Abilitare l\'accesso alla fotocamera? + Mostra le notifiche quando ricevi nuovi messaggi. + Termina chiamata per abilitare Ti piace {app_name}? Da migliorare {emoji} È fantastica {emoji} Stai usando {app_name} da un po\', come ti trovi? Ci farebbe piacere conoscere la tua opinione. + Accedi + Inserisci la password impostata per {app_name} + Inserisci la password che usi per sbloccare {app_name} all’avvio, non la tua Recovery Password + Errore durante la verifica dello stato {pro} Controlla la tua connessione e riprova. Copia l\'errore ed esci Errore del database Si è verificato un errore. Riprova più tardi. + Errore nel caricamento dell\'accesso a {pro} + {app_name} non è riuscito a cercare questo ONS. Controlla la connessione di rete e riprova. Si è verificato un errore sconosciuto. + Questo ONS non è registrato. Controlla che sia corretto e riprova. + Invio dell\'invito non riuscito a {name} in {group_name} + Invio dell\'invito non riuscito a {name} e altri {count} in {group_name} + Invio dell\'invito non riuscito a {name} e {other_name} in {group_name} + Impossibile reinviare la promozione a {name} nel gruppo {group_name} + Impossibile reinviare la promozione a {name} e altri {count} nel gruppo {group_name} + Impossibile reinviare la promozione a {name} e {other_name} nel gruppo {group_name} Download non riuscito Errori + Feedback + Condividi la tua esperienza con {app_name} compilando un breve sondaggio. File File + Utilizza le impostazioni di sistema. Per sempre Da: Modalità schermo intero @@ -488,6 +569,9 @@ Sei sicuro di voler abbandonare {group_name}? Sei sicuro di voler uscire da {group_name}?\n\nFacendo questo rimuoverai tutti i membri dal gruppo e cancellerai tutto il suo contenuto. Impossibile abbandonare {group_name} + {name} è stato invitato a unirsi al gruppo. La cronologia della chat degli ultimi 14 giorni è stata condivisa. + {name} e {count} altri sono stati invitati a unirsi al gruppo. La cronologia della chat degli ultimi 14 giorni è stata condivisa. + {name} e {other_name} sono stati invitati a unirsi al gruppo. La cronologia della chat degli ultimi 14 giorni è stata condivisa. {name} ha lasciato il gruppo. {name} e altri {count} hanno lasciato il gruppo. {name} e {other_name} hanno lasciato il gruppo. @@ -499,6 +583,9 @@ {name} e {other_name} hanno ricevuto un invito a unirsi al gruppo. Tu e altri {count} avete ricevuto un invito a unirvi al gruppo. La cronologia della chat è condivisa. Tu e {other_name} siete stati invitati a unirvi al gruppo. La cronologia della chat è stata condivisa. + Impossibile rimuovere {name} da {group_name} + Impossibile rimuovere {name} e altri {count} da {group_name} + Impossibile rimuovere {name} e {other_name} da {group_name} Hai lasciato il gruppo. Membri del gruppo Non ci sono altri membri in questo gruppo. @@ -512,6 +599,7 @@ Non ci sono messaggi su {group_name}. Invia un messaggio e inizia la conversazione! Questo gruppo non è stato aggiornato da oltre 30 giorni. Potresti riscontrare problemi nell\'invio dei messaggi o nella visualizzazione delle informazioni del gruppo. Sei l\'unico amministratore in {group_name}.\n\nI membri e le impostazioni del gruppo non possono essere modificati senza un amministratore. + Sei l\'unico amministratore in {group_name}.\n\nI membri e le impostazioni del gruppo non possono essere modificati senza un amministratore. Per uscire dal gruppo senza eliminarlo, aggiungi prima un nuovo amministratore. Rimozione in corso Sei stato promosso amministratore. Tu e altri {count} siete stati promossi ad amministratori. @@ -539,22 +627,32 @@ Gruppo aggiornato Gestione dei candidati alla connessione FAQ + Consulta le FAQ di {app_name} per trovare risposte alle domande più frequenti. Aiutaci a tradurre {app_name} + Segnala un errore Condividi alcuni dettagli per aiutarci a risolvere il tuo problema. Esporta i tuoi log, poi carica il file tramite l\'Help Desk di {app_name}. Esporta i log Esporta i tuoi log, quindi carica il file attraverso l\'Help Desk di {app_name}. Salva sul desktop + Salva questo file, poi condividilo con gli sviluppatori di {app_name}. Assistenza + Aiuta a tradurre {app_name} in oltre 80 lingue! Ci piacerebbe avere un tuo feedback Nascondi + Attiva/disattiva la visibilità della barra dei menu di sistema. Sei sicuro di voler nascondere Note to Self dalla tua lista di conversazioni? Nascondi altri Immagine immagini + Importante Tastiera in incognito Richiedi la modalità incognito se disponibile. A seconda della tastiera in uso, la tua tastiera potrebbe ignorare questa richiesta. Info Scorciatoia non valida + + Invita contatto + Invita contatti + Invito Fallito Inviti Falliti @@ -563,8 +661,17 @@ L\'invito non può essere inviato. Vuoi riprovare? Gli inviti non possono essere inviati. Vuoi riprovare? + + Invita membro + Invita membri + + Invita un nuovo membro al gruppo inserendo l\'ID utente, l\'ONS del tuo amico o scansionando il loro codice QR {icon} + Invita un nuovo membro al gruppo inserendo l\'Account ID o l\'ONS del tuo amico oppure scansionando il suo codice QR Unisciti Più tardi + Avvia automaticamente {app_name} quando il computer viene avviato. + Avvia all\'avvio + Questa impostazione è gestita dal tuo sistema su Linux. Per abilitare l\'avvio automatico, aggiungi {app_name} alle applicazioni di avvio nelle impostazioni di sistema. Per saperne di più Abbandona Abbandonando... @@ -579,6 +686,8 @@ Tu e {other_name} vi siete uniti al gruppo. {name} e {other_name} fanno ora parte del gruppo. Ti sei unito al gruppo. + Limitare l\'attività in background? + Attualmente consenti a {app_name} di funzionare in background per migliorare l\'affidabilità delle notifiche. Modificando questa impostazione le notifiche potrebbero essere meno affidabili. Anteprime dei link Mostra le anteprime dei link per gli indirizzi web supportati. Abilita anteprima link @@ -589,6 +698,7 @@ Inviando le anteprime dei link non avrai la protezione completa dei metadati. Anteprime dei link disabilitate {app_name} deve interfacciarsi con i siti web condivisi per generare l\'anteprima dei link che invii e ricevi.\n\nPuoi attivarli nelle impostazioni di {app_name}. + Link Carica account Caricamento del tuo account Caricamento... @@ -601,9 +711,17 @@ Attualmente bloccato Tocca per sbloccare {app_name} è sbloccato + Log + Gestisci amministratori Gestisci membri + Gestisci {pro} Massimo + Magari più tardi Contenuti multimediali + + %1$d membro selezionato + %1$d membri selezionati + %1$d membro %1$d membri @@ -613,7 +731,9 @@ %1$d membri attivi Aggiungi ID utente o ONS + I membri possono essere promossi solo dopo aver accettato l\'invito a unirsi al gruppo. Invita contatti + Non hai contatti da invitare in questo gruppo.\nTorna indietro e invita membri usando il loro ID utente o ONS. Invita utente Invita utenti @@ -622,10 +742,14 @@ Vuoi condividere la cronologia dei messaggi di gruppo con {name} e altri {count}? Vuoi condividere la cronologia dei messaggi di gruppo con {name} e {other_name}? Condividi cronologia messaggi + Condividi la cronologia dei messaggi degli ultimi 14 giorni Condividi solo nuovi messaggi Invita + Membri (non amministratori) + Barra dei menu Messaggio Leggi di più + Copia messaggio Il messaggio è vuoto. L\'invio del messaggio non è riuscito Limite messaggio raggiunto @@ -640,6 +764,7 @@ Inizia una nuova chat inserendo l\'ID utente oppure l\'ONS del tuo amico. Inizia una nuova chat inserendo l\'ID utente, l\'ONS del tuo amico o scansionando il loro codice QR. + Inizia una nuova conversazione inserendo l\'ID utente, l\'ONS del tuo amico o scansionando il loro codice QR {icon} Hai ricevuto un nuovo messaggio. Hai ricevuto %1$d nuovi messaggi. @@ -688,20 +813,29 @@ Messaggio troppo lungo Riduci il tuo messaggio a {limit} caratteri o meno. Messaggio troppo lungo + Nuova password Avanti + Prossimi passaggi Scegli un soprannome per {name}. Apparirà nelle tue conversazioni private e chat di gruppo. Inserisci soprannome Per favore, inserisci un nickname più corto Rimuovi soprannome Imposta soprannome No + Non ci sono membri non amministratori in questo gruppo. Nessun suggerimento + Invia messaggi fino a 10.000 caratteri in tutte le conversazioni. + Organizza le chat con un numero illimitato di conversazioni appuntate. Niente Non ora Note personali Non hai messaggi nelle note personali. Nascondi note personali Sei sicuro di voler nascondere le Note personali? + NOTA: Procedendo con {action_type}, accetti i Termini di servizio {icon} e l\'Informativa sulla privacy {icon} di {app_pro} + Visualizzazione notifiche + Mostra il nome del mittente e un\'anteprima del contenuto del messaggio. + Mostra solo il nome del mittente senza alcun contenuto del messaggio. Tutti i messaggi Contenuto notifiche Le informazioni mostrate nelle notifiche. @@ -712,6 +846,7 @@ Riceverai notifiche di nuovi messaggi in modo affidabile e immediato utilizzando i server di notifica di Google. Riceverai notifiche di nuovi messaggi in modo affidabile e immediato utilizzando i server di notifica di Huawei. Riceverai notifiche di nuovi messaggi in modo affidabile e immediato utilizzando i server di notifica di Apple. + Mostra una notifica generica di {app_name} senza il nome del mittente né il contenuto del messaggio. Vai alle impostazioni di notifica del dispositivo Notifiche - Tutte Notifiche - Solo se menzionato @@ -719,6 +854,7 @@ {name} a {conversation_name} Potresti aver ricevuto messaggi mentre il tuo {device} si riavviava. Colore del LED + Riproduci un suono quando ricevi nuovi messaggi. Solo menzioni Notifiche messaggi Il più recente da: {name} @@ -740,6 +876,12 @@ Off Okay On + Sul tuo dispositivo {device_type} + Apri questo account {app_name} su un dispositivo {device_type} connesso al {platform_account} con cui ti sei originariamente registrato. Poi, annulla {pro} tramite le impostazioni {app_pro}. + Apri questo account {app_name} su un dispositivo {device_type} connesso con l’account {platform_account} che hai usato per la registrazione. Poi aggiorna il tuo accesso {pro} dalle impostazioni di {app_pro}. + Su un dispositivo collegato + Sul sito web {platform_store} + Sul sito web {platform} Crea account Account creato Ho già un account @@ -764,10 +906,17 @@ Non è stato possibile riconoscere questo ONS. Si prega di controllare e riprovare. Non siamo riusciti a cercare questo ONS. Riprovare più tardi. Apri + Apri il sito web {platform_store} + Apri il sito web di {platform} + Apri Impostazioni Apri sondaggio Altro + Password Cambia password + Cambia la password richiesta per sbloccare {app_name}. + La tua password è stata modificata. Per favore conservala in un luogo sicuro. Conferma password + Crea password La tua password attuale non è corretta. Inserisci password Per favore, inserisci la tua password attuale @@ -777,9 +926,24 @@ Le password non coincidono Impossibile impostare la password Password non corretta + Conferma la nuova password Rimuovi password + Rimuovi la password richiesta per sbloccare {app_name} + La tua password è stata rimossa. Imposta password + La tua password è stata impostata. Si prega di tenerla al sicuro. + Richiedi la password per sbloccare {app_name} all\'avvio. + Più di 12 caratteri + Include un numero + Include una lettera minuscola + Contiene un simbolo + Include una lettera maiuscola + Indicatore della robustezza della password + Impostare una password forte aiuta a proteggere i tuoi messaggi e allegati in caso di smarrimento o furto del dispositivo. + Password Incolla + Errore di pagamento + Il tuo pagamento è stato elaborato correttamente, ma si è verificato un errore durante {action_type} lo stato {pro}.\n\nControlla la connessione di rete e riprova. Modifica autorizzazione L\'accesso a musica e audio è stato negato. {app_name} richiede l\'accesso a musica e audio per inviare file, musica e audio. Vai su Impostazioni → Autorizzazioni, e abilita i permessi per musica e audio. {app_name} deve utilizzare Apple Music per riprodurre gli allegati multimediali. @@ -791,6 +955,7 @@ Consenti l\'accesso alla fotocamera per le videochiamate. La funzione di blocco schermo su {app_name} usa il Face ID. Mantieni attivo + {app_name} continuerà a funzionare in background quando chiudi la finestra. {app_name} necessita l\'accesso alla libreria fotografica per continuare. Puoi abilitare l\'accesso nelle impostazioni di iOS. L\'accesso alla rete locale è necessario per facilitare le chiamate. Per continuare, attiva l\'autorizzazione \"Rete locale\" nelle Impostazioni. {app_name} necessita dell\'accesso alla rete locale per effettuare chiamate vocali e video. @@ -817,24 +982,172 @@ Fissa chat Non mettere in evidenza Non mettere in evidenza la chat + E molto altro ancora... + Nuove funzionalità in arrivo su {pro}. Scopri cosa sta per arrivare nella roadmap di {pro} {icon} + Preferenze Anteprima + Anteprima notifica + Il tuo accesso {pro} è attivo!\n\nIl tuo accesso {pro} si rinnoverà automaticamente per un altro {current_plan_length} il {date}. + Il tuo accesso {pro} è attivo!\n\nIl tuo accesso {pro} si rinnoverà automaticamente per un altro\n{current_plan_length} il {date}. Ogni modifica effettuata qui avrà effetto al prossimo rinnovo. + Errore accesso {pro} + Il tuo accesso {pro} scadrà il {date}. + Caricamento accesso {pro} + Le informazioni di accesso a {pro} sono ancora in fase di caricamento. Non puoi eseguire aggiornamenti finché il processo non è completo. + caricamento accesso a {pro}... + Impossibile connettersi alla rete per caricare le informazioni di accesso a {pro}. L\'aggiornamento di {pro} tramite {app_name} sarà disabilitato finché la connessione non sarà ripristinata.\n\nControlla la connessione di rete e riprova. + Accesso {pro} non trovato + {app_name} ha rilevato che il tuo account non dispone dell\'accesso {pro}. Se pensi si tratti di un errore, contatta l\'assistenza di {app_name} per ricevere aiuto. + Recupera accesso {pro} + Rinnova accesso {pro} + Attualmente, l’accesso {pro} può essere acquistato e rinnovato solo tramite {platform_store} o {platform_store_other}. Poiché stai usando {app_name} Desktop, non puoi rinnovare da qui.\n\nGli sviluppatori di {app_name} stanno lavorando duramente per fornire metodi di pagamento alternativi che consentano agli utenti di acquistare l’accesso {pro} al di fuori di {platform_store} e {platform_store_other}. Roadmap di {pro} {icon} + Rinnova il tuo accesso {pro} dal sito web di {platform_store} utilizzando l\'account {platform_account} con cui ti sei registrato a {pro}. + Rinnova sul sito web {platform} utilizzando il {platform_account} con cui ti sei registrato a {pro}. + Rinnova il tuo accesso {pro} per iniziare a usare di nuovo le potenti funzionalità Beta di {app_pro}. + Accesso {pro} recuperato + {app_name} ha rilevato e ripristinato l\'accesso {pro} per il tuo account. Il tuo stato {pro} è stato ripristinato! + Poiché ti sei registrato inizialmente a {app_pro} tramite il {platform_store}, dovrai usare il tuo {platform_account} per aggiornare il tuo accesso {pro}. + Attualmente, l\'accesso a {pro} può essere acquistato solo tramite {platform_store} o {platform_store_other}. Poiché stai usando {app_name} Desktop, non puoi eseguire l\'upgrade a {pro} da qui.\n\nGli sviluppatori di {app_name} stanno lavorando attivamente su opzioni di pagamento alternative per consentire agli utenti di acquistare l\'accesso a {pro} al di fuori di {platform_store} e {platform_store_other}. Roadmap di {pro} {icon} Attivato + attivazione + Tutto pronto! + Il tuo accesso {app_pro} è stato aggiornato! Ti verrà addebitato il costo quando {pro} sarà rinnovato automaticamente il {date}. Hai già attivato Carica GIF e immagini WebP animate per la tua immagine del profilo! + Ottieni immagini del profilo animate e sblocca funzionalità premium con {app_pro} Beta Immagine del profilo animata gli utenti possono caricare GIF + Immagini del profilo animate + Imposta GIF animate e immagini WebP come immagine del profilo. Carica GIF con + {pro} si rinnova automaticamente tra {time} + Badge {pro} + Mostra il badge {app_pro} agli altri utenti + Badge + Mostra il tuo supporto per {app_name} con un badge esclusivo accanto al tuo nome visibile. + + %1$s badge %2$s inviato + %1$s badge %2$s inviati + + Funzionalità Beta di {pro} + {price} fatturati annualmente + {price} fatturati mensilmente + {price} fatturati trimestralmente + Vuoi inviare messaggi più lunghi?\nInvia più testo e sblocca funzionalità premium con {app_pro} Beta + Vuoi più chat bloccate?\nOrganizza le tue chat e sblocca funzionalità premium con {app_pro} Beta + Vuoi più di {limit} chat bloccate?\nOrganizza le tue chat e sblocca funzionalità premium con {app_pro} Beta + Ci dispiace che tu stia annullando {pro}. Ecco cosa devi sapere prima di annullare il tuo accesso {pro}. + Annullamento + Annullare l\'accesso {pro} impedirà il rinnovo automatico prima della scadenza dell\'accesso {pro}. Annullare {pro} non comporta un rimborso. Potrai continuare a utilizzare le funzionalità {app_pro} fino alla scadenza del tuo accesso {pro}.\n\nPoiché hai effettuato l\'iscrizione a {app_pro} utilizzando il tuo {platform_account}, dovrai usare lo stesso {platform_account} per annullare {pro}. + Due modi per annullare l’accesso {pro}: + Annullare l\'accesso {pro} impedirà il rinnovo automatico prima della scadenza di {pro}.\n\nAnnullare {pro} non comporta un rimborso. Potrai continuare a usare le funzionalità {app_pro} fino alla scadenza del tuo accesso {pro}. + Scegli l\'opzione di accesso {pro} più adatta a te.\nUn periodo più lungo significa sconti maggiori. + Sei sicuro di voler eliminare i tuoi dati da questo dispositivo?\n\n{app_pro} non può essere trasferito su un altro account. Salva la tua Recovery Password per garantire di poter ripristinare l\'accesso {pro} in seguito. + Sei sicuro di voler eliminare i tuoi dati dalla rete? Se prosegui, non potrai ripristinare i tuoi messaggi o contatti.\n\n{app_pro} non può essere trasferito su un altro account. Salva la tua Recovery Password per garantire di poter ripristinare l\'accesso {pro} in seguito. + Il tuo accesso {pro} è già scontato del {percent}% rispetto al prezzo intero di {app_pro}. + Errore durante l\'aggiornamento dello stato {pro} + Scaduto + Purtroppo, il tuo accesso {pro} è scaduto.\nRinnova per riattivare i vantaggi e le funzionalità esclusive di {app_pro} Beta. + In scadenza + Il tuo accesso {pro} scadrà tra {time}.\nAggiorna ora per continuare ad accedere ai vantaggi e alle funzionalità esclusive di {app_pro} Beta + {pro} scade tra {time} + FAQ {pro} + Trova risposte alle domande frequenti nelle FAQ di {app_pro}. Carica immagini profilo in formato GIF e WebP Chat di gruppo maggiori fino a 300 membri E tante altre funzionalità esclusive Messaggi fino a 10.000 caratteri Blocca un numero illimitato di conversazioni + Vuoi usare {app_name} al massimo del suo potenziale?\nEsegui l\'upgrade a {app_pro} Beta per accedere a una serie di vantaggi e funzionalità esclusive. Gruppo attivato Questo gruppo ha una capacità estesa! Può supportare fino a 300 membri perché un amministratore del gruppo ha + + %1$s gruppo aggiornato + %1$s gruppi aggiornati + + La richiesta di rimborso è definitiva. Se approvata, il tuo accesso {pro} verrà annullato immediatamente e perderai l\'accesso a tutte le funzionalità {pro}. Dimensione allegato aumentata Lunghezza messaggio aumentata + Gruppi più numerosi + I gruppi di cui sei amministratore vengono aggiornati automaticamente per supportare 300 membri. + Le chat di gruppo più grandi (fino a 300 membri) saranno presto disponibili per tutti gli utenti Pro Beta! + Messaggi più lunghi + Puoi inviare messaggi fino a 10.000 caratteri in tutte le conversazioni. + + %1$s messaggio più lungo inviato + %1$s messaggi lunghi inviati + Questo messaggio ha utilizzato le seguenti funzionalità di {app_pro}: + Con una nuova installazione + Reinstalla {app_name} su questo dispositivo tramite {platform_store}, ripristina il tuo account con la tua Recovery Password e rinnova {pro} dalle impostazioni di {app_pro}. + Reinstalla {app_name} su questo dispositivo tramite {platform_store}, ripristina il tuo account con la tua Password di Recupero e aggiorna a {pro} dalle impostazioni di {app_pro}. + Al momento, ci sono tre modi per rinnovare: + Al momento, ci sono due modi per rinnovare: + {percent}% di sconto + + %1$s conversazione fissata + %1$s conversazioni fissate + + Poiché ti sei registrato inizialmente a {app_pro} tramite il {platform_store}, dovrai usare il tuo {platform_account} per richiedere un rimborso. + Poiché ti sei registrato inizialmente a {app_pro} tramite il {platform_store}, la tua richiesta di rimborso sarà gestita dall\'assistenza di {app_name}.\n\nRichiedi un rimborso facendo clic sul pulsante qui sotto e compilando il modulo di richiesta di rimborso.\n\nSebbene l\'assistenza di {app_name} si impegni a elaborare le richieste di rimborso entro 24-72 ore, l\'elaborazione potrebbe richiedere più tempo in caso di elevato volume di richieste. + Il tuo accesso {app_pro} è stato rinnovato! Grazie per aver supportato {network_name}. + 1 mese - {monthly_price} / mese + 3 mesi - {monthly_price} / mese + 12 mesi - {monthly_price} / mese + riattivazione + Apri questo account {app_name} su un dispositivo {device_type} connesso al {platform_account} con cui ti sei originariamente registrato. Poi richiedi un rimborso tramite le impostazioni {app_pro}. + Ci dispiace vederti andare. Ecco cosa devi sapere prima di richiedere un rimborso. + {platform} sta elaborando la tua richiesta di rimborso. In genere ci vogliono 24-48 ore. In base alla loro decisione, potresti vedere il tuo stato {pro} cambiare in {app_name}. + La tua richiesta di rimborso sarà gestita dall\'assistenza di {app_name}.\n\nRichiedi un rimborso facendo clic sul pulsante qui sotto e compilando il modulo di richiesta di rimborso.\n\nSebbene l\'assistenza di {app_name} si impegni a elaborare le richieste di rimborso entro 24-72 ore, l\'elaborazione potrebbe richiedere più tempo in caso di elevato volume di richieste. + La tua richiesta di rimborso sarà gestita esclusivamente da {platform} tramite il sito web di {platform}.\n\nA causa delle politiche di rimborso di {platform}, gli sviluppatori di {app_name} non hanno modo di influenzare l\'esito delle richieste di rimborso. Questo include l\'approvazione o il rifiuto della richiesta, così come la concessione di un rimborso totale o parziale. + Contatta {platform} per ulteriori aggiornamenti sulla tua richiesta di rimborso. A causa delle politiche di rimborso di {platform}, gli sviluppatori di {app_name} non possono influenzare l\'esito delle richieste di rimborso.\n\nSupporto rimborsi {platform} + Rimborso di {pro} + I rimborsi per {app_pro} sono gestiti esclusivamente da {platform} tramite il {platform_store}.\n\nA causa delle politiche di rimborso di {platform}, gli sviluppatori di {app_name} non possono influenzare l\'esito delle richieste di rimborso. Questo include sia l\'approvazione o il rifiuto della richiesta, sia l\'emissione di un rimborso totale o parziale. + Vuoi tornare a usare immagini del profilo animate?\nRinnova il tuo accesso {pro} per sbloccare le funzionalità che ti sei perso. + Rinnova {pro} Beta + Rinnova il tuo accesso {pro} dalle impostazioni di {app_pro} su un dispositivo collegato con {app_name} installato tramite {platform_store} o {platform_store_other}. + Vuoi tornare a inviare messaggi più lunghi?\nRinnova il tuo accesso {pro} per sbloccare le funzionalità che ti sei perso. + Vuoi usare {app_name} di nuovo al massimo del suo potenziale?\nRinnova il tuo accesso a {pro} per sbloccare le funzionalità che ti sei perso. + Vuoi appuntare di nuovo più di {limit} conversazioni?\nRinnova il tuo accesso a {pro} per sbloccare le funzionalità che ti sei perso. + Vuoi appuntare di nuovo più conversazioni?\nRinnova il tuo accesso a {pro} per sbloccare le funzionalità che ti sei perso. + Rinnovando, accetti i Termini di servizio {icon} e l\'Informativa sulla privacy {icon} di {app_pro} + rinnovo + Attualmente, l\'accesso {pro} può essere acquistato e rinnovato solo tramite {platform_store} o {platform_store_other}. Poiché hai installato {app_name} utilizzando la versione {build_variant}, non puoi rinnovare da qui.\n\nGli sviluppatori di {app_name} stanno lavorando duramente su opzioni di pagamento alternative per consentire agli utenti di acquistare l’accesso {pro} al di fuori di {platform_store} e {platform_store_other}. Roadmap {pro} {icon} + Rimborso richiesto Invia di più con + Impostazioni {pro} + Inizia a usare {pro} + Le tue statistiche {pro} + Caricamento statistiche {pro} + Le tue statistiche {pro} sono in fase di caricamento, attendere. + Le statistiche {pro} riflettono l\'utilizzo su questo dispositivo e potrebbero apparire diversamente su quelli collegati. + Errore stato {pro} + Impossibile connettersi alla rete per verificare lo stato {pro}. Le informazioni visualizzate su questa pagina potrebbero essere inesatte finché la connessione non sarà ripristinata.\n\nControlla la connessione di rete e riprova. + Caricamento stato {pro} + Le informazioni {pro} sono in fase di caricamento. Alcune azioni di questa pagina potrebbero non essere disponibili fino al completamento del caricamento. + caricamento stato {pro} + Impossibile connettersi alla rete per verificare il tuo stato {pro}. Non è possibile continuare finché la connettività non viene ripristinata.\n\nControlla la tua connessione di rete e riprova. + Impossibile connettersi alla rete per verificare lo stato {pro}. Non potrai eseguire l\'upgrade a {pro} finché la connessione non sarà ripristinata.\n\nControlla la connessione di rete e riprova. + Impossibile connettersi alla rete per aggiornare lo stato {pro}. Alcune azioni su questa pagina saranno disattivate finché la connessione non sarà ripristinata.\n\nControlla la connessione di rete e riprova. + Impossibile connettersi alla rete per caricare il tuo accesso attuale {pro}. Il rinnovo di {pro} tramite {app_name} sarà disabilitato fino al ripristino della connettività.\n\nControlla la tua connessione di rete e riprova. + Hai bisogno di aiuto con {pro}? Invia una richiesta al team di supporto. + Procedendo con {action_type}, stai {activation_type} {app_pro} tramite il protocollo {app_name}. {entity} faciliterà l\'attivazione ma non è il fornitore di {app_pro}. {entity} non è responsabile delle prestazioni, disponibilità o funzionalità di {app_pro}. + Aggiornando, accetti i Termini di servizio {icon} e l\'Informativa sulla privacy {icon} di {app_pro} + Pin illimitati + Organizza tutte le tue chat con conversazioni appuntate illimitate. + La tua opzione di fatturazione attuale include {current_plan_length} di accesso a {pro}. Sei sicuro di voler passare all\'opzione di fatturazione {selected_plan_length_singular}?\n\nAggiornando, il tuo accesso a {pro} si rinnoverà automaticamente il {date} per ulteriori {selected_plan_length} di accesso a {pro}. + Il tuo accesso a {pro} scadrà il {date}.\n\nAggiornando, il tuo accesso a {pro} si rinnoverà automaticamente il {date} per ulteriori {selected_plan_length} di accesso a {pro}. + aggiornamento + Esegui l\'upgrade a {app_pro} Beta per accedere a una serie di vantaggi e funzionalità esclusive. + Esegui l\'upgrade a {pro} dalle impostazioni di {app_pro} su un dispositivo collegato con {app_name} installato tramite {platform_store} o {platform_store_other}. + Attualmente, l\'accesso a {pro} può essere acquistato solo tramite {platform_store} o {platform_store_other}. Poiché hai installato {app_name} utilizzando {build_variant}, non puoi eseguire l\'upgrade a {pro} da qui.\n\nGli sviluppatori di {app_name} stanno lavorando duramente su opzioni di pagamento alternative per consentire agli utenti di acquistare l\'accesso a {pro} al di fuori di {platform_store} e {platform_store_other}. Roadmap di {pro} {icon} + Al momento, c\'è solo un modo per eseguire l\'upgrade: + Al momento, ci sono due modi per eseguire l\'upgrade: + Hai eseguito l\'upgrade a {app_pro}!\nGrazie per sostenere {network_name}. + aggiornamento + Aggiornamento a {pro} in corso + Eseguendo l\'upgrade, accetti i Termini di servizio {icon} e l\'Informativa sulla privacy {icon} di {app_pro} + Vuoi ottenere di più da {app_name}?\nPassa a {app_pro} Beta per un\'esperienza di messaggistica più potente. + {platform} sta elaborando la tua richiesta di rimborso Profilo Mostra immagine Impossibile rimuovere l\'immagine del profilo. @@ -842,6 +1155,11 @@ Scegli un file più piccolo. Impossibile aggiornare il profilo. Promuovi + Gli amministratori potranno visualizzare gli ultimi 14 giorni di cronologia dei messaggi e non potranno essere retrocessi né rimossi dal gruppo. + + Promuovi membro + Promuovi membri + Promozione Fallita Promozioni Fallite @@ -871,6 +1189,7 @@ Consigliato Salva la tua password di recupero per assicurarti di non perdere l\'accesso al tuo account. Salva la tua password di recupero + Utilizza la tua password di recupero per caricare il tuo account su nuovi dispositivi.\n\nIl tuo account non può essere recuperato senza la tua password di recupero. Assicurati che sia conservata in un luogo sicuro e protetto — e non condividerla con nessuno. Inserisci la tua password di recupero Si è verificato un errore durante il caricamento della tua password di recupero.\n\nEsporta i log, quindi invia il file tramite il Centro Assistenza di {app_name} per aiutare a risolvere il problema. Controlla la tua password di recupero e riprova. @@ -880,27 +1199,69 @@ Per caricare il tuo account, inserisci la tua password di recupero. Nascondi la password di recupero permanentemente Senza la tua password di recupero, non puoi caricare il tuo account su nuovi dispositivi. \n\nTi consigliamo vivamente di salvare la tua password di recupero in un luogo sicuro prima di continuare. + Sei sicuro di voler nascondere permanentemente la tua password di recupero su questo dispositivo?\n\nQuesta azione non può essere annullata. Nascondi password di recupero Nascondi permanentemente la tua password di recupero su questo dispositivo. Inserisci la tua password di recupero per caricare il tuo account. Se non l\'hai salvata, puoi trovarla nelle impostazioni dell\'app. + Visualizza password di recupero + Visibilità della password di recupero Questa è la tua password di recupero. Se la dovessi inviare a qualcuno, avrà pieno accesso al tuo account. Ricrea gruppo Ripeti + Poiché ti sei registrato originalmente a {app_pro} tramite un altro {platform_account}, dovrai usare quell\'account {platform_account} per aggiornare il tuo accesso {pro}. + Due modi per richiedere un rimborso: Riduci la lunghezza del messaggio di {count} %1$d carattere rimanente %1$d caratteri rimanenti + Ricordamelo più tardi Rimuovi + + Rimuovi membro + Rimuovi membri + + + Rimuovi membro e i suoi messaggi + Rimuovi membri e i loro messaggi + Impossibile rimuovere la password + Rimuovi la password attuale per {app_name}. I dati memorizzati localmente verranno nuovamente crittografati con una chiave generata casualmente e archiviata sul tuo dispositivo. + + Rimozione del membro + Rimozione dei membri + + Rinnova + Rinnovo di {pro} Rispondi + Richiedi rimborso + Richiedi un rimborso sul sito web {platform}, utilizzando il {platform_account} con cui ti sei registrato a {pro}. Reinvia + + Reinvia invito + Reinvia inviti + + + Invia nuovamente promozione + Invia nuovamente promozioni + + + Invio dell\'invito in corso + Invio degli inviti in corso + + + Invio promozione + Invio promozioni + Caricamento delle informazioni sul paese... Riavvia Sincronizza di nuovo Riprova Limite recensioni Sembra che tu abbia già recensito {app_name} di recente, grazie per il tuo feedback! + Esegui l\'app in background + Eseguire {app_name} in background? + Poiché stai utilizzando la modalità lenta, ti consigliamo di consentire a {app_name} di funzionare in background per migliorare le notifiche. Questo può migliorare la coerenza delle notifiche, anche se il tuo sistema potrebbe comunque limitare automaticamente l\'attività in background.\n\nPuoi modificare questa impostazione in seguito in Impostazioni. Salva Salvato Messaggi salvati @@ -909,6 +1270,8 @@ Sicurezza Schermo Notifiche Screenshot Ricevi una notifica quando un contatto fa uno screenshot di una chat privata. + Nascondi la finestra di {app_name} negli screenshot acquisiti su questo dispositivo. + Protezione da screenshot {name} ha fatto uno screenshot. Cerca Cerca tra i contatti @@ -929,6 +1292,10 @@ Invio in corso Invio offerta di chiamata Invio candidati per la connessione + + Invio promozione + Invio promozioni + Inviato: Aspetto Elimina dati @@ -949,38 +1316,56 @@ Notifiche Permessi Privacy + {app_pro} Beta Password di recupero Impostazioni Imposta Imposta immagine della Community + Imposta una password per {app_name}. I dati memorizzati localmente verranno crittografati con questa password. Ti verrà chiesto di inserirla ogni volta che {app_name} viene avviato. + Impossibile aggiornare impostazione È necessario riavviare {app_name} per applicare le modifiche. Sicurezza Schermo + Avvio Condividi Invita i tuoi amici su {app_name} condividendo con loro il tuo codice utente. Condividi l\'ID utente con i tuoi amici in qualsiasi app di messaggistica utilizziate per comunicare — successivamente potrete spostare qui la conversazione. C\'è un problema nell\'apertura del database. Riavvia l\'app e riprova. Ops! Sembra che tu non abbia ancora un account {app_name}. \n\n Devi creare un account {app_name} prima di condividere. + Vuoi condividere la cronologia dei messaggi del gruppo con questo utente? Condividi su {app_name} + Spiacenti, {app_name} supporta solo la condivisione di più immagini e video contemporaneamente + La condivisione supporta solo file multimediali. I file non multimediali sono stati esclusi Mostra Mostra tutto Mostra meno Mostra note personali Sei sicuro di voler mostrare Note to Self nella tua lista di conversazioni? + Controllo ortografico Adesivi + Robustezza + Hai riscontrato problemi? Consulta gli articoli di aiuto o apri un ticket con il Supporto {app_name}. Vai alla pagina di supporto Informazioni di sistema: {information} Tocca per riprovare Continua Predefinito Errore + Indietro + Anteprima tema L\'Account ID di {name} è visibile in base alle interazioni precedenti Gli ID offuscati vengono utilizzati nelle Community per ridurre lo spam e aumentare la privacy + Traduci + Area di notifica Riprova Indicatori di scrittura Visualizza e condividi gli indicatori di digitazione. Non disponibile Annulla Sconosciuto + CPU non supportata + Aggiorna + Aggiorna accesso {pro} + Due modi per aggiornare il tuo accesso {pro}: Aggiornamenti app Aggiorna le informazioni della Comunità Il nome e la descrizione della Comunità sono visibili a tutti i membri @@ -995,17 +1380,26 @@ Inserisci una descrizione del gruppo più breve È disponibile una nuova versione di {app_name}, tocca per aggiornare È disponibile una nuova versione ({version}) di {app_name}. + Aggiorna informazioni del profilo + Il tuo nome visualizzato e l\'immagine del profilo sono visibili in tutte le conversazioni. Vai alle note di rilascio {app_name} ha un nuovo aggiornamento disponibile Versione {version} Ultimo aggiornamento {relative_time} fa + Aggiornamenti + Aggiornamento... + Aggiorna + Aggiorna {app_name} Passa a Invio in corso Copia link Apri link Questo link si aprirà nel tuo browser. Sei sicuro di voler aprire questo link nel tuo browser?\n\n{url} + I link si apriranno nel tuo browser. Usa la Modalità rapida + Modifica il tuo piano utilizzando il {platform_account} con cui ti sei registrato, tramite il sito web {platform}. + Tramite il sito web di {platform} Video Impossibile riprodurre il video. Vedi @@ -1014,7 +1408,12 @@ Potrebbe richiedere alcuni minuti. Un attimo, per favore... Attenzione + Il supporto per iOS 15 è terminato. Aggiorna a iOS 16 o versioni successive per continuare a ricevere aggiornamenti dell\'app. Finestra Tu + La CPU non supporta le istruzioni SSE 4.2, necessarie a {app_name} sui sistemi operativi Linux x64 per elaborare le immagini. Aggiorna a una CPU compatibile o utilizza un sistema operativo differente. + La tua password di recupero + Fattore di ingrandimento + Regola la dimensione del testo e degli elementi visivi. \ No newline at end of file diff --git a/app/src/main/res/values-b+ja+JP/strings.xml b/app/src/main/res/values-b+ja+JP/strings.xml index 81e830e3b0..706bb9a863 100644 --- a/app/src/main/res/values-b+ja+JP/strings.xml +++ b/app/src/main/res/values-b+ja+JP/strings.xml @@ -15,7 +15,12 @@ これはあなたのアカウントIDです。他のユーザーはこれをスキャンしてあなたと会話を始めることができます。 実サイズ 追加 + + 管理者を追加する + + 管理者を追加 管理者に昇格させるユーザーのAccount IDを入力してください。\n\n複数のユーザーを追加するには、各Account IDをカンマで区切って入力してください。一度に最大20件まで指定できます。 + 管理者は降格またはグループから削除できません。 アドミンを削除することはできません {name}{count}人 がアドミンに昇格しました アドミンを昇格 @@ -40,11 +45,17 @@ {name} はアドミンから削除されました {name}{count}名 がAdminから削除されました。 {name}{other_name} がAdminに昇格しました。 + + %1$d人の管理者が選択されました + アドミンへの昇進を送信中 アドミン設定 + 自分の管理者ステータスは変更できません。グループを退出するには、会話の設定を開いて「グループを退出」を選択してください。 {name}{other_name} がアドミンに昇格しました + 管理者 + 許可する +{count} 匿名 アプリアイコン @@ -64,6 +75,8 @@ メモ 株式 天気 + {app_pro} バッジ + オートダークモード メニューバーを隠す 言語 {app_name}の言語設定を選択してください。言語設定を変更すると{app_name}が再起動されます。 @@ -158,6 +171,8 @@ 本当に{name}{count}人の他の人のブロックを解除しますか? 本当に{name}と1人の他の人のブロックを解除しますか? ブロック解除済み {name} + ブロックされている連絡先を表示および管理します。 + そのURLを開くためのブラウザが見つかりません。代わりにURLをコピーしてみてください 通話 {name}からの通話 新しい通話を開始できません。まず現在の通話を終了してください。 @@ -182,9 +197,14 @@ 通話 (ベータ版) 音声とビデオ通話 音声通話とビデオ通話 (ベータ版) + ベータ通話を使用している間、あなたのIPは通話相手と{session_foundation}サーバーに表示されます。 他のユーザーとの音声通話やビデオ通話を有効にします {name} に発信 {name}さんからの通話を逃しました。プライバシー設定で音声通話とビデオ通話を有効にしていません。 + {app_name} ではビデオ通話を有効にするためにカメラへのアクセスが必要ですが、この許可が拒否されています。通話中にカメラのアクセス許可を変更することはできません。\n\n今すぐ通話を終了してカメラアクセスを有効にしますか?それとも、通話後にリマインドを受けますか? + カメラへのアクセスを許可するには、設定を開いてカメラの権限をオンにしてください。 + 最後の通話中にビデオを使用しようとしましたが、以前にカメラへのアクセスを拒否されたため使用できませんでした。カメラへのアクセスを許可するには、設定を開いてカメラの権限をオンにしてください。 + カメラアクセスが必要です カメラが見つかりません カメラは利用できません カメラへのアクセスを許可する @@ -192,7 +212,19 @@ {app_name}で写真や動画を撮るには、またはQRコードをスキャンするにはカメラへのアクセスが必要です。 {app_name}でQRコードをスキャンするにはカメラへのアクセスが必要です キャンセル + {pro} をキャンセル + {platform} のウェブサイトで、{pro} に登録した {platform_account} を使用してキャンセルしてください。 + {platform_store} のウェブサイトで、{pro} に登録した {platform_account} を使用してキャンセルしてください。 + 変更 パスワードの変更に失敗しました + {app_name} のパスワードを変更します。ローカルに保存されたデータは、新しいパスワードで再暗号化されます。 + 設定を変更する + {pro} ステータスを確認中 + {pro} のステータスを確認しています。この確認が完了すると続行できます。 + {pro} 詳細を確認中です。この確認が完了するまで、このページ上の一部の操作は利用できない場合があります。 + {pro} のステータスを確認中... + {pro} の詳細を確認しています。確認が完了するまで更新できません。 + {pro} ステータスを確認中です。この確認が完了すると、{pro} にアップグレードできるようになります。 消去 すべて消去する すべてのデータを消去する @@ -250,11 +282,16 @@ コミュニティ URL コミュニティURLをコピー 確認する + 昇格を確認 + 本当によろしいですか?管理者は降格またはグループから削除できません。 連絡先 連絡先を削除 本当に連絡先から{name}を削除しますか?{name}からの新しいメッセージはメッセージリクエストとして到着します。 まだ連絡先がありません 連絡先を選択 + + %1$d件の連絡先が選択されました + ユーザーの詳細 カメラ 会話を開始するアクションを選択してください @@ -262,6 +299,7 @@ メッセージ作成 引用されたメッセージから画像のサムネール 新しい連絡先と会話を作成する + 新しいメッセージを受信したときに、ローカル通知で表示される内容を選択してください。 ホーム画面に追加する ホーム画面に追加しました 音声メッセージ @@ -274,12 +312,18 @@ 会話を削除しました {conversation_name} にはメッセージがありません。 キーを入力 + 会話中に Enter キーおよび Shift+Enter キーがどのように機能するかを定義します。 + Shift + Enterでメッセージを送信、Enterで改行を開始します。 + Enterキーでメッセージを送信、Shift + Enterで改行を開始します。 グループ メッセージの削減 コミュニティをトリムする + 2,000件以上のメッセージがあるコミュニティでは、6か月以上前のメッセージを自動的に削除します。 新しい会話 まだ通知はありません + エンターキーで送信 Enterキーをタップすると、改行ではなく、メッセージが送信されます。 + Shift+Enter で送信 すべてのメディア スペルチェック メッセージを入力するときにスペルチェックを有効にします @@ -288,7 +332,10 @@ コピーする 作成する 通話を作成中 + 現在の請求 + 現在のパスワード 切り取り + ダークモード このデバイス上のすべてのメッセージ、添付ファイル、アカウントデータを削除し、新しいアカウントを作成してもよろしいですか? データベースエラーが発生しました。\n\nトラブルシューティングのために、アプリのログをエクスポートして共有してください。この操作が失敗した場合は、{app_name} を再インストールし、アカウントを復元してください。 このデバイス上のすべてのメッセージ、添付ファイル、アカウントデータを削除し、ネットワークからアカウントを復元してもよろしいですか? @@ -313,6 +360,12 @@ グループが作成されるまでお待ちください... グループの更新ができませんでした 他のユーザーの投稿を削除する権限はありません + + 選択した添付ファイルを削除 + + + 選択した添付ファイルを削除してもよろしいですか? 添付ファイルに関連付けられているメッセージも削除されます。 + {name}を連絡先から削除してもよろしいですか?\n\nこの操作により、すべての会話(メッセージや添付ファイルを含む)が削除されます。{name}からの今後のメッセージはメッセージリクエストとして表示されます。 {name}との会話を削除してよろしいですか?\nこの操作により、すべてのメッセージと添付ファイルが完全に削除されます。 @@ -345,6 +398,7 @@ すべてのユーザーのメッセージを削除してもよろしいですか? 削除中 開発者ツールを切り替える + デバイスの通知設定 音声入力の開始… 消えるメッセージ メッセージは {time_large} に削除されます @@ -380,6 +434,7 @@ {admin_name}が消えるメッセージの設定を更新しました You は消えるメッセージの設定を更新しました キャンセル + 表示 それは本当の名前、エイリアス、または好きなものにすることができます — そしていつでもそれを変更できます。 表示名を入力してください 表示名を入力してください @@ -391,6 +446,8 @@ あなたの表示名は、やり取りするユーザー、グループ、コミュニティに表示されます。 ドキュメント 寄付 + 強大な勢力がプライバシーの弱体化を試みていますが、私たちだけではこの戦いを続けられません。\n\n寄付により、{app_name}の安全性、独立性、そしてオンライン維持を支援できます。 + {app_name}へのご支援をお願いします 完了 ダウンロード ダウンロード中... @@ -419,19 +476,38 @@ あなたと{name}が{emoji_name}で反応しました メッセージにリアクションしました {emoji} 有効にする + カメラへのアクセスを有効にしますか? + 新しいメッセージを受信したときに通知を表示します。 + 有効にするには通話を終了 {app_name}を楽しんでいますか? 改善が必要です {emoji} すばらしいです {emoji} {app_name}をご利用いただいてしばらく経ちましたね。調子はいかがですか?ご意見をお聞かせいただけると嬉しいです。 + 入力 + {app_name} に設定したパスワードを入力してください + 起動時に {app_name} のロックを解除する際に使用するパスワードを入力してください(リカバリーパスワードではありません) + {pro} ステータスの確認中にエラーが発生しました インターネット接続を確認して、もう一度やり直してください エラーの文章をコピーして終了 データベースエラー 問題が発生しました。後でもう一度お試しください。 + {pro} アクセスの読み込み中にエラーが発生しました + {app_name} はこのONSを検索できませんでした。ネットワーク接続を確認して、もう一度お試しください。 未知のエラー + このONSは登録されていません。正しいか確認して、もう一度お試しください。 + {group_name} 内の {name} への招待の再送信に失敗しました + {group_name} 内の {name}{count}人 への招待の再送信に失敗しました + {group_name} 内の {name}{other_name} への招待の再送信に失敗しました + {group_name}{name}への昇進の再送信に失敗しました。 + {group_name}{name}および{count}人への昇進の再送信に失敗しました。 + {group_name}{name}および{other_name}への昇進の再送信に失敗しました。 ダウンロードに失敗しました 失敗 + フィードバック + 簡単なアンケートにお答えいただくことで、{app_name} に関するご意見をお聞かせください。 ファイル ファイル + システム設定に合わせる 常に 差出人: フルスクリーンを切り替える @@ -477,6 +553,9 @@ 本当に{group_name}を退出しますか? 本当に{group_name}を退会しますか?\n\nこれにより、すべてのメンバーが削除され、すべてのグループコンテンツが削除されます。 {group_name} を退出できませんでした + {name} がグループに招待されました。過去14日間のチャット履歴が共有されました。 + {name}{count} 人がグループに招待されました。過去14日間のチャット履歴が共有されました。 + {name}{other_name} がグループに招待されました。過去14日間のチャット履歴が共有されました。 {name} がグループを退会しました. {name}{count}人 がグループから退会しました {name}{other_name} がグループから退会しました @@ -488,6 +567,9 @@ {name}{other_name} がグループに招待されました。 あなた{count}名 がグループに招待されました。チャット履歴が共有されました。 あなた{other_name} はグループに招待されました。チャット履歴が共有されました。 + {group_name} から {name} を削除できませんでした + {group_name} から {name}{count}人 を削除できませんでした + {group_name} から {name}{other_name} を削除できませんでした Youがグループを退会しました グループメンバー このグループには他のメンバーがいません。 @@ -501,6 +583,7 @@ {group_name}からのメッセージがありません。会話を開始するにはメッセージを送信してください。 このグループは30日以上更新されていません。メッセージの送信やグループ情報の表示に問題が発生する可能性があります。 あなたは{group_name}で唯一の管理者です。\n\n管理者がいないと、グループメンバーと設定は変更できません。 + あなたは{group_name}で唯一の管理者です。\n\n管理者がいないと、グループメンバーや設定は変更できません。グループを削除せずに退出するには、まず新しい管理者を追加してください 削除保留中 You はアドミンに昇格しました あなた{count}名 はAdminに昇格しました。 @@ -526,30 +609,47 @@ グループが更新されました 接続候補を処理中 よくある質問 + よくある質問への回答については、{app_name} のFAQをご覧ください。 {app_name}の翻訳にご協力ください + バグを報告 詳細を共有して問題解決にご協力ください。ログをエクスポートして、{app_name}のヘルプデスクからファイルをアップロードしてください ログのエクスポート ログをエクスポートし、{app_name} のヘルプデスクにてファイルをアップロードします。 デスクトップに保存 + このファイルを保存し、{app_name}の開発者と共有してください。 サポート + {app_name} を 80 以上の言語に翻訳するお手伝いをしてください! ご意見をお聞かせください。 非表示 + システムメニューバーの表示を切り替えます。 自分用メモを会話リストから非表示にしますか? 他のウィンドウを隠す 画像 画像 + 重要 プライバシーキーボード 利用可能な場合はインコグニートモードを要求します。使用しているキーボードによっては、この要求を無視することがあります。 詳細 不正なショートカット + + 連絡先を招待 + 招待に失敗しました 招待を送信できませんでした。再試行しますか? + + メンバーを招待 + + 友達のアカウントID、ONS、またはQRコードをスキャンして、グループに新しいメンバーを招待してください {icon} + 友達のアカウントID、ONSを入力するか、QRコードをスキャンして新しいメンバーをグループに招待してください 参加 後で + コンピューターの起動時に {app_name} を自動的に起動します。 + 起動時に起動 + この設定は Linux 上でシステムによって管理されています。自動起動を有効にするには、{app_name} をシステム設定のスタートアップアプリケーションに追加してください。 詳細を知る 抜ける 終了しています... @@ -564,6 +664,8 @@ あなた{other_name} がグループに加わりました {name}{other_name} がグループに加わりました あなたがグループに加わりました + バックグラウンドの動作を制限しますか? + 現在、通知の信頼性を高めるために{app_name}がバックグラウンドで実行されるよう許可されています。この設定を変更すると、通知の信頼性が低下する可能性があります。 リンクプレビュー サポートされている URL のリンクプレビューを表示します リンクプレビューを有効にする @@ -574,6 +676,7 @@ リンクのプレビューを送信するとき、完全なメタデータ保護はありません。 リンクプレビューが無効です {app_name}は、送受信するリンクのプレビューを生成するためにリンクされたウェブサイトに接続する必要があります。\n\n{app_name}の設定でプレビューをオンにできます。 + リンク アカウントを読み込み アカウントを読み込み中 読み込み中... @@ -586,9 +689,16 @@ ロック状態 タッチしてロック解錠 {app_name}はロック解除されています + ログ + 管理者を管理 メンバーの管理 + {pro} を管理 最大 + あとで メディア + + %1$d人のメンバーが選択されました + %1$d 人のメンバー @@ -596,7 +706,9 @@ %1$d 人のアクティブなメンバー Account ID または ONS を追加 + メンバーは、グループへの招待を承諾した後にのみ昇格できます。 連絡先を招待 + このグループに招待できる連絡先がありません。\n戻って、アカウントIDまたはONSを使ってメンバーを招待してください。 招待状を送信 @@ -604,10 +716,14 @@ {name}{count}人にグループメッセージ履歴を共有しますか? {name}{other_name}にグループメッセージ履歴を共有しますか? メッセージ履歴を共有 + 過去14日間のメッセージ履歴を共有 新しいメッセージのみを共有 招待 + メンバー(管理者以外) + メニューバー メッセージ 続きを読む + メッセージをコピー このメッセージは空です。 メッセージの配信に失敗しました メッセージの上限に達しました @@ -621,6 +737,7 @@ アカウントIDまたはONSを入力して新しい会話を開始します。 アカウントID、ONSまたはQRコードをスキャンして新しい会話を開始します。 + 友達のアカウントID、ONS、またはQRコードをスキャンして新しい会話を開始してください {icon} %1$d 件の新規メッセージがあります @@ -666,20 +783,29 @@ メッセージが長すぎます メッセージを{limit}文字以内に短くしてください。 メッセージが長すぎます + 新しいパスワード + 次のステップ {name}のニックネームを選んでください。これが1対1およびグループ会話で表示されます。 ニックネームを入力してください 短いニックネームを入力してください ニックネームを削除 ニックネームをセット いいえ + このグループには管理者以外のメンバーがいません。 候補はありません + すべての会話で最大10,000文字のメッセージを送信できます。 + 無制限のピン留め会話でチャットを整理できます。 なし 後で 自分用メモ Note to Selfにはメッセージがありません。 自分用メモを隠す 本当に「自分用メモ」を非表示にしますか? + ご注意ください:{action_type} することで、{app_pro} の利用規約 {icon} および プライバシーポリシー {icon} に同意したものとみなされます + 通知表示 + 送信者の名前とメッセージ内容のプレビューを表示します。 + メッセージ内容を表示せず、送信者の名前のみを表示します。 すべてのメッセージ 通知内容 通知に表示される情報 @@ -690,6 +816,7 @@ Googleの通知サーバーを使用して、新しいメッセージが確実かつ即座に通知されます。 Huaweiの通知サーバーを使用することで、新しいメッセージの通知を即時かつ確実に受け取ることができます。 Appleの通知サーバーの利用で、すぐかつ確実に新しいメッセージの受信を通知されます。 + 送信者名やメッセージ内容なしで、一般的な {app_name} 通知を表示します。 端末の通知設定に移動 通知 - 全部 通知設定 メンションのみ @@ -697,6 +824,7 @@ {name}から{conversation_name}へ {device}の再起動中にメッセージが届いたかもしれません LED色 + 新しいメッセージを受信したときにサウンドを再生します。 メンションのみ メッセージ通知 最新の受信: {name} @@ -718,6 +846,11 @@ オフ オーケー オン + {device_type} デバイス上 + 以前に登録した {platform_account} にログインした {device_type} デバイスで、この {app_name} アカウントを開いてください。その後、{app_pro} の設定から {pro} アクセスを更新してください。 + リンク済みデバイスで + {platform_store} のウェブサイトで + {platform} のウェブサイトで アカウントを作成 アカウントが作成されました アカウントを持っています @@ -742,10 +875,17 @@ このONSを認識できませんでした。内容を確認して再度お試しください。 このONSを検索できませんでした。後でもう一度お試しください。 開く + {platform_store} のウェブサイトを開く + {platform} のウェブサイトを開く + 設定を開く アンケートを開く その他 + パスワード パスワードを変更 + {app_name}のロック解除に必要なパスワードを変更します。 + パスワードが変更されました。安全に保管してください。 パスワードを再確認 + パスワードを作成 現在のパスワードが間違っています。 パスワードを入力してください 現在のパスワードを入力してください @@ -755,9 +895,24 @@ パスワードが一致しません パスワードの設定に失敗しました パスワードが正しくありません + 新しいパスワードを確認 パスワードを削除 + {app_name} のロックを解除するために必要なパスワードを削除します。 + パスワードが削除されました。 パスワードをセット + パスワードが設定されました。安全に保管してください。 + 起動時に{app_name}のロックを解除するにはパスワードが必要です。 + 12文字以上 + 数字を含む + 小文字を含む + 記号を含む + 大文字を含む + パスワード強度インジケーター + 強力なパスワードを設定することで、デバイスが紛失または盗難にあってもメッセージや添付ファイルを保護できます。 + パスワード 貼り付け + 支払いエラー + お支払いは正常に処理されましたが、{pro}ステータスの{action_type}中にエラーが発生しました。\n\nネットワーク接続を確認して、再試行してください。 権限の変更 {app_name} は、ファイル、音楽、およびオーディオを送信するために音楽およびオーディオアクセスが必要ですが、それが恒久的に拒否されています。設定に移動して、「権限」を選択し、「音楽およびオーディオ」を有効にしてください。 {app_name}はメディア添付ファイルを再生するためにApple Musicを使用する必要があります @@ -769,6 +924,7 @@ ビデオ通話のためにカメラへのアクセスを許可してください。 {app_name} の画面ロック機能はFace IDを使用します。 システムトレイに常駐 + ウィンドウを閉じても、{app_name}はバックグラウンドで実行され続けます。 {app_name}を続行するにはフォトライブラリへのアクセスが必要です。iOS設定でアクセスを有効にできます。 通話を行うにはローカルネットワークへのアクセスが必要です。続行するには、設定で「ローカルネットワーク」の許可をオンにしてください。 {app_name} は音声・ビデオ通話を行うためにローカルネットワークへのアクセスが必要です。 @@ -795,24 +951,166 @@ 会話をピン留めする ピン留めを外す 会話のピン留めを外す + さらに多くの機能... + {pro} にまもなく新機能が登場します。{pro} ロードマップ {icon} で次に来るものをチェックしましょう + 設定 プレビュー + 通知をプレビュー + {pro} のアクセスは有効です!\n\n{pro} のアクセスは {date} に自動的に {current_plan_length} に更新されます。 + {pro} のアクセスは {date} に終了します。\n\n{pro} のアクセスが終了する前に自動更新されるよう、今すぐ {pro} を更新してください。 + {pro} のアクセスは有効です!\n\n{pro} のアクセスは {date} に自動的に \n{current_plan_length} に更新されます。ここで行った変更は、次回の更新時に適用されます。 + {pro} アクセスエラー + {pro} のアクセスは {date} に終了します。 + {pro} アクセスを読み込み中 + {pro} アクセス情報はまだ読み込み中です。この処理が完了するまで更新はできません。 + {pro} アクセスを読み込み中... + {pro} アクセス情報を読み込むためにネットワークに接続できません。接続が回復するまで、{app_name} を通じた {pro} の更新は無効になります。\n\nネットワーク接続を確認して、再試行してください。 + {pro} アクセスが見つかりません + {app_name} は、お客様のアカウントに {pro} アクセスがないことを検出しました。これが誤りだと思われる場合は、サポートが必要ですので、{app_name} サポートへご連絡ください。 + {pro} アクセスを復元 + {pro} アクセスを更新 + 現在、{pro} アクセスは {platform_store} または {platform_store_other} を通じてのみ購入および更新が可能です。{app_name} デスクトップを使用しているため、ここでは更新できません。\n\n{app_name} の開発者は、{platform_store} および {platform_store_other} 以外でも {pro} アクセスを購入できるよう、代替の支払いオプションを鋭意開発中です。{pro} 開発ロードマップ {icon} + {pro} に登録した {platform_account} を使用して、{platform_store} ウェブサイト 上で {pro} アクセスを更新してください。 + {platform} のウェブサイト で、{pro} に登録した {platform_account} を使って更新してください。 + {pro} アクセスを更新して、強力な {app_pro} ベータ機能を再びご利用ください。 + {pro} アクセスが復元されました + {app_name} がアカウントの {pro} アクセスを検出し、復元しました。{pro} ステータスが復元されました! + {app_pro} に最初に {platform_store} を通じて登録したため、{pro} へのアクセスを更新するには {platform_account} を使用する必要があります。 + 現在、{pro} アクセスは {platform_store} または {platform_store_other} 経由でのみ購入可能です。{app_name} デスクトップ版をご利用のため、ここでは {pro} にアップグレードできません。\n\n{app_name} の開発者は、{platform_store} および {platform_store_other} 以外でも {pro} アクセスを購入できるように、代替の支払いオプションに取り組んでいます。 {pro} ロードマップ {icon} アクティベート済み + アクティベート中 + 準備完了です! + {app_pro} のアクセスが更新されました!{pro} は {date} に自動更新される際に請求されます。 すでにご利用中です ディスプレイ画像としてGIFやアニメーションWebP画像をアップロードできます! + アニメーションディスプレイ画像を取得し、{app_pro} Betaでプレミアム機能を解除しましょう。 アニメーション表示画像 ユーザーはGIFをアップロードできます + アニメーション表示画像 + アニメーションGIFおよびWebP画像を表示画像として設定できます。 GIFをアップロード(PRO) + {pro} は {time} に自動更新されます + {pro} バッジ + {app_pro} バッジを他のユーザーに表示する + バッジ + 表示名の横にある限定バッジで {app_name} をサポートしていることを示してください。 + + %1$s 件の %2$s バッジを送信済み + + {pro} ベータ機能 + {price}(年額請求) + {price}(月額請求) + {price}(四半期ごとの請求) + 長文を送りたいですか?\n{app_pro} Betaでさらに多くのテキストを送り、プレミアム機能を解除しましょう。 + さらにピン留めしますか?\n{app_pro} Betaでチャットを整理して、プレミアム機能を解除しましょう。 + {limit}件以上ピン留めしたいですか?\n{app_pro} Betaでチャットを整理して、プレミアム機能を解除しましょう。 + {pro} のキャンセルを検討されているのですね。{pro} アクセスをキャンセルする前に知っておくべきことがあります。 + キャンセル + {pro} アクセスをキャンセルする方法は2つあります: + {pro} アクセスをキャンセルすると、{pro} が期限切れになる前に自動更新が行われなくなります。\n\n{pro} をキャンセルしても払い戻しは発生しません。{pro} アクセスの有効期間中は、引き続き {app_pro} の機能を使用できます。 + 自分に合った {pro} アクセスオプションを選択してください。\nより長期間のアクセスほどお得になります。 + このデバイスからデータを削除してもよろしいですか?\n\n{app_pro} は別のアカウントに移行できません。後で {pro} アクセスを復元できるよう、リカバリーパスワードを保存してください。 + ネットワークからデータを削除してもよろしいですか? 続行すると、メッセージや連絡先を復元できなくなります。\n\n{app_pro} は別のアカウントに移行できません。後で {pro} アクセスを復元できるよう、リカバリーパスワードを保存してください。 + {app_pro} の通常価格の {percent}% 割引がすでに {pro} のアクセスに適用されています。 + {pro} ステータスの更新エラー + 期限切れ + 申し訳ありませんが、{pro} のアクセスは期限切れとなりました。\n{app_pro} Beta の限定特典と機能を再度有効化するには、更新してください。 + まもなく期限切れ + {pro} のアクセスは {time} で期限切れになります。\n{app_pro} Beta の限定特典と機能に引き続きアクセスするために、今すぐ更新してください。 + {pro} は {time} で期限切れになります + {pro} よくある質問 + {app_pro} のよくある質問で一般的な質問への回答を見つけましょう。 GIFとWebPのディスプレイ画像をアップロード 最大300人の大型グループチャット さらに多数の限定機能 最大10,000文字までのメッセージ ピン留め可能な会話が無制限 + {app_name} を最大限に活用したいですか?\n{app_pro} Beta にアップグレードして、数多くの限定特典や機能にアクセスしましょう。 グループが有効化されました このグループは拡張されています!グループ管理者の設定により、最大300人のメンバーに対応できます + + アップグレードされたグループ %1$s 件 + + 返金リクエストは最終的なものです。承認された場合、{pro} のアクセスは即座にキャンセルされ、すべての {pro} 機能へのアクセスを失います。 添付ファイルサイズの増加 メッセージの文字数増加 + より大きなグループ + あなたが管理者のグループは自動的に300人のメンバーをサポートするようにアップグレードされます。 + より大人数のグループチャット(最大300人まで)が、すべての Pro ベータユーザーにまもなく提供されます! + より長いメッセージ + すべての会話で最大10,000文字のメッセージを送信できます。 + + 長文メッセージを %1$s 件送信済み + このメッセージには以下の {app_pro} 機能が使用されています: + 新たにインストールして + このデバイスに {platform_store} を通じて {app_name} を再インストールし、リカバリーパスワードでアカウントを復元し、{app_pro} の設定から {pro} を更新してください。 + {platform_store} 経由でこのデバイスに {app_name} を再インストールし、リカバリーパスワードを使用してアカウントを復元した後、{app_pro} の設定から {pro} にアップグレードしてください。 + 現在、更新するには以下の3つの方法があります: + 現時点で、更新する方法は 2 つあります: + {percent}% オフ + + ピン留めされた会話 %1$s 件 + + {app_pro} に最初に {platform_store} を通じて登録したため、払い戻しを申請するには同じ {platform_account} を使用する必要があります。 + {app_pro} に最初に {platform_store} を通じて登録したため、払い戻しリクエストは {app_name} サポートによって処理されます。\n\n下のボタンを押して払い戻しリクエストフォームに記入することで、払い戻しを申請できます。\n\n{app_name} サポートは通常24~72時間以内の処理を目指していますが、リクエストが集中している場合は処理に時間がかかることがあります。 + {app_pro} アクセスが更新されました!{network_name} をご支援いただきありがとうございます。 + 1か月 - {monthly_price} / 月 + 3か月 - {monthly_price} / 月 + 12か月 - {monthly_price} / 月 + 再アクティベート中 + ご利用いただけなくなるのは残念です。返金をリクエストする前に知っておくべきことがあります。 + {platform} で返金申請の処理中です。通常は 24~48 時間かかります。決定に応じて、{app_name} 内の {pro} ステータスが変更される場合があります。 + 払い戻しリクエストは {app_name} サポートによって処理されます。\n\n下のボタンを押して払い戻しリクエストフォームに記入することで、払い戻しを申請できます。\n\n{app_name} サポートは通常24~72時間以内の処理を目指していますが、リクエストが集中している場合は処理に時間がかかることがあります。 + 払い戻しリクエストは、{platform} ウェブサイトを通じて{platform}によってのみ処理されます。\n\n{platform}の払い戻しポリシーにより、{app_name} の開発者は払い戻しリクエストの結果に影響を与えることができません。これには、リクエストが承認されるか却下されるか、および全額または一部の返金が行われるかが含まれます。 + 返金申請の進捗については、{platform} にお問い合わせください。{platform} の返金ポリシーにより、{app_name} の開発者は返金申請の結果に影響を与えることができません。\n\n{platform} 返金サポート + {pro} の返金処理中 + {app_pro} の返金処理はすべて {platform_store} を通じて {platform} によって行われます。\n\n{platform} の返金ポリシーにより、{app_name} の開発者は返金申請の結果に影響を与えることができません。これには申請の承認・却下の判断、および全額または一部返金かどうかも含まれます。 + アニメーション付き表示画像を再び使いたいですか?\n{pro} アクセスを更新して、見逃していた機能を解放しましょう。 + {pro} ベータ版を更新 + {platform_store} または {platform_store_other} 経由で {app_name} がインストールされたリンク済みデバイスの {app_pro} 設定から {pro} アクセスを更新してください。 + 長いメッセージを再び送りたいですか?\n{pro} アクセスを更新して、見逃していた機能を解放しましょう。 + {app_name} を最大限に活用したいですか?\n{pro} アクセスを更新して、見逃していた機能を解放しましょう。 + {limit} 件以上の会話を再びピン留めしたいですか?\n{pro} アクセスを更新して、見逃していた機能を解放しましょう。 + さらに多くの会話を再びピン留めしたいですか?\n{pro} アクセスを更新して、見逃していた機能を解放しましょう。 + 更新することで、{app_pro} の利用規約 {icon} および プライバシーポリシー {icon} に同意したことになります + 更新中 + 現在、{pro} へのアクセスの購入および更新は、{platform_store} または {platform_store_other} を通じてのみ可能です。{build_variant} を使用して {app_name} をインストールしたため、ここでは更新できません。\n\n{app_name} の開発者は、{platform_store} および {platform_store_other} 以外からも {pro} アクセスを購入できるよう、代替支払い方法の提供に向けて取り組んでいます。{pro} のロードマップ {icon} + 返金をリクエストしました さらに送信: + {pro} 設定 + {pro} の利用を開始する + あなたの {pro} 統計 + {pro} 統計情報を読み込み中 + {pro} 統計情報を読み込んでいます。しばらくお待ちください。 + {pro} 統計は、このデバイスでの使用状況を反映しており、連携された他のデバイスでは異なる場合があります。 + {pro} ステータスエラー + {pro} ステータスを確認するためにネットワークに接続できません。接続が回復するまで、このページに表示される情報は不正確な場合があります。\n\nネットワーク接続を確認して、再試行してください。 + {pro} ステータスを読み込み中 + {pro} 情報を読み込んでいます。このページ上の一部の操作は読み込みが完了するまで利用できない場合があります。 + {pro} ステータスを読み込み中 + {pro} のステータスを確認するためにネットワークに接続できません。接続が回復するまで続行できません。\n\nネットワーク接続を確認して、再試行してください。 + {pro} ステータスを確認するためにネットワークに接続できません。接続が回復するまで、{pro} へのアップグレードはできません。\n\nネットワーク接続を確認して、再試行してください。 + {pro} ステータスを更新するためにネットワークに接続できません。このページ上の一部の操作は接続が回復するまで無効になります。\n\nネットワーク接続を確認して、再試行してください。 + 現在の {pro} アクセスを読み込むためにネットワークへ接続できません。接続が回復するまでは、{app_name} から {pro} の更新は無効になります。\n\nネットワーク接続を確認し、再試行してください。 + {pro} に関するヘルプが必要ですか?サポートチームにリクエストを送信してください。 + {action_type}によって、{app_pro}が{app_name}プロトコルを通じて{activation_type}されます。{entity}はそのアクティベーションを支援しますが、{app_pro}の提供者ではありません。また、{entity}は{app_pro}のパフォーマンス、可用性、または機能に関して責任を負いません。 + アップデートすることで、{app_pro} の 利用規約 {icon} および プライバシーポリシー {icon} に同意したことになります + ピン留め無制限 + 無制限にピン留めされた会話で、すべてのチャットを整理できます。 + 現在の請求オプションでは、{current_plan_length}の {pro} アクセスが付与されています。{selected_plan_length_singular} の請求オプションに切り替えてもよろしいですか?\n\n更新することで、{pro} へのアクセスは {date} に自動的に更新され、さらに {selected_plan_length} の {pro} アクセスが追加されます。 + 更新中 + {app_pro} Beta にアップグレードして、数多くの限定特典や機能にアクセスしましょう。 + {platform_store} または {platform_store_other} 経由で {app_name} をインストールしたリンク済みデバイスの {app_pro} 設定から {pro} にアップグレードしてください。 + 現在、{pro} アクセスは {platform_store} または {platform_store_other} 経由でのみ購入できます。{build_variant} を使用して {app_name} をインストールしているため、ここでは {pro} にアップグレードできません。\n\n{app_name} の開発者は、{platform_store} および {platform_store_other} 以外でも {pro} アクセスを購入できるよう、代替の支払い方法に取り組んでいます。{pro} ロードマップ {icon} + 現時点で、アップグレードする方法は 1 つだけです: + 現時点で、アップグレードする方法は 2 つあります: + {app_pro} にアップグレードしました!\n{network_name} をサポートしていただきありがとうございます。 + アップグレード中 + {pro} にアップグレード中 + アップグレードすることにより、{app_pro} の利用規約 {icon} および プライバシーポリシー {icon}に同意したことになります + {app_name}をもっと活用したいですか?\nより強力なメッセージ体験のために{app_pro} Betaへアップグレードしましょう。 + {platform} が返金申請を処理中です プロフィール ディスプレイの画像 表示画像の削除に失敗しました。 @@ -820,6 +1118,10 @@ 小さいファイルを選んでください プロフィールを更新できませんでした 昇格 + 管理者は過去14日間のメッセージ履歴を閲覧でき、降格やグループから削除することはできません。 + + メンバーを昇格 + 昇進に失敗しました @@ -847,6 +1149,7 @@ オススメ リカバリーパスワードを保存して、アカウントにアクセスできなくならないようにしてください リカバリーパスワードを保存してください + リカバリパスワードを使用して、新しいデバイスでアカウントを読み込みます。\n\nリカバリパスワードがないとアカウントを復元できません。安全な場所に保管し、誰とも共有しないでください リカバリーフレーズを入力してください リカバリパスワードの読み込み中にエラーが発生しました。\n\nログをエクスポートし、 {app_name} のヘルプデスクにファイルをアップロードしてこの問題の解決に役立ててください。 リカバリーパスワードを確認してもう一度やり直してください @@ -856,26 +1159,61 @@ アカウントをロードするには、リカバリーパスワードを入力してください。 リカバリパスワードを永久に隠す リカバリパスワードがなければ、新しいデバイスでアカウントを読み込むことはできません。\n\n続行する前に、リカバリパスワードを安全で安全な場所に保存することを強くお勧めします。 + この端末でリカバリパスワードを永久に非表示にしてもよろしいですか?\n\nこれは元に戻すことはできません。 リカバリパスワードを隠す このデバイスでリカバリーパスワードを永久に非表示にします アカウントを読み込むためにリカバリーフレーズを入力してください。 保存していない場合は、アプリの設定で確認できます。 + リカバリパスワードを表示 + リカバリパスワードの表示 これはあなたのリカバリーパスワードです。誰かに送信すると、その人はあなたのアカウントにフルアクセスできます。 グループを再作成 やり直す + 元々 {app_pro} に登録した別の {platform_account} 経由でサインアップしているため、その {platform_account} を使用して {pro} アクセスを更新する必要があります。 + 払い戻しをリクエストする方法は2つあります: メッセージをあと{count}字削減してください 残り %1$d 字 + 後で通知する 削除 + + メンバーを削除 + + + メンバーとそのメッセージを削除 + パスワードを削除できませんでした + {app_name} の現在のパスワードを削除します。ローカルに保存されたデータは、端末に保存されるランダムに生成されたキーで再暗号化されます。 + + メンバーを削除中 + + 更新 + {pro} を更新中 返信 + 払い戻しをリクエスト + {platform} のウェブサイトで、{pro} に登録した {platform_account} を使って払い戻しをリクエストしてください。 再送 + + 招待を再送信 + + + 昇格を再送信 + + + 招待を再送信中 + + + 昇格を再送信中 + 国名を読み込み中... 再起動 再同期 再試行 レビュー制限 最近すでに {app_name} を評価いただいているようです。フィードバックありがとうございます! + アプリをバックグラウンドで実行 + {app_name}をバックグラウンドで実行しますか? + スローモードを使用しているため、通知の改善のために{app_name}をバックグラウンドで実行することをおすすめします。これにより通知の一貫性が向上する可能性がありますが、システムによってバックグラウンド活動が制限されることがあります。\n\nこの設定は後で[設定]で変更できます。 保存 保存済み 保存済みのメッセージ @@ -884,6 +1222,8 @@ スクリーンセキュリティ スクリーンショット通知 連絡先が1対1チャットのスクリーンショットを撮ったときに通知を受け取ります + このデバイスで撮影されたスクリーンショットに、{app_name} ウィンドウを表示しないようにします。 + スクリーンショット保護 {name}はスクリーンショットを撮りました 検索 連絡先を検索 @@ -903,6 +1243,9 @@ 送信中 通話オファーを送信中 接続候補を送信中 + + 昇進を送信中 + 送信済み: デザイン設定 データを消去する @@ -923,38 +1266,56 @@ 通知 権限 プライバシー + {app_pro} ベータ版 リカバリパスワード 設定 セット コミュニティのプロフィール写真を設定 + {app_name} のパスワードを設定します。ローカルに保存されたデータはこのパスワードで暗号化されます。{app_name} の起動時に毎回このパスワードの入力が求められます。 + 設定を更新できません 新しい設定を適用するために {app_name} を再起動する必要があります. スクリーンセキュリティ + 起動 共有 友達を{app_name} に招待して、チャットを始めましょう。アカウントIDを共有して招待できます。 いつでもどこでも友達と共有 — ここで会話を始めましょう データベースを開く際に問題が発生しました。アプリを再起動して再度お試しください。 {app_name} のアカウントをまだお持ちでないようです。\n\n共有するには、 {app_name} アプリで作成する必要があります。 + このユーザーとグループメッセージ履歴を共有しますか? {app_name}に共有 + 申し訳ありませんが、{app_name}は複数の画像および動画の同時共有のみをサポートしています + 共有はメディアファイルのみをサポートしています。非メディアファイルは除外されました 表示 すべて表示 少なく表示 自分用メモを表示 自分用メモを会話リストに表示しますか? + スペルチェック ステッカー + 強度 + 問題がありますか?ヘルプ記事を確認するか、{app_name} サポートにチケットを送信してください。 サポートページへ システム情報: {information} タップして再試行 続行 デフォルト エラー + 戻る + テーマプレビュー {name} のAccount IDは、以前のやり取りに基づき表示されます ブラインドIDは、スパムを減らしプライバシーを高めるためにCommunityで利用されます + 翻訳する + トレイ 再試行 入力中アイコン 入力中アイコンを表示 利用不可 元に戻す 不明 + 非対応 CPU + 更新する + {pro} アクセスを更新 + {pro} アクセスを更新する方法は 2 つあります: アプリ更新 コミュニティ情報を更新 コミュニティ名と説明はすべてのメンバーに表示されます @@ -969,17 +1330,26 @@ グループの説明をもっと短く入力してください {app_name} のバージョンアップが利用可能です。タップすると更新します。 {app_name}の新しいバージョン({version})が利用可能です。 + プロフィール情報を更新 + 表示名と表示画像はすべての会話で表示されます。 リリースノートを閲覧 {app_name}アップデート バージョン {version} 最終更新: {relative_time} 前 + アップデート + 更新中... + アップグレード + {app_name} をアップグレード アップグレード先: 送信中 URLをコピー URLを開く これをブラウザで開きます。 本当にこのURLをブラウザで開きますか?\n\n{url} + リンクはブラウザで開かれます。 高速モードを使用する + {platform} のウェブサイト を通じて、登録時に使用した {platform_account} を使用してプランを変更してください。 + {platform} のウェブサイト経由 動画 動画を再生できません。 見る @@ -988,7 +1358,12 @@ 数分かかる可能性があります。 少々お待ちください… 警告 + iOS 15 のサポートは終了しました。アプリの更新を受け取るには、iOS 16 以降にアップデートしてください。 ウィンドウ はい あなた + お使いの CPU は SSE 4.2 命令をサポートしていません。これは Linux x64 オペレーティングシステム上の {app_name} が画像を処理するために必要です。互換性のある CPU にアップグレードするか、別のオペレーティングシステムをご使用ください。 + リカバリパスワード + ズーム倍率 + テキストと視覚要素のサイズを調整します。 \ No newline at end of file diff --git a/app/src/main/res/values-b+my+MM/strings.xml b/app/src/main/res/values-b+my+MM/strings.xml index c1ab9be13a..bb2ab5bc65 100644 --- a/app/src/main/res/values-b+my+MM/strings.xml +++ b/app/src/main/res/values-b+my+MM/strings.xml @@ -228,7 +228,7 @@ ဘလော့ထားသော အဆက်အသွယ်များ Community စကားပြောဆိုမှု ဖျက်မည် - သင် {name} နှင့်သော ဆက်သွယ်မှုကို ဖျက်လိုကြောင်း ယဉ်သောပါသလား? {name} မှ ပေးပို့သော သစ်စ(Python)သည် အသစ်သော ဆက်သွယ်မှု တစ်ခုစတင်မှာဖြစ်သည်။ + {name} နဲ့ စကားဝိုင်းကို ဖျက်ချင်တာ သေချာလား။ {name} ကနေ မက်ဆေ့ချ်အသစ်တွေက စကားဝိုင်းအသစ်တစ်ခု စတင်ပါလိမ့်မယ်။ စကားပြောဆိုမှု ဖျက်ပြီးပါပြီ {conversation_name} တွင် မက်ဆေ့ချ် မရှိပါ။ ခြေလှမ်းရွေးချယ်ပါ diff --git a/app/src/main/res/values-b+nb+NO/strings.xml b/app/src/main/res/values-b+nb+NO/strings.xml index 9bd7ddbd74..494bc1da55 100644 --- a/app/src/main/res/values-b+nb+NO/strings.xml +++ b/app/src/main/res/values-b+nb+NO/strings.xml @@ -3,6 +3,7 @@ Om Godta Kopier konto-ID + Konto-ID Kontoid kopiert Kopier konto-IDen din og del den med vennene dine slik at de kan sende meldinger til deg. Skriv inn konto-ID @@ -14,6 +15,13 @@ Dette er din Account ID. Andre brukere kan skanne den for å starte en samtale med deg. Opprinnelig størrelse Legg til + + Legg til administrator + Legg til administratorer + + Legg til administrator + Skriv inn kontoid-en til brukeren du skal gjøre til administrator.\n\nFor å legge til flere brukere, skriv inn hver kontoid separert med komma. Du kan legge inn opptil 20 konto-ID-er om gangen. + Administratorer kan ikke degraderes eller fjernes fra gruppen. Administratorer kan ikke fjernes. {name} og {count} andre ble forfremmet til Admin. Fremhev administratorer @@ -26,7 +34,9 @@ Kunne ikke promotere {name} i {group_name} Kunne ikke promotere {name} og {count} andre i {group_name} Kunne ikke promotere {name} og {other_name} i {group_name} + Opprykk ikke sendt Admin forfremmelse sendt + Opprykksstatus ukjent Fjern administratorer Fjern som administrator Det er ingen Admins i denne Community. @@ -36,14 +46,40 @@ {name} ble fjernet som Admin. {name} og {count} andre ble fjernet som Admin. {name} og {other_name} ble fjernet som Admin. + + %1$d administrator valgt + %1$d administratorer valgt + Sender adminforfremmelse Sender adminforfremmelser Admin Innstillinger + Du kan ikke endre din egen administratorstatus. For å forlate gruppen, åpne samtaleinnstillingene og velg Forlat gruppe. {name} og {other_name} ble forfremmet til Admin. + Administratorer + Tillat +{count} Anonym + App-ikon + Endre app-ikon og -navn + For å endre app-ikon og -navn må {app_name} lukkes. Varsler vil fortsatt bruke standardikonet og -navnet til {app_name}. + Alternativt app-ikon og navn vises på startskjermen og i app-skuffen. + Det valgte app-ikonet og navnet vises på startskjermen og i applisten. + Ikon og navn + Alternativ app-ikon vises på Hjem-skjermen og i appbiblioteket. Appnavnet vil fortsatt vises som \"{app_name}\". + Bruk alternativt app-ikon + Bruk alternativt app-ikon og navn + Velg alternativt app-ikon + Ikon + Kalkulator + MeetingSE + Nyheter + Notater + Aksjer + Vær + {app_pro}-merke + Automatisk mørkmodus Skjul menylinjen Språk Velg språkinnstilling for {app_name}. {app_name} vil starte på nytt når du endrer språkinnstillingen. @@ -60,6 +96,7 @@ Zoom inn Zoom ut Vedlegg + Vedlegg Legg til vedlegg Navnløst bildealbum Automatisk nedlasting av vedlegg @@ -121,9 +158,12 @@ Utestengelse mislyktes Oppheving av utestengelse mislyktes Opphev utestengelse av bruker + Skriv inn kontoid-en til brukeren du fjerner utestengelsen for Bruker opphevet utestengelse Utesteng bruker Bruker utestengt + Skriv inn kontoid-en til brukeren du utestenger + Blindet ID Blokker Avblokker denne kontakten for å sende en beskjed. Ingen blokkerte kontakter @@ -134,6 +174,8 @@ Er du sikker på at du vil fjerne blokkeringen av {name} og {count} andre? Er du sikker på at du vil fjerne blokkeringen av {name} og 1 annen? Opphev blokkering {name} + Vis og administrer blokkerte kontakter. + Fant ingen nettleser for å åpne denne URL-en, prøv heller å kopiere URL-en Ring {name} ringte deg Du kan ikke starte en ny samtale. Fullfør den nåværende samtalen først. @@ -145,20 +187,27 @@ Samtale pågår Innkommende anrop fra {name} Innkommende anrop + Du gikk glipp av en samtale fra {name} fordi du ikke har gitt mikrofontilgang. Ubesvart anrop Tapt anrop fra {name} Lyd- og videosamtaler krever at meldingsvarsler er aktivert i enhetsinnstillingene dine. Samtaletillatelser er påkrevd Du kan aktivere \"Lyd- og videosamtaler\"-tillatelsen i personvernsinnstillingene. + Du kan aktivere tillatelsen \"Lyd- og videosamtaler\" i personvernsinnstillingene. Kobler til på nytt… Ringer... {app_name} Call Samtaler (Beta) Lyd- og videosamtaler Lyd- og videosamtaler (Beta) + IP-adressen din er synlig for samtalepartneren og en {session_foundation}-server når du bruker beta-samtaler. Aktiverer tale- og videosamtaler til og fra andre brukere. Du ringte {name} Du gikk glipp av en samtale fra {name} fordi du ikke har aktivert Tal & Video-samtaler i Personverninnstillingene. + {app_name} trenger tilgang til kameraet ditt for å aktivere videosamtaler, men denne tillatelsen har blitt avslått. Du kan ikke oppdatere kameratilgang under en samtale.\n\nVil du avslutte samtalen nå og aktivere kameratilgang, eller vil du bli påminnet etter samtalen? + Åpne innstillinger og slå på kameratilgang for å tillate tilgang til kameraet. + Under din siste samtale prøvde du å bruke video, men det var ikke mulig fordi kameratilgang tidligere ble avslått. Åpne innstillinger og aktiver kameratilgang for å tillate det. + Kameratilgang kreves Intet kamera funnet Kamera utilgjengelig. Gi kameratilgang @@ -166,7 +215,19 @@ {app_name} trenger kameratilgang for å ta bilder og videoer eller skanne QR-koder. {app_name} trenger kameratilgang for å skanne QR-koder Avbryt + Kanseller {pro} + Avbryt på {platform}-nettstedet, ved å bruke {platform_account} du registrerte deg for {pro} med. + Avbryt på {platform_store}-nettstedet, ved å bruke {platform_account} du registrerte deg for {pro} med. + Endre Kunne ikke endre passord + Endre passordet ditt for {app_name}. Lokalt lagrede data vil bli kryptert på nytt med det nye passordet ditt. + Endre innstilling + Sjekker {pro}-status + Sjekker {pro}-statusen din. Du kan fortsette så snart denne sjekken er fullført. + Sjekker {pro}-detaljene dine. Enkelte handlinger på denne siden kan være utilgjengelige til denne sjekken er fullført. + Sjekker {pro}-status... + Sjekker detaljene for {pro}. Du kan ikke fornye før denne sjekken er fullført. + Sjekker {pro}-statusen din. Du kan oppgradere til {pro} når denne sjekken er fullført. Tøm Tøm alle Fjern alle data @@ -182,19 +243,29 @@ Er du sikker på at du vil slette dine data fra nettverket? Hvis du fortsetter, vil du ikke være i stand til å gjenopprette meldingene eller kontaktene dine. Er du sikker på at du vil tømme enheten din? Fjern enhet kun + Tøm enhet og start på nytt + Tøm enhet og gjenopprett Fjern alle meldinger Er du sikker på at du vil fjerne alle meldinger fra samtalen din med {name} fra enheten din? + Er du sikker på at du vil slette alle meldinger fra samtalen med {name} på denne enheten? Er du sikker på at du vil fjerne alle {community_name} meldinger fra enheten din? + Er du sikker på at du vil slette alle meldinger fra {community_name} på denne enheten? Fjern for alle Fjern for meg Er du sikker på at du vil fjerne alle {group_name} meldinger? + Er du sikker på at du vil slette alle meldinger fra {group_name}? Er du sikker på at du vil fjerne alle {group_name} meldinger fra enheten din? + Er du sikker på at du vil slette alle meldinger fra {group_name} på denne enheten? Er du sikker på at du vil fjerne alle Notat til meg selv meldinger fra enheten din? + Er du sikker på at du vil slette alle Note to Self-meldinger på denne enheten? + Tøm på denne enheten Lukk + Lukk appen Lukk vindu Commit Hash: {hash} Dette vil utestenge den valgte brukeren fra dette Community og slette alle meldingene deres. Er du sikker på at du vil fortsette? Dette vil utestenge den valgte brukeren fra dette Community. Er du sikker på at du vil fortsette? + Skriv inn en beskrivelse av samfunnet Angi samfunnets URL Ugyldig URL Vennligst sjekk Community URL og prøv igjen. @@ -209,15 +280,23 @@ Du er allerede medlem av dette fellesskapet. Forlat nettsamfunn Kunne ikke forlate {community_name} + Skriv inn et navn på samfunnet + Vennligst skriv inn et navn på samfunnet Ukjent Community Fellesskaps-URL Kopier fellesskapets nettadresse Bekreft + Bekreft forfremmelse + Er du sikker? Administratorer kan ikke degraderes eller fjernes fra gruppen. Kontakter Slette kontakt Er du sikker på at du vil slette {name} fra dine kontakter? Nye meldinger fra {name} vil komme som en meldingsforespørsel. Du har ingen kontakter ennå Velg kontakter + + %1$d kontakt valgt + %1$d kontakter valgt + Bruker detaljer Kamera Velg en handling for å starte en samtale @@ -225,6 +304,7 @@ Meldingsskriving Miniatyrbilde i sitert melding Opprett en samtale med en ny kontakt + Velg innholdet som vises i lokale varsler når en innkommende melding mottas. Legg til på startskjermen Lagt til på startskjermen Lydmeldinger @@ -237,12 +317,18 @@ Samtalen slettet Det er ingen meldinger i {conversation_name}. Skriv inn nøkkel + Definer hvordan Enter- og Shift+Enter-tastene fungerer i samtaler. + SHIFT + ENTER sender en melding, ENTER starter en ny linje. + ENTER sender en melding, SHIFT + ENTER starter en ny linje. Grupper Automatisk sletting Trim Communities + Slett meldinger eldre enn 6 måneder i samfunn med over 2 000 meldinger. Ny samtale Du har ingen samtaler ennå + Send med Enter Ved å trykke på Enter nøkkel vil sende melding i stedet for å starte en ny linje. + Send med Shift+Enter Alle medier Stavekontroll Aktiver stavekontroll når du skriver meldinger. @@ -250,8 +336,14 @@ Kopiert Kopier Opprett + Oppretter samtale + Gjeldende fakturering + Nåværende passord Klipp ut + Mørk modus + Er du sikker på at du vil slette alle meldinger, vedlegg og kontodata fra denne enheten og opprette en ny konto? En databasefeil har oppstått.\n\n Eksporter dine applikasjon logger for å dele feilsøkingen. Hvis dette ikke vellykkes, installer {app_name} på nytt og gjenopprett kontoen din. + Er du sikker på at du vil slette alle meldinger, vedlegg og kontodata fra denne enheten og gjenopprette kontoen din fra nettverket? Vi har lagt merke til at {app_name} tar lang tid å starte.\n\nDu kan vente videre, eksportere loggene på enheten din for å dele for feilsøking, eller prøve å starte {app_name} på nytt. App-databasen din er inkompatibel med denne versjonen av {app_name}. Installer appen på nytt og gjenopprett kontoen din for å generere en ny database og fortsette å bruke {app_name}.\n\nAdvarsel: Dette vil resultere i tap av alle meldinger og vedlegg eldre enn to uker. Optimaliserer databasen @@ -273,6 +365,16 @@ Vennligst vent mens gruppen opprettes... Kunne ikke oppdatere gruppen Du har ikke tillatelse til å slette andres beskjeder + + Slett valgt vedlegg + Slett valgte vedlegg + + + Er du sikker på at du vil slette det valgte vedlegget? Meldingen som er knyttet til vedlegget vil også bli slettet. + Er du sikker på at du vil slette de valgte vedleggene? Meldingen som er tilknyttet vedleggene vil også bli slettet. + + Er du sikker på at du vil slette {name} fra kontaktene dine?\n\nDette vil slette samtalen din, inkludert alle meldinger og vedlegg. Fremtidige meldinger fra {name} vil vises som en meldingsforespørsel. + Er du sikker på at du vil slette samtalen din med {name}?\nDette vil permanent slette alle meldinger og vedlegg. Slett melding Slett meldinger @@ -310,6 +412,7 @@ Er du sikker på at du vil slette disse meldingene for alle? Sletter Skru av/på Utviklerverktøy + Enhetens varslingsinnstillinger Start diktering... Tidsbegrensede Meldinger Melding vil slettes om {time_large} @@ -345,6 +448,7 @@ {admin_name} oppdaterte innstillingene for forsvinnende meldinger. Du oppdaterte innstillinger for forsvinnende meldinger. Avvis + Skjerm Det kan være ditt virkelige navn, et alias eller noe annet du liker — og du kan endre det når som helst. Skriv inn navnet som skal vises Vennligst skriv inn et visningsnavn @@ -353,7 +457,11 @@ Velg et nytt visningsnavn Velg ditt visningsnavn Sett visningsnavn + Visningsnavnet ditt er synlig for brukere, grupper og fellesskap du samhandler med. Dokument + Doner + Mektige krefter prøver å svekke personvernet, men vi kan ikke kjempe denne kampen alene.\n\nDonasjoner bidrar til å holde {app_name} sikkert, uavhengig og tilgjengelig. + {app_name} trenger din hjelp Ferdig Last ned Laster ned... @@ -383,23 +491,53 @@ Du og {name} reagerte med {emoji_name} Reagerte på meldingen din {emoji} Aktiver + Vil du aktivere kameratilgang? + Vis varsler når du mottar nye meldinger. + Avslutt samtalen for å aktivere + Liker du {app_name}? + Trenger forbedring {emoji} + Det er flott {emoji} + Du har brukt {app_name} en liten stund, hvordan går det? Vi vil gjerne høre dine tanker. + Enter + Skriv inn passordet du har angitt for {app_name} + Skriv inn passordet du bruker for å låse opp {app_name} ved oppstart, ikke gjenopprettingspassordet ditt + Feil under sjekking av {pro}-status Sjekk internettforbindelsen din og prøv igjen. Kopier feil og avslutt Databasefeil + Noe gikk galt. Prøv igjen senere. + Feil ved innlasting av tilgang til {pro} + {app_name} kunne ikke søke etter denne ONS-en. Kontroller nettverkstilkoblingen din og prøv igjen. En ukjent feil oppstod. + Denne ONS-en er ikke registrert. Kontroller at den er riktig og prøv igjen. + Kunne ikke sende invitasjon på nytt til {name} i {group_name} + Kunne ikke sende invitasjon på nytt til {name} og {count} andre i {group_name} + Kunne ikke sende invitasjon på nytt til {name} og {other_name} i {group_name} + Kunne ikke sende forfremmelse på nytt til {name} i {group_name} + Kunne ikke sende forfremmelse på nytt til {name} og {count} andre i {group_name} + Kunne ikke sende forfremmelse på nytt til {name} og {other_name} i {group_name} + Kunne ikke laste ned Feil + Tilbakemelding + Del din erfaring med {app_name} ved å fullføre en kort undersøkelse. Fil Filer + Følg systeminnstillinger. + For alltid Fra: Skru av/på Fullskjerm GIF Giphy {app_name} vil koble til Giphy for å levere søkeresultater. Du vil ikke ha full metadatabeskyttelse når du sender GIF-er. + Gi tilbakemelding? + Beklager å høre at opplevelsen din med {app_name} ikke har vært ideell. Vi setter stor pris på om du kan dele dine tanker i en kort undersøkelse. Grupper kan ha maksimalt 100 medlemmer Opprett gruppe Vennligst velg minst ett annet gruppemedlem. Slette gruppe Er du sikker på at du vil slette {group_name}?\n\nDette vil fjerne alle medlemmer og slette alt av innholdet i gruppen. + Er du sikker på at du vil slette {group_name}? + {group_name} har blitt slettet av en gruppeadministrator. Du vil ikke kunne sende flere meldinger. Skriv inn en gruppebeskrivelse Gruppeprofilbildet oppdatert. Rediger gruppe @@ -412,6 +550,9 @@ Kunne ikke invitere {name} og {count} andre til {group_name} Kunne ikke invitere {name} og {other_name} til {group_name} Kunne ikke invitere {name} til {group_name} + Invitasjon ikke sendt + {name} inviterte deg til å bli med igjen i {group_name}, der du er administrator. + Du ble invitert til å bli med igjen i {group_name}, der du er administrator. Sender invitasjon Sender invitasjoner @@ -423,10 +564,14 @@ Du ble invitert til å bli med i gruppen. Du og {count} andre ble invitert til gruppen. Du og {other_name} ble invitert til gruppen. + Du ble invitert til å bli med i gruppen. Chat-historikk ble delt. Forlat gruppe Er du sikker på at du vil forlate {group_name}? Er du sikker på at du vil forlate {group_name}?\n\nDette vil fjerne alle medlemmer og slette alt gruppearbeid. Kunne ikke forlate {group_name} + {name} ble invitert til å bli med i gruppen. Chat-historikk fra de siste 14 dagene ble delt. + {name} og {count} andre ble invitert til å bli med i gruppen. Chat-historikken fra de siste 14 dagene ble delt. + {name} og {other_name} ble invitert til å bli med i gruppen. Chat-historikken fra de siste 14 dagene ble delt. {name} forlot gruppen. {name} og {count} andre forlot gruppen. {name} og {other_name} forlot gruppen. @@ -437,6 +582,10 @@ {name} og {count} andre ble invitert til gruppen. {name} og {other_name} ble invitert til gruppen. Du og {count} andre ble invitert til gruppen. Chat-historikk ble delt. + Du og {other_name} ble invitert til å bli med i gruppen. Chat-historikk ble delt. + Kunne ikke fjerne {name} fra {group_name} + Kunne ikke fjerne {name} og {count} andre fra {group_name} + Kunne ikke fjerne {name} og {other_name} fra {group_name} Du forlot gruppen. Gruppemedlemmer Det er ingen andre medlemmer i denne gruppen. @@ -446,10 +595,15 @@ Vennligst skriv inn et kortere gruppenavn. Gruppens navn er nå {group_name}. Gruppenavnet oppdatert. + Gruppenavnet er synlig for alle gruppemedlemmer. Du har ingen meldinger fra {group_name}. Send en melding for å starte samtalen! + Denne gruppen har ikke blitt oppdatert på over 30 dager. Du kan oppleve problemer med å sende meldinger eller vise gruppeinformasjon. Du er den eneste administratoren i {group_name}.\n\nGruppemedlemmer og innstillinger kan ikke endres uten en administrator. + Du er den eneste administratoren i {group_name}.\n\nGruppemedlemmer og innstillinger kan ikke endres uten en administrator. For å forlate gruppen uten å slette den, legg til en ny administrator først. + Venter på fjerning Du ble oppgradert til administrator. Du og {count} andre ble forfremmet til Admin. + Du og {other_name} ble forfremmet til Admin. Vil du fjerne {name} fra {group_name}? Vil du fjerne {name} og {count} andre fra {group_name}? Vil du fjerne {name} og {other_name} fra {group_name}? @@ -465,27 +619,40 @@ {name} og {count} andre ble fjernet fra gruppen. {name} og {other_name} ble fjernet fra gruppen. Du ble fjernet fra {group_name}. + Du ble fjernet fra gruppen. Du og {count} andre ble fjernet fra gruppen. Du og {other_name} ble fjernet fra gruppen. Sett gruppeprofilbilde Ukjent gruppe Gruppe oppdatert + Behandler tilkoblingskandidater Ofte Stilte Spørsmål + Se i {app_name} vanlige spørsmål for svar på vanlige spørsmål. Hjelp oss med å oversette {app_name} + Rapporter en bug Del noen detaljer for å hjelpe oss med å løse problemet ditt. Eksporter loggene dine, og last deretter opp filen gjennom Hjelpesenteret til {app_name}. Eksportlogger Eksporter logger, deretter last opp filen gjennom {app_name}\'s Help Desk. Lagre til skrivebordet Lagre denne filen, så del den med {app_name}-utviklerne. Brukerstøtte + Hjelp til med å oversette {app_name} til over 80 språk! Vi ønsker gjerne dine tilbakemeldinger Skjul + Vis eller skjul systemmenylinjen. + Er du sikker på at du vil skjule Note to Self fra samtalelisten din? Skjul andre Bilde + bilder + Viktig Inkognito-tastatur Be om inkognitomodus hvis tilgjengelig. Avhengig av tastaturet du bruker, kan det hende tastaturet ignorerer denne forespørselen. Info Ugyldig snarvei + + Inviter kontakt + Inviter kontakter + Invitasjon mislykket Invitasjoner mislykket @@ -494,17 +661,33 @@ Invitasjon kunne ikke bli sent. Vil du prøve på nytt? Invitasjoner kunne ikke bli sent. Vil du prøve på nytt? + + Inviter medlem + Inviter medlemmer + + Inviter et nytt medlem til gruppen ved å skrive inn vennens kontoid, ONS eller skanne QR-koden deres {icon} + Inviter et nytt medlem til gruppen ved å skrive inn kontoen til vennen din, ONS eller ved å skanne deres QR-kode Bli med Senere + Start {app_name} automatisk når datamaskinen starter. + Start ved oppstart + Denne innstillingen styres av operativsystemet på Linux. For å aktivere automatisk oppstart, legg til {app_name} i oppstartsprogrammene i systeminnstillingene. Lær mer Forlat Forlater... + Denne gruppen er nå skrivebeskyttet. Opprett denne gruppen på nytt for å fortsette samtalen. + Denne gruppen er nå skrivebeskyttet. Be gruppeadministratoren om å opprette denne gruppen på nytt for å fortsette samtalen. + Grupper har blitt oppgradert! Opprett denne gruppen på nytt for forbedret pålitelighet. Denne gruppen blir skrivebeskyttet {date}. + Grupper har blitt oppgradert! Be gruppeadministratoren om å opprette denne gruppen på nytt for forbedret pålitelighet. Denne gruppen blir skrivebeskyttet {date}. + Chatteloggen vil ikke bli overført til den nye gruppen. Du kan fremdeles se hele chatteloggen i den gamle gruppen din. {name} ble med i gruppen. {name} og {count} andre ble med i gruppen. Du og {count} andre ble med i gruppen. Du og {other_name} ble med i gruppen. {name} og {other_name} ble med i gruppen. Du ble med i gruppen. + Begrense bakgrunnsaktivitet? + Du tillater for øyeblikket at {app_name} kjører i bakgrunnen for å forbedre påliteligheten til varslinger. Hvis du endrer denne innstillingen, kan varslinger bli mindre pålitelige. Forhåndsvisning av lenker Vis lenkeforhåndsvisninger for støttede URL-er. Aktiver lenkeforhåndsvisninger @@ -515,6 +698,7 @@ Du vil ikke ha full metadatabeskyttelse når du sender lenkeforhåndsvisninger. Forhåndsvisning av lenker er av {app_name} må kontakte lenkede nettsteder for å generere forhåndsvisning av lenker du sender og mottar.\n\nDu kan slå dem på i {app_name}s innstillinger. + Lenker Last inn konto Laster inn kontoen din Laster... @@ -527,8 +711,17 @@ Lås status Trykk for å låse opp {app_name} er ulåst + Logger + Administrer administratorer + Administrer medlemmer + Administrer {pro} Maks + Kanskje senere Media + + %1$d medlem valgt + %1$d medlemmer valgt + %1$d medlem %1$d medlemmer @@ -538,7 +731,9 @@ %1$d aktive medlemmer Legg til Account ID eller ONS + Medlemmer kan kun bli forfremmet etter at de har akseptert invitasjonen om å bli med i gruppen. Innby kontakter + Du har ingen kontakter å invitere til denne gruppen.\nGå tilbake og inviter medlemmer ved å bruke kontoid eller ONS-en deres. Send innbydelse Send innbydelser @@ -547,9 +742,14 @@ Vil du dele gruppehistorikken med {name} og {count} andre? Vil du dele gruppehistorikken med {name} og {other_name}? Del meldingshistorikk + Del meldingshistorikk fra de siste 14 dagene Del kun nye meldinger Inviter + Medlemmer (ikke-administratorer) + Menylinje Melding + Les mer + Kopier melding Denne meldingen er tom. Levering av melding mislyktes Meldingsgrense nådd @@ -564,6 +764,7 @@ Start en ny samtale ved å skrive inn din venns Konto ID eller ONS. Start en ny samtale ved å skrive inn din venns Konto ID, ONS eller ved å skanne deres QR-kode. + Start en ny samtale ved å skrive inn vennen din sitt bruker-ID, ONS eller ved å skanne deres QR-kode {icon} Du har fått en ny melding. Du har %1$d nye meldinger. @@ -573,6 +774,8 @@ Du har fått %1$d nye meldinger i %2$s. Svarer på + Du kan ikke sende vedlegg før meldingsforespørselen din er godtatt + Du kan ikke sende talemeldinger før meldingsforespørselen din er godtatt. {name} inviterte deg til å bli med i {group_name}. Ved å sende en melding til denne gruppen medfører det at du automatisk godtar gruppeinnbydelsen. Din meldingsforespørsel står for øyeblikket på vent. @@ -583,6 +786,7 @@ Er du sikker på at du vil slette alle meldingsforespørsler og gruppeinvitasjoner? Meldingsforespørsler for fellesskap Tillat meldingsforespørsler fra Community samtaler. + Er du sikker på at du vil slette denne meldingsforespørselen og den tilknyttede kontakten? Er du sikker på at du ønsker å slette denne meldingsforespørselen? Du har en ny meldingsforespørsel Ingen ventende meldingsforespørsler @@ -604,19 +808,34 @@ Meldinger har en tegngrense på %1$s tegn. Du har %2$d tegn igjen. Meldinger har en tegngrense på %1$s tegn. Du har %2$d tegn igjen. + Meldingslengde + Du har overskredet tegngriksen for denne meldingen. Vennligst forkort meldingen til {limit} tegn eller færre. + Meldingen er for lang + Vennligst forkort meldingen din til {limit} tegn eller færre. + Meldingen er for lang + Nytt passord Neste + Neste steg Velg et kallenavn for {name}. Dette vil vises for deg i dine en-til-en og gruppekonversasjoner. Skriv inn et kallenavn + Vennligst skriv inn et kortere kallenavn Fjern kallenavn Sett kallenavn Nei + Det er ingen ikke-administratorer i denne gruppen. Ingen forslag + Send meldinger på opptil 10 000 tegn i alle samtaler. + Organiser chatter med ubegrenset antall festede samtaler. Ingen Ikke nå Notat til meg selv Du har ingen meldinger i Egne notater. Skjul Notat til meg selv Er du sikker på at du vil skjule Notat til meg selv? + VÆR OPPMERKSOM: Ved å {action_type} samtykker du til {app_pro} sine Vilkår for bruk {icon} og Personvernerklæring {icon} + Varslingsvisning + Vis avsenderens navn og en forhåndsvisning av meldingsinnholdet. + Vis kun avsenderens navn uten noe meldingsinnhold. Alle meldinger Varsling innhold Informasjonen som vises i varslinger. @@ -625,7 +844,9 @@ Uten navn eller innhold Rask modus Du vil bli varslet om nye meldinger, pålitelighet og med en gang ved hjelp av Googles varslingsservere. + Du vil bli varslet om nye meldinger pålitelig og umiddelbart ved bruk av Huaweis varslingsservere. Du vil bli varslet om nye meldinger, pålitelighet og med en gang ved hjelp av Apple\'s varslingsserver. + Vis et generisk {app_name}-varsel uten avsenderens navn eller meldingsinnhold. Gå til enhetens varslingsinnstillinger Varsler - Alle Varsler - Kun nevnte @@ -633,6 +854,7 @@ {name} til {conversation_name} Det er mulig du har mottatt meldinger mens din {device} startet på nytt. LED-farge + Spill av en lyd når du mottar nye meldinger. Kun omtaler Meldingsvarsler Nyeste fra: {name} @@ -640,6 +862,8 @@ Demp for {time_large} Opphev demp Dempet + Dempet i {time_large} + Dempet til {date_time} Saktemodus {app_name} vil av og til sjekke nye meldinger i bakgrunnen. Lyd @@ -652,6 +876,12 @@ Av Okay + På din {device_type}-enhet + Åpne denne {app_name}-kontoen på en {device_type}-enhet som er logget inn med {platform_account}-kontoen du opprinnelig registrerte deg med. Deretter kan du kansellere {pro} via innstillingene i {app_pro}. + Åpne denne {app_name}-kontoen på en {device_type}-enhet som er logget inn med {platform_account}-kontoen du opprinnelig registrerte deg med. Oppdater deretter {pro}-tilgangen din via {app_pro}-innstillingene. + På en tilknyttet enhet + På {platform_store}-nettstedet + På {platform}-nettstedet Lag konto Konto opprettet Jeg har en konto @@ -676,34 +906,70 @@ Vi kunne ikke gjenkjenne denne ONS. Vennligst sjekk den og prøv igjen. Vi klarte ikke å søke etter denne ONS. Vennligst prøv igjen senere. Åpne + Åpne {platform_store}-nettstedet + Åpne {platform}-nettstedet + Åpne innstillinger + Åpne spørreundersøkelsen Annet + Passord Forandre passord + Endre passordet som kreves for å låse opp {app_name}. + Passordet ditt er endret. Vennligst oppbevar det trygt. Bekreft passordet + Opprett passord Ditt nåværende passord er feil. Skriv inn passord Vennligst tast inn ditt nåværende passord Vennligst tast inn ditt nye passord Passordet kan kun inneholde bokstaver, tall og symboler + Passordet må være mellom {min} og {max} tegn langt Passordene stemmer ikke overens Kunne ikke stille passordet Galt passord + Bekreft nytt passord Fjern passord + Fjern passordet som kreves for å låse opp {app_name} + Passordet ditt har blitt fjernet. Still passord + Passordet ditt er satt. Vennligst oppbevar det trygt. + Krev passord for å låse opp {app_name} ved oppstart. + Lengre enn 12 tegn + Inkluderer et tall + Inkluderer en liten bokstav + Inneholder et symbol + Inkluderer en stor bokstav + Indikator for passordstyrke + Å sette et sterkt passord hjelper med å beskytte meldingene og vedleggene dine hvis enheten din blir mistet eller stjålet. + Passord Lim inn + Betalingsfeil + Betalingen din ble behandlet, men det oppstod en feil ved {action_type} av {pro}-statusen din.\n\nSjekk nettverkstilkoblingen og prøv igjen. + Tillatelsesendring {app_name} trenger tilgang til musikk og lyd for å sende filer, musikk og lyd, men det har blitt permanent nektet. Trykk på Innstillinger → Tillatelser, og slå på \'Musikk og lyd\'. {app_name} trenger å bruke Apple Music for å spille av mediavedlegg. Automatisk oppdatering Automatisk sjekk for oppdateringer ved oppstart + Kameratilgang er nødvendig for å foreta videosamtaler. Slå på tillatelsen \"Kamera\" i Innstillinger for å fortsette. + Tilgang til kamera er for øyeblikket aktivert. For å deaktivere det, slå av tillatelsen \"Kamera\" i Innstillinger. {app_name} trenger kameratilgang for å ta bilder og videoer, men det har blitt permanent nektet. Trykk på Innstillinger → Tillatelser, og slå på \'Kamera\'. + Tillat tilgang til kamera for videosamtaler. Skjermlåsfunksjonen på {app_name} bruker Face ID. Behold i systemstatusfeltet {app_name} fortsetter å kjøre i bakgrunnen når du lukker vinduet. {app_name} trenger tilgang til bildebiblioteket for å fortsette. Du kan aktivere tilgang i iOS-innstillingene. + Tilgang til lokalt nettverk er nødvendig for å kunne foreta samtaler. Slå på tillatelsen \"Lokalt nettverk\" i Innstillinger for å fortsette. + {app_name} må ha tilgang til det lokale nettverket for å kunne foreta tale- og videosamtaler. + Tilgang til lokalnettverk er for øyeblikket aktivert. For å deaktivere det, slå av tillatelsen \"Lokalnettverk\" i Innstillinger. + Tillat tilgang til det lokale nettverket for å muliggjøre tale- og videosamtaler. + Lokalt nettverk Mikrofon {app_name} trenger mikrofontilgang for å ringe og sende lydmeldinger, men det har blitt permanent nektet. Trykk innstillinger → Tillatelser, og slå på \"Mikrofon\". + Mikrofontilgang er nødvendig for å ringe og spille inn lydmeldinger. Slå på tillatelsen \"Mikrofon\" i Innstillinger for å fortsette. Du kan aktivere mikrofontilgang i {app_name}s personverninnstillinger {app_name} trenger mikrofontilgang for å ringe og spille inn lydmeldinger. + Mikrofontilgang er for øyeblikket aktivert. For å deaktivere den, slå av tillatelsen \"Mikrofon\" i Innstillinger. Gi tilgang til mikrofonen. + Tillat tilgang til mikrofon for taleanrop og lydmeldinger. {app_name} trenger tilgang til musikk og lyd for å sende filer, musikk og lyd. Tillatelse kreves {app_name} trenger tilgang til bildebiblioteket for at du skal kunne sende bilder og videoer, men tilgangen har blitt permanent avslått. Trykk Innstillinger → Tillatelser, og slå på \"Bilder og videoer\". @@ -711,27 +977,174 @@ {app_name} trenger lagringstilgang for å lagre vedlegg og media. {app_name} trenger lagringstilgang for å lagre bilder og videoer, men den har blitt permanent nektet. Fortsett til appinnstillingene, velg \"Tillatelser\" og aktiver \"Lagring\". {app_name} trenger lagringstilgang for å sende bilder og videoer. + Du har ikke skrivetillatelser i dette fellesskapet Fest Fest samtale Løsne Løsne samtale + Pluss mye mer... + Nye funksjoner kommer snart til {pro}. Oppdag hva som kommer i {pro}-veikartet {icon} + Innstillinger Forhåndsvisning + Forhåndsvis varsling + Feil ved tilgang til {pro} + Din {pro}-tilgang utløper {date}. + {pro}-tilgang lastes + Informasjonen om {pro}-tilgang lastes fortsatt. Du kan ikke oppdatere før denne prosessen er fullført. + {pro}-tilgang lastes... + Kan ikke koble til nettverket for å laste tilgangsinformasjon for {pro}. Oppdatering av {pro} via {app_name} vil være deaktivert til tilkoblingen er gjenopprettet.\n\nSjekk nettverkstilkoblingen din og prøv igjen. + Fant ikke {pro}-tilgang + {app_name} oppdaget at kontoen din ikke har {pro}-tilgang. Hvis du mener dette er en feil, vennligst kontakt brukerstøtte hos {app_name} for hjelp. + Gjenopprett {pro}-tilgang + Forny {pro}-tilgang + For øyeblikket kan {pro}-tilgang kun kjøpes og fornyes via {platform_store} eller {platform_store_other}. Siden du bruker {app_name} Desktop, kan du ikke fornye her.\n\nUtviklerne av {app_name} jobber hardt med alternative betalingsalternativer som lar brukere kjøpe {pro}-tilgang utenfor {platform_store} og {platform_store_other}. {pro}-Veikart {icon} + Forny {pro}-tilgangen din på {platform_store}-nettstedet ved å bruke {platform_account}-kontoen du brukte da du registrerte deg for {pro}. + Forny på {platform}-nettstedet ved å bruke {platform_account}-kontoen du registrerte {pro} med. + Forny {pro}-tilgangen din for å begynne å bruke de kraftige funksjonene i {app_pro} Beta igjen. + {pro}-tilgang gjenopprettet + {app_name} oppdaget og gjenopprettet {pro}-tilgang for kontoen din. Din {pro}-status er gjenopprettet! + Fordi du opprinnelig registrerte deg for {app_pro} via {platform_store}, må du bruke din {platform_account} for å oppdatere tilgangen din til {pro}. + For øyeblikket kan {pro}-tilgang kun kjøpes via {platform_store} eller {platform_store_other}. Fordi du bruker {app_name} Desktop, kan du ikke oppgradere til {pro} her.\n\nUtviklerne av {app_name} jobber hardt med alternative betalingsløsninger slik at brukere kan kjøpe {pro}-tilgang utenom {platform_store} og {platform_store_other}. Veikart for {pro} {icon} + Aktivert + aktiverer + Alt er klart! + Tilgangen din til {app_pro} ble oppdatert! Du vil bli belastet når {pro} fornyes automatisk den {date}. + Du har allerede + Last opp GIF-er og animerte WebP-bilder som visningsbilde! + Få animerte visningsbilder og lås opp premiumfunksjoner med {app_pro} Beta + Animasjonsvisningsbilde + brukere kan laste opp GIF-er + Animerte visningsbilder + Angi animerte GIF-er og WebP-bilder som visningsbilde. + Last opp GIF-er med + {pro} fornyes automatisk om {time} + {pro}-merke + Vis {app_pro}-merket til andre brukere + Merker + Vis at du støtter {app_name} med et eksklusivt merke ved siden av visningsnavnet ditt. %1$s %2$s Merke Sendt %1$s %2$s Merker Sendt + {pro} betafunksjoner + {price} Faktureres årlig + {price} Faktureres månedlig + {price} Faktureres kvartalsvis + Vil du sende lengre meldinger?\nSend mer tekst og lås opp premiumfunksjoner med {app_pro} Beta + Vil du feste flere samtaler?\nOrganiser samtalene dine og lås opp premiumfunksjoner med {app_pro} Beta + Vil du ha mer enn {limit} festede samtaler?\nOrganiser samtalene dine og lås opp premiumfunksjoner med {app_pro} Beta + Det er synd at du kansellerer {pro}. Her er det du bør vite før du avslutter {pro}-tilgangen din. + Oppsigelse + Å avslutte {pro}-tilgang vil forhindre automatisk fornyelse før {pro}-tilgangen utløper. Å avslutte {pro} gir ingen refusjon. Du kan fortsatt bruke {app_pro}-funksjoner til {pro}-tilgangen utløper.\n\nFordi du opprinnelig registrerte deg for {app_pro} med kontoen {platform_account}, må du bruke den samme {platform_account} for å avslutte {pro}. + To måter å avslutte din {pro}-tilgang på: + Ved å kansellere {pro}-tilgangen vil du forhindre automatisk fornyelse før {pro} utløper.\n\nÅ kansellere {pro} gir ikke rett til refusjon. Du vil kunne bruke {app_pro}-funksjonene til tilgangen til {pro} utløper. + Velg det {pro}-abonnementet som passer best for deg.\nLengre tilgang gir større rabatter. + Er du sikker på at du vil slette dataene dine fra denne enheten?\n\n{app_pro} kan ikke overføres til en annen konto. Vennligst lagre gjenopprettingspassordet ditt for å sikre at du kan gjenopprette tilgangen til {pro} senere. + Er du sikker på at du vil slette dataene dine fra nettverket? Hvis du fortsetter, vil du ikke kunne gjenopprette meldinger eller kontakter.\n\n{app_pro} kan ikke overføres til en annen konto. Vennligst lagre gjenopprettingspassordet ditt for å sikre at du kan gjenopprette tilgangen til {pro} senere. + Tilgangen din til {pro} er allerede rabattert med {percent}% av full pris for {app_pro}. + Feil ved oppdatering av {pro}-status + Utløpt + Dessverre har din {pro}-tilgang utløpt.\nForny for å aktivere de eksklusive fordelene og funksjonene i {app_pro} Beta på nytt. + Utløper snart + Din {pro}-tilgang utløper om {time}.\nOppdater nå for å fortsette å bruke de eksklusive fordelene og funksjonene i {app_pro} Beta + {pro} utløper om {time} + {pro} Vanlige spørsmål + Finn svar på vanlige spørsmål i {app_pro} FAQ. + Last opp GIF- og WebP-visningsbilder + Større gruppesamtaler med opptil 300 medlemmer + Og mange flere eksklusive funksjoner + Meldinger på opptil 10 000 tegn + Fest ubegrenset med samtaler + Vil du bruke {app_name} til sitt fulle potensial?\nOppgrader til {app_pro} Beta for å få tilgang til en mengde eksklusive fordeler og funksjoner. + Gruppe aktivert + Denne gruppen har utvidet kapasitet! Den støtter opptil 300 medlemmer fordi en gruppeadministrator har %1$s Gruppe Oppgradert %1$s Grupper Oppgradert + Forespørsel om refusjon er endelig. Hvis den godkjennes, vil tilgangen din til {pro} bli kansellert umiddelbart, og du mister tilgang til alle {pro}-funksjoner. + Økt vedleggsstørrelse + Økt meldingslengde + Større grupper + Grupper der du er administrator oppgraderes automatisk til å støtte 300 medlemmer. + Større gruppechatter (opptil 300 medlemmer) kommer snart for alle Pro Beta-brukere! + Lengre meldinger + Du kan sende meldinger på opptil 10 000 tegn i alle samtaler. %1$s Lengre Melding Sendt %1$s Lengre Meldinger Sendt + Denne meldingen brukte følgende {app_pro}-funksjoner: + Med en ny installasjon + Installer {app_name} på nytt på denne enheten via {platform_store}, gjenopprett kontoen din med gjenopprettingspassordet, og forny {pro} fra innstillingene i {app_pro}. + Installer {app_name} på nytt på denne enheten via {platform_store}, gjenopprett kontoen din med Gjenopprettingspassordet ditt, og oppgrader til {pro} fra innstillingene i {app_pro}. + Foreløpig finnes det tre måter å fornye på: + Foreløpig finnes det to måter å fornye på: + {percent}% rabatt %1$s Samtale Festet %1$s Samtaler Festet + Fordi du opprinnelig registrerte deg for {app_pro} via {platform_store}, må du bruke din {platform_account} for å be om refusjon. + Fordi du opprinnelig registrerte deg for {app_pro} via {platform_store}, vil refusjonsforespørselen din behandles av {app_name}-støtte.\n\nBe om refusjon ved å trykke på knappen nedenfor og fylle ut refusjonsskjemaet.\n\n{app_name}-støtte tilstreber å behandle refusjonsforespørsler innen 24–72 timer, men behandlingstiden kan være lengre ved stor pågang. + Tilgangen til {app_pro} er fornyet! Takk for at du støtter {network_name}. + 1 måned – {monthly_price} / måned + 3 måneder – {monthly_price} / måned + 12 måneder – {monthly_price} / måned + aktiverer på nytt + Åpne denne {app_name}-kontoen på en {device_type}-enhet som er logget inn med {platform_account}-kontoen du opprinnelig registrerte deg med. Be deretter om refusjon via {app_pro}-innstillingene. + Vi er lei for at du forlater oss. Her er hva du bør vite før du ber om refusjon. + {platform} behandler nå refusjonsforespørselen din. Dette tar vanligvis 24–48 timer. Avhengig av avgjørelsen deres, kan du se at {pro}-statusen din endres i {app_name}. + Refusjonsforespørselen din vil bli behandlet av {app_name} kundestøtte.\n\nSend inn en forespørsel ved å trykke på knappen nedenfor og fylle ut refusjonsskjemaet.\n\nSelv om {app_name} kundestøtte tilstreber å behandle refusjonsforespørsler innen 24–72 timer, kan det ta lenger tid ved høyt forespørselsvolum. + Refusjonsforespørselen din vil utelukkende bli behandlet av {platform} via {platform}-nettstedet.\n\nPå grunn av {platform}s refusjonsretningslinjer har utviklerne av {app_name} ingen mulighet til å påvirke resultatet av refusjonsforespørsler. Dette inkluderer om forespørselen blir godkjent eller avslått, samt om du får full eller delvis refusjon. + Ta kontakt med {platform} for oppdateringer om refusjonsforespørselen din. På grunn av refusjonspolicyene til {platform}, har ikke utviklerne av {app_name} mulighet til å påvirke utfallet av refusjonsforespørsler.\n\n{platform} Refusjonsstøtte + Refunderer {pro} + Refusjoner for {app_pro} håndteres utelukkende av {platform} via {platform_store}.\n\nPå grunn av refusjonspolicyene til {platform}, har ikke utviklerne av {app_name} mulighet til å påvirke utfallet av refusjonsforespørsler. Dette inkluderer hvorvidt forespørselen blir godkjent eller avslått, samt om det gis full eller delvis refusjon. + Vil du bruke animerte visningsbilder igjen?\nForny {pro}-tilgangen din for å låse opp funksjonene du har gått glipp av. + Forny {pro} Beta + Forny {pro}-tilgangen din fra {app_pro}-innstillingene på en tilkoblet enhet med {app_name} installert via {platform_store} eller {platform_store_other}. + Vil du sende lengre meldinger igjen?\nForny {pro}-tilgangen din for å låse opp funksjonene du har gått glipp av. + Vil du bruke {app_name} til sitt fulle potensial igjen?\nForny {pro}-tilgangen din for å låse opp funksjonene du har gått glipp av. + Vil du feste mer enn {limit} samtaler igjen?\nForny {pro}-tilgangen din for å låse opp funksjonene du har gått glipp av. + Vil du feste flere samtaler igjen?\nForny {pro}-tilgangen din for å låse opp funksjonene du har gått glipp av. + Ved å fornye godtar du {app_pro} sine Vilkår for tjenesten {icon} og Personvernerklæring {icon} + fornyer + For øyeblikket kan {pro}-tilgang bare kjøpes og fornyes via {platform_store} eller {platform_store_other}. Fordi du installerte {app_name} ved bruk av {build_variant}, kan du ikke fornye her.\n\nUtviklerne av {app_name} jobber hardt med alternative betalingsalternativer for å gjøre det mulig å kjøpe {pro}-tilgang utenfor {platform_store} og {platform_store_other}. Veikart for {pro} {icon} + Refusjon forespurt + Send mer med + {pro}-innstillinger + Start å bruke {pro} + Dine {pro}-statistikker + {pro}-statistikk lastes inn + {pro}-statistikken din lastes inn, vennligst vent. + {pro}-statistikkene gjenspeiler bruken på denne enheten og kan se annerledes ut på tilknyttede enheter. + Feil med {pro}-status + Kan ikke koble til nettverket for å sjekke {pro}-statusen din. Informasjonen som vises på denne siden kan være unøyaktig til forbindelsen er gjenopprettet.\n\nSjekk nettverkstilkoblingen din og prøv igjen. + {pro}-status lastes + Informasjonen om {pro} lastes inn. Enkelte handlinger på denne siden kan være utilgjengelige inntil innlastingen er fullført. + Laster inn {pro}-status + Kan ikke koble til nettverket for å sjekke {pro}-statusen din. Du kan ikke fortsette før tilkoblingen er gjenopprettet.\n\nVennligst sjekk nettverkstilkoblingen din og prøv igjen. + Kan ikke koble til nettverket for å sjekke {pro}-statusen din. Du kan ikke oppgradere til {pro} før tilkoblingen er gjenopprettet.\n\nSjekk nettverkstilkoblingen din og prøv igjen. + Kan ikke koble til nettverket for å oppdatere {pro}-statusen din. Enkelte handlinger på denne siden vil være deaktivert til tilkoblingen er gjenopprettet.\n\nSjekk nettverkstilkoblingen din og prøv igjen. + Kan ikke koble til nettverket for å laste inn din nåværende {pro}-tilgang. Fornyelse av {pro} via {app_name} vil være deaktivert til tilkoblingen er gjenopprettet.\n\nVennligst kontroller nettverksforbindelsen og prøv igjen. + Trenger du hjelp med {pro}? Send en forespørsel til brukerstøtten. + Ved å oppdatere godtar du {app_pro} sine Vilkår for tjenesten {icon} og Personvernerklæring {icon} + Ubegrenset antall festede meldinger + Organiser alle samtalene dine med ubegrensede festede samtaler. + Fakturering nåværende valg gir deg {current_plan_length} med tilgang til {pro}. Er du sikker på at du vil bytte til faktureringsvalget {selected_plan_length_singular}?\n\nVed oppdatering fornyes din tilgang til {pro} automatisk den {date} for ytterligere {selected_plan_length} med {pro}-tilgang. + Tilgangen din til {pro} utløper {date}.\n\nVed oppdatering fornyes din tilgang til {pro} automatisk den {date} for ytterligere {selected_plan_length} med {pro}-tilgang. + oppdaterer + Oppgrader til {app_pro} Beta for å få tilgang til mange eksklusive fordeler og funksjoner. + Oppgrader til {pro} fra {app_pro}-innstillingene på en tilkoblet enhet med {app_name} installert via {platform_store} eller {platform_store_other}. + For øyeblikket kan {pro}-tilgang kun kjøpes via {platform_store} eller {platform_store_other}. Fordi du installerte {app_name} ved bruk av {build_variant}, kan du ikke oppgradere til {pro} her.\n\nUtviklerne av {app_name} jobber hardt med alternative betalingsløsninger slik at brukere kan kjøpe {pro}-tilgang utenom {platform_store} og {platform_store_other}. Veikart for {pro} {icon} + Foreløpig finnes det bare én måte å oppgradere på. + Foreløpig finnes det to måter å oppgradere på: + Du har oppgradert til {app_pro}!\nTakk for at du støtter {network_name}. + oppgraderer + Oppgraderer til {pro} + Ved å oppgradere godtar du {app_pro} sine Vilkår for tjenesten {icon} og Personvernerklæring {icon} + Vil du få mer ut av {app_name}?\nOppgrader til {app_pro} Beta for en kraftigere meldingsopplevelse. + {platform} behandler refusjonsforespørselen din Profil Visningsbilde Kunne ikke fjerne profilbilde. @@ -739,6 +1152,11 @@ Vennligst velg en mindre fil. Kunne ikke oppdatere profil. Promotere + Administratorer vil kunne se de siste 14 dagene med meldingshistorikk og kan ikke degraderes eller fjernes fra gruppen. + + Forfrem medlem + Forfrem medlemmer + Forfremmelse mislykket Forfremmelser mislykket @@ -755,14 +1173,20 @@ Venner kan sende deg meldinger ved å skanne din QR-kode. Avslutt {app_name} Avslutt + Vurdere {app_name}? + Vurder appen + Så bra at du liker {app_name}. Hvis du har litt tid, hjelper en vurdering oss i {storevariant} med å gjøre privat og sikker meldingsutveksling kjent for andre! Lest Lesekvitteringer Vis lesebekreftelser for alle meldinger du sender og mottar. Mottatt: Eingegangene Antwort + Mottar samtaletilbud + Mottar forhåndstilbud Anbefalt Lagre ditt gjenopprettingspassord for å sikre at du ikke mister tilgang til kontoen din. Lagre ditt gjenopprettingspassord + Bruk gjenopprettingspassordet ditt til å laste inn kontoen din på nye enheter.\n\nKontoen din kan ikke gjenopprettes uten dette passordet. Sørg for at det er lagret på et trygt og sikkert sted — og ikke del det med noen. Angi ditt gjenopprettingspassord En feil oppstod når ditt gjenopprettingspassord forsøkte å laste.\n\nVennligst eksporter loggene dine, så opplast filen gjennom Hjelpesenteret til {app_name}. Sjekk gjenopprettingspassordet ditt og prøv igjen. @@ -779,19 +1203,62 @@ Vis Gjenopprettingspassord Gjenopprettingspassord Synlighet Dette er din gjenopprettingsfrase. Hvis du sender den til noen vil de ha full tilgang til din konto. + Opprett gruppe på nytt Gjenta + Fordi du opprinnelig registrerte deg for {app_pro} via en annen {platform_account}, må du bruke den {platform_account} for å oppdatere {pro}-tilgangen din. + To måter å be om refusjon på: + Reduser meldingslengden med {count} %1$d tegn igjen %1$d tegn igjen + Påminn meg senere Fjern + + Fjern medlem + Fjern medlemmer + + + Fjern medlem og meldingene deres + Fjern medlemmer og meldingene deres + Kunne ikke fjerne passord + Fjern gjeldende passord for {app_name}. Lokalt lagrede data vil bli kryptert på nytt med en tilfeldig generert nøkkel som lagres på enheten din. + + Fjerner medlem + Fjerner medlemmer + + Forny + Fornyer {pro} Svare + Be om refusjon + Be om refusjon på {platform}-nettstedet, ved å bruke {platform_account} som du registrerte deg for {pro} med. Send på nytt + + Send invitasjon på nytt + Send invitasjoner på nytt + + + Send forfremmelse på nytt + Send kampanjer på nytt + + + Sender invitasjon på nytt + Sender invitasjoner på nytt + + + Sender kampanje på nytt + Sender kampanjer på nytt + Laster inn land... Start på nytt Synkroniser på nytt Prøv på nytt + Vurderingsgrense + Det virker som du nylig har vurdert {app_name}, takk for tilbakemeldingen! + Kjør app i bakgrunnen + Kjøre {app_name} i bakgrunnen? + Siden du bruker treg modus, anbefaler vi at du lar {app_name} kjøre i bakgrunnen for å forbedre varsler. Dette kan gi mer konsistente varsler, selv om systemet ditt fortsatt kan begrense bakgrunnsaktivitet automatisk.\n\nDu kan endre dette senere i Innstillinger. Lagre Lagret Lagrede meldinger @@ -800,6 +1267,8 @@ Skjermsikkerhet Skjermbilde varsler Krev et varsel når en kontakt tar et skjermbilde av en en-til-en-chat. + Skjul {app_name}-vinduet i skjermbilder tatt på denne enheten. + Beskyttelse mot skjermbilder {name} tok et skjermbilde. Søk Søk etter kontakter @@ -815,8 +1284,15 @@ Søker... Velg Velg alle + Velg app-ikon Send Sender + Sender samtaletilbud + Sender tilkoblingskandidater + + Sender admin-forfremmelse + Sender admin-forfremmelser + Sendt: Utseende Fjern data @@ -824,57 +1300,117 @@ Hjelp Inviter en venn Meldingsforespørsler + Nåværende {token_name_short}-pris + Meldinger sendes via {network_name}. Nettverket består av noder som får insentiver i {token_name_long}, noe som holder {app_name} desentralisert og sikkert. Les mer {icon} + Lær om staking + Markedsverdi + {app_name}-noder som sikrer meldingene dine + {app_name}-noder i svermen din + {token_name_long} er lansert! Utforsk den nye {network_name}-delen i Innstillinger for å lære hvordan {token_name_long} driver Session. + Nettverk sikret av + Når du staker {token_name_long} for å sikre nettverket, tjener du belønninger i {token_name_short} fra {staking_reward_pool}. + Ny Varsler Tillatelser Personvern + {app_pro} Beta Gjenopprettingspassord Innstillinger Sett + Angi visningsbilde for fellesskap + Angi et passord for {app_name}. Lokalt lagrede data vil bli kryptert med dette passordet. Du vil bli bedt om å skrive inn dette passordet hver gang {app_name} startes. + Kan ikke oppdatere innstilling Du må starte {app_name} på nytt for å ta i bruk dine nye innstillinger. Skjermsikkerhet + Oppstart Del Inviter vennen din til å chatte med deg på {app_name} ved å dele Account ID-en din med dem. Del med vennene dine der du vanligvis snakker med dem — flytt deretter samtalen hit. Det er et problem med å åpne databasen. Vennligst omstart appen og prøv igjen. + Obs! Det ser ut til at du ikke har en {app_name}-konto ennå.\n\nDu må opprette en i {app_name}-appen før du kan dele. + Vil du dele gruppemeldingshistorikken med denne brukeren? Del til {app_name} + Beklager, {app_name} støtter kun deling av flere bilder og videoer samtidig + Deling støtter kun medier. Ikke-mediefiler er utelatt Vis Vis alle Vis færre + Vis Notat til meg selv + Er du sikker på at du vil vise Notat til meg selv i samtalelisten? + Stavekontroll Klistremerker + Styrke + Har du problemer? Utforsk hjelpeartiklene eller opprett en sak med {app_name} kundestøtte. Gå til støttesiden Systeminformasjon: {information} + Trykk for å prøve på nytt Fortsett Forvalgt Feil + Tilbake + Temaforhåndsvisning + Konto-ID-en til {name} er synlig basert på tidligere interaksjoner + Blindrede ID-er brukes i fellesskap for å redusere søppelpost og øke personvernet. + Oversett + Systemkurv Prøv igjen Skriver indikatorer Se og del skriveindikatorer. + Ikke tilgjengelig Angre Ukjent + Ikke støttet CPU + Oppdater + Oppdater {pro}-tilgang + To måter å oppdatere {pro}-tilgangen din på: App oppdateringer + Oppdater samfunnsinformasjon + Samfunnsnavn og beskrivelse er synlige for alle medlemmer av samfunnet + Skriv inn en kortere samfunnsbeskrivelse + Skriv inn et kortere navn på samfunnet Oppdatering installert, klikk for å starte på nytt Laster ned oppdatering: {percent_loader}% Kan ikke oppdatere {app_name} kunne ikke oppdateres. Gå til {session_download_url} og installer den nye versjonen manuelt, og kontakt vårt hjelpesenter for å informere oss om dette problemet. + Oppdater gruppeinformasjon + Gruppenavn og beskrivelse er synlige for alle gruppemedlemmer. + Vennligst oppgi en kortere gruppetekst En ny versjon av {app_name} er tilgjengelig, trykk for å oppdatere En ny versjon ({version}) av {app_name} er tilgjengelig. + Oppdater profilinformasjon + Visningsnavnet ditt og visningsbildet ditt er synlige i alle samtaler. Gå til utgivelsesmerknader {app_name}-oppdatering Versjon {version} + Sist oppdatert for {relative_time} siden + Oppdateringer + Oppdaterer... + Oppgrader + Oppgrader {app_name} + Oppgrader til Laster opp Kopier URL Åpne URL Dette vil åpne i nettleseren din. Er du sikker på at du vil åpne denne URL-en i nettleseren din?\n\n{url} + Lenker åpnes i nettleseren din. Bruk rask modus + Endre abonnementet ditt ved å bruke {platform_account}-kontoen du registrerte deg med, via {platform}-nettstedet. + Via {platform}-nettstedet Video Kan ikke spille av video. Vis + Vis mindre + Vis mer Dette kan ta noen minutter. Ett øyeblikk, vær så snill... Advarsel + Støtten for iOS 15 er avsluttet. Oppdater til iOS 16 eller nyere for å fortsette å motta appoppdateringer. Vindu Ja Du + CPU-en din støtter ikke SSE 4.2-instruksjoner, som kreves av {app_name} på Linux x64-operativsystemer for å behandle bilder. Oppgrader til en kompatibel CPU eller bruk et annet operativsystem. Ditt Gjenopprettingspassord + Zoomfaktor + Juster størrelsen på tekst og visuelle elementer. \ No newline at end of file diff --git a/app/src/main/res/values-b+nl+NL/strings.xml b/app/src/main/res/values-b+nl+NL/strings.xml index 561f39a6a6..9965c162d9 100644 --- a/app/src/main/res/values-b+nl+NL/strings.xml +++ b/app/src/main/res/values-b+nl+NL/strings.xml @@ -15,7 +15,13 @@ Dit is uw Account-ID. Andere gebruikers kunnen het scannen om een gesprek met u te beginnen. Werkelijke grootte Toevoegen + + Beheerder toevoegen + Beheerders toevoegen + + Beheerder toevoegen Voer de account-ID in van de gebruiker die u promoot als admin.\n\nOm meerdere gebruikers toe te voegen, voer elk account-ID in, gescheiden door een komma. Tot 20 Account ID\'s kunnen tegelijkertijd worden opgegeven. + Beheerders kunnen niet gedegradeerd of verwijderd worden uit de groep. Admins kunnen niet worden verwijderd. {name} en {count} anderen zijn gepromoveerd tot Admin. Beheerders promoveren @@ -40,12 +46,19 @@ {name} is verwijderd als Admin. {name} en {count} anderen zijn verwijderd als beheerder. {name} en {other_name} zijn verwijderd als beheerder. + + %1$d beheerder geselecteerd + %1$d beheerders geselecteerd + Beheerder promotie versturen Beheerder promoties versturen Admin instellingen + Je kunt je eigen beheerdersstatus niet wijzigen. Om de groep te verlaten, open je de gespreksinstellingen en kies je Groep verlaten. {name} en {other_name} zijn gepromoveerd tot Admin. + Beheerders + Toestaan +{count} Anoniem App icoon @@ -162,6 +175,7 @@ Weet je zeker dat je {name} en 1 andere wilt deblokkeren? Gedeblokkeerd {name} Bekijk en beheer geblokkeerde contacten. + Geen browser gevonden om die URL te openen. Probeer in plaats daarvan de URL te kopiëren Bellen {name} heeft je gebeld U kunt geen nieuw gesprek starten. Maak eerst uw huidige gesprek af. @@ -190,6 +204,10 @@ Inschakelen van spraak- en video-oproepen naar en van andere gebruikers. U belde {name} U heeft een gemiste oproep van {name} omdat u Geluid- en Video-oproepen niet heeft ingeschakeld in Privacy-instellingen. + {app_name} heeft toegang tot uw camera nodig om videogesprekken mogelijk te maken, maar deze toestemming is geweigerd. U kunt uw cameramachtigingen niet bijwerken tijdens een gesprek.\n\nWilt u het gesprek nu beëindigen en camera-toegang inschakelen, of wilt u na het gesprek een herinnering ontvangen? + Om camera-toegang toe te staan, opent u de instellingen en schakelt u de machtiging Camera in. + Tijdens uw laatste gesprek probeerde u video te gebruiken, maar dat lukte niet omdat de toegang tot de camera eerder was geweigerd. Om camera-toegang toe te staan, opent u de instellingen en schakelt u de machtiging Camera in. + Camera-toegang vereist Geen camera gevonden Camera niet beschikbaar. Toegang tot camera verlenen @@ -197,9 +215,19 @@ {app_name} heeft toegang tot de camera nodig om foto\'s en video\'s te maken of QR-codes te scannen. {app_name} heeft toegang tot de camera nodig om QR-codes te scannen Annuleren + {pro} annuleren + Annuleer op de {platform}-website met het {platform_account} waarmee je je voor {pro} hebt aangemeld. + Annuleer op de {platform_store}-website met het {platform_account} waarmee je je voor {pro} hebt aangemeld. Wijzigen Wachtwoord wijzigen mislukt Wijzig je wachtwoord voor {app_name}. Lokaal opgeslagen gegevens worden opnieuw versleuteld met je nieuwe wachtwoord. + Instelling wijzigen + {pro}-status controleren + Uw {pro}-status wordt gecontroleerd. U kunt doorgaan zodra deze controle is voltooid. + Jouw {pro}-gegevens worden gecontroleerd. Sommige acties op deze pagina zijn mogelijk niet beschikbaar tot deze controle is voltooid. + {pro}-status controleren... + {pro}-gegevens worden gecontroleerd. Je kunt pas verlengen nadat deze controle is voltooid. + Je {pro}-status wordt gecontroleerd. Je kunt upgraden naar {pro} zodra deze controle is voltooid. Wissen Alles wissen Wis alle gegevens @@ -229,7 +257,7 @@ Weet u zeker dat u alle {group_name} berichten van uw apparaat wilt wissen? Weet je zeker dat je alle berichten van {group_name} op dit apparaat wilt wissen? Weet u zeker dat u alle \"Notitie aan mijzelf\" berichten van uw apparaat wilt wissen? - Weet u zeker dat u alle {Bericht aan Jezelf} op dit apparaat wilt wissen? + Weet u zeker dat u alle Bericht aan Jezelf op dit apparaat wilt wissen? Wissen op dit apparaat Sluiten Applicatie sluiten @@ -258,11 +286,17 @@ Community URL Kopieer Community URL Bevestigen + Promotie bevestigen + Weet je het zeker? Beheerders kunnen niet worden gedegradeerd of uit de groep worden verwijderd. Contacten Verwijder contactpersoon Weet u zeker dat u {name} uit uw contacten wilt verwijderen? Nieuwe berichten van {name} komen binnen als een berichtverzoek. U heeft nog geen contactpersonen Contacten selecteren + + %1$d contact geselecteerd + %1$d contactpersonen geselecteerd + Gebruikersgegevens Camera Kies een actie om een gesprek te beginnen @@ -303,6 +337,7 @@ Kopieer Aanmaken Oproep starten + Huidige facturering Huidig wachtwoord Knippen Donkere modus @@ -330,6 +365,14 @@ Een moment geduld, de groep wordt aangemaakt... Het is mislukt om de groep bij te werken Je hebt geen toestemming om andermans berichten te verwijderen + + Geselecteerde bijlage verwijderen + Geselecteerde bijlagen verwijderen + + + Weet u zeker dat u de geselecteerde bijlage wilt verwijderen? Het bericht dat aan de bijlage is gekoppeld, wordt ook verwijderd. + Weet u zeker dat u de geselecteerde bijlagen wilt verwijderen? De berichten die aan de bijlagen zijn gekoppeld, worden ook verwijderd. + Weet u zeker dat u {name} wilt verwijderen uit uw contacten?\n\nDit zal je gesprek, inclusief alle berichten en bijlagen, verwijderen. Toekomstige berichten van {name} worden weergegeven als een berichtverzoek. Weet u zeker dat u uw gesprek met {name}wilt verwijderen?\nAlle berichten en bijlagen worden permanent verwijderd. @@ -369,6 +412,7 @@ Weet u zeker dat u deze berichten voor iedereen wilt wissen? Aan het verwijderen Ontwikkelopties weergeven + Apparaatmeldingsinstellingen Dicteren starten... Zelf-wissende berichten Bericht verdwijnt over {time_large} @@ -416,6 +460,8 @@ Uw weergeven naam is zichtbaar voor gebruikers, groepen en gemeenschappen waarmee u communiceert. Document Doneer + Krachtige machten proberen privacy te ondermijnen, maar we kunnen deze strijd niet alleen voeren.\n\nDonaties helpen om {app_name} veilig, onafhankelijk en online te houden. + {app_name} heeft je hulp nodig Klaar Downloaden Aan het downloaden... @@ -445,17 +491,31 @@ U en {name} reageerden met {emoji_name} Reageerde op je bericht {emoji} Inschakelen + Camera-toegang inschakelen? Toon meldingen wanneer je nieuwe berichten ontvangt. + Beëindig gesprek om in te schakelen Geniet je van {app_name}? Moet beter {emoji} Geweldig {emoji} Je gebruikt {app_name} al een tijdje, hoe gaat het? We horen graag je mening. Verder + Voer het wachtwoord in dat je voor {app_name} hebt ingesteld + Voer het wachtwoord in dat je gebruikt om {app_name} bij het opstarten te ontgrendelen, niet je Herstelwachtwoord + Fout bij het controleren van {pro}-status Controleer je internetverbinding en probeer het opnieuw. Foutmelding kopiëren en afsluiten Databasefout Er ging iets mis. Probeer het later nog eens. + Fout bij laden van {pro}-toegang + {app_name} kon deze ONS niet opzoeken. Controleer je netwerkverbinding en probeer het opnieuw. Er is een onbekende fout opgetreden. + Deze ONS is niet geregistreerd. Controleer of deze correct is en probeer het opnieuw. + Het opnieuw verzenden van de uitnodiging naar {name} in {group_name} is mislukt + Het opnieuw verzenden van de uitnodiging naar {name} en {count} anderen in {group_name} is mislukt + Het opnieuw verzenden van de uitnodiging naar {name} en {other_name} in {group_name} is mislukt + Promotie opnieuw verzenden naar {name} in {group_name} is mislukt + Promotie opnieuw verzenden naar {name} en {count} anderen in {group_name} is mislukt + Promotie opnieuw verzenden naar {name} en {other_name} in {group_name} is mislukt Downloaden mislukt Mislukkingen Feedback @@ -509,6 +569,9 @@ Weet u zeker dat u {group_name} wilt verlaten? Weet u zeker dat u de groep {group_name} wil verlaten?\n\nDit zal alle leden en de inhoud van de groep verwijderen. Het verlaten van {group_name} is mislukt + {name} is uitgenodigd om lid te worden van de groep. Gespreksgeschiedenis van de afgelopen 14 dagen is gedeeld. + {name} en {count} anderen zijn uitgenodigd om lid te worden van de groep. Gespreksgeschiedenis van de afgelopen 14 dagen is gedeeld. + {name} en {other_name} zijn uitgenodigd om lid te worden van de groep. Gespreksgeschiedenis van de afgelopen 14 dagen is gedeeld. {name} heeft de groep verlaten. {name} en {count} anderen hebben de groep verlaten. {name} en {other_name} hebben de groep verlaten. @@ -520,6 +583,9 @@ {name} en {other_name} zijn uitgenodigd om lid te worden van de groep. U en {count} anderen zijn uitgenodigd om lid te worden van de groep. Geschiedenis van het gesprek is gedeeld. U en {other_name} zijn uitgenodigd om lid te worden van de groep. Gespreksgeschiedenis wordt gedeeld. + {name} kon niet worden verwijderd uit {group_name} + {name} en {count} anderen konden niet worden verwijderd uit {group_name} + {name} en {other_name} konden niet worden verwijderd uit {group_name} U heeft de groep verlaten. Groepsleden Er zijn geen andere leden in deze groep. @@ -533,6 +599,7 @@ U heeft geen berichten van {group_name}. Stuur een bericht om het gesprek te starten! De groep is niet bijgewerkt gedurende de afgelopen 30 dagen. U kunt problemen ervaren bij het verzenden van berichten of bekijken van de groepsinformatie. U bent de enige beheerder in {group_name}.\n\nGroepsleden en instellingen kunnen niet worden gewijzigd zonder een beheerder. + Je bent de enige beheerder van {group_name}.\n\nGroepsleden en instellingen kunnen niet worden gewijzigd zonder een beheerder. Om de groep te verlaten zonder deze te verwijderen, voeg eerst een nieuwe beheerder toe. In afwachting van verwijdering U bent gepromoveerd tot Admin. U en {count} anderen zijn gepromoveerd tot Admin. @@ -582,6 +649,10 @@ Vraag incognito modus aan indien beschikbaar. Afhankelijk van het toetsenbord dat je gebruikt, kan je toetsenbord dit verzoek negeren. Info Ongeldige snelkoppeling + + Contactpersoon uitnodigen + Contactpersonen uitnodigen + Uitnodiging mislukt Uitnodigingen mislukt @@ -590,8 +661,17 @@ De uitnodiging kon niet worden verzonden. Wilt u het opnieuw proberen? De uitnodigingen konden niet worden verzonden. Wilt u het opnieuw proberen? + + Lid uitnodigen + Leden uitnodigen + + Nodig een nieuw lid uit voor de groep door de Account-ID of ONS van je vriend in te voeren of hun QR-code te scannen {icon} + Nodig een nieuw lid uit voor de groep door het Account-ID of ONS van je vriend in te voeren of hun QR-code te scannen Deelnemen Later + Start {app_name} automatisch wanneer je computer wordt opgestart. + Starten bij opstarten + Deze instelling wordt beheerd door je systeem op Linux. Om automatisch opstarten in te schakelen, voeg je {app_name} toe aan je opstarttoepassingen in de systeeminstellingen. Kom meer te weten Verlaten Vertrekken... @@ -606,6 +686,8 @@ U en {other_name} zijn lid geworden van de groep. {name} en {other_name} zijn lid geworden van de groep. U bent lid geworden van de groep. + Achtergrondactiviteit beperken? + Je staat momenteel toe dat {app_name} op de achtergrond wordt uitgevoerd om de betrouwbaarheid van meldingen te verbeteren. Het wijzigen van deze instelling kan leiden tot minder betrouwbare meldingen. Link-voorbeelden Toon linkvoorbeelden voor ondersteunde URL\'s. Linkvoorbeelden inschakelen @@ -630,10 +712,16 @@ Tik om te ontgrendelen {app_name} is ontgrendeld Logboeken + Beheerders beheren Leden beheren {pro} beheren Maximaal + Misschien later Media + + %1$d lid geselecteerd + %1$d leden geselecteerd + %1$d lid %1$d leden @@ -643,7 +731,9 @@ %1$d actieve leden Account-ID of ONS toevoegen + Leden kunnen pas worden gepromoveerd nadat ze een uitnodiging hebben geaccepteerd om lid te worden van de groep. Contactpersonen uitnodigen + Je hebt geen contacten om uit te nodigen voor deze groep.\nGa terug en nodig leden uit via hun Account-ID of ONS. Uitnodiging verzenden Uitnodigingen verzenden @@ -652,8 +742,10 @@ Wilt u de groepsberichtgeschiedenis delen met {name} en {count} anderen? Wilt u de groepsberichtgeschiedenis delen met {name} en {other_name}? Berichtgeschiedenis delen + Berichtgeschiedenis van de laatste 14 dagen delen Alleen nieuwe berichten delen Uitnodigen + Leden (geen beheerders) Menubalk Bericht Lees meer @@ -672,6 +764,7 @@ Start een nieuw gesprek door het invoeren van de Account-ID van uw vriend of ONS. Start een nieuw gesprek door de Account-ID van uw vriend, ONS in te voeren of door zijn/haar/hen QR-code te scannen. + Start een nieuw gesprek door de Account-ID van je vriend, ONS in te voeren of hun QR-code te scannen {icon} Je hebt een nieuw bericht. Je hebt %1$d nieuwe berichten. @@ -729,13 +822,17 @@ Verwijder bijnaam Bijnaam instellen Nee + Er zijn geen leden zonder beheerdersrechten in deze groep. Geen suggesties + Verzend berichten tot 10.000 tekens in alle gesprekken. + Orden gesprekken met onbeperkt vastgezette gesprekken. Geen Nu niet Notitie aan mezelf U heeft geen berichten in Notitie naar Mijzelf. Notitie aan mezelf verbergen Weet u zeker dat u Notitie aan jezelf wilt verbergen? + LET OP: Door {action_type} ga je akkoord met de Gebruiksvoorwaarden {icon} en het Privacybeleid {icon} van {app_pro} Notificatie weergave Toon de naam van de afzender en een voorbeeld van de berichtinhoud. Toon alleen de naam van de afzender zonder enige berichtinhoud. @@ -780,6 +877,11 @@ Oké Aan Op je {device_type} apparaat + Open dit {app_name}-account op een {device_type}-apparaat dat is aangemeld met het {platform_account} waarmee je je oorspronkelijk hebt geregistreerd. Annuleer {pro} vervolgens via de {app_pro}-instellingen. + Open dit {app_name}-account op een {device_type} waarop je bent aangemeld met het {platform_account} waarmee je je oorspronkelijk hebt geregistreerd. Werk vervolgens je {pro}-toegang bij via de instellingen van {app_pro}. + Op een gekoppeld apparaat + Op de {platform_store}-website + Op de {platform}-website Account aanmaken Account aangemaakt Ik heb een account @@ -804,7 +906,9 @@ We konden deze ONS niet herkennen. Controleer deze alsjeblieft en probeer het opnieuw. We konden niet zoeken naar deze ONS. Probeer het later opnieuw. Openen + Open de {platform_store}-website Open de {platform} website + Instellingen openen Enquête openen Overige Wachtwoord @@ -838,6 +942,8 @@ Een sterk wachtwoord helpt je berichten en bijlagen te beschermen als je apparaat ooit verloren raakt of wordt gestolen. Wachtwoorden Plakken + Betalingsfout + Je betaling is succesvol verwerkt, maar er is een fout opgetreden bij het {action_type} van je {pro}-status.\n\nControleer je netwerkverbinding en probeer het opnieuw. Machtiging wijzigen {app_name} heeft toegang nodig tot muziek en audio om bestanden, muziek en audio te verzenden, maar dit is permanent geweigerd. Ga naar Instellingen → Toestemmingen en schakel \"Muziek en audio\" in. {app_name} moet Apple Music gebruiken om mediabijlagen af te spelen. @@ -881,10 +987,33 @@ Voorkeuren Voorbeeld Voorbeeldmelding + Je toegang tot {pro} is actief!\n\nJe toegang tot {pro} wordt automatisch verlengd voor nog eens {current_plan_length} op {date}. + Je toegang tot {pro} is actief!\n\nJe toegang tot {pro} wordt automatisch verlengd voor nog eens\n{current_plan_length} op {date}. Alle wijzigingen die je hier aanbrengt, gaan in bij je volgende verlenging. + Fout bij {pro}-toegang + Je toegang tot {pro} verloopt op {date}. + {pro}-toegang wordt geladen + Je toegangsgegevens voor {pro} worden nog steeds geladen. Je kunt geen updates uitvoeren totdat dit proces is voltooid. + {pro}-toegang wordt geladen... + Kan geen verbinding maken met het netwerk om je {pro}-toegangsgegevens te laden. Bijwerken van {pro} via {app_name} is uitgeschakeld totdat de verbinding is hersteld.\n\nControleer je netwerkverbinding en probeer het opnieuw. + {pro}-toegang niet gevonden + {app_name} heeft vastgesteld dat je account geen {pro}-toegang heeft. Als je denkt dat dit een vergissing is, neem dan contact op met de ondersteuning van {app_name} voor hulp. + {pro}-toegang herstellen + {pro}-toegang verlengen + Momenteel kan {pro}-toegang alleen worden gekocht en verlengd via de {platform_store} of {platform_store_other}. Omdat je {app_name} Desktop gebruikt, kun je hier niet verlengen.\n\nDe ontwikkelaars van {app_name} werken hard aan alternatieve betaalmogelijkheden om gebruikers toe te staan {pro}-toegang aan te schaffen buiten de {platform_store} en {platform_store_other} om. {pro}-routekaart {icon} + Verleng je {pro}-toegang op de {platform_store}-website met het {platform_account} waarmee je je voor {pro} hebt aangemeld. + Verleng via de {platform}-website met het {platform_account} waarmee je je voor {pro} hebt aangemeld. + Verleng je {pro}-toegang om opnieuw gebruik te maken van krachtige {app_pro} bètaversie-functies. + {pro}-toegang hersteld + {app_name} heeft {pro}-toegang voor je account gedetecteerd en hersteld. Je {pro}-status is hersteld! + Omdat je je oorspronkelijk voor {app_pro} hebt aangemeld via de {platform_store}, moet je je {platform_account} gebruiken om je {pro}-toegang bij te werken. + {pro}-toegang kan momenteel alleen worden gekocht via de {platform_store} of {platform_store_other}. Omdat u {app_name} Desktop gebruikt, kunt u hier niet upgraden naar {pro}.\n\nDe ontwikkelaars van {app_name} werken hard aan alternatieve betaalmogelijkheden zodat gebruikers {pro}-toegang buiten de {platform_store} en {platform_store_other} kunnen aanschaffen. {pro} Routekaart {icon} Geactiveerd + activeren Alles is geregeld! + Je {app_pro} toegang is bijgewerkt! Je wordt gefactureerd wanneer {pro} automatisch wordt verlengd op {date}. Je hebt al Upload nu GIF\'s en geanimeerde WebP-afbeeldingen voor je profielfoto! + Krijg geanimeerde profielfoto\'s en ontgrendel premiumfuncties met {app_pro} Beta Geanimeerde profielfoto gebruikers kunnen GIF\'s uploaden Geanimeerde profielfoto\'s @@ -895,12 +1024,32 @@ Toon het {app_pro} badge aan andere gebruikers Badges Toon je steun voor {app_name} met een exclusieve badge naast je schermnaam. + + %1$s %2$s-badge verzonden + %1$s %2$s-badges verzonden + {pro} functies {price} Jaarlijks gefactureerd {price} Maandelijks gefactureerd {price} per kwartaal gefactureerd + Wil je langere berichten versturen?\nVerstuur meer tekst en ontgrendel premiumfuncties met {app_pro} Beta + Wil je meer vastzetten?\nOrganiseer je chats en ontgrendel premiumfuncties met {app_pro} Beta + Wil je meer dan {limit} vastgezette gesprekken?\nOrganiseer je chats en ontgrendel premiumfuncties met {app_pro} Beta + Jammer dat je {pro} annuleert. Dit moet je weten voordat je je {pro}-toegang annuleert. + Annulering + Door {pro}-toegang te annuleren wordt automatische verlenging uitgeschakeld voordat je {pro}-toegang verloopt. Het annuleren van {pro} leidt niet tot een terugbetaling. Je kunt {app_pro}-functies blijven gebruiken totdat je {pro}-toegang verloopt.\n\nOmdat je je oorspronkelijk hebt aangemeld voor {app_pro} met je {platform_account}, moet je dezelfde {platform_account} gebruiken om {pro} te annuleren. + Twee manieren om je {pro}-toegang te annuleren: + Door {pro}-toegang te annuleren wordt automatische verlenging uitgeschakeld voordat {pro} verloopt.\n\nHet annuleren van {pro} leidt niet tot een terugbetaling. Je kunt {app_pro}-functies blijven gebruiken totdat je {pro}-toegang verloopt. + Kies de {pro}-toegangsoptie die het beste bij u past.\nLangere toegang betekent grotere kortingen. + Weet je zeker dat je je gegevens van dit apparaat wilt verwijderen?\n\n{app_pro} kan niet worden overgedragen naar een ander account. Sla je Herstelwachtwoord op om ervoor te zorgen dat je later je {pro}-toegang kunt herstellen. + Weet je zeker dat je je gegevens van het netwerk wilt verwijderen? Als je doorgaat, kun je je berichten of contacten niet herstellen.\n\n{app_pro} kan niet worden overgedragen naar een ander account. Sla je Herstelwachtwoord op om ervoor te zorgen dat je later je {pro}-toegang kunt herstellen. + Je toegang tot {pro} is al met {percent}% verlaagd ten opzichte van de volledige prijs van {app_pro}. + Fout bij verversen van {pro}-status Verlopen + Helaas is je toegang tot {pro} verlopen.\nVerleng om opnieuw toegang te krijgen tot de exclusieve voordelen en functies van {app_pro} Beta. Verloopt binnenkort + Je toegang tot {pro} verloopt over {time}.\nWerk nu bij om toegang te behouden tot de exclusieve voordelen en functies van {app_pro} Beta + {pro} verloopt over {time} {pro} FAQ Vind antwoorden op veelgestelde vragen in de {app_pro} FAQ. Upload GIF- en WebP-profielfoto\'s @@ -908,35 +1057,96 @@ En nog veel meer exclusieve functies Berichten tot 10.000 tekens Onbeperkt gesprekken vastzetten + Wilt u {app_name} maximaal benutten?\nUpgrade naar {app_pro} Beta om toegang te krijgen tot tal van exclusieve voordelen en functies. Groep geactiveerd Deze groep heeft een grotere capaciteit! Er kunnen nu tot 300 leden deelnemen omdat een groepsbeheerder een + + %1$s groep geüpgraded + %1$s groepen geüpgraded + + Het aanvragen van een terugbetaling is definitief. Indien goedgekeurd, wordt je toegang tot {pro} onmiddellijk geannuleerd en verlies je toegang tot alle {pro}-functies. Verhoogde bijlagegrootte Verlengde berichtlengte Grotere groepen Groepen waarvan jij beheerder bent, worden automatisch geüpgraded om 300 leden te ondersteunen. + Grotere groepsgesprekken (tot 300 leden) komen binnenkort beschikbaar voor alle Pro-bètagebruikers! Langere berichten Je kunt berichten tot 10.000 tekens verzenden in alle gesprekken. + + %1$s langer verzonden bericht + %1$s langere berichten verzonden + Dit bericht maakte gebruik van de volgende {app_pro}-functies: + Met een nieuwe installatie + Installeer {app_name} opnieuw op dit apparaat via de {platform_store}, herstel je account met je Herstelwachtwoord en verleng {pro} vanuit de {app_pro}-instellingen. + Installeer {app_name} opnieuw op dit apparaat via de {platform_store}, herstel uw account met uw herstelwachtwoord en upgrade naar {pro} via de {app_pro}-instellingen. + Voor nu zijn er drie manieren om te verlengen: + Er zijn momenteel twee manieren om te verlengen: {percent}% korting + + %1$s vastgezette conversatie + %1$s vastgezette gesprekken + Omdat je je oorspronkelijk hebt aangemeld voor {app_pro} via de {platform_store} winkel, moet je hetzelfde {platform_account} gebruiken om een terugbetaling aan te vragen. Omdat je je oorspronkelijk hebt aangemeld voor {app_pro} via de {platform_store} winkel, wordt je restitutieverzoek afgehandeld door {app_name} Support.\n\nVraag een restitutie aan door op de knop hieronder te drukken en het restitutieformulier in te vullen.\n\nHoewel {app_name} Support ernaar streeft om restitutieverzoeken binnen 24-72 uur te verwerken, kan het tijdens drukte langer duren. + Je {app_pro}-toegang is verlengd! Bedankt voor je steun aan het {network_name}. 1 maand - {monthly_price} / maand 3 maanden - {monthly_price} / maand 12 maanden - {monthly_price} / maand + opnieuw activeren + Open dit {app_name}-account op een {device_type}-apparaat dat is aangemeld met het {platform_account} waarmee je je oorspronkelijk hebt geregistreerd. Vraag daarna een terugbetaling aan via de {app_pro}-instellingen. Het spijt ons dat je vertrekt. Dit moet je weten voordat je een terugbetaling aanvraagt. {platform} verwerkt nu je terugbetalingsverzoek. Dit duurt meestal 24-48 uur. Afhankelijk van hun beslissing kan je {pro} status wijzigen in {app_name}. Je restitutieverzoek wordt afgehandeld door {app_name} Support.\n\nVraag een restitutie aan door op de knop hieronder te drukken en het restitutieformulier in te vullen.\n\nHoewel {app_name} Support ernaar streeft om restitutieverzoeken binnen 24-72 uur te verwerken, kan het tijdens drukte langer duren. Je terugbetalingsverzoek wordt uitsluitend afgehandeld door {platform} via de website van {platform}.\n\nVanwege het restitutiebeleid van {platform} hebben ontwikkelaars van {app_name} geen invloed op de uitkomst van terugbetalingsverzoeken. Dit geldt zowel voor de goedkeuring of afwijzing van het verzoek als voor het wel of niet toekennen van een volledige of gedeeltelijke terugbetaling. Neem contact op met {platform} voor verdere updates over je restitutieverzoek. Vanwege het restitutiebeleid van {platform} hebben de ontwikkelaars van {app_name} geen invloed op de uitkomst van restitutieverzoeken.\n\n{platform} Terugbetalingsondersteuning Terugbetalen {pro} + Terugbetalingen voor {app_pro} worden uitsluitend afgehandeld door {platform} via de {platform_store}.\n\nVanwege het restitutiebeleid van {platform} hebben ontwikkelaars van {app_name} geen invloed op de uitkomst van terugbetalingsverzoeken. Dit omvat zowel de goedkeuring als afwijzing van het verzoek, evenals of een volledige of gedeeltelijke terugbetaling wordt uitgegeven. + Wil je opnieuw geanimeerde profielfoto\'s gebruiken?\nVerleng je {pro}-toegang om de functies te ontgrendelen die je hebt gemist. + {pro} Beta verlengen + Verleng je {pro}-toegang via de instellingen van {app_pro} op een gekoppeld apparaat met {app_name} geïnstalleerd via de {platform_store} of {platform_store_other}. + Wilt u weer langere berichten versturen?\nVerleng uw {pro}-toegang om de functies te ontgrendelen die u hebt gemist. + Wil je {app_name} opnieuw maximaal benutten?\nVerleng je {pro}-toegang om de functies te ontgrendelen die je hebt gemist. + Wil je opnieuw meer dan {limit} gesprekken vastmaken?\nVerleng je {pro}-toegang om de functies te ontgrendelen die je hebt gemist. + Wil je opnieuw meer gesprekken vastmaken?\nVerleng je {pro}-toegang om de functies te ontgrendelen die je hebt gemist. + Door te verlengen gaat u akkoord met de {app_pro} Gebruiksvoorwaarden {icon} en het Privacybeleid {icon} + verlengen + Momenteel kan {pro}-toegang alleen worden gekocht en verlengd via de {platform_store} of {platform_store_other}. Omdat je {app_name} hebt geïnstalleerd met de {build_variant}, kun je hier niet verlengen.\n\nDe ontwikkelaars van {app_name} werken hard aan alternatieve betaalopties zodat gebruikers {pro}-toegang kunnen aanschaffen buiten de {platform_store} en {platform_store_other}. {pro} Routekaart {icon} Terugbetaling aangevraagd Verstuur meer met {pro} instellingen + Begin met {pro} gebruiken Je {pro} statistieken + {pro}-statistieken worden geladen + Je {pro}-statistieken worden geladen, even geduld. {pro} statistieken weerspiegelen het gebruik op dit apparaat en kunnen anders weergegeven worden op gekoppelde apparaten + Fout {pro}-status + Kan geen verbinding maken met het netwerk om je {pro}-status te controleren. De informatie op deze pagina kan onjuist zijn totdat de verbinding is hersteld.\n\nControleer je netwerkverbinding en probeer het opnieuw. + {pro}-status wordt geladen + Je {pro}-gegevens worden geladen. Sommige acties op deze pagina zijn mogelijk niet beschikbaar tot het laden is voltooid. + {pro}-status wordt geladen + Kan geen verbinding maken met het netwerk om je {pro}-status te controleren. Je kunt niet doorgaan totdat de verbinding is hersteld.\n\nControleer je netwerkverbinding en probeer het opnieuw. + Kan geen verbinding maken met het netwerk om je {pro}-status te controleren. Je kunt niet upgraden naar {pro} totdat de verbinding is hersteld.\n\nControleer je netwerkverbinding en probeer het opnieuw. + Kan geen verbinding maken met het netwerk om je {pro}-status te vernieuwen. Sommige acties op deze pagina worden uitgeschakeld totdat de verbinding is hersteld.\n\nControleer je netwerkverbinding en probeer het opnieuw. + Kan geen verbinding maken met het netwerk om je huidige {pro}-toegang te laden. {pro} verlengen via {app_name} wordt uitgeschakeld totdat de verbinding is hersteld.\n\nControleer je netwerkverbinding en probeer opnieuw. + Hulp nodig met {pro}? Dien een verzoek in bij het ondersteuningsteam. + Door {action_type} ben je {activation_type} {app_pro} via het {app_name}-protocol. {entity} faciliteert deze activatie, maar is niet de aanbieder van {app_pro}. {entity} is niet verantwoordelijk voor de prestaties, beschikbaarheid of functionaliteit van {app_pro}. Door bij te werken ga je akkoord met de {app_pro} Gebruiksvoorwaarden {icon} en het Privacybeleid {icon} Onbeperkte Pins Organiseer al je chats met onbeperkt vastgezette gesprekken. + Je huidige factureringsoptie geeft {current_plan_length} {pro}-toegang. Weet je zeker dat je wilt overschakelen naar de factureringsoptie {selected_plan_length_singular}?\n\nBij het bijwerken wordt je {pro}-toegang automatisch verlengd op {date} voor een extra {selected_plan_length} {pro}-toegang. + Je {pro}-toegang verloopt op {date}.\n\nBij het bijwerken wordt je {pro}-toegang automatisch verlengd op {date} voor een extra {selected_plan_length} {pro}-toegang. + bijwerken + Upgrade naar {app_pro} Beta om toegang te krijgen tot tal van exclusieve voordelen en functies. + Upgrade naar {pro} via de {app_pro}-instellingen op een gekoppeld apparaat waarop {app_name} is geïnstalleerd via de {platform_store} of {platform_store_other}. + {pro}-toegang kan momenteel alleen worden aangeschaft via de {platform_store} of {platform_store_other}. Omdat u {app_name} hebt geïnstalleerd met de {build_variant}, kunt u hier niet naar {pro} upgraden.\n\nOntwikkelaars van {app_name} werken hard aan alternatieve betaalmogelijkheden waarmee gebruikers {pro}-toegang buiten de {platform_store} en {platform_store_other} kunnen aanschaffen. {pro} Roadmap {icon} + Er is momenteel slechts één manier om te upgraden: + Er zijn momenteel twee manieren om te upgraden: + U bent geüpgraded naar {app_pro}!\nBedankt voor uw steun aan het {network_name}. + opwaarderen + Upgrade naar {pro} + Door te upgraden gaat u akkoord met de {app_pro} Gebruiksvoorwaarden {icon} en het Privacybeleid {icon} + Wil je meer uit {app_name} halen?\nUpgrade naar {app_pro} Beta voor een krachtigere berichtbeleving. {platform} verwerkt je restitutieverzoek Profiel Toon afbeelding @@ -945,6 +1155,11 @@ Kies een kleiner bestand. Profiel bijwerken mislukt. Promoveren + Beheerders kunnen de laatste 14 dagen van de berichtgeschiedenis bekijken en kunnen niet worden gedegradeerd of uit de groep worden verwijderd. + + Lid promoveren + Leden promoveren + Promotie mislukt Promoties mislukt @@ -993,24 +1208,60 @@ Dit is uw herstelwachtwoord. Als u het naar iemand stuurt hebben ze volledige toegang tot uw account. Groep opnieuw aanmaken Opnieuw doen + Omdat je je oorspronkelijk hebt aangemeld voor {app_pro} via een ander {platform_account}, moet je dat {platform_account} gebruiken om je {pro}-toegang bij te werken. + Twee manieren om een terugbetaling aan te vragen: Verkort berichtlengte met {count} %1$d teken resterend %1$d tekens resterend + Herinner me later Verwijderen + + Lid verwijderen + Leden verwijderen + + + Lid en diens berichten verwijderen + Leden en hun berichten verwijderen + Verwijderen wachtwoord mislukt Verwijder je huidige wachtwoord voor {app_name}. Lokaal opgeslagen gegevens worden opnieuw versleuteld met een willekeurig gegenereerde sleutel, opgeslagen op je apparaat. + + Lid wordt verwijderd + Leden worden verwijderd + Verlengen + {pro} verlengen Antwoord Terugbetaling aanvragen + Vraag een terugbetaling aan op de {platform}-website met het {platform_account} waarmee je je voor {pro} hebt aangemeld. Opnieuw verzenden + + Uitnodiging opnieuw verzenden + Uitnodigingen opnieuw verzenden + + + Promotie opnieuw verzenden + Promoties opnieuw verzenden + + + Uitnodiging wordt opnieuw verzonden + Uitnodigingen worden opnieuw verzonden + + + Promotie wordt opnieuw verzonden + Promoties worden opnieuw verzonden + Landinformatie wordt geladen... Herstart Opnieuw synchroniseren Opnieuw proberen Beoordelingslimiet Het lijkt erop dat je {app_name} onlangs al hebt beoordeeld, bedankt voor je feedback! + App op de achtergrond uitvoeren + {app_name} op de achtergrond uitvoeren? + Aangezien je de trage modus gebruikt, raden we aan {app_name} toe te staan op de achtergrond uit te voeren om de meldingen te verbeteren. Dit kan de consistentie van meldingen verbeteren, hoewel je systeem mogelijk de achtergrondactiviteit nog beperkt.\n\nJe kunt dit later wijzigen in Instellingen. Opslaan Opgeslagen Opgeslagen berichten @@ -1019,6 +1270,8 @@ Scherm beveiliging Screenshot Notificaties Een melding vereisen wanneer een contact een screenshot maakt van een een-op-een-chat. + Verberg het venster van {app_name} in screenshots die op dit apparaat worden gemaakt. + Screenshotbeveiliging {name} heeft een schermafbeelding gemaakt. Zoeken Contacten zoeken @@ -1039,6 +1292,10 @@ Verzenden Oproepaanbod verzenden Kandidaten voor verbinding verzenden + + Beheerder promotie versturen + Beheerder promoties versturen + Verzonden: Uiterlijk Gegevens wissen @@ -1065,14 +1322,19 @@ Instellen Instellen Groeps afbeelding Stel een wachtwoord in voor {app_name}. Lokaal opgeslagen gegevens worden versleuteld met dit wachtwoord. Je wordt gevraagd dit wachtwoord in te voeren telkens wanneer {app_name} wordt gestart. + Kan instelling niet bijwerken Uw moet {app_name} opnieuw starten om uw nieuwe instellingen toe te passen. Scherm beveiliging + Opstarten Delen Nodig uw vriend uit om met u te chatten op {app_name} door uw Account ID met hen te delen. Deel met uw vrienden waar u normaal gesproken contact mee hebt - en vervolg vervolgens het gesprek hier. Er is een probleem bij het openen van de database. Herstart de app en probeer het opnieuw. Oeps! Het lijkt er op dat u nog geen {app_name} account heeft.\n\nU dient er een aan te maken in de {app_name} app voordat u iets kunt delen. + Wil je de berichtgeschiedenis van de groep delen met deze gebruiker? Delen met {app_name} + Sorry, {app_name} ondersteunt alleen het tegelijk delen van meerdere afbeeldingen en video\'s + Delen ondersteunt alleen media. Niet-mediale bestanden zijn uitgesloten Tonen Alles tonen Minder tonen @@ -1100,6 +1362,10 @@ Niet beschikbaar Ongedaan maken Onbekend + Niet-ondersteunde CPU + Update + Toegang tot {pro} bijwerken + Twee manieren om je {pro}-toegang bij te werken: Nieuwe versies van de app Community-informatie bijwerken Communitynaam en beschrijving zijn zichtbaar voor alle communityleden @@ -1122,6 +1388,8 @@ {relative_time} geleden voor het laatst bijgewerkt Updates Bijwerken... + Opwaarderen + {app_name} upgraden Upgraden naar Uploaden Kopieer URL @@ -1130,6 +1398,7 @@ Weet u zeker dat u deze URL in uw browser wilt openen?\n\n{url} Links worden in uw browser geopend. Gebruik Snelle Modus + Wijzig je abonnement met het {platform_account} waarmee je je hebt aangemeld, via de {platform}-website. Via de {platform} website Video Kan video niet afspelen. @@ -1139,9 +1408,11 @@ Dit kan een paar minuten duren. Een moment geduld aub... Waarschuwing + De ondersteuning voor iOS 15 is beëindigd. Werk bij naar iOS 16 of hoger om app-updates te blijven ontvangen. Venster Ja Uw + Je CPU ondersteunt geen SSE 4.2-instructies, die vereist zijn door {app_name} op Linux x64-besturingssystemen om afbeeldingen te verwerken. Upgrade naar een compatibele CPU of gebruik een ander besturingssysteem. Je herstelwachtwoord Zoomfactor Pas de grootte van tekst en visuele elementen aan. diff --git a/app/src/main/res/values-b+nn+NO/strings.xml b/app/src/main/res/values-b+nn+NO/strings.xml index 427a685198..2c567333c7 100644 --- a/app/src/main/res/values-b+nn+NO/strings.xml +++ b/app/src/main/res/values-b+nn+NO/strings.xml @@ -3,6 +3,7 @@ Om Godta Kopier konto-ID + Konto-ID Konto-ID kopiert Kopier din konto-ID og del den med vennene dine slik at dei kan sende deg ei melding. Skriv inn konto-ID @@ -14,6 +15,13 @@ Dette er din kontoid. Andre brukere kan skanne den for å starte en samtale med deg. Opprinnelig størrelse Legg til + + Legg til administrator + Legg til administratorer + + Legg til administrator + Skriv inn kontoid-en til brukeren du skal gjøre til administrator.\n\nFor å legge til flere brukere, skriv inn hver kontoid separert med komma. Du kan legge inn opptil 20 konto-ID-er om gangen. + Administratorer kan ikke degraderes eller fjernes fra gruppen. Administratoren kan ikkje fjernast. {name} og {count} andre vart promoterte til admin. Fremj Administrators @@ -26,7 +34,9 @@ Klarte ikkje forfremma {name} i {group_name} Klarte ikkje forfremma {name} og {count} andre til admin i {group_name} Klarte ikkje forfremma {name} og {other_name} i {group_name} + Opprykk ikke sendt Administratoropprykk sendt + Opprykksstatus ukjent Fjern Administrators Fjern som Administrators Det er ingen administratorer i denne Samfunnet. @@ -36,10 +46,40 @@ {name} vart fjerna som admin. {name} og {count} andre vart fjerna som Admin. {name} og {other_name} vart fjerna som Admin. + + %1$d administrator valgt + %1$d administratorer valgt + + + Sender admin-forfremmelse + Sender admin-forfremmelser + Administratorinnstillingar + Du kan ikke endre din egen administratorstatus. For å forlate gruppen, åpne samtaleinnstillingene og velg Forlat gruppe. {name} og {other_name} vart promoterte til admin. + Administratorer + Tillat +{count} Anonym + App-ikon + Endre app-ikon og -navn + For å endre app-ikon og -navn må {app_name} lukkes. Varsler vil fortsatt bruke standardikonet og -navnet til {app_name}. + Alternativt app-ikon og navn vises på startskjermen og i app-skuffen. + Det valgte app-ikonet og navnet vises på startskjermen og i applisten. + Ikon og navn + Alternativ app-ikon vises på Hjem-skjermen og i appbiblioteket. Appnavnet vil fortsatt vises som \"{app_name}\". + Bruk alternativt app-ikon + Bruk alternativt app-ikon og navn + Velg alternativt app-ikon + Ikon + Kalkulator + MeetingSE + Nyheter + Notater + Aksjer + Vær + {app_pro}-merke + Automatisk mørkmodus Skjul menylinjen Språk Vel språkinstillinga di for {app_name}. {app_name} vil starte på nytt når du endrar språkinstillinga. @@ -56,6 +96,7 @@ Zoom inn Zoom ut Vedlegg + Vedlegg Legg til vedlegg Navlaust biletalbum Automatisk nedlasting av vedlegg @@ -117,9 +158,12 @@ Utestenging mislykka Oppheving av utestengelse mislyktes Opphev utestengelse av bruker + Skriv inn kontoid-en til brukeren du fjerner utestengelsen for Brukar oppheva Bannlys brukar Bruker utestengt + Skriv inn kontoid-en til brukeren du utestenger + Blindet ID Blokker Opphev blokkeringen på denne kontakten for å sende en melding Ingen blokkerte kontakter @@ -130,6 +174,8 @@ Er du sikker på at du ønskjer å oppheve blokkeringen av {name} og {count} andre? Er du sikker på at du ønskjer å oppheve blokkeringen av {name} og ein annan? Blokkering opphevet {name} + Vis og administrer blokkerte kontakter. + Fant ingen nettleser for å åpne denne URL-en, prøv heller å kopiere URL-en Ring {name} ringte deg Du kan ikkje starte ein ny samtale. Fullfør den gjeldande samtalen først. @@ -141,20 +187,27 @@ Samtale pågår Inngåande samtale frå {name} Inngåande samtale + Du gikk glipp av en samtale fra {name} fordi du ikke har gitt mikrofontilgang. Tapt anrop Tapt anrop frå {name} Lyd- og videosamtalar krev at varslar er aktivert i systeminstillingane til enheten din. Samtaletillatelser krevst Du kan aktivere \'Lyd- og videosamtaler\' tillatelsen i personvernsinnstillingene. + Du kan aktivere tillatelsen \"Lyd- og videosamtaler\" i personvernsinnstillingene. Kobler til på nytt… Ringar... {app_name} Call Samtaler (Beta) Lyd- og videosamtaler Lyd- og videosamtaler (Beta) + IP-adressen din er synlig for samtalepartneren og en {session_foundation}-server når du bruker beta-samtaler. Aktiverer tale- og videosamtaler til og frå andre brukarar. Du ringte {name} Du missa ein samtale frå {name} fordi du ikkje har aktivert tale- og videosamtalar i personverninnstillingane. + {app_name} trenger tilgang til kameraet ditt for å aktivere videosamtaler, men denne tillatelsen har blitt avslått. Du kan ikke oppdatere kameratilgang under en samtale.\n\nVil du avslutte samtalen nå og aktivere kameratilgang, eller vil du bli påminnet etter samtalen? + Åpne innstillinger og slå på kameratilgang for å tillate tilgang til kameraet. + Under din siste samtale prøvde du å bruke video, men det var ikke mulig fordi kameratilgang tidligere ble avslått. Åpne innstillinger og aktiver kameratilgang for å tillate det. + Kameratilgang kreves Intet kamera funnet Kamera utilgjengeleg. Gi kameratilgang @@ -162,7 +215,19 @@ {app_name} treng tilgang til kameraet for å ta bilete eller videoar, eller skanna QR-kodar. {app_name} trenger kameratilgang for å skanne QR-koder Avbryt + Kanseller {pro} + Avbryt på {platform}-nettstedet, ved å bruke {platform_account} du registrerte deg for {pro} med. + Avbryt på {platform_store}-nettstedet, ved å bruke {platform_account} du registrerte deg for {pro} med. + Endre Kunne ikkje endre passordet + Endre passordet ditt for {app_name}. Lokalt lagrede data vil bli kryptert på nytt med det nye passordet ditt. + Endre innstilling + Sjekker {pro}-status + Sjekker {pro}-statusen din. Du kan fortsette så snart denne sjekken er fullført. + Sjekker {pro}-detaljene dine. Enkelte handlinger på denne siden kan være utilgjengelige til denne sjekken er fullført. + Sjekker {pro}-status... + Sjekker detaljene for {pro}. Du kan ikke fornye før denne sjekken er fullført. + Sjekker {pro}-statusen din. Du kan oppgradere til {pro} når denne sjekken er fullført. Tøm Tøm alle Fjern alle data @@ -178,19 +243,29 @@ Er du sikker på at du ønskjer å slette dataene frå nettverket? Kontakter du ikkje kan gjenopprette meldingar eller no. Er du sikker på at du vil tømme eininga di? Tøm berre eininga + Tøm enhet og start på nytt + Tøm enhet og gjenopprett Fjern alle meldingar Er du sikker på at du vil slette alle meldingane frå samtalen med {name} frå eininga di? + Er du sikker på at du vil slette alle meldinger fra samtalen med {name} på denne enheten? Er du sikker på at du vil slette alle {community_name}-meldinger frå eininga di? + Er du sikker på at du vil slette alle meldinger fra {community_name} på denne enheten? Fjern for alle Fjern for meg Er du sikker på at du vil slette alle {group_name}-meldinger? + Er du sikker på at du vil slette alle meldinger fra {group_name}? Er du sikker på at du vil slette alle {group_name}-meldinger frå eininga di? + Er du sikker på at du vil slette alle meldinger fra {group_name} på denne enheten? Er du sikker på at du vil slette alle Notat til meg sjølv-meldingane frå eininga di? + Er du sikker på at du vil slette alle Note to Self-meldinger på denne enheten? + Tøm på denne enheten Lukk + Lukk appen Lukk vindu Commit-Hash: {hash} Dette vil utestenge den valde brukaren frå dette samfunnet og slette alle meldingane deira. Er du sikker på at du vil fortsette? Dette vil utestenge den valde brukaren frå dette samfunnet. Er du sikker på at du vil fortsette? + Skriv inn en beskrivelse av samfunnet Skriv inn samfunns-URL Ugyldig URL Vennligst sjekk URL-en til Samfunn og prøv igjen. @@ -205,15 +280,23 @@ Du er allereie medlem av denne fellesskapet. Forlat samfunn Klarte ikkje forlata {community_name} + Skriv inn et navn på samfunnet + Vennligst skriv inn et navn på samfunnet Ukjend Community Samfunns-URL Kopier samfunns-URL Bekreft + Bekreft forfremmelse + Er du sikker? Administratorer kan ikke degraderes eller fjernes fra gruppen. Kontaktar Slett kontakt Er du sikker på at du vil slette {name} frå kontaktene dine? Nye meldinger frå {name} vil komme som ei meldingsforespørsel. Du har inga kontaktar enno Vel kontakter + + %1$d kontakt valgt + %1$d kontakter valgt + Brukaroplysningar Kamera Vel ei handling for å starte ein samtale @@ -221,6 +304,7 @@ Meldingsskriving Miniatyrbilde av bilde frå sitert beskjed Opprett ei samtale med ein ny kontakt + Velg innholdet som vises i lokale varsler når en innkommende melding mottas. Legg til heimeskjermen Lagt til heimeskjermen Lydmeldinger @@ -233,12 +317,18 @@ Konversasjonen sletta Det er ingen meldinger i {conversation_name}. Skriv inn Tast + Definer hvordan Enter- og Shift+Enter-tastene fungerer i samtaler. + SHIFT + ENTER sender en melding, ENTER starter en ny linje. + ENTER sender en melding, SHIFT + ENTER starter en ny linje. Grupper Meldingsbeskjæring Trim Samfunn + Slett meldinger eldre enn 6 måneder i samfunn med over 2 000 meldinger. Ny samtale Du har inga samtalar enno + Send med Enter Ved å trykke på Enter nøkkel vil sende melding i stedet for å starte en ny linje. + Send med Shift+Enter Alle medier Stavekontroll Skru på stavekontroll når du skriv meldingar. @@ -246,7 +336,14 @@ Kopiert Kopier Opprett + Oppretter samtale + Gjeldende fakturering + Nåværende passord Klipp ut + Mørk modus + Er du sikker på at du vil slette alle meldinger, vedlegg og kontodata fra denne enheten og opprette en ny konto? + En databasefeil oppstod.\n\nEksporter applikasjonsloggene dine for å dele dem ved feilsøking. Hvis dette ikke lykkes, installer {app_name} på nytt og gjenopprett kontoen din. + Er du sikker på at du vil slette alle meldinger, vedlegg og kontodata fra denne enheten og gjenopprette kontoen din fra nettverket? Vi har merka at {app_name} tar lang tid på å starte.\n\nDu kan vente, eksportere loggar frå eininga di for deling til feilsøking, eller prøve å starte {app_name} på nytt. Databasen til appen din er ikkje kompatibel med denne versjonen av {app_name}. Installer appen på nytt og gjenopprett kontoen din for å generera ein ny database og fortset å bruka {app_name}.\n\nAdvarsel: Dette vil resultera i at alle meldingar og vedlegg eldre enn to veker går tapt. Optimaliserer databasen @@ -268,16 +365,34 @@ Please wait while the group is created... Klarte ikkje å oppdatera gruppa Du har ikke tillatelse til å slette andres beskjeder + + Slett valgt vedlegg + Slett valgte vedlegg + + + Er du sikker på at du vil slette det valgte vedlegget? Meldingen som er knyttet til vedlegget vil også bli slettet. + Er du sikker på at du vil slette de valgte vedleggene? Meldingen som er tilknyttet vedleggene vil også bli slettet. + + Er du sikker på at du vil slette {name} fra kontaktene dine?\n\nDette vil slette samtalen din, inkludert alle meldinger og vedlegg. Fremtidige meldinger fra {name} vil vises som en meldingsforespørsel. + Er du sikker på at du vil slette samtalen din med {name}?\nDette vil permanent slette alle meldinger og vedlegg. Slett beskjed Slett beskjeder + + Er du sikker på at du ønsker å slette denne meldingen? + Er du sikker på at du ønsker å slette disse meldingene? + Beskjed slettet Beskjeder slettet Denne meldingen har blitt slettet Denne meldingen ble slettet på denne enheten + + Er du sikker på at du vil slette denne meldingen kun fra denne enheten? + Er du sikker på at du vil slette disse meldingene kun fra denne enheten? + Er du sikker på at du ønskjer å slette denne meldinga for alle? Slett berre på denne eininga Slett på alle einingane mine @@ -286,6 +401,10 @@ Klarte ikkje sletta meldinga Klarte ikkje sletta meldingane + + Denne meldingen kan ikke slettes fra alle enhetene dine + Noen av meldingene du har valgt kan ikke slettes fra alle enhetene dine + Diese Nachricht kann nicht gelöscht werden Ein paar Nachrichten könnten nicht gelöscht werden @@ -293,6 +412,7 @@ Er du sikker på at du vil slette desse meldingane for alle? Slettar Skru av/på Utviklerverktøy + Enhetens varslingsinnstillinger Start diktering... Forsvinnande meldingar Melding blir slettet om {time_large} @@ -328,6 +448,7 @@ {admin_name} oppdaterte innstillingane for forsvinnande meldingar. Du oppdaterte innstillingene for tidsbegrensede beskjeder. Avvis + Skjerm Det kan vera ditt ekte namn, eit alias, eller noko anna du likar — og du kan endre det når som helst. Skriv inn visningsnamnet ditt Vennligst skriv inn navnet som skal vises @@ -336,7 +457,11 @@ Vel eit nytt visningsnamn Vel ditt visningsnamn Set Display Name + Visningsnavnet ditt er synlig for brukere, grupper og fellesskap du samhandler med. Dokument + Doner + Mektige krefter prøver å svekke personvernet, men vi kan ikke kjempe denne kampen alene.\n\nDonasjoner bidrar til å holde {app_name} sikkert, uavhengig og tilgjengelig. + {app_name} trenger din hjelp Ferdig Last ned Lastar ned… @@ -366,22 +491,53 @@ Du og {name} reagerte med {emoji_name} Reagerte på meldinga di {emoji} Skru på + Vil du aktivere kameratilgang? + Vis varsler når du mottar nye meldinger. + Avslutt samtalen for å aktivere + Liker du {app_name}? + Trenger forbedring {emoji} + Det er flott {emoji} + Du har brukt {app_name} en liten stund, hvordan går det? Vi vil gjerne høre dine tanker. + Enter + Skriv inn passordet du har angitt for {app_name} + Skriv inn passordet du bruker for å låse opp {app_name} ved oppstart, ikke gjenopprettingspassordet ditt + Feil under sjekking av {pro}-status Vennligst sjekk internettforbindelsen din og prøv igjen. Kopier feilmelding og avslutt Databasefeil + Noe gikk galt. Prøv igjen senere. + Feil ved innlasting av tilgang til {pro} + {app_name} kunne ikke søke etter denne ONS-en. Kontroller nettverkstilkoblingen din og prøv igjen. En ukjent feil oppstod. + Denne ONS-en er ikke registrert. Kontroller at den er riktig og prøv igjen. + Kunne ikke sende invitasjon på nytt til {name} i {group_name} + Kunne ikke sende invitasjon på nytt til {name} og {count} andre i {group_name} + Kunne ikke sende invitasjon på nytt til {name} og {other_name} i {group_name} + Kunne ikke sende forfremmelse på nytt til {name} i {group_name} + Kunne ikke sende forfremmelse på nytt til {name} og {count} andre i {group_name} + Kunne ikke sende forfremmelse på nytt til {name} og {other_name} i {group_name} + Kunne ikke laste ned Feil + Tilbakemelding + Del din erfaring med {app_name} ved å fullføre en kort undersøkelse. Fil Filer + Følg systeminnstillinger. + For alltid Frå: Skru av/på Fullskjerm GIF Giphy {app_name} vil koble til Giphy for å gi søkeresultater. Du vil ikke ha full metadatabeskyttelse når du sender GIF-er. + Gi tilbakemelding? + Beklager å høre at opplevelsen din med {app_name} ikke har vært ideell. Vi setter stor pris på om du kan dele dine tanker i en kort undersøkelse. Grupper kan ha maks 100 medlemmar Opprett gruppe Vennligst vel minst eitt anna gruppemedlem. Slett gruppe + Er du sikker på at du vil slette {group_name}?\n\nDette vil fjerne alle medlemmer og slette alt gruppeinnhold. + Er du sikker på at du vil slette {group_name}? + {group_name} har blitt slettet av en gruppeadministrator. Du vil ikke kunne sende flere meldinger. Skriv inn ei gruppebeskriving Gruppeprofilbiletet er oppdatert Rediger gruppe @@ -394,16 +550,28 @@ Klarte ikkje invitera {name} og {count} andre til {group_name} Klarte ikkje invitera {name} og {other_name} til {group_name} Klarte ikkje invitera {name} til {group_name} + Invitasjon ikke sendt + {name} inviterte deg til å bli med igjen i {group_name}, der du er administrator. + Du ble invitert til å bli med igjen i {group_name}, der du er administrator. + + Sender invitasjon + Sender invitasjoner + Invitasjon sendt + Invitasjonsstatus ukjent Gruppeinnbydelse var vellukka Brukarar må ha den nyaste versjonen for å ta imot invitasjonar Du vart invitert til å bli med i gruppa. Du og {count} andre vart invitert til å bli med i gruppa. Du og {other_name} vart invitert til å bli med i gruppa. + Du ble invitert til å bli med i gruppen. Chat-historikk ble delt. Forlat gruppe Er du sikker på at du ønskjer å forlate {group_name}? Er du sikker på at du vil forlate {group_name}?\n\nDette vil fjerne alle medlemmane og slette alt av gruppeinnhald. Klarte ikkje forlata {group_name} + {name} ble invitert til å bli med i gruppen. Chat-historikk fra de siste 14 dagene ble delt. + {name} og {count} andre ble invitert til å bli med i gruppen. Chat-historikken fra de siste 14 dagene ble delt. + {name} og {other_name} ble invitert til å bli med i gruppen. Chat-historikken fra de siste 14 dagene ble delt. {name} forlot gruppa. {name} og {count} andre forlot gruppa. {name} og {other_name} forlot gruppa. @@ -414,6 +582,10 @@ {name} og {count} andre vart invitert til å bli med i gruppa. {name} og {other_name} vart invitert til å bli med i gruppa. Du og {count} andre vart invitert til å bli med i gruppa. Chathistorikk vart delt. + Du og {other_name} ble invitert til å bli med i gruppen. Chat-historikk ble delt. + Kunne ikke fjerne {name} fra {group_name} + Kunne ikke fjerne {name} og {count} andre fra {group_name} + Kunne ikke fjerne {name} og {other_name} fra {group_name} Du forlot gruppa. Gruppemedlemmar Det er ingen andre medlemmer i denne gruppa. @@ -423,10 +595,15 @@ Vennligst skriv inn et kortere gruppenavn. Gruppenamnet er no «{group_name}». Gruppenamn oppdatert. + Gruppenavnet er synlig for alle gruppemedlemmer. Du har inga meldingar frå {group_name}. Send ei melding for å starta samtalen! + Denne gruppen har ikke blitt oppdatert på over 30 dager. Du kan oppleve problemer med å sende meldinger eller vise gruppeinformasjon. Du er den einaste administrator i {group_name}.\n\nGruppe medlemmer og innstillinger kan ikkje endrast utan ein administrator. + Du er den eneste administratoren i {group_name}.\n\nGruppemedlemmer og innstillinger kan ikke endres uten en administrator. For å forlate gruppen uten å slette den, legg til en ny administrator først. + Venter på fjerning Du vart promotert til admin. Du og {count} andre vart promoterte til admin. + Du og {other_name} ble forfremmet til Admin. Vil du fjerne {name} frå {group_name}? Vil du fjerne {name} og {count} andre frå {group_name}? Vil du fjerne {name} og {other_name} frå {group_name}? @@ -442,37 +619,75 @@ {name} og {count} andre vart fjerna frå gruppa. {name} og {other_name} vart fjerna frå gruppa. Du blei fjerna frå {group_name}. + Du ble fjernet fra gruppen. Du og {count} andre vart fjerna frå gruppa. Du og {other_name} vart fjerna frå gruppa. Set Group Display Picture Ukjend gruppe Gruppe oppdatert + Behandler tilkoblingskandidater Ofte stilte spørsmål + Se i {app_name} vanlige spørsmål for svar på vanlige spørsmål. Hjelp oss med å oversette {app_name} + Rapporter en bug Del nokre detaljar for å hjelpe oss med å løyse problemet ditt. Eksporter loggane dine, og last opp fila gjennom {app_name} sin Hjelp Desk. Eksportlogger Eksporter loggane dine, deretter last opp fila gjennom {app_name} sin hjelpedesk. Lagre til skrivebordet + Lagre denne filen, og del den deretter med utviklerne av {app_name}. Støtte + Hjelp til med å oversette {app_name} til over 80 språk! Vi ønsker gjerne dine tilbakemeldinger Skjul + Vis eller skjul systemmenylinjen. + Er du sikker på at du vil skjule Note to Self fra samtalelisten din? Skjul andre Bilete + bilder + Viktig Inkognitotastatur Be om anonymmodus om tilgjengeleg. Avhengig av tastaturet ditt kan tastaturet ignorerer denne førespurnaden. Info Ugyldig snarveg + + Inviter kontakt + Inviter kontakter + + + Invitasjon mislyktes + Invitasjoner mislyktes + + + Invitasjonen kunne ikke sendes. Vil du prøve igjen? + Invitasjonene kunne ikke sendes. Vil du prøve på nytt? + + + Inviter medlem + Inviter medlemmer + + Inviter et nytt medlem til gruppen ved å skrive inn vennens kontoid, ONS eller skanne QR-koden deres {icon} + Inviter et nytt medlem til gruppen ved å skrive inn kontoen til vennen din, ONS eller ved å skanne deres QR-kode Bli med Seinare + Start {app_name} automatisk når datamaskinen starter. + Start ved oppstart + Denne innstillingen styres av operativsystemet på Linux. For å aktivere automatisk oppstart, legg til {app_name} i oppstartsprogrammene i systeminnstillingene. Lær meir Forlat Forlatar... + Denne gruppen er nå skrivebeskyttet. Opprett denne gruppen på nytt for å fortsette samtalen. + Denne gruppen er nå skrivebeskyttet. Be gruppeadministratoren om å opprette denne gruppen på nytt for å fortsette samtalen. + Grupper har blitt oppgradert! Opprett denne gruppen på nytt for forbedret pålitelighet. Denne gruppen blir skrivebeskyttet {date}. + Grupper har blitt oppgradert! Be gruppeadministratoren om å opprette denne gruppen på nytt for forbedret pålitelighet. Denne gruppen blir skrivebeskyttet {date}. + Chatteloggen vil ikke bli overført til den nye gruppen. Du kan fremdeles se hele chatteloggen i den gamle gruppen din. {name} vart med i gruppa. {name} og {count} andre vart med i gruppa. Du og {count} andre vart med i gruppa. Du og {other_name} vart med i gruppa. {name} og {other_name} vart med i gruppa. Du vart med i gruppa. + Begrense bakgrunnsaktivitet? + Du tillater for øyeblikket at {app_name} kjører i bakgrunnen for å forbedre påliteligheten til varslinger. Hvis du endrer denne innstillingen, kan varslinger bli mindre pålitelige. Forhåndsvisning av lenker Vis lenkeforhåndsvisningar for støtta URL-ar. Aktiver lenkjesniktittar @@ -483,6 +698,7 @@ Du vil ikkje ha full metadatabeskyttelse når du sender lenkeforhåndsvisningar. Lenkeforhåndsvisning er avslått {app_name} må kontakte lenkede nettsider for å generere forhåndsvisninger av lenker du sender og mottar.\n\nDu kan slå dem på i {app_name}\'s innstillinger. + Lenker Last inn konto Lastar inn kontoen din Lastar... @@ -495,8 +711,17 @@ Låsestatus Trykk for å låse opp {app_name} er ulåst + Logger + Administrer administratorer + Administrer medlemmer + Administrer {pro} Maks + Kanskje senere Media + + %1$d medlem valgt + %1$d medlemmer valgt + %1$d medlem %1$d medlemmer @@ -506,7 +731,9 @@ %1$d aktive medlemmer Legg til Account ID eller ONS + Medlemmer kan kun bli forfremmet etter at de har akseptert invitasjonen om å bli med i gruppen. Inviter kontaktar + Du har ingen kontakter å invitere til denne gruppen.\nGå tilbake og inviter medlemmer ved å bruke kontoid eller ONS-en deres. Send invitasjon Send invitasjonar @@ -515,9 +742,14 @@ Vil du dele gruppemeldingshistorikk med {name} og {count} andre? Vil du dele gruppemeldingshistorikk med {name} og {other_name}? Del meldingshistorikken + Del meldingshistorikk fra de siste 14 dagene Dele berre nye meldingar Inviter + Medlemmer (ikke-administratorer) + Menylinje Melding + Les mer + Kopier melding Meldinga er tom. Feil ved meldingslevering Meldingsgrensen er nådd @@ -532,11 +764,18 @@ Start ein ny samtale ved å skrive inn kontonummeret eller ONS-en til vennen din. Start ein ny samtale ved å skrive inn kontonummeret, ONS-en eller skanne QR-koden til vennen din. + Start en ny samtale ved å skrive inn vennen din sitt bruker-ID, ONS eller ved å skanne deres QR-kode {icon} Du har fått ei ny melding. Du har %1$d nye meldingar. + + Du har fått en ny melding i %1$s. + Du har fått %1$d nye meldinger i %2$s. + Svarer på + Du kan ikke sende vedlegg før meldingsforespørselen din er godtatt + Du kan ikke sende talemeldinger før meldingsforespørselen din er godtatt. {name} inviterte deg til å bli med i {group_name}. Å senda ei melding til denne gruppa vil automatisk akseptera gruppeinvitasjonen. Din meldingsforespørsel står for øyeblikket på vent. @@ -547,6 +786,7 @@ Er du sikker på at du vil sletta alle meldingsforespørsler og gruppeinvitasjonar? Samfunnsmeldingsforespørslar Tillat meldingsforespørsler frå Samfunns-samtaler. + Er du sikker på at du vil slette denne meldingsforespørselen og den tilknyttede kontakten? Er du sikker på at du ønskjer å slette denne meldingsforespørselen? Du har en ny meldingsforespørsel Ingen ventande meldingsforespørsler @@ -564,19 +804,38 @@ {author}: {emoji} Talemelding Meldingar Minimer + + Meldinger har en tegngrense på %1$s tegn. Du har %2$d tegn igjen. + Meldinger har en tegngrense på %1$s tegn. Du har %2$d tegn igjen. + + Meldingslengde + Du har overskredet tegngriksen for denne meldingen. Vennligst forkort meldingen til {limit} tegn eller færre. + Meldingen er for lang + Vennligst forkort meldingen din til {limit} tegn eller færre. + Meldingen er for lang + Nytt passord Neste + Neste steg Vel eit kallenamn for {name}. Dette vil visast for deg i dine 1-1 og gruppe-samtalar. Skriv inn eit kallenamn + Vennligst skriv inn et kortere kallenavn Fjern kallenavn Set Nickname Nei + Det er ingen ikke-administratorer i denne gruppen. Ingen forslag + Send meldinger på opptil 10 000 tegn i alle samtaler. + Organiser chatter med ubegrenset antall festede samtaler. Ingen Ikkje no Notat til meg sjølv Du har inga meldingar i Note to Self. Skjul Notat til meg sjølv Er du sikker på at du ønskjer å skjule Note to Self? + VÆR OPPMERKSOM: Ved å {action_type} samtykker du til {app_pro} sine Vilkår for bruk {icon} og Personvernerklæring {icon} + Varslingsvisning + Vis avsenderens navn og en forhåndsvisning av meldingsinnholdet. + Vis kun avsenderens navn uten noe meldingsinnhold. Alle meldingar Varslingsinnhold Informasjonen som vises i varslinger. @@ -585,7 +844,9 @@ Verken navn eller innhold Fast Mode Du vil bli varsla om nye meldingar på ein pålitelig måte, og umiddelbart ved hjelp av Googles varslingsservere. + Du vil bli varslet om nye meldinger pålitelig og umiddelbart ved bruk av Huaweis varslingsservere. Du vil bli varsla om nye meldingar på pålitelig og umiddelbar ved hjelp av Apple\'s varslingsservere. + Vis et generisk {app_name}-varsel uten avsenderens navn eller meldingsinnhold. Gå til enhetens varslingsinnstillinger Varsler - Alle Varsler - Kun omtaler @@ -593,6 +854,7 @@ {name} til {conversation_name} Det er mogleg du har motteke meldingar mens din {device} starta på nytt. LED-farge + Spill av en lyd når du mottar nye meldinger. Kun omtaler Meldingsvarsel Nyaste frå {name} @@ -600,6 +862,8 @@ Demp for {time_large} Opphev demp Dempet + Dempet i {time_large} + Dempet til {date_time} Slow Mode {app_name} vil av og til sjekke nye meldinger i bakgrunnen. Lyd @@ -612,6 +876,12 @@ Av Ok + På din {device_type}-enhet + Åpne denne {app_name}-kontoen på en {device_type}-enhet som er logget inn med {platform_account}-kontoen du opprinnelig registrerte deg med. Deretter kan du kansellere {pro} via innstillingene i {app_pro}. + Åpne denne {app_name}-kontoen på en {device_type}-enhet som er logget inn med {platform_account}-kontoen du opprinnelig registrerte deg med. Oppdater deretter {pro}-tilgangen din via {app_pro}-innstillingene. + På en tilknyttet enhet + På {platform_store}-nettstedet + På {platform}-nettstedet Opprett konto Konto oppretta Eg har ein konto @@ -636,33 +906,70 @@ Vi kunne ikkje kjenne att denne ONS-en. Ver venleg kontroller den og prøv igjen. Vi kunne ikkje søke opp denne ONS-en. Vennligast prøv igjen seinare. Åpne + Åpne {platform_store}-nettstedet + Åpne {platform}-nettstedet + Åpne innstillinger + Åpne spørreundersøkelsen Annan + Passord Endre passord + Endre passordet som kreves for å låse opp {app_name}. + Passordet ditt er endret. Vennligst oppbevar det trygt. Bekreft passordet + Opprett passord Ditt nåværande passord er feil. Skriv inn passord Vennligst skriv inn ditt nåværende passord Vennligst skriv inn ditt nye passord Passordet kan kun inneholde bokstaver, tall og symboler + Passordet må være mellom {min} og {max} tegn langt Passordene stemmer ikke overens Kunne ikkje stilla passordet Galt passord + Bekreft nytt passord Fjern passord + Fjern passordet som kreves for å låse opp {app_name} + Passordet ditt har blitt fjernet. Set Password + Passordet ditt er satt. Vennligst oppbevar det trygt. + Krev passord for å låse opp {app_name} ved oppstart. + Lengre enn 12 tegn + Inkluderer et tall + Inkluderer en liten bokstav + Inneholder et symbol + Inkluderer en stor bokstav + Indikator for passordstyrke + Å sette et sterkt passord hjelper med å beskytte meldingene og vedleggene dine hvis enheten din blir mistet eller stjålet. + Passord Lim inn + Betalingsfeil + Betalingen din ble behandlet, men det oppstod en feil ved {action_type} av {pro}-statusen din.\n\nSjekk nettverkstilkoblingen og prøv igjen. + Tillatelsesendring {app_name} trenger musikk- og lydtilgang for å sende filer, musikk og lyd, men tilgangen er permanent avslått. Trykk Innstillinger → Tillatelser, og slå på \"Musikk og lyd\". {app_name} trenger Apple Music for å spille av media-vedlegg. Automatisk oppdatering Søk etter oppdateringar automatisk ved oppstart + Kameratilgang er nødvendig for å foreta videosamtaler. Slå på tillatelsen \"Kamera\" i Innstillinger for å fortsette. + Tilgang til kamera er for øyeblikket aktivert. For å deaktivere det, slå av tillatelsen \"Kamera\" i Innstillinger. {app_name} treng tilgang til kameraet for å ta bilete og videoar, men tilgangen er permanent avslått. Trykk Innstillinger → Tillatelser, og slå på \"Kamera\". + Tillat tilgang til kamera for videosamtaler. Skjermlåsfunksjonen på {app_name} bruker Face ID. Behald i systemstatusfeltet + {app_name} fortsetter å kjøre i bakgrunnen når du lukker vinduet. {app_name} trenger tilgang til fotobibliotek for å fortsette. Du kan skru på tilgangen i iOS-innstillingene. + Tilgang til lokalt nettverk er nødvendig for å kunne foreta samtaler. Slå på tillatelsen \"Lokalt nettverk\" i Innstillinger for å fortsette. + {app_name} må ha tilgang til det lokale nettverket for å kunne foreta tale- og videosamtaler. + Tilgang til lokalnettverk er for øyeblikket aktivert. For å deaktivere det, slå av tillatelsen \"Lokalnettverk\" i Innstillinger. + Tillat tilgang til det lokale nettverket for å muliggjøre tale- og videosamtaler. + Lokalt nettverk Mikrofon {app_name} treng tilgang til mikrofonen for å ringe samtalar og senda lydklipp, men tilgangen er permanent avslått. Trykk på innstillingar → Tilgangar og slå på «Mikrofon». + Mikrofontilgang er nødvendig for å ringe og spille inn lydmeldinger. Slå på tillatelsen \"Mikrofon\" i Innstillinger for å fortsette. Du kan aktivere mikrofontilgang i {app_name}\'s personverninnstillinger {app_name} trenger mikrofontilgang for å ringe og ta opp lydmeldinger. + Mikrofontilgang er for øyeblikket aktivert. For å deaktivere den, slå av tillatelsen \"Mikrofon\" i Innstillinger. Gi tilgang til mikrofonen. + Tillat tilgang til mikrofon for taleanrop og lydmeldinger. {app_name} treng tilgang til musikk og lyd for å sende filer, musikk og lyd. Tilgang krevst {app_name} trenger tilgang til fotobiblioteket slik at du kan sende bilder og videoer, men det har blitt permanent avslått. Trykk Innstillinger → Tillatelser, og slå på \"Bilder og videoer\". @@ -670,11 +977,174 @@ {app_name} trenger lagringstilgang for å lagre vedlegg og media. {app_name} trenger lagringstilgang for å lagre bilete og videoar, men tilgangen er permanent avslått. Fortsett til appinnstillingene, vel \"Tillatelser\" og aktiver \"Lagring\". {app_name} trenger lagringstilgang for å sende bilete og videoar. + Du har ikke skrivetillatelser i dette fellesskapet Fest Fest samtale Løsne Løsne samtale + Pluss mye mer... + Nye funksjoner kommer snart til {pro}. Oppdag hva som kommer i {pro}-veikartet {icon} + Innstillinger Forhåndsvisning + Forhåndsvis varsling + Feil ved tilgang til {pro} + Din {pro}-tilgang utløper {date}. + {pro}-tilgang lastes + Informasjonen om {pro}-tilgang lastes fortsatt. Du kan ikke oppdatere før denne prosessen er fullført. + {pro}-tilgang lastes... + Kan ikke koble til nettverket for å laste tilgangsinformasjon for {pro}. Oppdatering av {pro} via {app_name} vil være deaktivert til tilkoblingen er gjenopprettet.\n\nSjekk nettverkstilkoblingen din og prøv igjen. + Fant ikke {pro}-tilgang + {app_name} oppdaget at kontoen din ikke har {pro}-tilgang. Hvis du mener dette er en feil, vennligst kontakt brukerstøtte hos {app_name} for hjelp. + Gjenopprett {pro}-tilgang + Forny {pro}-tilgang + For øyeblikket kan {pro}-tilgang kun kjøpes og fornyes via {platform_store} eller {platform_store_other}. Siden du bruker {app_name} Desktop, kan du ikke fornye her.\n\nUtviklerne av {app_name} jobber hardt med alternative betalingsalternativer som lar brukere kjøpe {pro}-tilgang utenfor {platform_store} og {platform_store_other}. {pro}-Veikart {icon} + Forny {pro}-tilgangen din på {platform_store}-nettstedet ved å bruke {platform_account}-kontoen du brukte da du registrerte deg for {pro}. + Forny på {platform}-nettstedet ved å bruke {platform_account}-kontoen du registrerte {pro} med. + Forny {pro}-tilgangen din for å begynne å bruke de kraftige funksjonene i {app_pro} Beta igjen. + {pro}-tilgang gjenopprettet + {app_name} oppdaget og gjenopprettet {pro}-tilgang for kontoen din. Din {pro}-status er gjenopprettet! + Fordi du opprinnelig registrerte deg for {app_pro} via {platform_store}, må du bruke din {platform_account} for å oppdatere tilgangen din til {pro}. + For øyeblikket kan {pro}-tilgang kun kjøpes via {platform_store} eller {platform_store_other}. Fordi du bruker {app_name} Desktop, kan du ikke oppgradere til {pro} her.\n\nUtviklerne av {app_name} jobber hardt med alternative betalingsløsninger slik at brukere kan kjøpe {pro}-tilgang utenom {platform_store} og {platform_store_other}. Veikart for {pro} {icon} + Aktivert + aktiverer + Alt er klart! + Tilgangen din til {app_pro} ble oppdatert! Du vil bli belastet når {pro} fornyes automatisk den {date}. + Du har allerede + Last opp GIF-er og animerte WebP-bilder som visningsbilde! + Få animerte visningsbilder og lås opp premiumfunksjoner med {app_pro} Beta + Animasjonsvisningsbilde + brukere kan laste opp GIF-er + Animerte visningsbilder + Angi animerte GIF-er og WebP-bilder som visningsbilde. + Last opp GIF-er med + {pro} fornyes automatisk om {time} + {pro}-merke + Vis {app_pro}-merket til andre brukere + Merker + Vis at du støtter {app_name} med et eksklusivt merke ved siden av visningsnavnet ditt. + + %1$s %2$s-merke sendt + %1$s %2$s-merker sendt + + {pro} betafunksjoner + {price} Faktureres årlig + {price} Faktureres månedlig + {price} Faktureres kvartalsvis + Vil du sende lengre meldinger?\nSend mer tekst og lås opp premiumfunksjoner med {app_pro} Beta + Vil du feste flere samtaler?\nOrganiser samtalene dine og lås opp premiumfunksjoner med {app_pro} Beta + Vil du ha mer enn {limit} festede samtaler?\nOrganiser samtalene dine og lås opp premiumfunksjoner med {app_pro} Beta + Det er synd at du kansellerer {pro}. Her er det du bør vite før du avslutter {pro}-tilgangen din. + Oppsigelse + Å avslutte {pro}-tilgang vil forhindre automatisk fornyelse før {pro}-tilgangen utløper. Å avslutte {pro} gir ingen refusjon. Du kan fortsatt bruke {app_pro}-funksjoner til {pro}-tilgangen utløper.\n\nFordi du opprinnelig registrerte deg for {app_pro} med kontoen {platform_account}, må du bruke den samme {platform_account} for å avslutte {pro}. + To måter å avslutte din {pro}-tilgang på: + Ved å kansellere {pro}-tilgangen vil du forhindre automatisk fornyelse før {pro} utløper.\n\nÅ kansellere {pro} gir ikke rett til refusjon. Du vil kunne bruke {app_pro}-funksjonene til tilgangen til {pro} utløper. + Velg det {pro}-abonnementet som passer best for deg.\nLengre tilgang gir større rabatter. + Er du sikker på at du vil slette dataene dine fra denne enheten?\n\n{app_pro} kan ikke overføres til en annen konto. Vennligst lagre gjenopprettingspassordet ditt for å sikre at du kan gjenopprette tilgangen til {pro} senere. + Er du sikker på at du vil slette dataene dine fra nettverket? Hvis du fortsetter, vil du ikke kunne gjenopprette meldinger eller kontakter.\n\n{app_pro} kan ikke overføres til en annen konto. Vennligst lagre gjenopprettingspassordet ditt for å sikre at du kan gjenopprette tilgangen til {pro} senere. + Tilgangen din til {pro} er allerede rabattert med {percent}% av full pris for {app_pro}. + Feil ved oppdatering av {pro}-status + Utløpt + Dessverre har din {pro}-tilgang utløpt.\nForny for å aktivere de eksklusive fordelene og funksjonene i {app_pro} Beta på nytt. + Utløper snart + Din {pro}-tilgang utløper om {time}.\nOppdater nå for å fortsette å bruke de eksklusive fordelene og funksjonene i {app_pro} Beta + {pro} utløper om {time} + {pro} Vanlige spørsmål + Finn svar på vanlige spørsmål i {app_pro} FAQ. + Last opp GIF- og WebP-visningsbilder + Større gruppesamtaler med opptil 300 medlemmer + Og mange flere eksklusive funksjoner + Meldinger på opptil 10 000 tegn + Fest ubegrenset med samtaler + Vil du bruke {app_name} til sitt fulle potensial?\nOppgrader til {app_pro} Beta for å få tilgang til en mengde eksklusive fordeler og funksjoner. + Gruppe aktivert + Denne gruppen har utvidet kapasitet! Den støtter opptil 300 medlemmer fordi en gruppeadministrator har + + %1$s gruppe oppgradert + %1$s grupper oppgradert + + Forespørsel om refusjon er endelig. Hvis den godkjennes, vil tilgangen din til {pro} bli kansellert umiddelbart, og du mister tilgang til alle {pro}-funksjoner. + Økt vedleggsstørrelse + Økt meldingslengde + Større grupper + Grupper der du er administrator oppgraderes automatisk til å støtte 300 medlemmer. + Større gruppechatter (opptil 300 medlemmer) kommer snart for alle Pro Beta-brukere! + Lengre meldinger + Du kan sende meldinger på opptil 10 000 tegn i alle samtaler. + + %1$s lengre melding sendt + %1$s lengre meldinger sendt + + Denne meldingen brukte følgende {app_pro}-funksjoner: + Med en ny installasjon + Installer {app_name} på nytt på denne enheten via {platform_store}, gjenopprett kontoen din med gjenopprettingspassordet, og forny {pro} fra innstillingene i {app_pro}. + Installer {app_name} på nytt på denne enheten via {platform_store}, gjenopprett kontoen din med Gjenopprettingspassordet ditt, og oppgrader til {pro} fra innstillingene i {app_pro}. + Foreløpig finnes det tre måter å fornye på: + Foreløpig finnes det to måter å fornye på: + {percent}% rabatt + + %1$s festet samtale + %1$s festede samtaler + + Fordi du opprinnelig registrerte deg for {app_pro} via {platform_store}, må du bruke din {platform_account} for å be om refusjon. + Fordi du opprinnelig registrerte deg for {app_pro} via {platform_store}, vil refusjonsforespørselen din behandles av {app_name}-støtte.\n\nBe om refusjon ved å trykke på knappen nedenfor og fylle ut refusjonsskjemaet.\n\n{app_name}-støtte tilstreber å behandle refusjonsforespørsler innen 24–72 timer, men behandlingstiden kan være lengre ved stor pågang. + Tilgangen til {app_pro} er fornyet! Takk for at du støtter {network_name}. + 1 måned – {monthly_price} / måned + 3 måneder – {monthly_price} / måned + 12 måneder – {monthly_price} / måned + aktiverer på nytt + Åpne denne {app_name}-kontoen på en {device_type}-enhet som er logget inn med {platform_account}-kontoen du opprinnelig registrerte deg med. Be deretter om refusjon via {app_pro}-innstillingene. + Vi er lei for at du forlater oss. Her er hva du bør vite før du ber om refusjon. + {platform} behandler nå refusjonsforespørselen din. Dette tar vanligvis 24–48 timer. Avhengig av avgjørelsen deres, kan du se at {pro}-statusen din endres i {app_name}. + Refusjonsforespørselen din vil bli behandlet av {app_name} kundestøtte.\n\nSend inn en forespørsel ved å trykke på knappen nedenfor og fylle ut refusjonsskjemaet.\n\nSelv om {app_name} kundestøtte tilstreber å behandle refusjonsforespørsler innen 24–72 timer, kan det ta lenger tid ved høyt forespørselsvolum. + Refusjonsforespørselen din vil utelukkende bli behandlet av {platform} via {platform}-nettstedet.\n\nPå grunn av {platform}s refusjonsretningslinjer har utviklerne av {app_name} ingen mulighet til å påvirke resultatet av refusjonsforespørsler. Dette inkluderer om forespørselen blir godkjent eller avslått, samt om du får full eller delvis refusjon. + Ta kontakt med {platform} for oppdateringer om refusjonsforespørselen din. På grunn av refusjonspolicyene til {platform}, har ikke utviklerne av {app_name} mulighet til å påvirke utfallet av refusjonsforespørsler.\n\n{platform} Refusjonsstøtte + Refunderer {pro} + Refusjoner for {app_pro} håndteres utelukkende av {platform} via {platform_store}.\n\nPå grunn av refusjonspolicyene til {platform}, har ikke utviklerne av {app_name} mulighet til å påvirke utfallet av refusjonsforespørsler. Dette inkluderer hvorvidt forespørselen blir godkjent eller avslått, samt om det gis full eller delvis refusjon. + Vil du bruke animerte visningsbilder igjen?\nForny {pro}-tilgangen din for å låse opp funksjonene du har gått glipp av. + Forny {pro} Beta + Forny {pro}-tilgangen din fra {app_pro}-innstillingene på en tilkoblet enhet med {app_name} installert via {platform_store} eller {platform_store_other}. + Vil du sende lengre meldinger igjen?\nForny {pro}-tilgangen din for å låse opp funksjonene du har gått glipp av. + Vil du bruke {app_name} til sitt fulle potensial igjen?\nForny {pro}-tilgangen din for å låse opp funksjonene du har gått glipp av. + Vil du feste mer enn {limit} samtaler igjen?\nForny {pro}-tilgangen din for å låse opp funksjonene du har gått glipp av. + Vil du feste flere samtaler igjen?\nForny {pro}-tilgangen din for å låse opp funksjonene du har gått glipp av. + Ved å fornye godtar du {app_pro} sine Vilkår for tjenesten {icon} og Personvernerklæring {icon} + fornyer + For øyeblikket kan {pro}-tilgang bare kjøpes og fornyes via {platform_store} eller {platform_store_other}. Fordi du installerte {app_name} ved bruk av {build_variant}, kan du ikke fornye her.\n\nUtviklerne av {app_name} jobber hardt med alternative betalingsalternativer for å gjøre det mulig å kjøpe {pro}-tilgang utenfor {platform_store} og {platform_store_other}. Veikart for {pro} {icon} + Refusjon forespurt + Send mer med + {pro}-innstillinger + Start å bruke {pro} + Dine {pro}-statistikker + {pro}-statistikk lastes inn + {pro}-statistikken din lastes inn, vennligst vent. + {pro}-statistikkene gjenspeiler bruken på denne enheten og kan se annerledes ut på tilknyttede enheter. + Feil med {pro}-status + Kan ikke koble til nettverket for å sjekke {pro}-statusen din. Informasjonen som vises på denne siden kan være unøyaktig til forbindelsen er gjenopprettet.\n\nSjekk nettverkstilkoblingen din og prøv igjen. + {pro}-status lastes + Informasjonen om {pro} lastes inn. Enkelte handlinger på denne siden kan være utilgjengelige inntil innlastingen er fullført. + Laster inn {pro}-status + Kan ikke koble til nettverket for å sjekke {pro}-statusen din. Du kan ikke fortsette før tilkoblingen er gjenopprettet.\n\nVennligst sjekk nettverkstilkoblingen din og prøv igjen. + Kan ikke koble til nettverket for å sjekke {pro}-statusen din. Du kan ikke oppgradere til {pro} før tilkoblingen er gjenopprettet.\n\nSjekk nettverkstilkoblingen din og prøv igjen. + Kan ikke koble til nettverket for å oppdatere {pro}-statusen din. Enkelte handlinger på denne siden vil være deaktivert til tilkoblingen er gjenopprettet.\n\nSjekk nettverkstilkoblingen din og prøv igjen. + Kan ikke koble til nettverket for å laste inn din nåværende {pro}-tilgang. Fornyelse av {pro} via {app_name} vil være deaktivert til tilkoblingen er gjenopprettet.\n\nVennligst kontroller nettverksforbindelsen og prøv igjen. + Trenger du hjelp med {pro}? Send en forespørsel til brukerstøtten. + Ved å oppdatere godtar du {app_pro} sine Vilkår for tjenesten {icon} og Personvernerklæring {icon} + Ubegrenset antall festede meldinger + Organiser alle samtalene dine med ubegrensede festede samtaler. + Fakturering nåværende valg gir deg {current_plan_length} med tilgang til {pro}. Er du sikker på at du vil bytte til faktureringsvalget {selected_plan_length_singular}?\n\nVed oppdatering fornyes din tilgang til {pro} automatisk den {date} for ytterligere {selected_plan_length} med {pro}-tilgang. + Tilgangen din til {pro} utløper {date}.\n\nVed oppdatering fornyes din tilgang til {pro} automatisk den {date} for ytterligere {selected_plan_length} med {pro}-tilgang. + oppdaterer + Oppgrader til {app_pro} Beta for å få tilgang til mange eksklusive fordeler og funksjoner. + Oppgrader til {pro} fra {app_pro}-innstillingene på en tilkoblet enhet med {app_name} installert via {platform_store} eller {platform_store_other}. + For øyeblikket kan {pro}-tilgang kun kjøpes via {platform_store} eller {platform_store_other}. Fordi du installerte {app_name} ved bruk av {build_variant}, kan du ikke oppgradere til {pro} her.\n\nUtviklerne av {app_name} jobber hardt med alternative betalingsløsninger slik at brukere kan kjøpe {pro}-tilgang utenom {platform_store} og {platform_store_other}. Veikart for {pro} {icon} + Foreløpig finnes det bare én måte å oppgradere på. + Foreløpig finnes det to måter å oppgradere på: + Du har oppgradert til {app_pro}!\nTakk for at du støtter {network_name}. + oppgraderer + Oppgraderer til {pro} + Ved å oppgradere godtar du {app_pro} sine Vilkår for tjenesten {icon} og Personvernerklæring {icon} + Vil du få mer ut av {app_name}?\nOppgrader til {app_pro} Beta for en kraftigere meldingsopplevelse. + {platform} behandler refusjonsforespørselen din Profil Visingsbilde Klarte ikkje fjerna visningsbilete. @@ -682,6 +1152,19 @@ Vennligst velg ei mindre fil. Klarte ikkje å oppdatera profil Promotere + Administratorer vil kunne se de siste 14 dagene med meldingshistorikk og kan ikke degraderes eller fjernes fra gruppen. + + Forfrem medlem + Forfrem medlemmer + + + Forfremmelse mislyktes + Forfremmelser mislyktes + + + Forfremmelsen kunne ikke brukes. Vil du prøve på nytt? + Forfremmelser kunne ikke brukes. Vil du prøve på nytt? + QR-kode Denne QR-koden inneholder ikke en kontoid Denne QR-koden inneholder ikke et gjenopprettingspassord @@ -690,15 +1173,22 @@ Venner kan senda melding til deg ved å skanna QR-koden din. Avslutt {app_name} Avslutt + Vurdere {app_name}? + Vurder appen + Så bra at du liker {app_name}. Hvis du har litt tid, hjelper en vurdering oss i {storevariant} med å gjøre privat og sikker meldingsutveksling kjent for andre! Lese Lesekvitteringar Vis lesekvitteringar for alle meldingane du sender og mottar. Motteke: Eingegangene Antwort + Mottar samtaletilbud + Mottar forhåndstilbud Anbefalt Lagre ditt Recovery password for å sikre at du ikkje mistar tilgangen til kontoen din. Lagre ditt Recovery password + Bruk gjenopprettingspassordet ditt til å laste inn kontoen din på nye enheter.\n\nKontoen din kan ikke gjenopprettes uten dette passordet. Sørg for at det er lagret på et trygt og sikkert sted — og ikke del det med noen. Skriv inn gjenopprettingspassordet ditt + Det oppstod en feil ved innlasting av gjenopprettingspassordet ditt.\n\nVennligst eksporter loggene dine, og last deretter opp filen via {app_name} kundestøtte for å bidra til å løse dette problemet. Vennligst sjekk gjenoppretting passordet ditt og prøv igjen. Nokre av orda i gjenopprettingspassordet ditt er feil. Ver venleg å sjekke og prøve igjen. Gjenopprettingspassordet du skrev inn er ikke langt nok. Vennligst sjekk og prøv igjen. @@ -706,19 +1196,69 @@ For å laste kontoen din, skriv inn gjenopprettingspassordet ditt. Skjul Recovery Password permanent Utan ditt gjenoppretta passord kan du ikkje laste kontoen din på nye einingar. \n\nVi tilrår sterkt at du lagrer ditt gjenoppretta passord på ein sikker stad før du fortsetter. + Er du sikker på at du vil skjule gjenopprettingspassordet ditt permanent på denne enheten?\n\nDette kan ikke angres. Skjul Recovery Password Permanently hide your recovery password on this device. Skriv inn gjenopprettingspassordet ditt for å lasta inn kontoen din. Om du ikkje har lagra det, kan du finna det i appinnstillingane dine. + Vis gjenopprettingspassord + Synlighet for gjenopprettingspassord Dette er ditt gjenopprettingspassord. Hvis du sender det til noen, vil de få full tilgang til kontoen din. + Opprett gruppe på nytt Gjenta + Fordi du opprinnelig registrerte deg for {app_pro} via en annen {platform_account}, må du bruke den {platform_account} for å oppdatere {pro}-tilgangen din. + To måter å be om refusjon på: + Reduser meldingslengden med {count} + + %1$d tegn igjen + %1$d tegn igjen + + Påminn meg senere Fjern + + Fjern medlem + Fjern medlemmer + + + Fjern medlem og meldingene deres + Fjern medlemmer og meldingene deres + Kunne ikkje fjerna passordet + Fjern gjeldende passord for {app_name}. Lokalt lagrede data vil bli kryptert på nytt med en tilfeldig generert nøkkel som lagres på enheten din. + + Fjerner medlem + Fjerner medlemmer + + Forny + Fornyer {pro} Svare + Be om refusjon + Be om refusjon på {platform}-nettstedet, ved å bruke {platform_account} som du registrerte deg for {pro} med. Send på nytt + + Send invitasjon på nytt + Send invitasjoner på nytt + + + Send forfremmelse på nytt + Send kampanjer på nytt + + + Sender invitasjon på nytt + Sender invitasjoner på nytt + + + Sender kampanje på nytt + Sender kampanjer på nytt + Lastar inn land … Restart Synk på nytt Prøv på nytt + Vurderingsgrense + Det virker som du nylig har vurdert {app_name}, takk for tilbakemeldingen! + Kjør app i bakgrunnen + Kjøre {app_name} i bakgrunnen? + Siden du bruker treg modus, anbefaler vi at du lar {app_name} kjøre i bakgrunnen for å forbedre varsler. Dette kan gi mer konsistente varsler, selv om systemet ditt fortsatt kan begrense bakgrunnsaktivitet automatisk.\n\nDu kan endre dette senere i Innstillinger. Lagra Lagra Saved messages @@ -727,6 +1267,8 @@ Skjermtryggleik Skjermbilde varsler Krev eit varsel når ein kontakt tek eit skjermbilete av ein en-til-en samtale. + Skjul {app_name}-vinduet i skjermbilder tatt på denne enheten. + Beskyttelse mot skjermbilder {name} tok eit skjermbilde. Søk Søk etter kontakter @@ -742,8 +1284,15 @@ Søker... Velg Vel alle + Velg app-ikon Send Sender + Sender samtaletilbud + Sender tilkoblingskandidater + + Sender admin-forfremmelse + Sender admin-forfremmelser + Sendt: Utsjåande Fjern data @@ -751,55 +1300,117 @@ Hjelp Inviter en venn Meldingsforespørsler + Nåværende {token_name_short}-pris + Meldinger sendes via {network_name}. Nettverket består av noder som får insentiver i {token_name_long}, noe som holder {app_name} desentralisert og sikkert. Les mer {icon} + Lær om staking + Markedsverdi + {app_name}-noder som sikrer meldingene dine + {app_name}-noder i svermen din + {token_name_long} er lansert! Utforsk den nye {network_name}-delen i Innstillinger for å lære hvordan {token_name_long} driver Session. + Nettverk sikret av + Når du staker {token_name_long} for å sikre nettverket, tjener du belønninger i {token_name_short} fra {staking_reward_pool}. + Ny Varsler Tillatelser Personvern + {app_pro} Beta Recovery Password Innstillingar Set + Angi visningsbilde for fellesskap + Angi et passord for {app_name}. Lokalt lagrede data vil bli kryptert med dette passordet. Du vil bli bedt om å skrive inn dette passordet hver gang {app_name} startes. + Kan ikke oppdatere innstilling Du må starte {app_name} på nytt for å bruke dei nye innstillingane dine. Skjermtryggleik + Oppstart Del Inviter vennen din til å chatte med deg på {app_name} ved å dele din Account ID med dei. Del med vennane dine der du vanlegvis pratar med dei — så flyttar du samtalen hit. Det er et problem med å åpne databasen. Vennligst start appen på nytt og prøv igjen. + Obs! Det ser ut til at du ikke har en {app_name}-konto ennå.\n\nDu må opprette en i {app_name}-appen før du kan dele. + Vil du dele gruppemeldingshistorikken med denne brukeren? Del til {app_name} + Beklager, {app_name} støtter kun deling av flere bilder og videoer samtidig + Deling støtter kun medier. Ikke-mediefiler er utelatt Vis Vis alle Vis færre + Vis Notat til meg selv + Er du sikker på at du vil vise Notat til meg selv i samtalelisten? + Stavekontroll Klistremerke + Styrke + Har du problemer? Utforsk hjelpeartiklene eller opprett en sak med {app_name} kundestøtte. Gå til støttesiden Systeminformasjon: {information} + Trykk for å prøve på nytt Hald fram Forvald Feil + Tilbake + Temaforhåndsvisning + Konto-ID-en til {name} er synlig basert på tidligere interaksjoner + Blindrede ID-er brukes i fellesskap for å redusere søppelpost og øke personvernet. + Oversett + Systemkurv Prøv igjen Inntastingsindikatorer Sjå og del tastevisning. + Ikke tilgjengelig Angre Ukjend + Ikke støttet CPU + Oppdater + Oppdater {pro}-tilgang + To måter å oppdatere {pro}-tilgangen din på: Programoppdateringar + Oppdater samfunnsinformasjon + Samfunnsnavn og beskrivelse er synlige for alle medlemmer av samfunnet + Skriv inn en kortere samfunnsbeskrivelse + Skriv inn et kortere navn på samfunnet Oppdatering installert, klikk for å starte på nytt Laster ned oppdatering: {percent_loader}% Kan ikke oppdatere {app_name} klarte ikkje å oppdatere. Gå til {session_download_url} og installer den nye versjonen manuelt, og kontakt deretter vårt brukersenter for å informere om dette problemet. + Oppdater gruppeinformasjon + Gruppenavn og beskrivelse er synlige for alle gruppemedlemmer. + Vennligst oppgi en kortere gruppetekst Det finst ei ny utgåve av {app_name}; trykk for å oppdatere + En ny versjon ({version}) av {app_name} er tilgjengelig. + Oppdater profilinformasjon + Visningsnavnet ditt og visningsbildet ditt er synlige i alle samtaler. Gå til utgivelsesmerknader {app_name} oppdatering Versjon {version} + Sist oppdatert for {relative_time} siden + Oppdateringer + Oppdaterer... + Oppgrader + Oppgrader {app_name} + Oppgrader til Laster opp Kopier URL Åpne URL Dette åpner i nettleseren din. Er du sikker på at du ønskjer å opne denne URL-en i nettlesaren din?\n\n{url} + Lenker åpnes i nettleseren din. Bruk rask modus + Endre abonnementet ditt ved å bruke {platform_account}-kontoen du registrerte deg med, via {platform}-nettstedet. + Via {platform}-nettstedet Video Klarte ikkje å spela av video. Vis + Vis mindre + Vis mer Dette kan ta noen minutter. Eitt augneblink... Advarsel + Støtten for iOS 15 er avsluttet. Oppdater til iOS 16 eller nyere for å fortsette å motta appoppdateringer. Vindu Ja Du + CPU-en din støtter ikke SSE 4.2-instruksjoner, som kreves av {app_name} på Linux x64-operativsystemer for å behandle bilder. Oppgrader til en kompatibel CPU eller bruk et annet operativsystem. + Ditt gjenopprettingspassord + Zoomfaktor + Juster størrelsen på tekst og visuelle elementer. \ No newline at end of file diff --git a/app/src/main/res/values-b+no+NO/strings.xml b/app/src/main/res/values-b+no+NO/strings.xml index 172ae6035b..e476019a6c 100644 --- a/app/src/main/res/values-b+no+NO/strings.xml +++ b/app/src/main/res/values-b+no+NO/strings.xml @@ -3,6 +3,7 @@ Om Godta Kopier kontoid + Konto-ID Kontonummer kopiert Kopier din kontoid og del den med vennene dine så de kan sende deg meldinger. Skriv inn Konto-ID @@ -14,6 +15,13 @@ Dette er din Kontoinformasjon. Andre brukere kan skanne den for å starte en samtale med deg. Opprinnelig størrelse Legg til + + Legg til administrator + Legg til administratorer + + Legg til administrator + Skriv inn kontoid-en til brukeren du skal gjøre til administrator.\n\nFor å legge til flere brukere, skriv inn hver kontoid separert med komma. Du kan legge inn opptil 20 konto-ID-er om gangen. + Administratorer kan ikke degraderes eller fjernes fra gruppen. Administratorer kan ikke fjernes. {name} og {count} andre ble forfremmet til Admin. Promoter Administratorer @@ -26,7 +34,9 @@ Kunne ikke promotere {name} i {group_name} Kunne ikke promotere {name} og {count} andre i {group_name} Kunne ikke promotere {name} og {other_name} i {group_name} + Opprykk ikke sendt Admin promotering sendt + Opprykksstatus ukjent Fjern Administratorer Fjern som Administrator Det er ingen administratorer i dette samfunnet. @@ -36,10 +46,40 @@ {name} ble fjernet som Admin. {name} og {count} andre ble fjernet som Admin. {name} og {other_name} ble fjernet som Admin. + + %1$d administrator valgt + %1$d administratorer valgt + + + Sender admin-forfremmelse + Sender admin-forfremmelser + Admin-innstillinger + Du kan ikke endre din egen administratorstatus. For å forlate gruppen, åpne samtaleinnstillingene og velg Forlat gruppe. {name} og {other_name} ble forfremmet til Admin. + Administratorer + Tillat +{count} Anonym + App-ikon + Endre app-ikon og -navn + For å endre app-ikon og -navn må {app_name} lukkes. Varsler vil fortsatt bruke standardikonet og -navnet til {app_name}. + Alternativt app-ikon og navn vises på startskjermen og i app-skuffen. + Det valgte app-ikonet og navnet vises på startskjermen og i applisten. + Ikon og navn + Alternativ app-ikon vises på Hjem-skjermen og i appbiblioteket. Appnavnet vil fortsatt vises som \"{app_name}\". + Bruk alternativt app-ikon + Bruk alternativt app-ikon og navn + Velg alternativt app-ikon + Ikon + Kalkulator + MeetingSE + Nyheter + Notater + Aksjer + Vær + {app_pro}-merke + Automatisk mørkmodus Skjul menylinjen Språk Velg språket for {app_name}. {app_name} vil starte på nytt når du endrer språket. @@ -56,6 +96,7 @@ Zoom inn Zoom ut Vedlegg + Vedlegg Legg til vedlegg Navnløst bildealbum Auto-download vedlegg @@ -117,9 +158,12 @@ Utestengelse mislyktes Oppheving av utestengelse mislyktes Opphev utestengelse av bruker + Skriv inn kontoid-en til brukeren du fjerner utestengelsen for Bruker utestengelse opphevet Bannlys bruker Bruker utestengt + Skriv inn kontoid-en til brukeren du utestenger + Blindet ID Blokker Opphev blokkeringen på denne kontakten for å sende en melding. Ingen blokkerte kontakter @@ -130,6 +174,8 @@ Er du sikker på at du ønsker å fjerne blokkeringen av {name} og {count} andre? Er du sikker på at du ønsker å fjerne blokkeringen av {name} og 1 til? Blokkering opphevet {name} + Vis og administrer blokkerte kontakter. + Fant ingen nettleser for å åpne denne URL-en, prøv heller å kopiere URL-en Ringe {name} ringte deg Du kan ikke starte en ny samtale. Fullfør din nåværende samtale først. @@ -141,20 +187,27 @@ Samtale pågår Inkommende anrop fra {name} Innkommende anrop + Du gikk glipp av en samtale fra {name} fordi du ikke har gitt mikrofontilgang. Tapt anrop Tapt anrop fra {name} Lyd- og videosamtaler krever at varslene er aktivert i systeminnstillingene på enheten din. Samtaletillatelser er påkrevd Du kan aktivere \"Lyd- og videosamtaler\"-tillatelsen i personvernsinnstillingene. + Du kan aktivere tillatelsen \"Lyd- og videosamtaler\" i personvernsinnstillingene. Kobler til på nytt… Ringer... {app_name} Call Samtaler (Beta) Lyd- og videosamtaler Lyd- og videosamtaler (Beta) + IP-adressen din er synlig for samtalepartneren og en {session_foundation}-server når du bruker beta-samtaler. Aktiverer tale- og videosamtaler til og fra andre brukere. Du ringte {name} Du gikk glipp av en samtale fra {name} fordi du ikke har aktivert Talekall og videosamtaler i Personverninnstillinger. + {app_name} trenger tilgang til kameraet ditt for å aktivere videosamtaler, men denne tillatelsen har blitt avslått. Du kan ikke oppdatere kameratilgang under en samtale.\n\nVil du avslutte samtalen nå og aktivere kameratilgang, eller vil du bli påminnet etter samtalen? + Åpne innstillinger og slå på kameratilgang for å tillate tilgang til kameraet. + Under din siste samtale prøvde du å bruke video, men det var ikke mulig fordi kameratilgang tidligere ble avslått. Åpne innstillinger og aktiver kameratilgang for å tillate det. + Kameratilgang kreves Intet kamera funnet Kamera utilgjengelig. Gi kameratilgang @@ -162,7 +215,19 @@ {app_name} trenger kameratilgang for å ta bilder og video, eller skanne QR-koder. {app_name} trenger kameratilgang for å skanne QR-koder Avbryt + Kanseller {pro} + Avbryt på {platform}-nettstedet, ved å bruke {platform_account} du registrerte deg for {pro} med. + Avbryt på {platform_store}-nettstedet, ved å bruke {platform_account} du registrerte deg for {pro} med. + Endre Kunne ikke endre passord + Endre passordet ditt for {app_name}. Lokalt lagrede data vil bli kryptert på nytt med det nye passordet ditt. + Endre innstilling + Sjekker {pro}-status + Sjekker {pro}-statusen din. Du kan fortsette så snart denne sjekken er fullført. + Sjekker {pro}-detaljene dine. Enkelte handlinger på denne siden kan være utilgjengelige til denne sjekken er fullført. + Sjekker {pro}-status... + Sjekker detaljene for {pro}. Du kan ikke fornye før denne sjekken er fullført. + Sjekker {pro}-statusen din. Du kan oppgradere til {pro} når denne sjekken er fullført. Tøm Tøm alle Fjern alle data @@ -178,19 +243,29 @@ Er du sikker på at du vil slette dataene dine fra nettverket? Hvis du fortsetter, vil du ikke kunne gjenopprette meldingene eller kontaktene dine. Er du sikker på at du vil tømme enheten din? Tøm bare enheten + Tøm enhet og start på nytt + Tøm enhet og gjenopprett Fjern alle meldinger Er du sikker på at du vil slette alle meldinger fra samtalen med {name} fra enheten din? + Er du sikker på at du vil slette alle meldinger fra samtalen med {name} på denne enheten? Er du sikker på at du vil slette alle {community_name}-meldinger fra enheten din? + Er du sikker på at du vil slette alle meldinger fra {community_name} på denne enheten? Slett for alle Slett hos meg Er du sikker på at du vil slette alle {group_name}-meldinger? + Er du sikker på at du vil slette alle meldinger fra {group_name}? Er du sikker på at du vil slette alle {group_name}-meldinger fra enheten din? + Er du sikker på at du vil slette alle meldinger fra {group_name} på denne enheten? Er du sikker på at du vil slette alle Note to Self-meldinger fra enheten din? + Er du sikker på at du vil slette alle Note to Self-meldinger på denne enheten? + Tøm på denne enheten Lukk + Lukk appen Lukk vindu Commit Hash: {hash} Dette vil utestenge den valgte brukeren fra dette Community og slette alle deres meldinger. Er du sikker på at du vil fortsette? Dette vil utestenge den valgte brukeren fra dette Community. Er du sikker på at du vil fortsette? + Skriv inn en beskrivelse av samfunnet Angi Samfunnets URL Ugyldig URL Vennligst sjekk Community-URL\'en og prøv igjen. @@ -205,15 +280,23 @@ Du er allerede medlem av dette Community. Forlat nettsamfunn Kunne ikke forlate {community_name} + Skriv inn et navn på samfunnet + Vennligst skriv inn et navn på samfunnet Ukjent Community Samfunnets URL Kopier samfunnets URL Bekreft + Bekreft forfremmelse + Er du sikker? Administratorer kan ikke degraderes eller fjernes fra gruppen. Kontakter Slette kontakt Er du sikker på at du vil slette {name} fra kontaktene dine? Nye meldinger fra {name} vil ankomme som en meldingsforespørsel. Du har ingen kontakter ennå Velg kontakter + + %1$d kontakt valgt + %1$d kontakter valgt + Brukerdetaljer Kamera Velg en handling for å starte en samtale @@ -221,6 +304,7 @@ Meldingsskriving Miniatyrbilde i sitert melding Opprett en samtale med en ny kontakt + Velg innholdet som vises i lokale varsler når en innkommende melding mottas. Legg til på startskjermen Lagt til startskjermen Lydmeldinger @@ -233,12 +317,18 @@ Samtalen slettet Det er ingen meldinger i {conversation_name}. Skriv inn nøkkel + Definer hvordan Enter- og Shift+Enter-tastene fungerer i samtaler. + SHIFT + ENTER sender en melding, ENTER starter en ny linje. + ENTER sender en melding, SHIFT + ENTER starter en ny linje. Grupper Meldingsbeskjæring Trim Samfunn + Slett meldinger eldre enn 6 måneder i samfunn med over 2 000 meldinger. Ny Samtale Du har ingen samtaler ennå + Send med Enter Ved å trykke på Enter nøkkel vil sende melding i stedet for å starte en ny linje. + Send med Shift+Enter Alle medier Stavekontroll Aktiver stavekontroll ved skriving av meldinger. @@ -246,7 +336,14 @@ Kopiert Kopier Opprett + Oppretter samtale + Gjeldende fakturering + Nåværende passord Klipp ut + Mørk modus + Er du sikker på at du vil slette alle meldinger, vedlegg og kontodata fra denne enheten og opprette en ny konto? + En databasefeil oppstod.\n\nEksporter applikasjonsloggene dine for å dele dem ved feilsøking. Hvis dette ikke lykkes, installer {app_name} på nytt og gjenopprett kontoen din. + Er du sikker på at du vil slette alle meldinger, vedlegg og kontodata fra denne enheten og gjenopprette kontoen din fra nettverket? Vi har lagt merke til at {app_name} tar lang tid å starte.\n\nDu kan fortsette å vente, eksportere enhetsloggene for å dele for feilsøking, eller prøve å starte {app_name} på nytt. Database til appen din er inkompatibel med denne versjonen av {app_name}. Installer appen på nytt og gjenopprett kontoen din for å generere en ny database og fortsette å bruke {app_name}.\n\nAdvarsel: Dette vil resultere i tap av alle meldinger og vedlegg eldre enn to uker. Optimaliserer databasen @@ -268,16 +365,34 @@ Vennligst vent mens gruppen opprettes... Kunne ikke oppdatere gruppen Du har ikke tillatelse til å slette andres beskjeder + + Slett valgt vedlegg + Slett valgte vedlegg + + + Er du sikker på at du vil slette det valgte vedlegget? Meldingen som er knyttet til vedlegget vil også bli slettet. + Er du sikker på at du vil slette de valgte vedleggene? Meldingen som er tilknyttet vedleggene vil også bli slettet. + + Er du sikker på at du vil slette {name} fra kontaktene dine?\n\nDette vil slette samtalen din, inkludert alle meldinger og vedlegg. Fremtidige meldinger fra {name} vil vises som en meldingsforespørsel. + Er du sikker på at du vil slette samtalen din med {name}?\nDette vil permanent slette alle meldinger og vedlegg. Slett melding Slett meldinger + + Er du sikker på at du ønsker å slette denne meldingen? + Er du sikker på at du ønsker å slette disse meldingene? + Beskjed slettet Beskjeder slettet Denne meldingen er slettet Denne meldingen ble slettet på denne enheten + + Er du sikker på at du vil slette denne meldingen kun fra denne enheten? + Er du sikker på at du vil slette disse meldingene kun fra denne enheten? + Er du sikker på at du ønsker å slette denne meldingen for alle? Slett bare på denne enheten Slett på alle mine enheter @@ -286,6 +401,10 @@ Klarte ikke å slette melding Klarte ikke å slette meldinger + + Denne meldingen kan ikke slettes fra alle enhetene dine + Noen av meldingene du har valgt kan ikke slettes fra alle enhetene dine + Diese Nachricht kann nicht gelöscht werden Ein paar Nachrichten könnten nicht gelöscht werden @@ -293,6 +412,7 @@ Er du sikker på at du vil slette disse meldingene for alle? Sletter Veksle utviklerverktøy + Enhetens varslingsinnstillinger Start diktering... Tidsbegrensede meldinger Beskjeden vil slettes om {time_large} @@ -328,6 +448,7 @@ {admin_name} oppdaterte innstillingene for forsvinnende meldinger. Du oppdaterte innstillinger for forsvinnende meldinger. Avvis + Skjerm Det kan være ditt ekte navn, et alias, eller hva som helst du liker — og du kan endre det når som helst. Skriv inn visningsnavnet ditt Vennligst skriv inn navnet som skal vises @@ -336,7 +457,11 @@ Velg et nytt visningsnavn Velg ditt visningsnavn Angi visningsnavn + Visningsnavnet ditt er synlig for brukere, grupper og fellesskap du samhandler med. Dokument + Doner + Mektige krefter prøver å svekke personvernet, men vi kan ikke kjempe denne kampen alene.\n\nDonasjoner bidrar til å holde {app_name} sikkert, uavhengig og tilgjengelig. + {app_name} trenger din hjelp Ferdig Last ned Laster ned... @@ -366,22 +491,53 @@ Du og {name} reagerte med {emoji_name} Reagerte på meldingen din {emoji} Aktiver + Vil du aktivere kameratilgang? + Vis varsler når du mottar nye meldinger. + Avslutt samtalen for å aktivere + Liker du {app_name}? + Trenger forbedring {emoji} + Det er flott {emoji} + Du har brukt {app_name} en liten stund, hvordan går det? Vi vil gjerne høre dine tanker. + Enter + Skriv inn passordet du har angitt for {app_name} + Skriv inn passordet du bruker for å låse opp {app_name} ved oppstart, ikke gjenopprettingspassordet ditt + Feil under sjekking av {pro}-status Vennligst sjekk internettforbindelsen din og prøv igjen. Kopier feilmelding og avslutt Databasefeil + Noe gikk galt. Prøv igjen senere. + Feil ved innlasting av tilgang til {pro} + {app_name} kunne ikke søke etter denne ONS-en. Kontroller nettverkstilkoblingen din og prøv igjen. En ukjent feil oppstod. + Denne ONS-en er ikke registrert. Kontroller at den er riktig og prøv igjen. + Kunne ikke sende invitasjon på nytt til {name} i {group_name} + Kunne ikke sende invitasjon på nytt til {name} og {count} andre i {group_name} + Kunne ikke sende invitasjon på nytt til {name} og {other_name} i {group_name} + Kunne ikke sende forfremmelse på nytt til {name} i {group_name} + Kunne ikke sende forfremmelse på nytt til {name} og {count} andre i {group_name} + Kunne ikke sende forfremmelse på nytt til {name} og {other_name} i {group_name} + Kunne ikke laste ned Feil + Tilbakemelding + Del din erfaring med {app_name} ved å fullføre en kort undersøkelse. Fil Filer + Følg systeminnstillinger. + For alltid Fra: Veksle fullskjerm GIF Giphy {app_name} vil koble til Giphy for å gi søkeresultater. Du vil ikke ha full metadatabeskyttelse når du sender GIF-er. + Gi tilbakemelding? + Beklager å høre at opplevelsen din med {app_name} ikke har vært ideell. Vi setter stor pris på om du kan dele dine tanker i en kort undersøkelse. Grupper kan ha maksimalt 100 medlemmer Opprett gruppe Vennligst velg minst ett annet gruppemedlem. Slette gruppe + Er du sikker på at du vil slette {group_name}?\n\nDette vil fjerne alle medlemmer og slette alt gruppeinnhold. + Er du sikker på at du vil slette {group_name}? + {group_name} har blitt slettet av en gruppeadministrator. Du vil ikke kunne sende flere meldinger. Skriv inn en gruppebeskrivelse Gruppens profilbilde ble oppdatert. Rediger gruppe @@ -394,16 +550,28 @@ Kunne ikke invitere {name} og {count} andre til {group_name} Kunne ikke invitere {name} og {other_name} til {group_name} Kunne ikke invitere {name} til {group_name} + Invitasjon ikke sendt + {name} inviterte deg til å bli med igjen i {group_name}, der du er administrator. + Du ble invitert til å bli med igjen i {group_name}, der du er administrator. + + Sender invitasjon + Sender invitasjoner + Invitasjon sendt + Invitasjonsstatus ukjent Gruppeinvitasjon vellykket Brukere må ha den nyeste versjonen for å motta invitasjoner Du ble invitert til å bli med i gruppen. Du og {count} andre ble invitert til å bli med i gruppen. Du og {other_name} ble invitert til å bli med i gruppen. + Du ble invitert til å bli med i gruppen. Chat-historikk ble delt. Forlat gruppe Er du sikker på at du ønsker å forlate {group_name}? Er du sikker på at du vil forlate {group_name}?\n\nDette vil fjerne alle medlemmer og slette alt gruppeinnhold. Kunne ikke forlate {group_name} + {name} ble invitert til å bli med i gruppen. Chat-historikk fra de siste 14 dagene ble delt. + {name} og {count} andre ble invitert til å bli med i gruppen. Chat-historikken fra de siste 14 dagene ble delt. + {name} og {other_name} ble invitert til å bli med i gruppen. Chat-historikken fra de siste 14 dagene ble delt. {name} forlot gruppen. {name} og {count} andre forlot gruppen. {name} og {other_name} forlot gruppen. @@ -414,6 +582,10 @@ {name} og {count} andre ble invitert til å bli med i gruppen. {name} og {other_name} ble invitert til å bli med i gruppen. Du og {count} andre ble invitert til å bli med i gruppen. Chat-historikk ble delt. + Du og {other_name} ble invitert til å bli med i gruppen. Chat-historikk ble delt. + Kunne ikke fjerne {name} fra {group_name} + Kunne ikke fjerne {name} og {count} andre fra {group_name} + Kunne ikke fjerne {name} og {other_name} fra {group_name} Du forlot gruppen. Gruppemedlemmer Det er ingen andre medlemmer i denne gruppen. @@ -423,10 +595,15 @@ Vennligst velg et kortere gruppenavn. Gruppens navn er nå {group_name}. Gruppenavnet ble oppdatert. + Gruppenavnet er synlig for alle gruppemedlemmer. Du har ingen meldinger fra {group_name}. Send en melding for å starte samtalen! + Denne gruppen har ikke blitt oppdatert på over 30 dager. Du kan oppleve problemer med å sende meldinger eller vise gruppeinformasjon. Du er den eneste admin i {group_name}.\n\nGruppemedlemmer og innstillinger kan ikke endres uten en admin. + Du er den eneste administratoren i {group_name}.\n\nGruppemedlemmer og innstillinger kan ikke endres uten en administrator. For å forlate gruppen uten å slette den, legg til en ny administrator først. + Venter på fjerning Du ble forfremmet til Admin. Du og {count} andre ble forfremmet til Admin. + Du og {other_name} ble forfremmet til Admin. Vil du fjerne {name} fra {group_name}? Vil du fjerne {name} og {count} andre fra {group_name}? Vil du fjerne {name} og {other_name} fra {group_name}? @@ -442,37 +619,75 @@ {name} og {count} andre ble fjernet fra gruppen. {name} og {other_name} ble fjernet fra gruppen. Du ble fjernet fra {group_name}. + Du ble fjernet fra gruppen. Du og {count} andre ble fjernet fra gruppen. Du og {other_name} ble fjernet fra gruppen. Angi gruppevisningsbilde Ukjent gruppe Gruppe oppdatert + Behandler tilkoblingskandidater Ofte stilte spørsmål + Se i {app_name} vanlige spørsmål for svar på vanlige spørsmål. Hjelp oss med å oversette {app_name} + Rapporter en bug Del noen opplysninger for å hjelpe oss med å løse problemet ditt. Eksporter loggene dine, og last deretter opp filen gjennom {app_name}s hjelpesenter. Eksportlogger Eksporter logger, deretter last opp filen gjennom {app_name}s hjelpeavdeling. Lagre til skrivebord + Lagre denne filen, og del den deretter med utviklerne av {app_name}. Støtte + Hjelp til med å oversette {app_name} til over 80 språk! Vi ønsker gjerne dine tilbakemeldinger Skjul + Vis eller skjul systemmenylinjen. + Er du sikker på at du vil skjule Note to Self fra samtalelisten din? Skjul andre Bilde + bilder + Viktig Inkognito-tastatur Forespør inkognitomodus hvis tilgjengelig. Avhengig av tastaturet du bruker, kan det hende at tastaturet ignorerer denne forespørselen. Info Ugyldig snarvei + + Inviter kontakt + Inviter kontakter + + + Invitasjon mislyktes + Invitasjoner mislyktes + + + Invitasjonen kunne ikke sendes. Vil du prøve igjen? + Invitasjonene kunne ikke sendes. Vil du prøve på nytt? + + + Inviter medlem + Inviter medlemmer + + Inviter et nytt medlem til gruppen ved å skrive inn vennens kontoid, ONS eller skanne QR-koden deres {icon} + Inviter et nytt medlem til gruppen ved å skrive inn kontoen til vennen din, ONS eller ved å skanne deres QR-kode Bli med Senere + Start {app_name} automatisk når datamaskinen starter. + Start ved oppstart + Denne innstillingen styres av operativsystemet på Linux. For å aktivere automatisk oppstart, legg til {app_name} i oppstartsprogrammene i systeminnstillingene. Lær mer Forlat Forlater... + Denne gruppen er nå skrivebeskyttet. Opprett denne gruppen på nytt for å fortsette samtalen. + Denne gruppen er nå skrivebeskyttet. Be gruppeadministratoren om å opprette denne gruppen på nytt for å fortsette samtalen. + Grupper har blitt oppgradert! Opprett denne gruppen på nytt for forbedret pålitelighet. Denne gruppen blir skrivebeskyttet {date}. + Grupper har blitt oppgradert! Be gruppeadministratoren om å opprette denne gruppen på nytt for forbedret pålitelighet. Denne gruppen blir skrivebeskyttet {date}. + Chatteloggen vil ikke bli overført til den nye gruppen. Du kan fremdeles se hele chatteloggen i den gamle gruppen din. {name} ble med i gruppen. {name} og {count} andre ble med i gruppen. Du og {count} andre ble med i gruppen. Du og {other_name} ble med i gruppen. {name} og {other_name} ble med i gruppen. Du ble med i gruppen. + Begrense bakgrunnsaktivitet? + Du tillater for øyeblikket at {app_name} kjører i bakgrunnen for å forbedre påliteligheten til varslinger. Hvis du endrer denne innstillingen, kan varslinger bli mindre pålitelige. Forhåndsvisning av lenker Vis lenkeforhåndsvisninger for støttede URL-er. Aktiver forhåndsvisning av lenker @@ -483,6 +698,7 @@ Du vil ikke ha full metadatabeskyttelse når du sender lenkeforhåndsvisninger. Lenkeforhåndsvisning deaktivert {app_name} må kontakte koblede nettsteder for å generere forhåndsvisninger av lenker du sender og mottar.\n\nDu kan slå dem på i {app_name}s innstillinger. + Lenker Last inn konto Laster kontoen din Laster... @@ -495,8 +711,17 @@ Lås status Trykk for å låse opp {app_name} er ulåst + Logger + Administrer administratorer + Administrer medlemmer + Administrer {pro} Maks + Kanskje senere Media + + %1$d medlem valgt + %1$d medlemmer valgt + %1$d medlem %1$d medlemmer @@ -506,7 +731,9 @@ %1$d aktive medlemmer Legg til Account ID eller ONS + Medlemmer kan kun bli forfremmet etter at de har akseptert invitasjonen om å bli med i gruppen. Innby kontakter + Du har ingen kontakter å invitere til denne gruppen.\nGå tilbake og inviter medlemmer ved å bruke kontoid eller ONS-en deres. Send invitasjon Send invitasjoner @@ -515,9 +742,14 @@ Vil du dele gruppemeldingshistorikken med {name} og {count} andre? Vil du dele gruppemeldingshistorikken med {name} og {other_name}? Del meldingshistorikk + Del meldingshistorikk fra de siste 14 dagene Del kun nye meldinger Invitere + Medlemmer (ikke-administratorer) + Menylinje Melding + Les mer + Kopier melding Denne meldingen er tom. Levering av melding mislyktes Meldingsgrensen er nådd @@ -532,11 +764,18 @@ Start en ny samtale ved å skrive inn din venns kontoid eller ONS. Start en ny samtale ved å skrive inn din venns kontoid eller ONS eller ved å skanne deres QR-kode. + Start en ny samtale ved å skrive inn vennen din sitt bruker-ID, ONS eller ved å skanne deres QR-kode {icon} Du har fått en ny melding. Du har %1$d nye meldinger. + + Du har fått en ny melding i %1$s. + Du har fått %1$d nye meldinger i %2$s. + Svarer på + Du kan ikke sende vedlegg før meldingsforespørselen din er godtatt + Du kan ikke sende talemeldinger før meldingsforespørselen din er godtatt. {name} inviterte deg til å bli med i {group_name}. Ved å sende en melding til denne gruppen godtar du gruppens invitasjon automatisk. Din meldingsforespørsel står for øyeblikket på vent. @@ -547,6 +786,7 @@ Er du sikker på at du vil slette alle meldingsforespørsler og gruppeinvitasjoner? Samfunnsforespørsler Tillat meldingsforespørsler fra Community-samtaler. + Er du sikker på at du vil slette denne meldingsforespørselen og den tilknyttede kontakten? Er du sikker på at du ønsker å slette denne meldingsforespørselen? Du har en ny meldingsforespørsel Ingen ventende meldingsforespørsler @@ -564,19 +804,38 @@ {author}: {emoji} Talemelding Meldinger Minimer + + Meldinger har en tegngrense på %1$s tegn. Du har %2$d tegn igjen. + Meldinger har en tegngrense på %1$s tegn. Du har %2$d tegn igjen. + + Meldingslengde + Du har overskredet tegngriksen for denne meldingen. Vennligst forkort meldingen til {limit} tegn eller færre. + Meldingen er for lang + Vennligst forkort meldingen din til {limit} tegn eller færre. + Meldingen er for lang + Nytt passord Neste + Neste steg Velg et kallenavn for {name}. Dette vil vises for deg i dine en-til-en-samtaler og gruppesamtaler. Skriv inn et kallenavn + Vennligst skriv inn et kortere kallenavn Fjern kallenavn Angi kallenavn Nei + Det er ingen ikke-administratorer i denne gruppen. Ingen forslag + Send meldinger på opptil 10 000 tegn i alle samtaler. + Organiser chatter med ubegrenset antall festede samtaler. Ingen Ikke nå Egne notater Du har ingen meldinger i Notat til meg selv. Skjul Notat til meg selv Er du sikker på at du ønsker å skjule Note to Self? + VÆR OPPMERKSOM: Ved å {action_type} samtykker du til {app_pro} sine Vilkår for bruk {icon} og Personvernerklæring {icon} + Varslingsvisning + Vis avsenderens navn og en forhåndsvisning av meldingsinnholdet. + Vis kun avsenderens navn uten noe meldingsinnhold. Alle meldinger Varsling innhold Informasjonen som vises i varslinger. @@ -585,7 +844,9 @@ Verken navn eller innhold Rask modus Du vil bli varslet om nye meldinger på en pålitelig måte, og umiddelbart ved hjelp av Googles varslingsservere. + Du vil bli varslet om nye meldinger pålitelig og umiddelbart ved bruk av Huaweis varslingsservere. Du vil bli varslet om nye meldinger, pålitelighet og med en gang ved hjelp av Apple\'s varslingsserver. + Vis et generisk {app_name}-varsel uten avsenderens navn eller meldingsinnhold. Gå til enhetens varslingsinnstillinger Varsler – Alle Varsler – Kun omtaler @@ -593,6 +854,7 @@ {name} til {conversation_name} Det er mulig du har mottatt meldinger mens din {device} startet på nytt. LED-farge + Spill av en lyd når du mottar nye meldinger. Kun omtaler Meldingsvarsel Siste fra {name} @@ -600,6 +862,8 @@ Demp for {time_large} Opphev demp Dempet + Dempet i {time_large} + Dempet til {date_time} Saktemodus {app_name} vil iblant sjekke etter nye meldinger i bakgrunnen. Lyd @@ -612,6 +876,12 @@ Av Okay + På din {device_type}-enhet + Åpne denne {app_name}-kontoen på en {device_type}-enhet som er logget inn med {platform_account}-kontoen du opprinnelig registrerte deg med. Deretter kan du kansellere {pro} via innstillingene i {app_pro}. + Åpne denne {app_name}-kontoen på en {device_type}-enhet som er logget inn med {platform_account}-kontoen du opprinnelig registrerte deg med. Oppdater deretter {pro}-tilgangen din via {app_pro}-innstillingene. + På en tilknyttet enhet + På {platform_store}-nettstedet + På {platform}-nettstedet Opprett konto Konto opprettet Jeg har en konto @@ -636,33 +906,70 @@ Vi kunne ikke gjenkjenne denne ONS. Vennligst sjekk den og prøv igjen. Vi klarte ikke å søke etter denne ONS. Vennligst prøv igjen senere. Åpne + Åpne {platform_store}-nettstedet + Åpne {platform}-nettstedet + Åpne innstillinger + Åpne spørreundersøkelsen Annet + Passord Endre passord + Endre passordet som kreves for å låse opp {app_name}. + Passordet ditt er endret. Vennligst oppbevar det trygt. Bekreft passordet + Opprett passord Ditt nåværende passord er feil. Skriv inn passord Vennligst skriv inn ditt nåværende passord Vennligst skriv inn det nye passordet ditt Passordet kan kun inneholde bokstaver, tall og symboler + Passordet må være mellom {min} og {max} tegn langt Passordene stemmer ikke overens Kunne ikke stille passordet Galt passord + Bekreft nytt passord Fjern passord + Fjern passordet som kreves for å låse opp {app_name} + Passordet ditt har blitt fjernet. Still passord + Passordet ditt er satt. Vennligst oppbevar det trygt. + Krev passord for å låse opp {app_name} ved oppstart. + Lengre enn 12 tegn + Inkluderer et tall + Inkluderer en liten bokstav + Inneholder et symbol + Inkluderer en stor bokstav + Indikator for passordstyrke + Å sette et sterkt passord hjelper med å beskytte meldingene og vedleggene dine hvis enheten din blir mistet eller stjålet. + Passord Lim inn + Betalingsfeil + Betalingen din ble behandlet, men det oppstod en feil ved {action_type} av {pro}-statusen din.\n\nSjekk nettverkstilkoblingen og prøv igjen. + Tillatelsesendring {app_name} trenger tilgang til musikk og lyd for å sende filer, musikk og lyd, men det har blitt permanent nektet. Trykk på Innstillinger → Tillatelser, og slå på «Musikk og lyd». {app_name} må bruke Apple Music for å spille medievedlegg. Automatisk oppdatering Se etter oppdateringer automatisk ved oppstart + Kameratilgang er nødvendig for å foreta videosamtaler. Slå på tillatelsen \"Kamera\" i Innstillinger for å fortsette. + Tilgang til kamera er for øyeblikket aktivert. For å deaktivere det, slå av tillatelsen \"Kamera\" i Innstillinger. {app_name} trenger kameratilgang for å ta bilder og videoer, men det har blitt permanent nektet. Trykk på Innstillinger → Tillatelser, og slå på «Kamera». + Tillat tilgang til kamera for videosamtaler. Skjermlåsfunksjonen på {app_name} bruker Face ID. Behold i systemstatusfeltet + {app_name} fortsetter å kjøre i bakgrunnen når du lukker vinduet. {app_name} trenger tilgang til fotobiblioteket for å fortsette. Du kan aktivere tilgang i iOS-innstillingene. + Tilgang til lokalt nettverk er nødvendig for å kunne foreta samtaler. Slå på tillatelsen \"Lokalt nettverk\" i Innstillinger for å fortsette. + {app_name} må ha tilgang til det lokale nettverket for å kunne foreta tale- og videosamtaler. + Tilgang til lokalnettverk er for øyeblikket aktivert. For å deaktivere det, slå av tillatelsen \"Lokalnettverk\" i Innstillinger. + Tillat tilgang til det lokale nettverket for å muliggjøre tale- og videosamtaler. + Lokalt nettverk Mikrofon {app_name} krever tillatelse fra systemet for å kunne bruke mikrofonen for å ringe og sende talemeldinger, men det har blitt permanent nektet. Trykk på innstillinger → Tillatelser, og slå på «Mikrofon». + Mikrofontilgang er nødvendig for å ringe og spille inn lydmeldinger. Slå på tillatelsen \"Mikrofon\" i Innstillinger for å fortsette. Du kan aktivere mikrofontilgang i {app_name}\'s personverninnstillinger {app_name} trenger mikrofontilgang for å foreta samtaler og ta opp lydmeldinger. + Mikrofontilgang er for øyeblikket aktivert. For å deaktivere den, slå av tillatelsen \"Mikrofon\" i Innstillinger. Gi tilgang til mikrofonen. + Tillat tilgang til mikrofon for taleanrop og lydmeldinger. {app_name} trenger tilgang til musikk og lyd for å sende filer, musikk og lyd. Tillatelse kreves {app_name} trenger tilgang til fotobiblioteket for å kunne sende bilder og videoer, men det har blitt permanent nektet. Trykk på Innstillinger → Tillatelser, og slå på \"Bilder og videoer\". @@ -670,11 +977,174 @@ {app_name} trenger lagringstilgang for å lagre vedlegg og media. {app_name} trenger lagringstilgang for å lagre bilder og videoer, men det har blitt permanent nektet. Fortsett til app-innstillingene, velg «Tillatelser», og aktiver «Lagring». {app_name} trenger lagringstilgang for å sende bilder og videoer. + Du har ikke skrivetillatelser i dette fellesskapet Fest Fest samtale Løsne Løsne samtale + Pluss mye mer... + Nye funksjoner kommer snart til {pro}. Oppdag hva som kommer i {pro}-veikartet {icon} + Innstillinger Forhåndsvisning + Forhåndsvis varsling + Feil ved tilgang til {pro} + Din {pro}-tilgang utløper {date}. + {pro}-tilgang lastes + Informasjonen om {pro}-tilgang lastes fortsatt. Du kan ikke oppdatere før denne prosessen er fullført. + {pro}-tilgang lastes... + Kan ikke koble til nettverket for å laste tilgangsinformasjon for {pro}. Oppdatering av {pro} via {app_name} vil være deaktivert til tilkoblingen er gjenopprettet.\n\nSjekk nettverkstilkoblingen din og prøv igjen. + Fant ikke {pro}-tilgang + {app_name} oppdaget at kontoen din ikke har {pro}-tilgang. Hvis du mener dette er en feil, vennligst kontakt brukerstøtte hos {app_name} for hjelp. + Gjenopprett {pro}-tilgang + Forny {pro}-tilgang + For øyeblikket kan {pro}-tilgang kun kjøpes og fornyes via {platform_store} eller {platform_store_other}. Siden du bruker {app_name} Desktop, kan du ikke fornye her.\n\nUtviklerne av {app_name} jobber hardt med alternative betalingsalternativer som lar brukere kjøpe {pro}-tilgang utenfor {platform_store} og {platform_store_other}. {pro}-Veikart {icon} + Forny {pro}-tilgangen din på {platform_store}-nettstedet ved å bruke {platform_account}-kontoen du brukte da du registrerte deg for {pro}. + Forny på {platform}-nettstedet ved å bruke {platform_account}-kontoen du registrerte {pro} med. + Forny {pro}-tilgangen din for å begynne å bruke de kraftige funksjonene i {app_pro} Beta igjen. + {pro}-tilgang gjenopprettet + {app_name} oppdaget og gjenopprettet {pro}-tilgang for kontoen din. Din {pro}-status er gjenopprettet! + Fordi du opprinnelig registrerte deg for {app_pro} via {platform_store}, må du bruke din {platform_account} for å oppdatere tilgangen din til {pro}. + For øyeblikket kan {pro}-tilgang kun kjøpes via {platform_store} eller {platform_store_other}. Fordi du bruker {app_name} Desktop, kan du ikke oppgradere til {pro} her.\n\nUtviklerne av {app_name} jobber hardt med alternative betalingsløsninger slik at brukere kan kjøpe {pro}-tilgang utenom {platform_store} og {platform_store_other}. Veikart for {pro} {icon} + Aktivert + aktiverer + Alt er klart! + Tilgangen din til {app_pro} ble oppdatert! Du vil bli belastet når {pro} fornyes automatisk den {date}. + Du har allerede + Last opp GIF-er og animerte WebP-bilder som visningsbilde! + Få animerte visningsbilder og lås opp premiumfunksjoner med {app_pro} Beta + Animasjonsvisningsbilde + brukere kan laste opp GIF-er + Animerte visningsbilder + Angi animerte GIF-er og WebP-bilder som visningsbilde. + Last opp GIF-er med + {pro} fornyes automatisk om {time} + {pro}-merke + Vis {app_pro}-merket til andre brukere + Merker + Vis at du støtter {app_name} med et eksklusivt merke ved siden av visningsnavnet ditt. + + %1$s %2$s-merke sendt + %1$s %2$s-merker sendt + + {pro} betafunksjoner + {price} Faktureres årlig + {price} Faktureres månedlig + {price} Faktureres kvartalsvis + Vil du sende lengre meldinger?\nSend mer tekst og lås opp premiumfunksjoner med {app_pro} Beta + Vil du feste flere samtaler?\nOrganiser samtalene dine og lås opp premiumfunksjoner med {app_pro} Beta + Vil du ha mer enn {limit} festede samtaler?\nOrganiser samtalene dine og lås opp premiumfunksjoner med {app_pro} Beta + Det er synd at du kansellerer {pro}. Her er det du bør vite før du avslutter {pro}-tilgangen din. + Oppsigelse + Å avslutte {pro}-tilgang vil forhindre automatisk fornyelse før {pro}-tilgangen utløper. Å avslutte {pro} gir ingen refusjon. Du kan fortsatt bruke {app_pro}-funksjoner til {pro}-tilgangen utløper.\n\nFordi du opprinnelig registrerte deg for {app_pro} med kontoen {platform_account}, må du bruke den samme {platform_account} for å avslutte {pro}. + To måter å avslutte din {pro}-tilgang på: + Ved å kansellere {pro}-tilgangen vil du forhindre automatisk fornyelse før {pro} utløper.\n\nÅ kansellere {pro} gir ikke rett til refusjon. Du vil kunne bruke {app_pro}-funksjonene til tilgangen til {pro} utløper. + Velg det {pro}-abonnementet som passer best for deg.\nLengre tilgang gir større rabatter. + Er du sikker på at du vil slette dataene dine fra denne enheten?\n\n{app_pro} kan ikke overføres til en annen konto. Vennligst lagre gjenopprettingspassordet ditt for å sikre at du kan gjenopprette tilgangen til {pro} senere. + Er du sikker på at du vil slette dataene dine fra nettverket? Hvis du fortsetter, vil du ikke kunne gjenopprette meldinger eller kontakter.\n\n{app_pro} kan ikke overføres til en annen konto. Vennligst lagre gjenopprettingspassordet ditt for å sikre at du kan gjenopprette tilgangen til {pro} senere. + Tilgangen din til {pro} er allerede rabattert med {percent}% av full pris for {app_pro}. + Feil ved oppdatering av {pro}-status + Utløpt + Dessverre har din {pro}-tilgang utløpt.\nForny for å aktivere de eksklusive fordelene og funksjonene i {app_pro} Beta på nytt. + Utløper snart + Din {pro}-tilgang utløper om {time}.\nOppdater nå for å fortsette å bruke de eksklusive fordelene og funksjonene i {app_pro} Beta + {pro} utløper om {time} + {pro} Vanlige spørsmål + Finn svar på vanlige spørsmål i {app_pro} FAQ. + Last opp GIF- og WebP-visningsbilder + Større gruppesamtaler med opptil 300 medlemmer + Og mange flere eksklusive funksjoner + Meldinger på opptil 10 000 tegn + Fest ubegrenset med samtaler + Vil du bruke {app_name} til sitt fulle potensial?\nOppgrader til {app_pro} Beta for å få tilgang til en mengde eksklusive fordeler og funksjoner. + Gruppe aktivert + Denne gruppen har utvidet kapasitet! Den støtter opptil 300 medlemmer fordi en gruppeadministrator har + + %1$s gruppe oppgradert + %1$s grupper oppgradert + + Forespørsel om refusjon er endelig. Hvis den godkjennes, vil tilgangen din til {pro} bli kansellert umiddelbart, og du mister tilgang til alle {pro}-funksjoner. + Økt vedleggsstørrelse + Økt meldingslengde + Større grupper + Grupper der du er administrator oppgraderes automatisk til å støtte 300 medlemmer. + Større gruppechatter (opptil 300 medlemmer) kommer snart for alle Pro Beta-brukere! + Lengre meldinger + Du kan sende meldinger på opptil 10 000 tegn i alle samtaler. + + %1$s lengre melding sendt + %1$s lengre meldinger sendt + + Denne meldingen brukte følgende {app_pro}-funksjoner: + Med en ny installasjon + Installer {app_name} på nytt på denne enheten via {platform_store}, gjenopprett kontoen din med gjenopprettingspassordet, og forny {pro} fra innstillingene i {app_pro}. + Installer {app_name} på nytt på denne enheten via {platform_store}, gjenopprett kontoen din med Gjenopprettingspassordet ditt, og oppgrader til {pro} fra innstillingene i {app_pro}. + Foreløpig finnes det tre måter å fornye på: + Foreløpig finnes det to måter å fornye på: + {percent}% rabatt + + %1$s festet samtale + %1$s festede samtaler + + Fordi du opprinnelig registrerte deg for {app_pro} via {platform_store}, må du bruke din {platform_account} for å be om refusjon. + Fordi du opprinnelig registrerte deg for {app_pro} via {platform_store}, vil refusjonsforespørselen din behandles av {app_name}-støtte.\n\nBe om refusjon ved å trykke på knappen nedenfor og fylle ut refusjonsskjemaet.\n\n{app_name}-støtte tilstreber å behandle refusjonsforespørsler innen 24–72 timer, men behandlingstiden kan være lengre ved stor pågang. + Tilgangen til {app_pro} er fornyet! Takk for at du støtter {network_name}. + 1 måned – {monthly_price} / måned + 3 måneder – {monthly_price} / måned + 12 måneder – {monthly_price} / måned + aktiverer på nytt + Åpne denne {app_name}-kontoen på en {device_type}-enhet som er logget inn med {platform_account}-kontoen du opprinnelig registrerte deg med. Be deretter om refusjon via {app_pro}-innstillingene. + Vi er lei for at du forlater oss. Her er hva du bør vite før du ber om refusjon. + {platform} behandler nå refusjonsforespørselen din. Dette tar vanligvis 24–48 timer. Avhengig av avgjørelsen deres, kan du se at {pro}-statusen din endres i {app_name}. + Refusjonsforespørselen din vil bli behandlet av {app_name} kundestøtte.\n\nSend inn en forespørsel ved å trykke på knappen nedenfor og fylle ut refusjonsskjemaet.\n\nSelv om {app_name} kundestøtte tilstreber å behandle refusjonsforespørsler innen 24–72 timer, kan det ta lenger tid ved høyt forespørselsvolum. + Refusjonsforespørselen din vil utelukkende bli behandlet av {platform} via {platform}-nettstedet.\n\nPå grunn av {platform}s refusjonsretningslinjer har utviklerne av {app_name} ingen mulighet til å påvirke resultatet av refusjonsforespørsler. Dette inkluderer om forespørselen blir godkjent eller avslått, samt om du får full eller delvis refusjon. + Ta kontakt med {platform} for oppdateringer om refusjonsforespørselen din. På grunn av refusjonspolicyene til {platform}, har ikke utviklerne av {app_name} mulighet til å påvirke utfallet av refusjonsforespørsler.\n\n{platform} Refusjonsstøtte + Refunderer {pro} + Refusjoner for {app_pro} håndteres utelukkende av {platform} via {platform_store}.\n\nPå grunn av refusjonspolicyene til {platform}, har ikke utviklerne av {app_name} mulighet til å påvirke utfallet av refusjonsforespørsler. Dette inkluderer hvorvidt forespørselen blir godkjent eller avslått, samt om det gis full eller delvis refusjon. + Vil du bruke animerte visningsbilder igjen?\nForny {pro}-tilgangen din for å låse opp funksjonene du har gått glipp av. + Forny {pro} Beta + Forny {pro}-tilgangen din fra {app_pro}-innstillingene på en tilkoblet enhet med {app_name} installert via {platform_store} eller {platform_store_other}. + Vil du sende lengre meldinger igjen?\nForny {pro}-tilgangen din for å låse opp funksjonene du har gått glipp av. + Vil du bruke {app_name} til sitt fulle potensial igjen?\nForny {pro}-tilgangen din for å låse opp funksjonene du har gått glipp av. + Vil du feste mer enn {limit} samtaler igjen?\nForny {pro}-tilgangen din for å låse opp funksjonene du har gått glipp av. + Vil du feste flere samtaler igjen?\nForny {pro}-tilgangen din for å låse opp funksjonene du har gått glipp av. + Ved å fornye godtar du {app_pro} sine Vilkår for tjenesten {icon} og Personvernerklæring {icon} + fornyer + For øyeblikket kan {pro}-tilgang bare kjøpes og fornyes via {platform_store} eller {platform_store_other}. Fordi du installerte {app_name} ved bruk av {build_variant}, kan du ikke fornye her.\n\nUtviklerne av {app_name} jobber hardt med alternative betalingsalternativer for å gjøre det mulig å kjøpe {pro}-tilgang utenfor {platform_store} og {platform_store_other}. Veikart for {pro} {icon} + Refusjon forespurt + Send mer med + {pro}-innstillinger + Start å bruke {pro} + Dine {pro}-statistikker + {pro}-statistikk lastes inn + {pro}-statistikken din lastes inn, vennligst vent. + {pro}-statistikkene gjenspeiler bruken på denne enheten og kan se annerledes ut på tilknyttede enheter. + Feil med {pro}-status + Kan ikke koble til nettverket for å sjekke {pro}-statusen din. Informasjonen som vises på denne siden kan være unøyaktig til forbindelsen er gjenopprettet.\n\nSjekk nettverkstilkoblingen din og prøv igjen. + {pro}-status lastes + Informasjonen om {pro} lastes inn. Enkelte handlinger på denne siden kan være utilgjengelige inntil innlastingen er fullført. + Laster inn {pro}-status + Kan ikke koble til nettverket for å sjekke {pro}-statusen din. Du kan ikke fortsette før tilkoblingen er gjenopprettet.\n\nVennligst sjekk nettverkstilkoblingen din og prøv igjen. + Kan ikke koble til nettverket for å sjekke {pro}-statusen din. Du kan ikke oppgradere til {pro} før tilkoblingen er gjenopprettet.\n\nSjekk nettverkstilkoblingen din og prøv igjen. + Kan ikke koble til nettverket for å oppdatere {pro}-statusen din. Enkelte handlinger på denne siden vil være deaktivert til tilkoblingen er gjenopprettet.\n\nSjekk nettverkstilkoblingen din og prøv igjen. + Kan ikke koble til nettverket for å laste inn din nåværende {pro}-tilgang. Fornyelse av {pro} via {app_name} vil være deaktivert til tilkoblingen er gjenopprettet.\n\nVennligst kontroller nettverksforbindelsen og prøv igjen. + Trenger du hjelp med {pro}? Send en forespørsel til brukerstøtten. + Ved å oppdatere godtar du {app_pro} sine Vilkår for tjenesten {icon} og Personvernerklæring {icon} + Ubegrenset antall festede meldinger + Organiser alle samtalene dine med ubegrensede festede samtaler. + Fakturering nåværende valg gir deg {current_plan_length} med tilgang til {pro}. Er du sikker på at du vil bytte til faktureringsvalget {selected_plan_length_singular}?\n\nVed oppdatering fornyes din tilgang til {pro} automatisk den {date} for ytterligere {selected_plan_length} med {pro}-tilgang. + Tilgangen din til {pro} utløper {date}.\n\nVed oppdatering fornyes din tilgang til {pro} automatisk den {date} for ytterligere {selected_plan_length} med {pro}-tilgang. + oppdaterer + Oppgrader til {app_pro} Beta for å få tilgang til mange eksklusive fordeler og funksjoner. + Oppgrader til {pro} fra {app_pro}-innstillingene på en tilkoblet enhet med {app_name} installert via {platform_store} eller {platform_store_other}. + For øyeblikket kan {pro}-tilgang kun kjøpes via {platform_store} eller {platform_store_other}. Fordi du installerte {app_name} ved bruk av {build_variant}, kan du ikke oppgradere til {pro} her.\n\nUtviklerne av {app_name} jobber hardt med alternative betalingsløsninger slik at brukere kan kjøpe {pro}-tilgang utenom {platform_store} og {platform_store_other}. Veikart for {pro} {icon} + Foreløpig finnes det bare én måte å oppgradere på. + Foreløpig finnes det to måter å oppgradere på: + Du har oppgradert til {app_pro}!\nTakk for at du støtter {network_name}. + oppgraderer + Oppgraderer til {pro} + Ved å oppgradere godtar du {app_pro} sine Vilkår for tjenesten {icon} og Personvernerklæring {icon} + Vil du få mer ut av {app_name}?\nOppgrader til {app_pro} Beta for en kraftigere meldingsopplevelse. + {platform} behandler refusjonsforespørselen din Profil Display Picture Kunne ikke fjerne visningsbildet. @@ -682,6 +1152,19 @@ Vennligst velg en mindre fil. Klarte ikke å oppdatere profilen. Promoter + Administratorer vil kunne se de siste 14 dagene med meldingshistorikk og kan ikke degraderes eller fjernes fra gruppen. + + Forfrem medlem + Forfrem medlemmer + + + Forfremmelse mislyktes + Forfremmelser mislyktes + + + Forfremmelsen kunne ikke brukes. Vil du prøve på nytt? + Forfremmelser kunne ikke brukes. Vil du prøve på nytt? + QR-kode Denne QR-koden inneholder ikke en Kontoinformasjon Denne QR-koden inneholder ikke et gjenopprettingspassord @@ -690,15 +1173,22 @@ Venner kan sende deg meldinger ved å skanne din QR-kode. Avslutt {app_name} Avslutt + Vurdere {app_name}? + Vurder appen + Så bra at du liker {app_name}. Hvis du har litt tid, hjelper en vurdering oss i {storevariant} med å gjøre privat og sikker meldingsutveksling kjent for andre! Les Lesekvitteringer Vis lese kvitteringer for alle meldinger du sender og mottar. Mottatt: Eingegangene Antwort + Mottar samtaletilbud + Mottar forhåndstilbud Anbefalt Lagre gjenopprettingspassordet ditt for å sikre at du ikke mister tilgang til kontoen din. Lagre gjenopprettingspassordet ditt + Bruk gjenopprettingspassordet ditt til å laste inn kontoen din på nye enheter.\n\nKontoen din kan ikke gjenopprettes uten dette passordet. Sørg for at det er lagret på et trygt og sikkert sted — og ikke del det med noen. Skriv inn gjenopprettingspassordet ditt + Det oppstod en feil ved innlasting av gjenopprettingspassordet ditt.\n\nVennligst eksporter loggene dine, og last deretter opp filen via {app_name} kundestøtte for å bidra til å løse dette problemet. Vennligst sjekk gjenopprettingspassordet ditt og prøv igjen. Noen av ordene i gjenopprettingspassordet ditt er feil. Vennligst sjekk og prøv igjen. Gjenopprettingspassordet du skrev inn er ikke langt nok. Vennligst sjekk og prøv igjen. @@ -706,19 +1196,69 @@ For å laste inn kontoen din, skriv inn gjenopprettingspassordet ditt. Skjul Recovery Password permanent Uten gjenopprettingspassordet ditt kan du ikke laste inn kontoen din på nye enheter. \n\nVi anbefaler sterkt at du lagrer gjenopprettingspassordet ditt på et trygt og sikkert sted før du fortsetter. + Er du sikker på at du vil skjule gjenopprettingspassordet ditt permanent på denne enheten?\n\nDette kan ikke angres. Skjul Recovery Password Skjul gjenopprett passord permanent på denne enheten. Skriv inn gjenopprettingspassordet ditt for å laste kontoen din. Hvis du ikke har lagret det, finner du det i appinnstillingene dine. + Vis gjenopprettingspassord + Synlighet for gjenopprettingspassord Dette er gjenopprettingspassordet ditt. Hvis du sender det til noen vil de ha full tilgang til kontoen din. + Opprett gruppe på nytt Gjenta + Fordi du opprinnelig registrerte deg for {app_pro} via en annen {platform_account}, må du bruke den {platform_account} for å oppdatere {pro}-tilgangen din. + To måter å be om refusjon på: + Reduser meldingslengden med {count} + + %1$d tegn igjen + %1$d tegn igjen + + Påminn meg senere Fjern + + Fjern medlem + Fjern medlemmer + + + Fjern medlem og meldingene deres + Fjern medlemmer og meldingene deres + Kunne ikke fjerne passord + Fjern gjeldende passord for {app_name}. Lokalt lagrede data vil bli kryptert på nytt med en tilfeldig generert nøkkel som lagres på enheten din. + + Fjerner medlem + Fjerner medlemmer + + Forny + Fornyer {pro} Svar + Be om refusjon + Be om refusjon på {platform}-nettstedet, ved å bruke {platform_account} som du registrerte deg for {pro} med. Send på nytt + + Send invitasjon på nytt + Send invitasjoner på nytt + + + Send forfremmelse på nytt + Send kampanjer på nytt + + + Sender invitasjon på nytt + Sender invitasjoner på nytt + + + Sender kampanje på nytt + Sender kampanjer på nytt + Laster inn landinformasjon... Start på nytt Resynk Prøv på nytt + Vurderingsgrense + Det virker som du nylig har vurdert {app_name}, takk for tilbakemeldingen! + Kjør app i bakgrunnen + Kjøre {app_name} i bakgrunnen? + Siden du bruker treg modus, anbefaler vi at du lar {app_name} kjøre i bakgrunnen for å forbedre varsler. Dette kan gi mer konsistente varsler, selv om systemet ditt fortsatt kan begrense bakgrunnsaktivitet automatisk.\n\nDu kan endre dette senere i Innstillinger. Lagre Lagret Lagrede meldinger @@ -727,6 +1267,8 @@ Skjermsikkerhet Skjermbilde varsler Krev varsel når en kontakt tar et skjermbilde av en en-til-en chat. + Skjul {app_name}-vinduet i skjermbilder tatt på denne enheten. + Beskyttelse mot skjermbilder {name} tok et skjermbilde. Søk Søk etter kontakter @@ -742,8 +1284,15 @@ Søker... Velg Velg alle + Velg app-ikon Send Sender + Sender samtaletilbud + Sender tilkoblingskandidater + + Sender admin-forfremmelse + Sender admin-forfremmelser + Sendt: Utseende Fjern data @@ -751,55 +1300,117 @@ Hjelp Inviter en venn Meldingsforespørsler + Nåværende {token_name_short}-pris + Meldinger sendes via {network_name}. Nettverket består av noder som får insentiver i {token_name_long}, noe som holder {app_name} desentralisert og sikkert. Les mer {icon} + Lær om staking + Markedsverdi + {app_name}-noder som sikrer meldingene dine + {app_name}-noder i svermen din + {token_name_long} er lansert! Utforsk den nye {network_name}-delen i Innstillinger for å lære hvordan {token_name_long} driver Session. + Nettverk sikret av + Når du staker {token_name_long} for å sikre nettverket, tjener du belønninger i {token_name_short} fra {staking_reward_pool}. + Ny Varsler Tillatelser Personvern + {app_pro} Beta Recovery Password Innstillinger Bli med i nettsamfunn + Angi visningsbilde for fellesskap + Angi et passord for {app_name}. Lokalt lagrede data vil bli kryptert med dette passordet. Du vil bli bedt om å skrive inn dette passordet hver gang {app_name} startes. + Kan ikke oppdatere innstilling Du må starte {app_name} på nytt for at dine nye innstillinger skal tre i kraft. Skjermsikkerhet + Oppstart Del Inviter vennen din til å chatte med deg på {app_name} ved å dele din Account ID med dem. Del med vennene dine der du vanligvis snakker med dem — og flytt deretter samtalen hit. Det er et problem med å åpne databasen. Start appen på nytt og prøv igjen. + Obs! Det ser ut til at du ikke har en {app_name}-konto ennå.\n\nDu må opprette en i {app_name}-appen før du kan dele. + Vil du dele gruppemeldingshistorikken med denne brukeren? Del til {app_name} + Beklager, {app_name} støtter kun deling av flere bilder og videoer samtidig + Deling støtter kun medier. Ikke-mediefiler er utelatt Vis Vis alle Vis mindre + Vis Notat til meg selv + Er du sikker på at du vil vise Notat til meg selv i samtalelisten? + Stavekontroll Klistremerker + Styrke + Har du problemer? Utforsk hjelpeartiklene eller opprett en sak med {app_name} kundestøtte. Gå til støttesiden Systeminformasjon: {information} + Trykk for å prøve på nytt Fortsett Forvalgt Feil + Tilbake + Temaforhåndsvisning + Konto-ID-en til {name} er synlig basert på tidligere interaksjoner + Blindrede ID-er brukes i fellesskap for å redusere søppelpost og øke personvernet. + Oversett + Systemkurv Prøv igjen Skriverindikatorer Se og del inn indikatorer. + Ikke tilgjengelig Angre Ukjent + Ikke støttet CPU + Oppdater + Oppdater {pro}-tilgang + To måter å oppdatere {pro}-tilgangen din på: Oppdateringer + Oppdater samfunnsinformasjon + Samfunnsnavn og beskrivelse er synlige for alle medlemmer av samfunnet + Skriv inn en kortere samfunnsbeskrivelse + Skriv inn et kortere navn på samfunnet Oppdatering installert, klikk for å starte på nytt Laster ned oppdatering: {percent_loader}% Kan ikke oppdatere {app_name} klarte ikke å oppdatere. Vennligst gå til {session_download_url} og installer den nye versjonen manuelt, og kontakt vår kundestøtte for å informere oss om dette problemet. + Oppdater gruppeinformasjon + Gruppenavn og beskrivelse er synlige for alle gruppemedlemmer. + Vennligst oppgi en kortere gruppetekst En ny versjon av {app_name} er tilgjengelig, trykk for å oppdatere + En ny versjon ({version}) av {app_name} er tilgjengelig. + Oppdater profilinformasjon + Visningsnavnet ditt og visningsbildet ditt er synlige i alle samtaler. Gå til utgivelsesmerknader {app_name} Oppdatering Versjon {version} + Sist oppdatert for {relative_time} siden + Oppdateringer + Oppdaterer... + Oppgrader + Oppgrader {app_name} + Oppgrader til Laster opp Kopier URL Åpne URL Dette vil åpne i nettleseren din. Er du sikker på at du ønsker å åpne denne URL-en i nettleseren din?\n\n{url} + Lenker åpnes i nettleseren din. Bruk rask modus + Endre abonnementet ditt ved å bruke {platform_account}-kontoen du registrerte deg med, via {platform}-nettstedet. + Via {platform}-nettstedet Video Kan ikke spille av video. Vis + Vis mindre + Vis mer Dette kan ta noen minutter. Eitt øyeblikk... Advarsel + Støtten for iOS 15 er avsluttet. Oppdater til iOS 16 eller nyere for å fortsette å motta appoppdateringer. Vindu Ja Du + CPU-en din støtter ikke SSE 4.2-instruksjoner, som kreves av {app_name} på Linux x64-operativsystemer for å behandle bilder. Oppgrader til en kompatibel CPU eller bruk et annet operativsystem. + Ditt gjenopprettingspassord + Zoomfaktor + Juster størrelsen på tekst og visuelle elementer. \ No newline at end of file diff --git a/app/src/main/res/values-b+pl+PL/strings.xml b/app/src/main/res/values-b+pl+PL/strings.xml index 168d526e5c..8bad8bb1c7 100644 --- a/app/src/main/res/values-b+pl+PL/strings.xml +++ b/app/src/main/res/values-b+pl+PL/strings.xml @@ -15,7 +15,15 @@ To Twój identyfikator konta. Inni użytkownicy mogą go zeskanować, aby rozpocząć z Tobą rozmowę. Rzeczywisty rozmiar Dodaj + + Dodaj administratora + Dodaj administratorów + Dodaj administratorów + Dodaj administratorów + + Dodaj administratora Wprowadź identyfikator konta użytkownika, którego chcesz awansować na administratora.\n\nAby dodać wielu użytkowników, wpisz każdy identyfikator konta oddzielone przecinkiem. Można jednocześnie podać maksymalnie 20 identyfikatorów kont. + Administratorów nie można zdegradować ani usunąć z grupy. Nie można usuwać administratorów. {name} i {count} innych zostali awansowani na administratów. Awansuj administratorów @@ -40,6 +48,12 @@ Usunięto z roli administratora: {name}. {name} i {count} innych użytkowników nie są już administratorami. Użytkownicy {name} i {other_name} nie są już administratorami. + + Wybrano %1$d administratora + Wybrano %1$d administratorów + Wybrano %1$d administratorów + Wybrano %1$d administratorów + Wysyłanie promocji administratora Wysyłanie promocji administratora @@ -47,7 +61,10 @@ Wysyłanie promocji administratora Ustawienia administratora + Nie możesz zmienić swojego statusu administratora. Aby opuścić grupę, otwórz ustawienia konwersacji i wybierz opcję \"Opuść grupę\". Użytkownicy {name} i {other_name} zostali administratorami. + Administratorzy + Zezwól +{count} Anonim Ikona aplikacji @@ -164,6 +181,7 @@ Czy na pewno chcesz odblokować użytkownika {name} i jedną inną osobę? Odblokowano {name} Przeglądaj i zarządzaj zablokowanymi kontaktami. + Nie znaleziono przeglądarki do otwarcia tego adresu URL, spróbuj zamiast tego go skopiować Zadzwoń {name} dzwonił(a) do Ciebie Nie można rozpocząć nowego połączenia. Najpierw należy zakończyć bieżące połączenie. @@ -192,6 +210,10 @@ Umożliwia wykonywanie połączeń głosowych i wideo do i od innych użytkowników. Zadzwoniono do {name} Nie odebrano połączenia od użytkownika {name}, ponieważ nie włączono funkcji „Połączenia głosowe i wideo” w ustawieniach prywatności. + {app_name} potrzebuje dostępu do kamery, aby umożliwić połączenia wideo, ale to uprawnienie zostało wcześniej odrzucone. Nie można zmienić dostępu do kamery podczas połączenia.\n\nCzy chcesz zakończyć teraz połączenie, aby włączyć dostęp do kamery, czy wolisz przypomnienie po zakończeniu rozmowy? + Aby zezwolić na dostęp do kamery, otwórz ustawienia i włącz uprawnienie do korzystania z kamery. + Podczas ostatniego połączenia próbowałeś użyć wideo, ale nie było to możliwe, ponieważ wcześniej odmówiono dostępu do kamery. Aby zezwolić na dostęp do kamery, otwórz ustawienia i włącz uprawnienie do korzystania z kamery. + Wymagany dostęp do aparatu Nie znaleziono aparatu Aparat jest niedostępny Przyznaj dostęp do aparatu @@ -199,9 +221,19 @@ Aby robić zdjęcia, nagrywać filmy i skanować kody QR, aplikacja {app_name} potrzebuje dostępu do aparatu Aby skanować kody QR, aplikacja {app_name} wymaga dostępu do aparatu Anulowanie + Anuluj {pro} + Anuluj na stronie {platform}, korzystając z konta {platform_account}, którego użyto do rejestracji w {pro}. + Anuluj na stronie {platform_store}, korzystając z konta {platform_account}, którego użyto do rejestracji w {pro}. Zmień Nie udało się zmienić hasła Zmień hasło dla {app_name}. Dane przechowywane lokalnie zostaną ponownie zaszyfrowane nowym hasłem. + Zmień ustawienie + Sprawdzanie statusu {pro} + Sprawdzamy Twój status {pro}. Będziesz mógł kontynuować, gdy sprawdzanie zostanie zakończone. + Trwa sprawdzanie Twoich danych {pro}. Niektóre działania na tej stronie mogą być niedostępne, dopóki kontrola nie zostanie zakończona. + Sprawdzanie statusu {pro}... + Trwa sprawdzanie danych {pro}. Nie można odnowić subskrypcji przed zakończeniem tej weryfikacji. + Trwa sprawdzanie Twojego statusu {pro}. Będziesz mógł zaktualizować do {pro}, gdy to sprawdzenie się zakończy. Wyczyść Wyczyść wszystko Wyczyść wszystkie dane @@ -262,11 +294,19 @@ Adres URL społeczności Kopiuj adres URL społeczności Potwierdź + Potwierdź awans + Czy na pewno? Administratorów nie można zdegradować ani usunąć z grupy. Kontakty Usuń kontakt Czy na pewno chcesz usunąć z konktaktów użytkownika {name}? Nowe wiadomości od użytkownika {name} będą pojawiać się jako prośby o wiadomość. Nie masz jeszcze żadnych kontaktów Wybierz kontakty + + Wybrano %1$d kontakt + Wybrano %1$d kontakty + Wybrano %1$d kontaktów + Wybrano %1$d kontaktów + Szczegóły użytkownika Aparat Aby rozpocząć rozmowę, wybierz akcję @@ -307,6 +347,7 @@ Kopiuj Utwórz Tworzenie połączenia + Bieżące rozliczenie Obecne hasło Wytnij Tryb ciemny @@ -334,6 +375,18 @@ Proszę czekać na utworzenie grupy… Nie udało się zaktualizować grupy Nie masz uprawnień do usuwania wiadomości innych osób + + Usuń wybrany załącznik + Usuń wybrane załączniki + Usuń wybrane załączniki + Usuń wybrane załączniki + + + Czy na pewno chcesz usunąć wybrany załącznik? Wiadomość powiązana z załącznikiem również zostanie usunięta. + Czy na pewno chcesz usunąć wybrane załączniki? Wiadomość powiązana z tymi załącznikami również zostanie usunięta. + Czy na pewno chcesz usunąć wybrane załączniki? Wiadomość powiązana z tymi załącznikami również zostanie usunięta. + Czy na pewno chcesz usunąć wybrane załączniki? Wiadomość powiązana z tymi załącznikami również zostanie usunięta. + Czy na pewno chcesz usunąć {name} ze swoich kontaktów?\n\nSpowoduje to usunięcie konwersacji, w tym wszystkich wiadomości i załączników. Przyszłe wiadomości od {name} będą wyświetlane jako prośba o wiadomość. Czy na pewno chcesz usunąć swoją rozmowę z {name}?\nSpowoduje to trwałe usunięcie wszystkich wiadomości i załączników. @@ -387,6 +440,7 @@ Czy na pewno chcesz usunąć te wiadomości u wszystkich? Usuwanie Narzędzia dla programistów + Ustawienia powiadomień urządzenia Rozpocznij dyktowanie... Znikające wiadomości Wiadomość zostanie usunięta za {time_large} @@ -422,6 +476,7 @@ {admin_name} zaktualizował(a) ustawienia znikających wiadomości. Zaktualizowano ustawienia znikających wiadomości. Wysłane wiadomości nie będą już znikały. Odrzuć + Wyświetlanie Może to być Twoje prawdziwe imię, pseudonim lub cokolwiek innego – nazwę możesz zmienić w dowolnym momencie. Wprowadź swoją nazwę wyświetlaną Wprowadź nazwę wyświetlaną @@ -433,6 +488,8 @@ Wyświetlana nazwa użytkownika jest widoczna dla użytkowników, grup i społeczności, z którymi użytkownik wchodzi w interakcje. Dokument Wspomóż + Potężne siły próbują osłabić prywatność, ale nie możemy prowadzić tej walki sami.\n\nTwoja darowizna pomoże utrzymać {app_name} jako bezpieczną, niezależną i działającą aplikację. + {app_name} potrzebuje Twojej pomocy Gotowe Pobierz Pobieranie... @@ -464,16 +521,31 @@ Ty i użytkownik {name} zareagowaliście za pomocą: {emoji_name} Zareagowano na Twoją wiadomość {emoji} Włącz + Włączyć dostęp do kamery? Wysyłaj powiadomienia o nowych wiadomościach. + Zakończ połączenie, aby włączyć Podoba Ci się {app_name}? Wymaga poprawek {emoji} Jest świetnie {emoji} Korzystasz z {app_name} już od jakiegoś czasu, jak Ci się podoba? Będziemy bardzo wdzięczni za Twoją opinię. + Wejdź + Wprowadź hasło ustawione dla {app_name} + Wprowadź hasło używane do odblokowania {app_name} przy uruchomieniu, a nie hasło odzyskiwania + Błąd podczas sprawdzania statusu {pro} Sprawdź swoje połączenie z internetem i spróbuj ponownie. Skopiuj błąd i zakończ Błąd bazy danych Coś poszło nie tak. Spróbuj ponownie później. + Błąd podczas ładowania dostępu do {pro} + {app_name} nie był w stanie wyszukać tego ONS. Sprawdź połączenie z siecią i spróbuj ponownie. Wystąpił nieznany błąd. + Ten ONS nie jest zarejestrowany. Sprawdź poprawność i spróbuj ponownie. + Nie udało się ponownie wysłać zaproszenia do {name} w grupie {group_name} + Nie udało się ponownie wysłać zaproszenia do {name} i {count} innych w grupie {group_name} + Nie udało się ponownie wysłać zaproszenia do {name} i {other_name} w grupie {group_name} + Nie udało się wysłać ponownie promocji do {name} w {group_name} + Nie udało się wysłać ponownie promocji do {name} i {count} innych w {group_name} + Nie udało się wysłać ponownie promocji do {name} i {other_name} w {group_name} Nie udało się pobrać Awarie Feedback @@ -529,6 +601,9 @@ Czy na pewno chcesz opuścić grupę {group_name}? Czy na pewno chcesz opuścić grupę {group_name}?\n\nUsunie to wszystkich jej członków i całą zawartość grupy. Nie udało się opuścić grupy {group_name} + Użytkownik {name} został zaproszony do dołączenia do grupy. Udostępniono historię wiadomości z ostatnich 14 dni. + Użytkownicy {name} oraz {count} innych zostali zaproszeni do dołączenia do grupy. Udostępniono historię wiadomości z ostatnich 14 dni. + Użytkownicy {name} i {other_name} zostali zaproszeni do dołączenia do grupy. Udostępniono historię wiadomości z ostatnich 14 dni. {name} opuścił(a) grupę. {name} i {count} innych użytkowników opuścili grupę. Użytkownicy {name} i {other_name} opuścili grupę. @@ -540,6 +615,9 @@ Użytkownicy {name} oraz {other_name} zostali zaproszeni do grupy. Ty i {count} innych użytkowników zostaliście zaproszeni do grupy. Udostępniono historię czatu. Ty i {other_name} zostaliście zaproszeni do grupy. Historia czatu została udostępniona. + Nie udało się usunąć {name} z grupy {group_name} + Nie udało się usunąć {name} i {count} innych z grupy {group_name} + Nie udało się usunąć {name} i {other_name} z grupy {group_name} Opuszczasz grupę. Członkowie grupy W grupie nie ma innych członków. @@ -553,6 +631,7 @@ Brak wiadomości w grupie {group_name}. Wyślij wiadomość, aby rozpocząć rozmowę! Ta grupa nie była aktualizowana od ponad 30 dni. Mogą wystąpić problemy z wysyłaniem wiadomości lub wyświetlaniem informacji o grupie. Jesteś jedynym administratorem grupy {group_name}.\n\nBez administratora nie można zmieniać członków grupy ani ustawień. + Jesteś jedynym administratorem grupy {group_name}.\n\nCzłonkowie grupy i ustawienia nie mogą zostać zmienieni bez administratora. Aby opuścić grupę bez jej usuwania, dodaj najpierw nowego administratora. Oczekuje na usunięcie Zostajesz administratorem. Ty i {count} innych użytkowników zostaliście administratorami. @@ -606,6 +685,12 @@ Zażądaj trybu prywatnego, jeśli jest dostępny. W zależności od używanej klawiatury może ona zignorować to żądanie. Informacje Nieprawidłowy skrót + + Zaproś kontakt + Zaproś kontakty + Zaproś kontakty + Zaproś kontakty + Zaproszenie nieudane Zaproszenia nieudane @@ -618,8 +703,19 @@ Zaproszenia nie mogły być wysłane. Czy chciałbyś spróbować jeszcze raz? Zaproszenia nie mogły być wysłane. Czy chciałbyś spróbować jeszcze raz? + + Zaproś członka + Zaproś członków + Zaproś członków + Zaproś członków + + Zaproś nowego członka do grupy, wpisując Identyfikator konta swojego znajomego, ONS lub skanując jego kod QR {icon} + Zaproś nowego członka do grupy, wpisując identyfikator konta znajomego, ONS lub skanując jego kod QR Dołącz Później + Uruchamiaj {app_name} automatycznie przy uruchomieniu komputera. + Uruchom przy starcie + To ustawienie jest zarządzane przez system w systemie Linux. Aby włączyć automatyczne uruchamianie, dodaj {app_name} do aplikacji startowych w ustawieniach systemowych. Dowiedz się więcej Opuść Opuszczanie... @@ -634,6 +730,8 @@ Ty oraz użytkownik {other_name} dołączyliście do grupy. {name} i {other_name} dołączyli do grupy. Dołączono do grupy. + Ograniczyć aktywność w tle? + Obecnie zezwalasz aplikacji {app_name} na działanie w tle w celu poprawy niezawodności powiadomień. Zmiana tego ustawienia może skutkować mniej niezawodnymi powiadomieniami. Podgląd linków Pokaż podglądy linków dla obsługiwanych adresów URL. Włącz podgląd linków @@ -658,10 +756,18 @@ Naciśnij, aby odblokować {app_name} jest odblokowany Dzienniki + Zarządzaj administratorami Zarządzaj członkami Zarządzaj {pro} Maks + Może później Multimedia + + Wybrano %1$d członka + Wybrano %1$d członków + Wybrano %1$d członków + Wybrano %1$d członków + %1$d członek %1$d członków @@ -675,7 +781,9 @@ %1$d aktywnych członków Dodaj ID konta lub ONS + Członkowie mogą zostać awansowani dopiero po zaakceptowaniu zaproszenia do grupy. Zaproś znajomych + Nie masz żadnych kontaktów do zaproszenia do tej grupy.\nWróć i zaproś członków, używając ich Identyfikatora konta lub ONS. Wyślij zaproszenie Wyślij zaproszenia @@ -686,8 +794,10 @@ Czy chcesz udostępnić historię wiadomości grupy użytkownikowi {name} i {count} innym użytkownikom? Czy chcesz udostępnić historię wiadomości grupy użytkownikom {name} i {other_name}? Udostępnij historię wiadomości + Udostępnij historię wiadomości z ostatnich 14 dni Udostępniaj tylko nowe wiadomości Zaproś + Członkowie (nie administratorzy) Pasek Menu Wiadomość Przeczytaj więcej @@ -708,6 +818,7 @@ Rozpocznij nową rozmowę, wprowadzając identyfikator konta lub ONS znajomego. Rozpocznij nową rozmowę, wprowadzając identyfikator konta lub ONS znajomego lub skanując jego kod QR. + Rozpocznij nową rozmowę, wpisując identyfikator konta znajomego (Account ID), ONS lub skanując jego kod QR {icon} Masz nową wiadomość. Masz %1$d nowe wiadomości. @@ -771,13 +882,17 @@ Usuń pseudonim Ustaw pseudonim Nie + W tej grupie nie ma członków niebędących administratorami. Brak sugestii + Wysyłaj wiadomości do 10 000 znaków we wszystkich konwersacjach. + Organizuj czaty z nieograniczoną liczbą przypiętych konwersacji. Brak Nie teraz Moje notatki Nie masz żadnych wiadomości w swoich notatkach. Ukryj swoje notatki Czy na pewno chcesz ukryć swoją notatkę? + WAŻNE: Przez {action_type} akceptujesz Warunki Świadczenia Usług {app_pro} {icon} oraz Politykę Prywatności {icon} Wyświetlanie powiadomień Wyświetlaj nazwę nadawcy i podgląd wiadomości. Wyświetlaj tylko nazwę nadawcy, bez podglądu wiadomości. @@ -821,6 +936,12 @@ Wyłączone Okej Włącz + Na Twoim urządzeniu {device_type} + Otwórz to konto {app_name} na urządzeniu {device_type}, zalogowanym na konto {platform_account}, którego użyto do rejestracji. Następnie anuluj {pro} w ustawieniach {app_pro}. + Otwórz to konto {app_name} na urządzeniu {device_type} zalogowanym do konta {platform_account}, za pomocą którego pierwotnie dokonano rejestracji. Następnie zaktualizuj dostęp {pro} w ustawieniach {app_pro}. + Na połączonym urządzeniu + Na stronie internetowej {platform_store} + Na stronie internetowej {platform} Utwórz konto Konto zostało utworzone Mam już konto @@ -845,6 +966,9 @@ Nie rozpoznano tego ONS. Sprawdź i spróbuj ponownie. Nie udało się wyszukać tego ONS. Spróbuj ponownie później. Otwórz + Otwórz stronę {platform_store} + Otwórz stronę internetową {platform} + Otwórz ustawienia Otwórz ankietę Inne Hasło @@ -878,6 +1002,8 @@ Ustawienie silnego hasła pomaga chronić Twoje wiadomości i załączniki w przypadku utraty lub kradzieży urządzenia. Hasła Wklej + Błąd płatności + Twoja płatność została pomyślnie przetworzona, ale wystąpił błąd podczas {action_type} statusu {pro}.\n\nSprawdź połączenie z siecią i spróbuj ponownie. Zmiana uprawnień Aby wysyłać pliki, muzykę i dźwięk, aplikacja {app_name} potrzebuje dostępu do muzyki i dźwięku, jednak na stałe go odmówiono. Naciśnij „Ustawienia” → „Uprawnienia” i włącz „Muzyka i dźwięk”. Do odtwarzania załączników multimedialnych aplikacja {app_name} potrzebuje używać aplikacji Apple Music. @@ -916,14 +1042,38 @@ Przypnij konwersację Odepnij Odepnij konwersację + I wiele więcej... Nowe funkcje niedługo pojawią się w {pro}. Odkryj, co nowego na {pro} Roadmap {icon} Preferencje Podgląd Podgląd powiadomień + Twój dostęp do {pro} jest aktywny!\n\nTwój dostęp do {pro} zostanie automatycznie odnowiony na kolejne {current_plan_length} dnia {date}. + Twój dostęp do {pro} jest aktywny!\n\nTwój dostęp do {pro} zostanie automatycznie odnowiony na kolejne\n{current_plan_length} dnia {date}. Wszelkie zmiany wprowadzone tutaj zostaną zastosowane przy następnym odnowieniu. + Błąd dostępu {pro} + Twój dostęp do {pro} wygaśnie {date}. + Ładowanie dostępu do {pro} + Informacje o dostępie do {pro} nadal się ładują. Nie możesz dokonać aktualizacji, dopóki proces nie zostanie zakończony. + Ładowanie dostępu do {pro}... + Nie można połączyć się z siecią, aby załadować informacje o Twoim dostępie do {pro}. Aktualizacja {pro} przez {app_name} będzie wyłączona do czasu przywrócenia połączenia.\n\nSprawdź swoje połączenie sieciowe i spróbuj ponownie. + Nie znaleziono dostępu {pro} + Aplikacja {app_name} wykryła, że Twoje konto nie posiada dostępu {pro}. Jeśli uważasz, że to pomyłka, skontaktuj się z pomocą techniczną {app_name}. + Odzyskaj dostęp do {pro} + Odnów dostęp do {pro} + Obecnie dostęp {pro} można zakupić i odnawiać wyłącznie za pośrednictwem {platform_store} lub {platform_store_other}. Ponieważ korzystasz z aplikacji {app_name} na komputerze stacjonarnym, nie możesz odnowić tutaj.\n\nTwórcy aplikacji {app_name} intensywnie pracują nad alternatywnymi metodami płatności, które pozwolą użytkownikom kupować dostęp {pro} poza {platform_store} i {platform_store_other}. Mapa drogowa {pro} {icon} + Odnów dostęp {pro} na stronie {platform_store} przy użyciu konta {platform_account}, za pomocą którego zasubskrybowałeś(-aś) {pro}. + Odnów na stronie internetowej {platform}, korzystając z konta {platform_account}, którego użyto do rejestracji w {pro}. + Odnów dostęp {pro}, aby ponownie korzystać z potężnych funkcji {app_pro} Beta. + Dostęp {pro} został przywrócony + Aplikacja {app_name} wykryła i przywróciła dostęp {pro} dla Twojego konta. Twój status {pro} został przywrócony! + Ponieważ początkowo zarejestrowano się w {app_pro} przez {platform_store}, musisz użyć swojego konta {platform_account}, aby zaktualizować dostęp do {pro}. + Obecnie dostęp do {pro} można uzyskać tylko za pośrednictwem {platform_store} lub {platform_store_other}. Ponieważ korzystasz z {app_name} Desktop, aktualizacja do {pro} w tym miejscu nie jest możliwa.\n\nZespół {app_name} pracuje nad alternatywnymi metodami płatności, które umożliwią zakup dostępu {pro} poza {platform_store} i {platform_store_other}. Mapa drogowa {pro} {icon} Aktywowano + aktywowanie Wszystko gotowe! + Twój dostęp do {app_pro} został zaktualizowany! Opłata zostanie pobrana, gdy {pro} automatycznie odnowi się {date}. Masz już Możesz przesyłać GIF-y i animowane obrazy WebP jako swoje zdjęcie profilowe! + Zyskaj animowane zdjęcia profilowe i odblokuj funkcje premium dzięki {app_pro} Beta Animowany obraz profilowy użytkownicy mogą przesyłać GIF-y Animowane obrazy profilu @@ -944,7 +1094,24 @@ Opłata roczna: {price} Opłata miesięczna: {price} Opłata kwartalna: {price} + Chcesz wysyłać dłuższe wiadomości?\nWyślij więcej tekstu i odblokuj funkcje premium dzięki {app_pro} Beta + Chcesz przypinać więcej czatów?\nZorganizuj swoje rozmowy i odblokuj funkcje premium dzięki {app_pro} Beta + Chcesz przypiąć więcej niż {limit} czatów?\nZorganizuj swoje rozmowy i odblokuj funkcje premium dzięki {app_pro} Beta + Szkoda, że rezygnujesz z {pro}. Oto, co musisz wiedzieć przed anulowaniem dostępu do {pro}. + Anulowanie + Anulowanie dostępu {pro} zapobiegnie automatycznemu odnowieniu przed wygaśnięciem dostępu {pro}. Anulowanie {pro} nie powoduje zwrotu pieniędzy. Nadal będziesz mieć dostęp do funkcji {app_pro} do momentu wygaśnięcia dostępu {pro}.\n\nPonieważ pierwotnie zarejestrowano się do {app_pro} przy użyciu konta {platform_account}, aby anulować {pro}, należy użyć tego samego {platform_account}. + Dwa sposoby anulowania dostępu {pro}: + Anulowanie dostępu do {pro} uniemożliwi automatyczne przedłużenie przed wygaśnięciem {pro}.\n\nAnulowanie {pro} nie oznacza zwrotu pieniędzy. Nadal możesz korzystać z funkcji {app_pro} do czasu wygaśnięcia dostępu do {pro}. + Wybierz opcję dostępu {pro}, która najlepiej Ci odpowiada.\nDłuższy czas dostępu oznacza większe zniżki. + Czy na pewno chcesz usunąć dane z tego urządzenia?\n\n{app_pro} nie można przenieść na inne konto. Zapisz swoje hasło odzyskiwania, aby później móc przywrócić dostęp do {pro}. + Czy na pewno chcesz usunąć swoje dane z sieci? Jeśli kontynuujesz, nie będziesz mógł odzyskać wiadomości ani kontaktów.\n\n{app_pro} nie można przenieść na inne konto. Zapisz swoje hasło odzyskiwania, aby później móc przywrócić dostęp do {pro}. + Twój dostęp do {pro} jest już przeceniony o {percent}% względem pełnej ceny {app_pro}. + Błąd podczas odświeżania statusu {pro} + Wygasło + Niestety, Twój dostęp {pro} wygasł.\nOdnów go, aby ponownie korzystać z ekskluzywnych korzyści i funkcji {app_pro} Beta. Niedługo wygaśnie + Twój dostęp do {pro} wygaśnie za {time}.\nZaktualizuj teraz, aby zachować dostęp do ekskluzywnych korzyści i funkcji {app_pro} Beta + {pro} wygaśnie za {time} FAQ {pro} Znajdź odpowiedzi na często zadawane pytania w sekcji FAQ {app_pro}. Prześlij obrazy profilowe w formacie GIF i WebP @@ -952,6 +1119,7 @@ I wiele więcej ekskluzywnych funkcji Wiadomości do 10,000 znaków Przypinaj nieograniczoną liczbę konwersacji + Chcesz wykorzystać pełny potencjał {app_name}?\nZaktualizuj do {app_pro} Beta, aby uzyskać dostęp do wielu ekskluzywnych korzyści i funkcji. Grupa została aktywowana Ta grupa ma zwiększoną pojemność! Może obsługiwać do 300 członków, ponieważ administrator grupy posiada @@ -960,9 +1128,12 @@ Ulepszono %1$s grup Ulepszono %1$s grup + Żądanie zwrotu jest ostateczne. Jeśli zostanie zatwierdzone, Twój dostęp do {pro} zostanie natychmiast anulowany i utracisz dostęp do wszystkich funkcji {pro}. Zwiększony rozmiar załączników Zwiększona długość wiadomości Większe grupy + Grupy, w których jesteś administratorem, są automatycznie uaktualniane do obsługi 300 członków. + Większe czaty grupowe (do 300 członków) już wkrótce dla wszystkich użytkowników Pro Beta! Dłuższe wiadomości Możesz wysyłać wiadomości aż do 10 000 znaków we wszystkich konwersacjach. @@ -972,25 +1143,77 @@ Wysłano %1$s dłuższych wiadomości Ta wiadomość zawierała następujące funkcje {app_pro}: + Za pomocą nowej instalacji + Zainstaluj ponownie {app_name} na tym urządzeniu za pośrednictwem {platform_store}, przywróć konto za pomocą Hasła odzyskiwania i odnow {pro} w ustawieniach {app_pro}. + Zainstaluj ponownie {app_name} na tym urządzeniu za pośrednictwem {platform_store}, przywróć konto używając hasła odzyskiwania i zaktualizuj do {pro} z poziomu ustawień {app_pro}. + Na razie są trzy sposoby odnowienia: + Na razie istnieją dwa sposoby odnowienia: + {percent}% zniżki %1$s przypięta konwersacja %1$s przypięte konwersacje %1$s przypiętych konwersacji %1$s przypiętych konwersacji + Ponieważ początkowo zarejestrowano się w {app_pro} przez {platform_store}, musisz użyć tego samego konta {platform_account}, aby złożyć wniosek o zwrot. + Ponieważ początkowo zarejestrowano się w {app_pro} przez {platform_store}, Twoim wnioskiem o zwrot zajmie się Zespół Wsparcia {app_name}.\n\nAby poprosić o zwrot, kliknij poniższy przycisk i wypełnij formularz zgłoszenia.\n\nChociaż Zespół Wsparcia {app_name} dokłada starań, aby przetwarzać wnioski o zwrot w ciągu 24–72 godzin, przetwarzanie może trwać dłużej w okresach dużej liczby zgłoszeń. + Twój dostęp {app_pro} został odnowiony! Dziękujemy za wspieranie {network_name}. 1 miesiąc - {monthly_price} / miesiąc 3 miesiące - {monthly_price} / miesiąc 12 miesięcy - {monthly_price} / miesiąc + ponowne aktywowanie + Otwórz to konto {app_name} na urządzeniu {device_type} zalogowanym na konto {platform_account}, którego użyto do rejestracji. Następnie poproś o zwrot pieniędzy przez ustawienia {app_pro}. Przykro nam, że odchodzisz. Tutaj znajdziesz wszystko, co powinieneś wiedzieć przed złożeniem wniosku o zwrot. + {platform} przetwarza obecnie Twój wniosek o zwrot. Zwykle trwa to 24–48 godzin. W zależności od decyzji, Twój status {pro} w {app_name} może się zmienić. + Twoją prośbą o zwrot zajmie się Zespół Wsparcia {app_name}.\n\nAby poprosić o zwrot, kliknij poniższy przycisk i wypełnij formularz zgłoszenia.\n\nChociaż Zespół Wsparcia {app_name} dokłada starań, aby przetwarzać wnioski o zwrot w ciągu 24–72 godzin, przetwarzanie może trwać dłużej w okresach dużej liczby zgłoszeń. + Twoja prośba o zwrot zostanie rozpatrzona wyłącznie przez {platform} za pośrednictwem strony internetowej {platform}.\n\nZe względu na zasady zwrotów {platform}, deweloperzy {app_name} nie mają wpływu na decyzję o przyznaniu zwrotu—ani czy wniosek zostanie zaakceptowany lub odrzucony, ani czy zostanie wydany pełny lub częściowy zwrot. + Skontaktuj się z {platform} w celu uzyskania dalszych informacji o statusie zwrotu. Ze względu na politykę zwrotów {platform}, twórcy aplikacji {app_name} nie mają możliwości wpływu na wynik takich wniosków.\n\nPomoc dotycząca zwrotów {platform} Zwrot {pro} + Zwroty za {app_pro} są obsługiwane wyłącznie przez {platform} za pośrednictwem {platform_store}.\n\nZe względu na polityki zwrotów {platform}, twórcy aplikacji {app_name} nie mają możliwości wpływu na wynik wniosków o zwrot – ani decyzji o jego akceptacji lub odrzuceniu, ani o wysokości kwoty (pełnej lub częściowej). + Chcesz ponownie korzystać z animowanych zdjęć profilowych?\nOdnów dostęp {pro}, aby odblokować brakujące funkcje. + Odnów {pro} Beta + Odnów dostęp {pro} w ustawieniach {app_pro} na powiązanym urządzeniu z zainstalowaną aplikacją {app_name} przez {platform_store} lub {platform_store_other}. + Chcesz znów wysyłać dłuższe wiadomości?\nOdnów dostęp {pro}, aby odblokować brakujące funkcje. + Chcesz ponownie wykorzystać pełen potencjał {app_name}?\nOdnów dostęp {pro}, aby odblokować brakujące funkcje. + Chcesz znowu przypiąć więcej niż {limit} konwersacji?\nOdnów dostęp {pro}, aby odblokować brakujące funkcje. + Chcesz ponownie przypinać więcej konwersacji?\nOdnów dostęp {pro}, aby odblokować brakujące funkcje. + Odnawiając, wyrażasz zgodę na Warunki świadczenia usług {icon} oraz Politykę prywatności {icon} {app_pro} + odnawianie + Obecnie dostęp do {pro} można kupić i odnowić wyłącznie przez {platform_store} lub {platform_store_other}. Ponieważ zainstalowałeś {app_name} za pomocą {build_variant}, nie możesz odnowić tutaj.\n\nDeweloperzy {app_name} intensywnie pracują nad alternatywnymi metodami płatności, które umożliwią zakup dostępu do {pro} poza {platform_store} i {platform_store_other}. Plan rozwoju {pro} {icon} Wniosek o zwrot wysłany Wyślij więcej z Ustawienia {pro} + Rozpocznij korzystanie z {pro} Twoje Statystyki {pro} + Ładowanie statystyk {pro} + Statystyki {pro} są ładowane, proszę czekać. Statystyki {pro} pokazują użycie na tym urządzeniu i mogą wyglądać różnie na połączonych urządzeniach + Błąd statusu {pro} + Nie można połączyć się z siecią, aby sprawdzić Twój status {pro}. Informacje wyświetlane na tej stronie mogą być niedokładne, dopóki połączenie nie zostanie przywrócone.\n\nSprawdź swoje połączenie sieciowe i spróbuj ponownie. + Ładowanie statusu {pro} + Ładowanie informacji o Twoim dostępie {pro}. Niektóre działania na tej stronie mogą być niedostępne do czasu zakończenia ładowania. + Ładowanie statusu {pro} + Nie można połączyć się z siecią, aby sprawdzić status {pro}. Aby kontynuować, należy przywrócić łączność.\n\nSprawdź połączenie sieciowe i spróbuj ponownie. + Nie można połączyć się z siecią, aby sprawdzić Twój status {pro}. Nie możesz zaktualizować do {pro}, dopóki połączenie nie zostanie przywrócone.\n\nSprawdź swoje połączenie sieciowe i spróbuj ponownie. + Nie można połączyć się z siecią, aby odświeżyć Twój status {pro}. Niektóre działania na tej stronie zostaną wyłączone, dopóki połączenie nie zostanie przywrócone.\n\nSprawdź swoje połączenie sieciowe i spróbuj ponownie. + Nie udało się połączyć z siecią, aby załadować aktualny dostęp do {pro}. Odnawianie {pro} przez {app_name} będzie niedostępne do czasu przywrócenia połączenia.\n\nSprawdź połączenie z siecią i spróbuj ponownie. + Potrzebujesz pomocy z {pro}? Wyślij zgłoszenie do zespołu wsparcia. + Przez {action_type} następuje {activation_type} {app_pro} za pośrednictwem protokołu {app_name}. {entity} ułatwia tę aktywację, ale nie jest dostawcą {app_pro}. {entity} nie ponosi odpowiedzialności za wydajność, dostępność ani funkcjonalność {app_pro}. Dokonując zmian wyrażasz zgodę na Warunki Świadczenia Usług {app_pro} {icon} oraz Politykę Prywatności {icon} Nielimitowane przypięcia Organizuj swoje czaty z nielimitowaną możliwością przypinania konwersacji. + aktualizowanie + Zaktualizuj do wersji {app_pro} Beta, aby uzyskać dostęp do wielu ekskluzywnych funkcji i korzyści. + Zaktualizuj do {pro} z ustawień {app_pro} na powiązanym urządzeniu, na którym {app_name} został zainstalowany za pośrednictwem {platform_store} lub {platform_store_other}. + Obecnie dostęp {pro} można zakupić wyłącznie za pośrednictwem {platform_store} lub {platform_store_other}. Ponieważ zainstalowano {app_name} za pomocą {build_variant}, aktualizacja do {pro} nie jest tutaj możliwa.\n\nZespół {app_name} pracuje nad alternatywnymi metodami płatności, które umożliwią zakup dostępu {pro} poza {platform_store} i {platform_store_other}. Mapa drogowa {pro} {icon} + Na razie dostępna jest tylko jedna opcja aktualizacji: + Na razie istnieją dwa sposoby aktualizacji: + Zaktualizowano do {app_pro}!\nDziękujemy za wspieranie sieci {network_name}. + aktualizacja + Aktualizacja do {pro} + Aktualizując, wyrażasz zgodę na Warunki świadczenia usług {icon} oraz Politykę prywatności {icon} {app_pro} + Chcesz więcej z {app_name}?\nZaktualizuj do {app_pro} Beta, aby uzyskać potężniejsze możliwości wiadomości. + {platform} przetwarza Twój wniosek o zwrot Profil Zdjęcie profilowe Nie udało się usunąć zdjęcia profilowego. @@ -998,6 +1221,13 @@ Wybierz mniejszy plik. Nie udało się zaktualizować profilu. Awansuj + Administratorzy będą mogli zobaczyć historię wiadomości z ostatnich 14 dni i nie będzie można ich zdegradować ani usunąć z grupy. + + Awansuj członka + Awansuj członków + Awansuj członków + Awansuj członków + Promocja nie powiodła się Promocje nie powiodły się @@ -1050,6 +1280,8 @@ To jest Twoje hasło odzyskiwania. Jeśli je komuś wyślesz, osoba ta będzie miała pełny dostęp do Twojego konta. Odtwórz Grupę Ponów + Ponieważ początkowo zarejestrowano się do {app_pro} przez inne konto {platform_account}, należy użyć tego {platform_account}, aby zaktualizować dostęp {pro}. + Dwa sposoby na zgłoszenie prośby o zwrot: Skróć wiadomość o {count} znaków Pozostał %1$d znak @@ -1057,18 +1289,67 @@ Pozostało %1$d znaków Pozostało %1$d znaków + Przypomnij mi później Usuń + + Usuń członka + Usuń członków + Usuń członków + Usuń członków + + + Usuń członka i jego wiadomości + Usuń członków i ich wiadomości + Usuń członków i ich wiadomości + Usuń członków i ich wiadomości + Nie udało się usunąć hasła Usuń swoje obecne hasło dla {app_name}. Dane przechowywane lokalnie zostaną ponownie zaszyfrowane losowo wygenerowanym kluczem, przechowywanym na Twoim urządzeniu. + + Usuwanie członka + Usuwanie członków + Usuwanie członków + Usuwanie członków + + Odnów + Odnawianie {pro} Odpowiedz Zawnioskuj o zwrot + Poproś o zwrot pieniędzy na stronie internetowej {platform}, korzystając z konta {platform_account}, którego użyto do rejestracji w {pro}. Wyślij ponownie + + Wyślij zaproszenie ponownie + Wyślij zaproszenia ponownie + Wyślij zaproszenia ponownie + Wyślij zaproszenia ponownie + + + Wyślij ponownie promocję + Wyślij ponownie promocje + Wyślij ponownie promocje + Wyślij ponownie promocje + + + Ponowne wysyłanie zaproszenia + Ponowne wysyłanie zaproszeń + Ponowne wysyłanie zaproszeń + Ponowne wysyłanie zaproszeń + + + Wysyłanie promocji + Wysyłanie promocji + Wysyłanie promocji + Wysyłanie promocji + Wczytywanie informacji o kraju... Uruchom ponownie Synchronizuj ponownie Ponów Limit opinii Wygląda na to, że ostatnio już oceniałeś {app_name}, dziękujemy za Twoją opinię! + Działanie aplikacji w tle + Uruchomić {app_name} w tle? + Ponieważ używasz trybu wolnego, zalecamy zezwolenie aplikacji {app_name} na działanie w tle w celu poprawy działania powiadomień. Może to zwiększyć spójność powiadomień, chociaż system nadal może automatycznie ograniczać aktywność w tle.\n\nMożesz to zmienić później w Ustawieniach. Zapisz Zapisano Zapisane wiadomości @@ -1077,6 +1358,8 @@ Ochrona ekranu Powiadomienia o zrzucie ekranu Wymagaj powiadomienia, gdy kontakt wykona zrzut ekranu rozmowy prywatnej. + Ukryj okno {app_name} na zrzutach ekranu wykonanych na tym urządzeniu. + Ochrona przed zrzutami ekranu {name} zrobił(a) zrzut ekranu. Szukaj Szukaj kontaktów @@ -1099,6 +1382,12 @@ Wysyłanie Wysyłanie oferty połączenia Wysyłanie kandydatów do połączenia + + Wysyłanie promocji + Wysyłanie promocji + Wysyłanie promocji + Wysyłanie promocji + Wysłano: Wygląd Wyczyść dane @@ -1125,14 +1414,19 @@ Ustawiono Ustaw zdjęcie profilowe grupy Ustaw hasło dla {app_name}. Dane przechowywane lokalnie będą zaszyfrowane tym hasłem. Będziesz musiał je podać za każdym razem, kiedy uruchamiasz {app_name}. + Nie można zaktualizować ustawienia Aby zastosować nowe ustawienia, należy ponownie uruchomić aplikację {app_name}. Ochrona ekranu + Uruchamianie Udostępnij Zaproś znajomego do rozmowy w aplikacji {app_name}, udostępniając mu swój identyfikator konta. Udostępnij znajomym tam, gdzie zwykle z nimi rozmawiasz, a następnie przenieś rozmowę tutaj. Wystąpił problem podczas otwierania bazy danych. Uruchom ponownie aplikację i spróbuj ponownie. Ups! Wygląda na to, że nie masz konta {app_name}.\n\nBędziesz musiał stworzyć konto w aplikacji {app_name}, zanim będziesz mógł to udostępnić. + Czy chcesz udostępnić temu użytkownikowi historię wiadomości grupy? Udostępnij w aplikacji {app_name} + Przykro nam, {app_name} obsługuje jedynie udostępnianie wielu obrazów i filmów jednocześnie + Udostępnianie obsługuje tylko pliki multimedialne. Pliki niemultimedialne zostały pominięte Pokaż Pokaż wszystko Pokaż mniej @@ -1153,12 +1447,17 @@ Identyfikator konta {name} jest widoczny na podstawie wcześniejszych interakcji Zanonimizowane identyfikatory są używane w społecznościach w celu ograniczenia spamu i zwiększenia prywatności Przetłumacz + Zasobnik Spróbuj ponownie Wskaźniki pisania Wyświetlaj i udostępniaj wskaźniki pisania. Niedostępny Cofnij Nieznane + Nieobsługiwany procesor + Aktualizuj + Zaktualizuj dostęp do {pro} + Dwa sposoby na aktualizację dostępu do {pro}: Aktualizacje aplikacji Zaktualizuj informacje o społeczności Nazwa i opis społeczności są widoczne dla wszystkich jej członków @@ -1181,6 +1480,8 @@ Ostatnia aktualizacja {relative_time} temu Aktualizacje Aktualizowanie... + Aktualizuj + Uaktualnij {app_name} Uaktualnij do Przesyłanie Skopiuj adres URL @@ -1189,6 +1490,8 @@ Czy na pewno chcesz otworzyć ten adres URL w przeglądarce?\n\n{url} Linki będą otwierane w Twojej przeglądarce. Użyj trybu szybkiego + Zmień swój plan, korzystając z konta {platform_account}, którego użyto do rejestracji, przez stronę {platform}. + Przez stronę internetową {platform} Wideo Nie można odtworzyć wideo. Zobacz @@ -1197,9 +1500,11 @@ Może to potrwać kilka minut. Chwileczkę... Uwaga + Zakończono obsługę iOS 15. Zaktualizuj do iOS 16 lub nowszej wersji, aby nadal otrzymywać aktualizacje aplikacji. Okno Tak Ty + Twój procesor nie obsługuje instrukcji SSE 4.2, które są wymagane przez {app_name} w systemach Linux x64 do przetwarzania obrazów. Proszę zaktualizować procesor do zgodnego lub skorzystać z innego systemu operacyjnego. Twoje hasło odzyskiwania Współczynnik powiększenia Dostosuj wielkość tekstu i elementów wizualnych. diff --git a/app/src/main/res/values-b+pt+BR/strings.xml b/app/src/main/res/values-b+pt+BR/strings.xml index 4d11c7abc6..c47591dafb 100644 --- a/app/src/main/res/values-b+pt+BR/strings.xml +++ b/app/src/main/res/values-b+pt+BR/strings.xml @@ -3,6 +3,7 @@ Sobre Aceitar Copiar ID da Conta + ID da conta ID da Conta Copiado Copie seu ID da Conta e compartilhe com seus amigos para que possam te enviar mensagens. Digite o ID da Conta @@ -14,6 +15,13 @@ Este é o seu ID de Conta. Outros usuários podem escaneá-lo para iniciar uma conversa com você. Tamanho normal Adicionar + + Adicionar Administrador + Adicionar Administradores + + Adicionar administrador + Digite o ID de Conta do usuário que você está promovendo a administrador.\n\nPara adicionar vários usuários, digite cada ID de Conta separado por vírgula. É possível especificar até 20 IDs de Conta por vez. + Administradores não podem ser rebaixados ou removidos do grupo. Administradores não podem ser removidos. {name} e {count} outros foram promovidos a Administrador. Promover Administradores @@ -26,7 +34,9 @@ Falha ao promover {name} no {group_name} Falha ao promover {name} e {count} outros no {group_name} Falha ao promover {name} e {other_name} no {group_name} + Promoção não enviada Enviado Promover a Administrador + Status da promoção desconhecido Remover Administradores Remover como Administrador Não há Administradores nesta Comunidade. @@ -36,10 +46,40 @@ {name} foi removido como Administrador. {name} e {count} outros foram removidos como Admin. {name} e {other_name} foram removidos como Admin. + + %1$d Administrador Selecionado + %1$d Administradores Selecionados + + + Enviando promoção de administrador + Enviando promoções de administrador + Configurações do Administrador + Você não pode alterar seu status de administrador. Para sair do grupo, abra as configurações da conversa e selecione Sair do grupo. {name} e {other_name} foram promovidos a Administrador. + Administradores + Permitir +{count} Anônimo + Ícone da aplicação + Alterar Ícone e Nome do Aplicativo + Alterar o ícone e o nome do aplicativo requer que o {app_name} seja fechado. As notificações continuarão a usar o ícone e o nome padrão do {app_name}. + O ícone e nome alternativos da aplicação são exibidos na tela principal e na gaveta de aplicações. + O ícone e o nome do aplicativo selecionados são exibidos na tela inicial e na gaveta de aplicativos. + Ícone e nome + O ícone alternativo da aplicação é exibido na tela principal e na biblioteca de aplicações. O nome da aplicação continuará a aparecer como \"{app_name}\". + Usar ícone alternativo da aplicação + Usar ícone e nome alternativos da aplicação + Selecionar ícone alternativo da aplicação + Ícone + Calculadora + ReuniãoSE + Notícias + Notas + Ações + Meteorologia + Distintivo do {app_pro} + Modo escuro automático Ocultar Barra de Menu Língua Escolha a configuração de idioma para {app_name}. {app_name} reiniciará quando você alterar a configuração de idioma. @@ -56,6 +96,7 @@ Aumentar zoom Diminuir zoom Anexo + Anexos Adicionar anexo Álbum sem nome Auto-download Attachments @@ -117,9 +158,12 @@ Banimento falhou Desbanimento falhou Desbanir Usuário + Digite o ID de Conta do usuário que você está desbloqueando Usuário desbanido Banir Usuário Usuário banido + Digite o ID de Conta do usuário que você está bloqueando + ID Oculto Bloquear Desbloquear este contato para enviar uma mensagem Nenhum contato bloqueado @@ -130,6 +174,8 @@ Você tem certeza que deseja desbloquear {name} e {count} outros? Você tem certeza que deseja desbloquear {name} e 1 outro? Desbloqueado {name} + Visualize e gerencie os contatos bloqueados. + Nenhum navegador encontrado para abrir essa URL, tente copiar a URL em vez disso Chamar {name} te ligou Você não pode iniciar uma nova chamada. Termine sua chamada atual primeiro. @@ -147,15 +193,21 @@ Chamadas de voz e vídeo exigem notificações ativadas nas configurações do sistema do seu dispositivo. Permissões de ligações necessárias Você pode habilitar a permissão de \"Voz e vídeo chamadas\" nas Configurações de Privacidade. + Você pode habilitar a permissão de \"Chamadas de Voz e Vídeo\" nas Configurações de Permissões. Reconectando... Chamando... {app_name} Call Chamadas (Beta) Chamadas de voz e vídeo Chamadas de voz e vídeo (Beta) + Seu IP estará visível para seu parceiro de chamada e para um servidor da {session_foundation} enquanto usa chamadas beta. Permite chamadas de voz e vídeo para e de outros usuários. Você ligou para {name} Você perdeu uma chamada de {name} porque você não ativou Chamadas de Voz e Vídeo nas Configurações de Privacidade. + {app_name} precisa de acesso à sua câmera para permitir chamadas de vídeo, mas essa permissão foi negada. Você não pode atualizar as permissões da câmera durante uma chamada.\n\nDeseja encerrar a chamada agora e ativar o acesso à câmera ou prefere ser lembrado após a chamada? + Para permitir o acesso à câmera, abra as configurações e ative a permissão da câmera. + Durante sua última chamada, você tentou usar o vídeo, mas não conseguiu porque o acesso à câmera foi negado anteriormente. Para permitir o acesso à câmera, abra as configurações e ative a permissão da câmera. + Acesso à câmera necessário Nenhuma câmera encontrada Câmera indisponível. Conceder acesso à câmera @@ -163,7 +215,19 @@ {app_name} precisa de acesso à câmera para tirar fotos e vídeos, ou escanear códigos QR. {app_name} precisa de acesso à câmera para escanear códigos QR Cancelar + Cancelar {pro} + Cancele no site da {platform}, usando a {platform_account} com a qual você se inscreveu no {pro}. + Cancele no site da {platform_store}, usando a {platform_account} com a qual você se inscreveu no {pro}. + Alterar Falha ao alterar a senha + Altere sua senha do {app_name}. Os dados armazenados localmente serão recriptografados com a nova senha. + Alterar configuração + Verificando status do {pro} + Verificando seu status {pro}. Você poderá continuar assim que essa verificação for concluída. + Verificando seus detalhes do {pro}. Algumas ações nesta página podem ficar indisponíveis até que essa verificação seja concluída. + Verificando status do {pro}... + Verificando os detalhes do seu {pro}. Você não pode renovar até que essa verificação seja concluída. + Verificando seu status do {pro}. Você poderá fazer upgrade para o {pro} assim que essa verificação for concluída. Limpar Apagar Tudo Limpar Todos os Dados @@ -179,19 +243,29 @@ Tem certeza que deseja excluir seus dados da rede? Se você continuar, não será capaz de restaurar suas mensagens ou contatos. Tem certeza que deseja limpar seu dispositivo? Limpar Somente o Dispositivo + Limpar Dispositivo e Reiniciar + Limpar Dispositivo e Restaurar Limpar Todas as Mensagens Tem certeza de que deseja apagar todas as mensagens da sua conversa com {name} do seu dispositivo? + Tem certeza de que deseja limpar todas as mensagens da sua conversa com {name} neste dispositivo? Tem certeza de que deseja limpar todas as mensagens de {community_name} do seu dispositivo? + Tem certeza de que deseja limpar todas as mensagens de {community_name} neste dispositivo? Limpar para todos Limpar para mim Tem certeza de que deseja limpar todas as mensagens de {group_name}? + Tem certeza de que deseja limpar todas as mensagens de {group_name}? Tem certeza de que deseja limpar todas as mensagens de {group_name} do seu dispositivo? + Tem certeza de que deseja limpar todas as mensagens de {group_name} neste dispositivo? Tem certeza de que deseja apagar todas as mensagens de Recado para mim do seu dispositivo? + Tem certeza de que deseja limpar todas as mensagens da Nota Pessoal neste dispositivo? + Limpar neste dispositivo Fechar + Fechar aplicativo Fechar janela Hash do Commit: {hash} Isso banirá o usuário selecionado desta Comunidade e excluirá todas as suas mensagens. Você tem certeza que deseja continuar? Isso banirá o usuário selecionado desta Comunidade. Você tem certeza que deseja continuar? + Digite uma descrição da Comunidade Insira URL da Comunidade URL inválido Por favor, verifique a URL da Comunidade e tente novamente. @@ -206,15 +280,23 @@ Você já é um membro deste Community. Sair da Comunidade Falha ao sair da {community_name} + Digite o nome da Comunidade + Por favor, insira um nome de Comunidade Comunidade Desconhecida URL da Comunidade Copiar URL da Comunidade Confirmar + Confirmar Promoção + Tem certeza? Administradores não podem ser rebaixados ou removidos do grupo. Contatos Excluir contato Tem certeza de que deseja apagar {name} dos seus contatos? Novas mensagens de {name} chegarão como uma solicitação de mensagem. Você ainda não possui contatos Selecionar Contatos + + %1$d Contato Selecionado + %1$d Contatos Selecionados + Detalhes do Usuário Câmera Escolha uma ação para iniciar uma conversa @@ -222,6 +304,7 @@ Composição de mensagem Miniatura da imagem na citação Criar uma conversa com um novo contato + Escolha o conteúdo exibido nas notificações locais ao receber uma mensagem. Adicionar a tela inicial Adicionada à tela inicial Mensagens de áudio @@ -234,12 +317,18 @@ Conversa excluída Não há mensagens em {conversation_name}. Insira a tecla Enter + Defina como as teclas Enter e Shift+Enter funcionam nas conversas. + SHIFT + ENTER envia uma mensagem, ENTER inicia uma nova linha. + ENTER envia uma mensagem, SHIFT + ENTER inicia uma nova linha. Grupos Corte de Mensagens Cortar Comunidades + Excluir automaticamente mensagens com mais de 6 meses em comunidades com mais de 2.000 mensagens. Nova Conversa Você ainda não tem nenhuma conversa + Enviar com a tecla Enter Tocar na tecla Enter enviará uma mensagem em vez de iniciar uma nova linha. + Enviar com Shift+Enter Todas as mídias Corretor ortográfico Habilitar verificação ortográfica ao digitar mensagens. @@ -247,7 +336,14 @@ Copiado Copiar Criar + Criando chamada + Faturamento atual + Senha Atual Cortar + Modo escuro + Tem certeza de que deseja excluir todas as mensagens, anexos e dados da conta deste dispositivo e criar uma nova conta? + Ocorreu um erro no banco de dados.\n\nExporte os registros do aplicativo para compartilhar e ajudar na solução de problemas. Se isso não funcionar, reinstale o {app_name} e recupere sua conta. + Tem certeza de que deseja excluir todas as mensagens, anexos e dados da conta deste dispositivo e restaurar sua conta a partir da rede? Observamos que {app_name} está demorando muito para iniciar.\n\nVocê pode continuar esperando, exportar os logs do seu dispositivo para compartilhar para solução de problemas, ou tentar reiniciar o {app_name}. O banco de dados do seu aplicativo é incompatível com esta versão do {app_name}. Reinstale o aplicativo e restaure sua conta para gerar um novo banco de dados e continuar usando {app_name}.\n\nAviso: Isso resultará na perda de todas as mensagens e anexos com mais de duas semanas. Otimizando base de dados @@ -269,16 +365,34 @@ Por favor, aguarde enquanto o grupo é criado... Falha ao atualizar o grupo Você não tem permissão para excluir as mensagens de outros + + Excluir anexo selecionado + Excluir anexos selecionados + + + Tem certeza de que deseja excluir o anexo selecionado? A mensagem associada ao anexo também será excluída. + Tem certeza de que deseja excluir os anexos selecionados? A mensagem associada aos anexos também será excluída. + + Tem certeza de que deseja excluir {name} dos seus contatos?\n\nIsso apagará sua conversa, incluindo todas as mensagens e anexos. Mensagens futuras de {name} aparecerão como uma solicitação de mensagem. + Tem certeza de que deseja excluir sua conversa com {name}?\nIsso excluirá permanentemente todas as mensagens e anexos. Excluir Mensagem Excluir mensagens + + Tem certeza de que deseja excluir esta mensagem? + Tem certeza de que deseja excluir essas mensagens? + Mensagem excluída Mensagens excluídas Essa mensagem foi deletada Esta mensagem foi deletada neste dispositivo + + Tem certeza de que deseja excluir esta mensagem apenas deste dispositivo? + Tem certeza de que deseja excluir essas mensagens somente deste dispositivo? + Você tem certeza que deseja excluir esta mensagem para todos? Excluir apenas neste dispositivo Excluir em todos os meus dispositivos @@ -298,6 +412,7 @@ Tem certeza de que deseja excluir essas mensagens para todos? Deletando Ferramentas de desenvolvimento + Configurações de notificação do dispositivo Iniciar Ditado... Mensagens efêmeras Mensagem será excluída em {time_large} @@ -333,6 +448,7 @@ {admin_name} atualizou as configurações das mensagens temporárias. Você atualizou as configurações de mensagens temporárias. Ignorar + Exibição Pode ser seu nome real, um apelido ou qualquer outra coisa que você goste — e você pode mudá-lo a qualquer momento. Digite seu nome de exibição Escolha um nome de exibição @@ -343,6 +459,9 @@ Definir Nome de Exibição Seu Nome de Exibição é visível para usuários, grupos e comunidades com os quais você interage. Documento + Doar + Forças poderosas estão tentando enfraquecer a privacidade, mas não podemos continuar essa luta sozinhos.\n\nDoar ajuda a manter o {app_name} seguro, independente e online. + {app_name} precisa da sua ajuda Pronto Baixar Baixando... @@ -372,22 +491,53 @@ Você e {name} reagiram com {emoji_name} Reagiu à sua mensagem {emoji} Ativar + Ativar acesso à câmera? + Mostrar notificações quando você receber novas mensagens. + Encerrar chamada para ativar + Está gostando do {app_name}? + Precisa de melhorias {emoji} + Está ótimo {emoji} + Você está usando o {app_name} há um tempo. Como está sendo a experiência? Adoraríamos saber sua opinião. + Entrar + Digite a senha que você definiu para o {app_name} + Digite a senha que você usa para desbloquear o {app_name} na inicialização, e não a sua senha de recuperação + Erro ao verificar o status do {pro} Por favor verifique sua conexão com a internet e tente novamente. Copiar Erro e Sair Erro na base de dados + Algo deu errado. Por favor, tente novamente mais tarde. + Erro ao carregar o acesso ao {pro} + {app_name} não conseguiu buscar este ONS. Verifique sua conexão de rede e tente novamente. Ocorreu um erro desconhecido. + Este ONS não está registrado. Verifique se está correto e tente novamente. + Falha ao reenviar convite para {name} no {group_name} + Falha ao reenviar convite para {name} e {count} outros no {group_name} + Falha ao reenviar convite para {name} e {other_name} no {group_name} + Falha ao reenviar promoção para {name} no grupo {group_name} + Falha ao reenviar promoção para {name} e {count} outros no grupo {group_name} + Falha ao reenviar promoção para {name} e {other_name} no grupo {group_name} + Falha ao baixar Falhas + Feedback + Compartilhe sua experiência com o {app_name} respondendo a uma breve pesquisa. Arquivo Arquivos + Acompanhar as configurações do sistema. + Para sempre De: Tela inteira GIF Giphy {app_name} se conectará ao Giphy para fornecer resultados de pesquisa. Você não terá proteção completa de metadados ao enviar GIFs. + Dar opinião? + Lamentamos saber que sua experiência com o {app_name} não foi ideal. Agradecemos se puder tirar um momento para compartilhar sua opinião em uma breve pesquisa Grupos têm um máximo de 100 membros Criar Grupo Escolha pelo menos 2 membros do grupo. Excluir grupo + Tem certeza de que deseja excluir {group_name}?\n\nIsso removerá todos os membros e excluirá todo o conteúdo do grupo. + Tem certeza de que deseja excluir {group_name}? + {group_name} foi excluído por um administrador do grupo. Você não poderá enviar mais mensagens. Digite a descrição do grupo Imagem do grupo atualizada. Editar grupo @@ -400,6 +550,7 @@ Falha ao convidar {name} e {count} outros para {group_name} Falha ao convidar {name} e {other_name} para {group_name} Falha ao convidar {name} para {group_name} + Convite não enviado {name} convidou você para voltar a {group_name}, onde você é um Admin. Você foi convidado a voltar para o {group_name}, onde você é um Admin. @@ -407,6 +558,7 @@ Enviando convites Convite enviado + Status do convite desconhecido Convite para grupo bem-sucedido Usuários devem ter a versão mais recente para receber convites Você foi convidado a entrar no grupo. @@ -417,6 +569,9 @@ Você tem certeza que deseja sair {group_name}? Tem certeza de que deseja sair de {group_name}?\n\nIsso removerá todos os membros e excluirá todo o conteúdo do grupo. Falha ao sair do {group_name} + {name} foi convidado para entrar no grupo. O histórico de mensagens dos últimos 14 dias foi compartilhado. + {name} e mais {count} foram convidados a entrar no grupo. O histórico de conversa dos últimos 14 dias foi compartilhado. + {name} e {other_name} foram convidados a entrar no grupo. O histórico de conversa dos últimos 14 dias foi compartilhado. {name} saiu do grupo. {name} e {count} outros saíram do grupo. {name} e {other_name} saíram do grupo. @@ -427,6 +582,10 @@ {name} e {count} outros foram convidados a juntar-se ao grupo. {name} e {other_name} foram convidados a juntar-se ao grupo. Você e {count} outros foram convidados a participar do grupo. O histórico de conversas foi compartilhado. + Você e {other_name} foram convidados a participar do grupo. O histórico de conversas foi compartilhado. + Falha ao remover {name} de {group_name} + Falha ao remover {name} e {count} outros de {group_name} + Falha ao remover {name} e {other_name} de {group_name} Você saiu do grupo. Participantes do Grupo Não há outros membros neste grupo. @@ -438,9 +597,13 @@ Nome do grupo atualizado. O nome do grupo está visível para todos os membros do grupo. Você não possui mensagens de {group_name}. Envie uma mensagem para começar a conversa! + Este grupo não foi atualizado há mais de 30 dias. Você pode ter problemas ao enviar mensagens ou visualizar as informações do grupo. Você é o único administrador em {group_name}.\n\nOs membros do grupo e as configurações não podem ser alterados sem um administrador. + Você é o único administrador em {group_name}.\n\nOs membros do grupo e as configurações não podem ser alterados sem um administrador. Para sair do grupo sem excluí-lo, adicione um novo administrador primeiro. + Remoção pendente Você foi promovido a Administrador. Você e {count} outros foram promovidos a Administrador. + Você e {other_name} foram promovidos a Administrador. Gostaria de remover {name} de {group_name}? Gostaria de remover {name} e {count} outros de {group_name}? Gostaria de remover {name} e {other_name} de {group_name}? @@ -462,32 +625,69 @@ Definir Imagem do Grupo Grupo Desconhecido Grupo atualizado + A processar candidatos de ligação Perguntas Frequentes + Consulte as Perguntas Frequentes do {app_name} para obter respostas às dúvidas mais comuns. Ajude-nos a traduzir {app_name} + Reportar um erro Compartilhe alguns detalhes para nos ajudar a resolver seu problema. Exporte seus logs e faça o upload do arquivo através do Help Desk da {app_name}. Exportar Logs Exporte seus logs, e envie o arquivo através do Help Desk do {app_name}. Salvar no desktop + Salve este arquivo e compartilhe-o com os desenvolvedores do {app_name}. Suporte + Ajude a traduzir o {app_name} para mais de 80 idiomas! Nós adoraríamos seu feedback Ocultar + Alternar visibilidade da barra de menu do sistema. + Tem certeza de que deseja ocultar Nota para Si da sua lista de conversas? Esconder todas Imagem + imagens + Importante Teclado incógnito Solicitar modo incógnito se disponível. Dependendo do teclado que você está usando, seu teclado pode ignorar essa solicitação. Informações Atalho inválido + + Convidar Contato + Convidar Contatos + + + O convite falhou + Os convites falharam + + + Não foi possível enviar o convite. Gostaria de tentar novamente? + Não foi possível enviar os convites. Gostaria de tentar novamente? + + + Convidar membro + Convidar membros + + Convide um novo membro para o grupo inserindo o ID da conta, ONS do seu amigo ou escaneando o código QR dele {icon} + Convide um novo membro para o grupo digitando o ID da conta do seu amigo, ONS ou escaneando o código QR dele Entrar Mais tarde + Iniciar automaticamente o {app_name} quando o computador for ligado. + Iniciar na inicialização + Esta configuração é gerenciada pelo seu sistema no Linux. Para ativar a inicialização automática, adicione o {app_name} aos seus aplicativos de inicialização nas configurações do sistema. Saber mais Sair Saindo... + Este grupo agora está em modo somente leitura. Recrie este grupo para continuar conversando. + Este grupo agora está em modo somente leitura. Peça ao administrador do grupo para recriar este grupo e continuar conversando. + Os grupos foram atualizados! Recrie este grupo para melhorar a confiabilidade. Este grupo se tornará somente leitura em {date}. + Os grupos foram atualizados! Peça ao administrador do grupo para recriar este grupo e melhorar a confiabilidade. Este grupo se tornará somente leitura em {date}. + O histórico de conversa não será transferido para o novo grupo. Você ainda pode visualizar todo o histórico no seu grupo antigo. {name} entrou no grupo. {name} e {count} outros entraram no grupo. Você e {count} outros entraram no grupo. Você e {other_name} entraram no grupo. {name} e {other_name} entraram no grupo. Você entrou no grupo. + Limitar atividade em segundo plano? + Atualmente, você permite que o {app_name} seja executado em segundo plano para melhorar a confiabilidade das notificações. Alterar essa configuração pode resultar em notificações menos confiáveis. Pré-visualizações de links Mostrar pré-visualização de links para URLs suportadas. Ativar pré-visualizações de link @@ -498,6 +698,7 @@ Você não terá proteção completa de metadados ao enviar pré-visualizações de links. Pré-visualizações de Link Desativadas {app_name} deve contatar sites vinculados para gerar pré-visualizações dos links que você envia e recebe.\n\nVocê pode ativá-los nas configurações do {app_name}. + Links Carregar Conta Carregando a sua conta Carregando... @@ -510,8 +711,17 @@ Status de tranca Toque para destrancar {app_name} está desbloqueado + Registros + Gerenciar administradores + Gerenciar membros + Gerenciar {pro} Max + Talvez mais tarde Mídia + + %1$d membro selecionado + %1$d membros selecionados + %1$d membro %1$d membros @@ -521,7 +731,9 @@ %1$d membros ativos Adicionar ID de Conta ou ONS + Os membros só podem ser promovidos após aceitarem o convite para entrar no grupo. Convidar Contatos + Você não tem contatos para convidar para este grupo.\nVolte e convide membros usando o ID da conta ou ONS. Enviar convite Enviar convites @@ -530,9 +742,14 @@ Gostaria de compartilhar o histórico de mensagens do grupo com {name} e {count} outros? Gostaria de compartilhar o histórico de mensagens do grupo com {name} e {other_name}? Compartilhar histórico de mensagens + Compartilhar histórico de mensagens dos últimos 14 dias Compartilhar apenas novas mensagens Convidar + Membros (não administradores) + Barra de menu Mensagem + Ler mais + Copiar mensagem Esta mensagem está vazia. Envio de mensagem falhou Limite de mensagens atingido. @@ -547,11 +764,18 @@ Comece uma nova conversa digitando o ID da conta ou ONS do seu amigo. Comece uma nova conversa digitando o ID da conta do seu amigo, ONS ou escaneando o código QR deles. + Comece uma nova conversa digitando o ID da Conta, ONS do seu amigo ou escaneando o código QR dele {icon} Você recebeu uma nova mensagem. Você tem %1$d novas mensagens. + + Você recebeu uma nova mensagem em %1$s. + Você recebeu %1$d novas mensagens em %2$s. + Respondendo para + Você não pode enviar anexos até que sua Solicitação de Mensagem seja aceita + Você não pode enviar mensagens de voz até que sua Solicitação de Mensagem seja aceita {name} convidou você para se juntar a {group_name}. Enviar uma mensagem para este grupo aceitará automaticamente o convite do grupo. Sua solicitação de mensagem está pendente. @@ -562,6 +786,7 @@ Tem certeza de que deseja limpar todos os pedidos de mensagens e convites de grupo? Solicitações de Mensagens de Comunidades Permitir solicitações de mensagens a partir de conversas de Comunidades. + Tem certeza de que deseja excluir esta solicitação de mensagem e o contato associado? Você tem certeza que deseja excluir esta solicitação de mensagem? Você tem uma nova solicitação de mensagem Nenhuma solicitação de mensagem pendente @@ -579,20 +804,38 @@ {author}: {emoji} Mensagem de voz Mensagens Minimizar + + As mensagens têm um limite de %1$s caracteres. Resta %2$d caractere. + As mensagens têm um limite de %1$s caracteres. Você ainda tem %2$d caracteres disponíveis. + + Comprimento da mensagem + Você excedeu o limite de caracteres para esta mensagem. Por favor, reduza sua mensagem para {limit} caracteres ou menos. + Mensagem muito longa + Por favor, reduza sua mensagem para {limit} caracteres ou menos. + Mensagem muito longa + Nova Senha Avançar + Próximas Etapas Escolha um apelido para {name}. Isso aparecerá para você em suas conversas individuais e em grupo. Insira um apelido Por favor, escolha um apelido mais curto Remover apelido Definir Apelido Não + Não há membros não administradores neste grupo. Sem Sugestões + Envie mensagens com até 10.000 caracteres em todas as conversas. + Organize os chats com conversas fixadas ilimitadas. Nenhuma Agora não Recado para mim Você não tem mensagens em Notas para si Mesmo. Ocultar Nota para Si Você tem certeza que deseja ocultar a Nota para Si Mesmo? + ATENÇÃO: Ao {action_type}, você concorda com os Termos de Serviço {icon} e Política de Privacidade {icon} do {app_pro} + Exibição de notificação + Exibir o nome do remetente e uma prévia do conteúdo da mensagem. + Exibir apenas o nome do remetente, sem o conteúdo da mensagem. Todas as Mensagens Conteúdo da notificação As informações exibidas nas notificações. @@ -601,7 +844,9 @@ Sem nome ou conteúdo Modo Rápido Você será notificado de forma confiável e imediata sobre novas mensagens usando os servidores de notificação da Google. + Você será notificado de forma confiável e imediata sobre novas mensagens usando os servidores de notificação da Huawei. Você será notificado de forma confiável e imediata sobre novas mensagens usando os servidores de notificação da Apple. + Exibir uma notificação genérica do {app_name}, sem o nome do remetente ou o conteúdo da mensagem. Ir para configurações de notificação do dispositivo Notificações - Todas Notificações - Somente menções @@ -609,6 +854,7 @@ {name} para {conversation_name} Pode ser que você tenha recebido mensagens enquanto seu {device} reiniciava. Cor do LED + Reproduzir um som quando você receber novas mensagens. Apenas menções Notificação de Mensangens Mais recente de {name} @@ -616,6 +862,8 @@ Mutar por {time_large} Desmutar Silenciado + Silenciado por {time_large} + Silenciado até {date_time} Slow Mode {app_name} verificará ocasionalmente por novas mensagens em segundo plano. Som @@ -628,6 +876,12 @@ Desligado Okay Ligado + No seu dispositivo {device_type} + Abra esta conta do {app_name} em um dispositivo {device_type} conectado à {platform_account} com a qual você se inscreveu originalmente. Em seguida, cancele o {pro} nas configurações do {app_pro}. + Abra esta conta do {app_name} em um dispositivo {device_type} conectado à conta {platform_account} usada no momento do cadastro. Em seguida, atualize seu acesso {pro} nas configurações do {app_pro}. + Em um dispositivo conectado + No site da {platform_store} + No site da {platform} Criar Conta Conta Criada Eu tenho uma conta @@ -652,33 +906,70 @@ Não foi possível reconhecer este ONS. Por favor, verifique e tente novamente. Não foi possível buscar este ONS. Por favor, tente novamente mais tarde. Abrir + Abrir site da {platform_store} + Abrir site do {platform} + Abrir configurações + Abrir pesquisa Outro + Senha Alterar Senha + Altere a senha necessária para desbloquear {app_name}. + Sua senha foi alterada. Por favor, mantenha-a segura. Confirmar senha + Criar senha Sua senha atual está incorreta. Digite sua senha Por favor, insira sua senha atual Por favor, digite a sua nova senha A senha deve conter apenas letras, números e símbolos + A senha deve ter entre {min} e {max} caracteres. As senhas não coincidem Falha ao definir a senha Senha incorreta + Confirmar nova senha Remover Senha + Remova a senha necessária para desbloquear {app_name} + Sua senha foi removida. Definir Senha + Sua senha foi definida. Por favor, mantenha-a segura. + Exigir senha para desbloquear o {app_name} na inicialização. + Mais de 12 caracteres + Inclui um número + Inclui uma letra minúscula + Inclui um símbolo + Inclui uma letra maiúscula + Indicador de Força da Senha + Definir uma senha forte ajuda a proteger suas mensagens e anexos caso seu dispositivo seja perdido ou roubado. + Senhas Colar + Erro de pagamento + Seu pagamento foi processado com sucesso, mas ocorreu um erro ao {action_type} seu status do {pro}.\n\nVerifique sua conexão de rede e tente novamente. + Alteração de permissão {app_name} precisa de acesso a música e áudio para enviar arquivos, músicas e áudio, mas o acesso foi permanentemente negado. Toque em Configurações → Permissões e ative \"Música e áudio\". {app_name} precisa usar a Apple Music para reproduzir anexos de mídia. Atualização Automática Verificar atualizações automaticamente ao iniciar + O acesso à câmera é necessário para fazer chamadas de vídeo. Ative a permissão \"Câmera\" nas Configurações para continuar. + O acesso à câmera está atualmente ativado. Para desativá-lo, altere a permissão \"Câmera\" nas Configurações. {app_name} precisa de acesso à câmera para tirar fotos e vídeos, mas ele foi permanentemente negado. Toque em Configurações → Permissões, e ligue \"Câmera\". + Permitir acesso à câmera para chamadas de vídeo. A funcionalidade de bloqueio de tela no {app_name} usa reconhecimento facial. Manter na Bandeja do Sistema + {app_name} continua sendo executado em segundo plano quando você fecha a janela. O {app_name} precisa de acesso à biblioteca de fotos para continuar. Você pode habilitar o acesso nas configurações do iOS. + É necessário acesso à rede local para permitir chamadas. Ative a permissão \"Rede local\" nas Configurações para continuar. + {app_name} precisa de acesso à rede local para realizar chamadas de voz e vídeo. + O acesso à rede local está atualmente ativado. Para desativá-lo, desligue a permissão \"Rede local\" nas Configurações. + Permitir acesso à rede local para facilitar chamadas de voz e vídeo. + Rede local Microfone {app_name} precisa de acesso ao microfone para fazer chamadas e enviar mensagens de áudio, mas foi permanentemente negado. Toque em Configurações → Permissões, e ligue \"Microfone\". + O acesso ao microfone é necessário para fazer chamadas e gravar mensagens de áudio. Ative a permissão \"Microfone\" nas Configurações para continuar. Você pode habilitar o acesso ao microfone nas configurações de privacidade do {app_name} {app_name} precisa de acesso ao microfone para fazer chamadas e gravar mensagens de áudio. + O acesso ao microfone está atualmente ativado. Para desativá-lo, altere a permissão \"Microfone\" nas Configurações. Permita acesso ao microfone. + Permitir acesso ao microfone para chamadas de voz e mensagens de áudio. {app_name} precisa de acesso a música e áudio para enviar arquivos, músicas e áudios. Permissão requerida {app_name} precisa de acesso à biblioteca de fotos para que você possa enviar fotos e vídeos, mas o acesso foi permanentemente negado. Toque em Configurações → Permissões e ative \"Fotos e vídeos\". @@ -686,11 +977,177 @@ {app_name} precisa de acesso ao armazenamento para salvar anexos e mídias. {app_name} precisa de acesso ao seu armazenamento para salvar fotos e vídeos, mas foi permanentemente negado. Por favor, continue para configurações do app, selecione \"Permissões\", e habilite \"Armazenamento\". {app_name} precisa de acesso ao seu armazenamento para enviar fotos e vídeos. + Você não tem permissões de escrita nesta comunidade Fixar Fixar conversa Desafixar Desafixar Conversa + E Muito Mais... + Novos recursos chegando em breve ao {pro}. Descubra o que vem por aí no Roteiro do {pro} {icon} + Preferências Pré-visualizar + Pré-visualização de notificação + Seu acesso ao {pro} está ativo!\n\nSeu acesso ao {pro} será renovado automaticamente por mais {current_plan_length} em {date}. + Seu acesso ao {pro} está ativo!\n\nSeu acesso ao {pro} será renovado automaticamente por mais\n{current_plan_length} em {date}. Quaisquer atualizações feitas aqui entrarão em vigor na próxima renovação. + Erro de acesso ao {pro} + Seu acesso ao {pro} expirará em {date}. + Carregando acesso {pro} + As informações de acesso ao {pro} ainda estão sendo carregadas. Você não pode atualizar até que esse processo seja concluído. + carregando acesso ao {pro}... + Não foi possível conectar à rede para carregar suas informações de acesso ao {pro}. A atualização do {pro} via {app_name} será desativada até que a conexão seja restabelecida.\n\nVerifique sua conexão de rede e tente novamente. + Acesso ao {pro} não encontrado + O {app_name} detectou que sua conta não possui acesso ao {pro}. Se você acredita que isso seja um erro, entre em contato com o suporte do {app_name} para obter ajuda. + Recuperar Acesso ao {pro} + Renovar Acesso ao {pro} + Atualmente, o acesso ao {pro} só pode ser adquirido e renovado por meio da {platform_store} ou da {platform_store_other}. Como você está usando o {app_name} para Desktop, não é possível renovar por aqui.\n\nOs desenvolvedores do {app_name} estão trabalhando intensamente em opções de pagamento alternativas para permitir que os usuários adquiram o acesso ao {pro} fora da {platform_store} e da {platform_store_other}. Roteiro do {pro} {icon} + Renove seu acesso ao {pro} no site da {platform_store} usando a {platform_account} com a qual você se cadastrou no {pro}. + Renove no site da {platform} usando a {platform_account} com a qual você se inscreveu no {pro}. + Renove seu acesso ao {pro} para voltar a usar os poderosos recursos Beta do {app_pro}. + Acesso ao {pro} recuperado + O {app_name} detectou e recuperou o acesso ao {pro} da sua conta. Seu status {pro} foi restaurado! + Como você se inscreveu originalmente no {app_pro} por meio da {platform_store}, será necessário usar sua conta {platform_account} para atualizar seu acesso ao {pro}. + Atualmente, o acesso ao {pro} só pode ser adquirido por meio da {platform_store} ou da {platform_store_other}. Como você está usando o {app_name} Desktop, não é possível fazer upgrade para o {pro} aqui.\n\nOs desenvolvedores do {app_name} estão trabalhando arduamente em opções de pagamento alternativas para permitir que os usuários comprem acesso ao {pro} fora das lojas {platform_store} e {platform_store_other}. Roteiro do {pro} {icon} + Ativado + ativando + Tudo pronto! + Seu acesso ao {app_pro} foi atualizado! A cobrança será feita quando o {pro} for renovado automaticamente em {date}. + Você já tem + Vá em frente e envie imagens GIF e WebP animadas para sua imagem de exibição! + Obtenha imagens de exibição animadas e desbloqueie recursos avançados com o {app_pro} Beta + Imagem de Exibição Animada + usuários podem enviar GIFs + Imagens de Exibição Animadas + Defina GIFs animados e imagens WebP como sua imagem de exibição. + Envie GIFs com + {pro} será renovado automaticamente em {time} + Insígnia do {pro} + Mostrar o emblema do {app_pro} para outros usuários + Emblemas + Mostre seu apoio ao {app_name} com um emblema exclusivo ao lado do seu nome de exibição. + + %1$s distintivo %2$s enviado + %1$s distintivos %2$s enviados + + Recursos Beta do {pro} + {price} faturado anualmente + {price} faturado mensalmente + {price} faturado trimestralmente + Quer enviar mensagens mais longas?\nEnvie mais texto e desbloqueie recursos premium com o {app_pro} Beta + Quer mais fixações?\nOrganize seus chats e desbloqueie recursos avançados com o {app_pro} Beta + Quer mais de {limit} fixações?\nOrganize seus chats e desbloqueie recursos avançados com o {app_pro} Beta + Lamentamos que você esteja cancelando o {pro}. Aqui está o que você precisa saber antes de cancelar seu acesso ao {pro}. + Cancelamento + Cancelar o acesso ao {pro} impedirá a renovação automática antes que o acesso ao {pro} expire. Cancelar o {pro} não gera reembolso. Você continuará podendo usar os recursos do {app_pro} até o vencimento do seu acesso ao {pro}.\n\nComo você se inscreveu originalmente no {app_pro} usando sua conta {platform_account}, será necessário usar a mesma conta {platform_account} para cancelar o {pro}. + Duas maneiras de cancelar seu acesso ao {pro}: + Cancelar o acesso ao {pro} impedirá a renovação automática antes que o {pro} expire.\n\nCancelar o {pro} não resulta em reembolso. Você continuará podendo usar os recursos do {app_pro} até que seu acesso ao {pro} expire. + Escolha a opção de acesso ao {pro} que é ideal para você.\nAcesso por mais tempo significa descontos maiores. + Tem certeza de que deseja excluir seus dados deste dispositivo?\n\n{app_pro} não pode ser transferido para outra conta. Salve sua senha de recuperação para garantir que você possa restaurar seu acesso ao {pro} posteriormente. + Tem certeza de que deseja excluir seus dados da rede? Se continuar, não será possível restaurar suas mensagens ou contatos.\n\n{app_pro} não pode ser transferido para outra conta. Salve sua senha de recuperação para garantir que você possa restaurar seu acesso ao {pro} posteriormente. + Seu acesso ao {pro} já está com {percent}% de desconto sobre o preço total do {app_pro}. + Erro ao atualizar o status do {pro} + Expirado + Infelizmente, seu acesso ao {pro} expirou.\nRenove para reativar os benefícios e recursos exclusivos do {app_pro} Beta. + Expirando em breve + Seu acesso ao {pro} expirará em {time}.\nAtualize agora para continuar acessando os benefícios e recursos exclusivos do {app_pro} Beta + {pro} expira em {time} + {pro} FAQ + Encontre respostas para perguntas frequentes na seção de Perguntas Frequentes do {app_pro}. + Envie imagens de exibição em GIF e WebP + Conversas em grupo maiores com até 300 membros + E muito mais recursos exclusivos + Mensagens com até 10.000 caracteres + Fixe conversas ilimitadas + Quer usar o {app_name} ao máximo?\nFaça upgrade para o {app_pro} Beta para obter acesso a muitos recursos e benefícios exclusivos. + Grupo Ativado + Este grupo tem capacidade expandida! Pode suportar até 300 membros porque um administrador do grupo tem + + %1$s grupo atualizado + %1$s grupos atualizados + + Solicitar um reembolso é definitivo. Se aprovado, seu acesso ao {pro} será cancelado imediatamente e você perderá o acesso a todos os recursos do {pro}. + Maior Tamanho de Anexo + Comprimento de mensagem aumentado + Grupos Maiores + Grupos nos quais você é administrador são atualizados automaticamente para suportar até 300 membros. + Em breve, bate-papos em grupo maiores (até 300 membros) estarão disponíveis para todos os usuários Pro Beta! + Mensagens Mais Longas + Você pode enviar mensagens com até 10.000 caracteres em todas as conversas. + + %1$s mensagem longa enviada + %1$s mensagens longas enviadas + + Esta mensagem utilizou os seguintes recursos do {app_pro}: + Com uma nova instalação + Reinstale o {app_name} neste dispositivo via {platform_store}, restaure sua conta com a Senha de Recuperação e renove o {pro} nas configurações do {app_pro}. + Reinstale o {app_name} neste dispositivo por meio da {platform_store}, restaure sua conta com sua Senha de Recuperação e faça o upgrade para o {pro} nas configurações do {app_pro}. + Por enquanto, existem três maneiras de renovar: + Por enquanto, há duas maneiras de renovar: + {percent}% de desconto + + %1$s conversa fixada + %1$s conversas fixadas + + Como você se inscreveu originalmente no {app_pro} por meio da {platform_store}, será necessário usar sua conta {platform_account} para solicitar um reembolso. + Como você se inscreveu originalmente no {app_pro} por meio da {platform_store}, sua solicitação de reembolso será processada pelo Suporte do {app_name}.\n\nSolicite um reembolso clicando no botão abaixo e preenchendo o formulário de solicitação de reembolso.\n\nEmbora o Suporte do {app_name} se esforce para processar as solicitações de reembolso em até 24–72 horas, esse processo pode demorar mais em períodos de alto volume de solicitações. + Seu acesso ao {app_pro} foi renovado! Obrigado por apoiar o {network_name}. + 1 mês - {monthly_price} / mês + 3 meses - {monthly_price} / mês + 12 meses - {monthly_price} / mês + reativando + Abra esta conta do {app_name} em um dispositivo {device_type} conectado à {platform_account} com a qual você se inscreveu originalmente. Em seguida, solicite um reembolso pelas configurações do {app_pro}. + Sentimos muito por ver você partir. Aqui está o que você precisa saber antes de solicitar um reembolso. + {platform} está processando sua solicitação de reembolso. Isso normalmente leva de 24 a 48 horas. Dependendo da decisão tomada, você poderá ver a alteração do status {pro} no {app_name}. + Sua solicitação de reembolso será tratada pelo Suporte do {app_name}.\n\nSolicite um reembolso clicando no botão abaixo e preenchendo o formulário de solicitação.\n\nEmbora o Suporte do {app_name} se esforce para processar as solicitações de reembolso dentro de 24 a 72 horas, este processo pode demorar mais em períodos de alta demanda. + Sua solicitação de reembolso será tratada exclusivamente pelo {platform} através do site do {platform}.\n\nDevido às políticas de reembolso do {platform}, os desenvolvedores do {app_name} não têm como interferir no resultado das solicitações. Isso inclui a aprovação ou recusa do pedido, assim como a emissão de reembolso total ou parcial. + Entre em contato com o {platform} para obter mais atualizações sobre sua solicitação de reembolso. Devido às políticas de reembolso do {platform}, os desenvolvedores do {app_name} não têm nenhuma capacidade de influenciar o resultado dos pedidos de reembolso.\n\nSuporte a reembolso do {platform} + Reembolsando {pro} + Os reembolsos do {app_pro} são administrados exclusivamente pelo {platform}, por meio da {platform_store}.\n\nDevido às políticas de reembolso do {platform}, os desenvolvedores do {app_name} não têm nenhuma capacidade de influenciar o resultado dos pedidos de reembolso. Isso inclui tanto a aprovação ou recusa do pedido quanto a decisão sobre a emissão de um reembolso total ou parcial. + Quer usar imagens de exibição animadas novamente?\nRenove seu acesso ao {pro} para desbloquear os recursos que você perdeu. + Renovar {pro} Beta + Renove seu acesso ao {pro} nas configurações do {app_pro} em um dispositivo vinculado com o {app_name} instalado via {platform_store} ou {platform_store_other}. + Quer enviar mensagens mais longas novamente?\nRenove seu acesso ao {pro} para desbloquear os recursos que você perdeu. + Quer usar o {app_name} ao máximo novamente?\nRenove seu acesso ao {pro} para desbloquear os recursos que você perdeu. + Quer fixar mais de {limit} conversas novamente?\nRenove seu acesso ao {pro} para desbloquear os recursos que você perdeu. + Quer fixar mais conversas novamente?\nRenove seu acesso ao {pro} para desbloquear os recursos que você perdeu. + Ao renovar, você concorda com os Termos de Serviço {icon} e a Política de Privacidade {icon} do {app_pro} + renovando + Atualmente, o acesso ao {pro} só pode ser adquirido e renovado por meio da {platform_store} ou da {platform_store_other}. Como você instalou o {app_name} usando o {build_variant}, não é possível renovar aqui.\n\nOs desenvolvedores do {app_name} estão trabalhando intensamente em opções alternativas de pagamento para permitir que os usuários adquiram o acesso ao {pro} fora da {platform_store} e da {platform_store_other}. Roteiro do {pro} {icon} + Reembolso Solicitado + Envie mais com + Configurações do {pro} + Comece a usar o {pro} + Suas Estatísticas {pro} + Carregando estatísticas do {pro} + Suas estatísticas do {pro} estão sendo carregadas, por favor, aguarde. + As estatísticas do {pro} refletem o uso neste dispositivo e podem parecer diferentes em dispositivos vinculados. + Erro no status do {pro} + Não foi possível conectar à rede para verificar seu status do {pro}. As informações exibidas nesta página podem estar incorretas até que a conexão seja restabelecida.\n\nVerifique sua conexão de rede e tente novamente. + Carregando status {pro} + As suas informações do {pro} estão sendo carregadas. Algumas ações nesta página podem não estar disponíveis até que o carregamento seja concluído. + Carregando status do {pro} + Não foi possível conectar à rede para verificar seu status {pro}. Você não poderá continuar até que a conectividade seja restaurada.\n\nVerifique sua conexão de rede e tente novamente. + Não foi possível conectar à rede para verificar seu status do {pro}. Você não poderá fazer upgrade para o {pro} até que a conexão seja restabelecida.\n\nVerifique sua conexão de rede e tente novamente. + Não foi possível conectar à rede para atualizar seu status do {pro}. Algumas ações nesta página ficarão desabilitadas até que a conexão seja restabelecida.\n\nVerifique sua conexão de rede e tente novamente. + Não foi possível conectar à rede para carregar seu acesso atual ao {pro}. A renovação do {pro} via {app_name} estará desativada até que a conectividade seja restabelecida.\n\nVerifique sua conexão com a rede e tente novamente. + Precisa de ajuda com o {pro}? Envie uma solicitação para a equipe de suporte. + Ao {action_type}, você está {activation_type} {app_pro} via o Protocolo {app_name}. {entity} facilitará essa ativação, mas não é o provedor do {app_pro}. {entity} não é responsável pelo desempenho, disponibilidade ou funcionalidade do {app_pro}. + Ao atualizar, você concorda com os Termos de Serviço {icon} e a Política de Privacidade {icon} do {app_pro}. + Fixações Ilimitadas + Organize todos os seus chats com conversas fixadas ilimitadas. + Sua opção de faturamento atual concede {current_plan_length} de acesso ao {pro}. Tem certeza de que deseja mudar para a opção de faturamento {selected_plan_length_singular}?\n\nAo atualizar, seu acesso ao {pro} será renovado automaticamente em {date} por mais {selected_plan_length} de acesso ao {pro}. + Seu acesso ao {pro} expirará em {date}.\n\nAo atualizar, seu acesso ao {pro} será renovado automaticamente em {date} por mais {selected_plan_length} de acesso ao {pro}. + atualizando + Faça upgrade para o {app_pro} Beta para acessar diversos benefícios e recursos exclusivos. + Faça o upgrade para o {pro} nas configurações do {app_pro} em um dispositivo vinculado com o {app_name} instalado pela {platform_store} ou {platform_store_other}. + Atualmente, o acesso ao {pro} só pode ser adquirido por meio da {platform_store} ou da {platform_store_other}. Como você instalou o {app_name} usando o {build_variant}, não é possível fazer upgrade para o {pro} aqui.\n\nOs desenvolvedores de {app_name} estão trabalhando arduamente em opções de pagamento alternativas para permitir que os usuários comprem acesso ao {pro} fora da {platform_store} e da {platform_store_other}. Roteiro do {pro} {icon} + Por enquanto, há apenas uma forma de atualizar: + Por enquanto, há duas maneiras de fazer o upgrade: + Você fez upgrade para {app_pro}!\nObrigado por apoiar a {network_name}. + atualizando + Atualizando para o {pro} + Ao fazer o upgrade, você concorda com os Termos de Serviço {icon} e com a Política de Privacidade {icon} do {app_pro} + Quer aproveitar ainda mais o {app_name}?\nAtualize para o {app_pro} Beta para uma experiência de mensagens mais poderosa. + {platform} está processando sua solicitação de reembolso. Perfil Imagem de Exibição Falha ao remover a imagem de exibição. @@ -698,6 +1155,19 @@ Escolha um arquivo menor, por favor. Falha na atualização do perfil. Promover + Administradores poderão ver o histórico de mensagens dos últimos 14 dias e não poderão ser rebaixados ou removidos do grupo. + + Promover Membro + Promover Membros + + + A promoção falhou + As promoções falharam + + + Não foi possível aplicar a promoção. Gostaria de tentar novamente? + Não foi possível aplicar as promoções. Gostaria de tentar novamente? + Código QR Este código QR não contém um ID de Conta Este código QR não contém uma senha de recuperação @@ -706,14 +1176,22 @@ Amigos podem enviar mensagens para você escaneando seu código QR. Sair de {app_name} Sair + Avaliar o {app_name}? + Avaliar o app + Ficamos felizes que você esteja gostando do {app_name}. Se tiver um momento, avaliá-lo na {storevariant} ajuda outras pessoas a descobrir mensagens privadas e seguras! Lido Confirmações de Leitura Mostrar recibos de leitura para todas as mensagens que você enviar e receber. Recebido: + Resposta recebida + Recebendo oferta de chamada + Recebendo pré-oferta Recomendado Salve sua senha de recuperação para garantir que você não perca o acesso à sua conta. Salve sua senha de recuperação + Use sua senha de recuperação para carregar sua conta em novos dispositivos.\n\nSua conta não pode ser recuperada sem sua senha de recuperação. Certifique-se de armazená-la em um lugar seguro e protegido — e não a compartilhe com ninguém. Digite sua senha de recuperação + Ocorreu um erro ao tentar carregar sua senha de recuperação.\n\nPor favor, exporte seus logs e, em seguida, envie o arquivo através do Help Desk do {app_name} para ajudar a resolver esse problema. Por favor, verifique sua senha de recuperação e tente novamente. Algumas das palavras em sua senha de recuperação estão incorretas. Por favor, verifique e tente novamente. A Recovery Password que você inseriu não é longa o suficiente. Por favor, verifique e tente novamente. @@ -721,19 +1199,69 @@ Para carregar sua conta, insira sua senha de recuperação. Ocultar Senha de Recuperação Permanentemente Sem sua senha de recuperação, você não pode carregar sua conta em novos dispositivos. \n\nRecomendamos fortemente que você salve sua senha de recuperação em um local seguro e seguro antes de continuar. + Tem certeza de que deseja ocultar permanentemente sua senha de recuperação neste dispositivo?\n\nIsso não pode ser desfeito. Ocultar Senha de Recuperação Permanentemente esconda sua senha de recuperação neste dispositivo. Digite sua senha de recuperação para carregar sua conta. Se você não a salvou, você pode encontrá-la nas configurações do aplicativo. + Visualizar Senha de Recuperação + Visibilidade da Senha de Recuperação Essa é sua senha de recuperação. Se você mandá-la para alguem, essa pessoa terá acesso completo a sua conta. + Recriar grupo Refazer + Como você se registrou originalmente no {app_pro} usando uma conta {platform_account} diferente, você precisará usar essa conta {platform_account} para atualizar seu acesso {pro}. + Duas maneiras de solicitar um reembolso: + Reduza o comprimento da mensagem em {count} + + %1$d caractere restante + %1$d caracteres restantes + + Lembrar mais tarde Remover + + Remover membro + Remover membros + + + Remover membro e suas mensagens + Remover membros e suas mensagens + Falha ao remover senha + Remova sua senha atual do {app_name}. Os dados armazenados localmente serão recriptografados com uma chave gerada aleatoriamente, armazenada no seu dispositivo. + + Removendo membro + Removendo membros + + Renovar + Renovando {pro} Responder + Solicitar Reembolso + Solicite um reembolso no site da {platform}, usando a {platform_account} com a qual você se inscreveu no {pro}. Reenviar + + Reenviar convite + Reenviar convites + + + Reenviar Promoção + Reenviar promoções + + + Reenviando convite + Reenviando convites + + + Reenviando promoção + Reenviando promoções + Carregando informações do país... Reiniciar Sincronizar novamente Tentar novamente + Limite de avaliações + Parece que você avaliou recentemente o {app_name}. Obrigado pelo seu feedback! + Executar app em segundo plano + Executar {app_name} em segundo plano? + Como você está usando o Modo Lento, recomendamos permitir que o {app_name} seja executado em segundo plano para melhorar as notificações. Isso pode melhorar a consistência das notificações, embora seu sistema ainda possa limitar automaticamente a atividade em segundo plano.\n\nVocê pode alterar isso depois em Configurações. Salvar Salvo Mensagens Salvas @@ -742,6 +1270,8 @@ Segurança de Tela Notificações de captura de tela Exigir uma notificação quando um contato fizer uma captura de tela de um chat um-a-um. + Ocultar a janela do {app_name} em capturas de tela feitas neste dispositivo. + Proteção contra capturas de tela {name} fez uma captura de tela. Procurar Procurar contatos @@ -757,8 +1287,15 @@ Procurando... Selecionar Selecionar todas + Selecionar ícone do aplicativo Enviar Enviando + Enviando oferta de chamada + A enviar candidatos de ligação + + Enviando promoção de administrador + Enviando promoções de administrador + Enviada: Aparência Limpar Dados @@ -766,55 +1303,117 @@ Ajuda Convide um amigo Pedidos de mensagem + Preço atual do {token_name_short} + As mensagens são enviadas através da {network_name}. A rede é composta por nós incentivados com {token_name_long}, o que mantém o {app_name} descentralizado e seguro. Saiba mais {icon} + Saiba mais sobre Staking + Capitalização de mercado + Nós do {app_name} protegendo suas mensagens + Nós do {app_name} no seu enxame + {token_name_long} está ativo! Explore a nova seção {network_name} em Configurações para entender como {token_name_long} impulsiona o Session. + Rede protegida por + Ao fazer o staking de {token_name_long} para proteger a rede, você ganha recompensas em {token_name_short} do {staking_reward_pool}. + Novo Notificações Permissões Privacidade + {app_pro} Beta Senha de Recuperação Configurações Aplicar + Definir imagem de exibição da Comunidade + Defina uma senha para o {app_name}. Os dados armazenados localmente serão criptografados com essa senha. Você precisará inseri-la toda vez que o {app_name} for iniciado. + Não foi possível atualizar a configuração Você precisa reiniciar o {app_name} para aplicar as novas configurações. Segurança de Tela + Inicialização Compartilhar Convide seu amigo para conversar com você no {app_name} compartilhando seu ID de Conta com ele. Compartilhe com seus amigos onde quer que você costuma falar com eles — então mova a conversa para cá. Existe um problema ao abrir o banco de dados. Por favor, reinicie o aplicativo e tente novamente. + Ops! Parece que você ainda não tem uma conta no {app_name}.\n\nVocê precisa criar uma no app {app_name} antes de poder compartilhar. + Você gostaria de compartilhar o histórico de mensagens do grupo com este usuário? Compartilhar com {app_name} + Desculpe, o {app_name} só oferece suporte ao compartilhamento de múltiplas imagens e vídeos de uma vez + O compartilhamento oferece suporte apenas a arquivos de mídia. Os arquivos que não são de mídia foram excluídos Mostrar Mostrar todas Mostrar menos + Mostrar Nota Pessoal + Tem certeza de que deseja mostrar a Nota Pessoal na sua lista de conversas? + Corretor ortográfico Figurinhas + Força + Está com problemas? Consulte os artigos de ajuda ou abra um chamado com o Suporte do {app_name}. Ir para Página de Suporte Informações do Sistema: {information} + Toque para tentar novamente Continuar Padrão Erro + Voltar + Pré-visualização do tema + O ID da Conta de {name} está visível com base em suas interações anteriores + IDs Ocultos são usados em comunidades para reduzir spam e aumentar a privacidade + Traduzir + Bandeja Tente novamente Indicadores de digitação Veja e compartilhe indicadores de digitação. + Indisponível Desfazer Desconhecido + CPU não compatível + Atualizar + Atualizar Acesso {pro} + Duas maneiras de atualizar seu acesso ao {pro}: Atualizações de app + Atualizar Informações da Comunidade + O nome e a descrição da Comunidade são visíveis para todos os membros da Comunidade + Por favor, insira uma descrição mais curta da Comunidade + Por favor, insira um nome de Comunidade mais curto Atualização instalada, clique para reiniciar Baixando atualização: {percent_loader}% Não foi possível atualizar {app_name} falhou em atualizar. Por favor, vá para {session_download_url} e instale a nova versão manualmente, depois contate nosso Centro de Ajuda para nos informar sobre o problema. + Atualizar informações do grupo + O nome e a descrição do grupo estão visíveis para todos os membros do grupo. + Por favor, insira uma descrição mais curta para o grupo Uma nova versão de {app_name} está disponível, toque para atualizar + Uma nova versão ({version}) do {app_name} está disponível. + Atualizar Informações do Perfil + Seu nome de exibição e imagem de exibição estão visíveis em todas as conversas. Ir para Notas de Lançamento Atualização de {app_name} Versão {version} + Última atualização há {relative_time} + Atualizações + Atualizando... + Atualizar + Atualizar {app_name} + Atualizar para Enviando Copiar URLs Abrir URL Isso será aberto no seu navegador. Tem certeza de que deseja abrir esta URL no seu navegador?\n\n{url} + Os links serão abertos no seu navegador. Usar Modo Rápido + Altere seu plano usando a {platform_account} com a qual você se inscreveu, através do site da {platform}. + Pelo site do {platform} Vídeo Não foi possível reproduzir o vídeo. Ver + Ver menos + Ver mais Isso pode levar alguns minutos. Um momento, por favor... Aviso + O suporte para iOS 15 foi encerrado. Atualize para o iOS 16 ou superior para continuar recebendo atualizações do app. Janela Sim Você + Sua CPU não oferece suporte às instruções SSE 4.2, que são exigidas pelo {app_name} em sistemas operacionais Linux x64 para processar imagens. Faça upgrade para uma CPU compatível ou utilize outro sistema operacional. + Sua Senha de Recuperação + Fator de zoom + Ajuste o tamanho do texto e dos elementos visuais. \ No newline at end of file diff --git a/app/src/main/res/values-b+pt+PT/strings.xml b/app/src/main/res/values-b+pt+PT/strings.xml index a4c4f24421..d686c94b9f 100644 --- a/app/src/main/res/values-b+pt+PT/strings.xml +++ b/app/src/main/res/values-b+pt+PT/strings.xml @@ -15,7 +15,13 @@ Este é o seu ID da Conta. Outros utilizadores podem verificá-lo para iniciar uma conversa consigo. Tamanho Real Adicionar + + Adicionar admin + Adicionar admins + + Adicionar admin Introduza o ID de Conta do utilizador que está a promover a administrador.\n\nPara adicionar vários utilizadores, introduza cada ID de Conta separado por vírgulas. Podem ser especificados até 20 IDs de Conta de cada vez. + Os admins não podem ser despromovidos ou removidos do grupo. Admins não podem ser removidos. {name} e {count} outros foram promovidos a administradores. Promover Admins @@ -40,12 +46,19 @@ {name} foi removido(a) como Admin. {name} e {count} outros foram removidos de Admin. {name} e {other_name} foram removidos de Admin. + + %1$d admin selecionado + %1$d admins selecionados + A enviar promoção de administrador A enviar promoções de administrador Definições Admin + Não pode alterar o seu estado de admin. Para sair do grupo, abra as definições da conversa e selecione Sair do grupo. {name} e {other_name} foram promovidos a Admin. + Administradores + Permitir +{count} Anónimo Ícone da aplicação @@ -65,6 +78,8 @@ Notas Ações Meteorologia + Distintivo {app_pro} + Modo escuro automático Ocultar Barra de Menu Idioma Escolha a configuração de idioma para {app_name}. {app_name} reiniciará quando alterar a configuração de idioma. @@ -159,6 +174,8 @@ Tem certeza que deseja desbloquear {name} e {count} outros? Tem certeza que deseja desbloquear {name} e mais uma pessoa? Desbloqueado {name} + Ver e gerenciar contatos bloqueados. + Nenhum navegador encontrado para abrir esta URL, tente copiar o URL em vez disso Chamar {name} ligou para si Não pode iniciar uma nova chamada. Termine a sua chamada atual primeiro. @@ -183,9 +200,14 @@ Chamadas (Beta) Chamadas de Voz/Vídeo Chamadas de Voz/Vídeo (Beta) + O seu IP está visível para seu parceiro de chamada e para um servidor {session_foundation} enquanto usa chamadas beta. Permitir chamadas de voz e vídeo para e a partir de outros utilizadores. Ligou para {name} Perdeu uma chamada de {name} porque não ativou Chamadas de Voz e Vídeo nas Configurações de Privacidade. + {app_name} precisa de acesso à sua câmera para permitir chamadas de vídeo, mas essa permissão foi negada. Você não pode atualizar as permissões da câmera durante uma chamada.\n\nDeseja encerrar a ligação agora e ativar o acesso à câmera ou prefere ser lembrado após a chamada? + Para permitir o acesso à câmera, abra as configurações e ative a permissão da Câmera. + Durante sua última chamada, você tentou usar o vídeo, mas não conseguiu porque o acesso à câmera foi negado anteriormente. Para permitir o acesso à câmera, abra as configurações e ative a permissão da Câmera. + Acesso à Câmera Necessário Não foi encontrada a câmara Câmera indisponível. Conceder Acesso à Câmera @@ -193,7 +215,19 @@ {app_name} precisa de acesso à câmera para tirar fotos e vídeos, ou escanear códigos QR. {app_name} precisa de acesso à câmera para escanear códigos QR Cancelar + Cancelar {pro} + Cancele no site da {platform}, usando a {platform_account} com a qual você se inscreveu no {pro}. + Cancele no site da {platform_store}, usando a {platform_account} com a qual você se inscreveu no {pro}. + Alterar Falha ao modificar a palavra-passe + Altere sua palavra-passe para {app_name}. Os dados armazenados localmente serão recriptografados com sua nova palavra-passe. + Alterar configuração + Verificando status {pro} + Verificando seu status do {pro}. Você poderá continuar assim que essa verificação for concluída. + Verificando seus dados {pro}. Algumas ações nesta página podem não estar disponíveis até que essa verificação seja concluída. + A verificar o estado do {pro}... + A verificar os detalhes do seu {pro}. Não é possível renovar enquanto esta verificação não for concluída. + Verificando seu status {pro}. Você poderá fazer upgrade para {pro} quando essa verificação for concluída. Limpar Limpar Tudo Limpar Todos os Dados @@ -252,11 +286,17 @@ URL da Comunidade Copiar URL da Comunidade Confirmar + Confirmar promoção + Tem a certeza? Os admins não podem ser despromovidos ou removidos do grupo. Contactos Apagar Contacto Tem a certeza que pretende eliminar {name} dos seus contactos? Novas mensagens de {name} chegarão como um pedido de mensagem. Ainda não tem contatos Selecionar Contactos + + %1$d contacto selecionado + %1$d contactos selecionados + Detalhes do Utilizador Câmara Escolha uma ação para começar uma conversa @@ -264,6 +304,7 @@ Composição da mensagem Miniatura da imagem da mensagem citada Criar uma conversa com um novo contacto + Escolha o conteúdo exibido nas notificações locais ao receber uma mensagem. Adicionar ao ecrã inicial Adicionado ao ecrã inicial Mensagens de Áudio @@ -276,12 +317,18 @@ Conversa excluída Não existem mensagens em {conversation_name}. Inserir ENTER + Defina como as teclas Enter e Shift+Enter funcionam nas conversas. + SHIFT + ENTER envia uma mensagem, ENTER começa uma nova linha. + ENTER envia uma mensagem, SHIFT + ENTER começa uma nova linha. Grupos Redução do tamanho de mensagem Aparar Comunidades + Eliminar automaticamente mensagens com mais de 6 meses em comunidades com mais de 2000 mensagens. Nova conversa Ainda não tem conversas + Enviar com Enter Tocar na tecla Enter enviará uma mensagem em vez de iniciar uma nova linha. + Enviar com Shift+Enter Toda a Multimédia Verificação ortográfica Permitir corretor ortográfico ao escrever mensagens. @@ -290,7 +337,10 @@ Copiar Criar A criar chamada + Cobrança atual + Palavra-passe atual Cortar + Modo escuro Tem a certeza de que pretende apagar todas as mensagens, anexos e dados da conta deste dispositivo e criar uma nova conta? Ocorreu um erro na base de dados.\n\nExporte os registos da sua aplicação para partilhar para apoio à resolução de problemas. Se isto não resultar, reinstale o {app_name} e recupere a sua conta. Tem a certeza de que pretende apagar todas as mensagens, anexos e dados da conta deste dispositivo e restaurar a sua conta a partir da rede? @@ -315,6 +365,14 @@ Por favor, espere enquanto o grupo é criado... Falha ao Atualizar Grupo Você não tem permissão para eliminar mensagens de outros + + Eliminar anexo selecionado + Eliminar anexos selecionados + + + Tem a certeza de que pretende eliminar o anexo selecionado? A mensagem associada ao anexo também será eliminada. + Tem certeza de que deseja excluir os anexos selecionados? A mensagem associada aos anexos também será excluída. + Tem a certeza de que pretende remover {name} dos seus contactos?\n\nIsto eliminará a sua conversa, incluindo todas as mensagens e anexos. Mensagens futuras de {name} aparecerão como um pedido de mensagem. Tem certeza de que deseja apagar sua conversa com {name}?\nIsto irá eliminar permanentemente todas as mensagens e anexos. @@ -354,6 +412,7 @@ Tem a certeza que pretende eliminar estas mensagens para todos? A eliminar Ativar ferramentas do programador + Configurações de notificação do dispositivo Iniciar Ditado... Destruição de Mensagens A mensagem será apagada em {time_large} @@ -389,6 +448,7 @@ {admin_name} atualizou as definições de mensagens que desaparecem. Você atualizou as definições de mensagens que desaparecem. Ignorar + Visualização Pode ser o seu nome, uma alcunha, ou qualquer coisa que goste — e pode ser alterado a qualquer momento. Introduza um nome de utilizador Por favor, insira um nome de exibição @@ -400,6 +460,8 @@ Seu Nome de Exibição é visível para usuários, grupos e comunidades com os quais você interage. Documento Fazer uma doação + Forças poderosas estão tentando enfraquecer a privacidade, mas não podemos continuar esta luta sozinhos.\n\nDoar ajuda a manter o {app_name} seguro, independente e online. + {app_name} precisa da sua ajuda Concluído Transferir Transferindo... @@ -429,19 +491,38 @@ Você e {name} reagiram com {emoji_name} Reagiu à sua mensagem {emoji} Ativar + Ativar Acesso à Câmera? + Mostrar notificações quando você receber novas mensagens. + Encerrar Chamada para Ativar Está a gostar do {app_name}? Precisa de melhorias {emoji} Está ótimo {emoji} Já usa o {app_name} há algum tempo, como tem corrido? Gostaríamos muito de ouvir a sua opinião. + Entrar + Insira a palavra-passe que você definiu para {app_name} + Insira a palavra-passe que você usa para desbloquear o {app_name} na inicialização, não a sua palavra-passe de recuperação + Erro ao verificar o status {pro} Por favor, verifique a sua conexão à Internet e tente novamente. Copiar erro e sair Erro na base de dados Ocorreu um erro. Por favor, tente novamente mais tarde. + Erro ao carregar acesso {pro} + {app_name} não conseguiu procurar por este ONS. Verifique a sua ligação à rede e tente novamente. Ocorreu um erro desconhecido. + Este ONS não está registrado. Verifique se está correto e tente novamente. + Falha ao reenviar convite para {name} em {group_name} + Falha ao reenviar convite para {name} e mais {count} em {group_name} + Falha ao reenviar convite para {name} e {other_name} em {group_name} + Falha ao reenviar a promoção para {name} no grupo {group_name} + Falha ao reenviar a promoção para {name} e {count} outros no grupo {group_name} + Falha ao reenviar a promoção para {name} e {other_name} no grupo {group_name} Falha ao transferir Falhas + Opinião + Compartilhe sua experiência com o {app_name} respondendo a uma breve pesquisa. Ficheiro Ficheiros + Alinhar com definições do sistema. Para sempre De: Ativar ecrã completo @@ -488,6 +569,9 @@ Tem a certeza de que pretende sair {group_name}? Tem a certeza de que pretende sair {group_name}?\n\nIsto irá remover todos os membros e eliminar todo o conteúdo do grupo. Erro ao sair do grupo {group_name} + {name} foi convidado a juntar-se ao grupo. O histórico de conversas dos últimos 14 dias foi partilhado. + {name} e mais {count} foram convidados a entrar no grupo. O histórico de mensagens dos últimos 14 dias foi partilhado. + {name} e {other_name} foram convidados a entrar no grupo. O histórico de mensagens dos últimos 14 dias foi partilhado. {name} saiu do grupo. {name} e {count} outros saíram do grupo. {name} e {other_name} saíram do grupo. @@ -499,6 +583,9 @@ {name} e {other_name} foram convidados a juntar-se ao grupo. Você e {count} outros foram convidados a juntar-se ao grupo. O histórico da conversa foi partilhado. Você e {other_name} foram convidados a juntar-se ao grupo. O histórico da conversa foi partilhado. + Falha ao remover {name} de {group_name} + Falha ao remover {name} e {count} outros de {group_name} + Falha ao remover {name} e {other_name} de {group_name} Você saiu do grupo. Membros do Grupo Não há outros membros neste grupo. @@ -512,6 +599,7 @@ Não possui mensagens de {group_name}. Envie uma mensagem para iniciar a conversa! Este grupo não foi atualizado nos últimos 30 dias. Pode ter problemas ao enviar mensagens ou ao visualizar informações do grupo. Você é o único admin em {group_name}.\n\nOs membros e as definições do grupo não podem ser alterados sem um admin. + Você é o único admin em {group_name}.\n\nOs membros e as definições do grupo não podem ser alterados sem um admin. Para sair do grupo sem o apagar, adicione primeiro um novo admin. Remoção pendente Foi promovido a administrador. Você e {count} outros foram promovidos a Admin. @@ -539,22 +627,32 @@ Grupo atualizado A processar candidatos de ligação FAQ (Perguntas Mais Frequentes) + Consulte as perguntas frequentes do {app_name} para obter respostas às questões mais comuns. Ajude-nos a traduzir {app_name} + Denunciar erro Partilhe alguns detalhes para nos ajudar a resolver o seu problema. Exporte os seus registos e envie o arquivo para o Suporte Técnico do {app_name}. Exportar Registos Exporte os seus registos e carregue o arquivo através do Suporte Técnico {app_name}. Guardar no desktop + Guarde este ficheiro e partilhe-o com os programadores do {app_name}. Suporte + Ajude a traduzir o {app_name} para mais de 80 idiomas! Adoraríamos ter o seu feedback Ocultar + Ativar a visibilidade da barra do menu do sistema. Tem a certeza de que pretende ocultar a Nota Pessoal da sua lista de conversas? Ocultar Outros Imagem imagens + Importante Teclado Anónimo Solicitar modo incognito se disponível. Dependendo do teclado que está a usar, o seu teclado pode ignorar este pedido. Informações Atalho inválido + + Convidar contacto + Convidar contactos + O convite falhou Os convites falharam @@ -563,8 +661,17 @@ O convite não pôde ser enviado. Gostaria de tentar novamente? Os convites não puderam ser enviados. Gostaria de tentar novamente? + + Convidar membro + Convidar membros + + Convide um novo membro para o grupo inserindo o ID da Conta, ONS do seu amigo ou lendo o código QR dele {icon} + Convide um novo membro para o grupo inserindo o ID da Conta, ONS do seu amigo ou digitalizando o respetivo código QR Entrar Mais tarde + Iniciar {app_name} automaticamente quando o computador for ligado. + Iniciar com o sistema + Esta definição é gerida pelo seu sistema no Linux. Para ativar a inicialização automática, adicione o {app_name} às suas aplicações de arranque nas definições do sistema. Saber mais Sair A sair... @@ -579,6 +686,8 @@ Você e {other_name} juntaram-se ao grupo. {name} e {other_name} juntaram-se ao grupo. Você juntou-se ao grupo. + Limitar atividade em segundo plano? + Atualmente você permite que o {app_name} seja executado em segundo plano para melhorar a confiabilidade das notificações. Alterar esta configuração pode resultar em notificações menos confiáveis. Pré-visualizações de links Exibir pré-visualizações de links para URLs suportados. Ativar pré-visualizações do link @@ -589,6 +698,7 @@ Não terá proteção total de metadados ao enviar visualizações de links. Pré-visualizações de links estão desligadas {app_name} precisa de se ligar aos respectivos sites para gerar pré-visualizações de links que envia e recebe.\n\nPode ativá-los nas configurações do {app_name}. + Ligações Carregar Conta A carregar a sua conta A carregar... @@ -601,9 +711,17 @@ Estado do bloqueio Toque para desbloquear {app_name} está desbloqueado + Registos + Gerir administradores Gerir membros + Gerenciar {pro} Máxima + Talvez mais tarde Multimédia + + %1$d membro selecionado + %1$d membros selecionados + %1$d membro %1$d membros @@ -613,7 +731,9 @@ %1$d membros ativos Adicionar ID da Conta ou ONS + Os membros só podem ser promovidos após aceitarem o convite para entrar no grupo. Convidar contactos + Não tem contactos para convidar para este grupo.\nVolte atrás e convide membros usando o ID da Conta ou ONS. Enviar Convite Enviar Convites @@ -622,10 +742,14 @@ Gostaria de partilhar o histórico de mensagens do grupo com {name} e {count} outros? Gostaria de partilhar o histórico de mensagens do grupo com {name} e {other_name}? Partilhar histórico de mensagens + Partilhar histórico de mensagens dos últimos 14 dias Partilhar apenas novas mensagens Convidar + Membros (não administradores) + Barra de menu Mensagem Ler mais + Copiar mensagem Esta mensagem está vazia. Falha na entrega de mensagem Atingido o limite de mensagens @@ -640,6 +764,7 @@ Inicie uma nova conversa inserindo o ID da Conta ou ONS do seu amigo. Inicie uma nova conversa inserindo o ID da Conta, ONS do seu amigo ou verificando o código QR. + Inicie uma nova conversa inserindo o ID da conta, ONS do seu amigo ou escaneando o código QR dele {icon} Tem uma nova mensagem. Tem %1$d novas mensagens. @@ -688,20 +813,29 @@ Mensagem muito longa Por favor, reduza a sua mensagem para {limit} caracteres ou menos. Mensagem muito longa + Nova palavra-passe Seguinte + Próximas etapas Escolha o nome de utilizador para {name}. Isto vai-lhe aparecer no seu um-para-um e conversas de grupo. Introduza uma alcunha Por favor, escolha um nome de exibição mais curto Remover alcunha Configurar Alcunha Não + Não há membros não administradores neste grupo. Sem Sugestões + Envie mensagens de até 10.000 caracteres em todas as conversas. + Organize os chats com conversas fixadas ilimitadas. Nenhum Agora não Nota para mim Não possui mensagens em Nota Pessoais. Ocultar Nota Pessoal Tem certeza que pretende ocultar a Nota pessoal? + ATENÇÃO: Ao {action_type}, você concorda com os Termos de Serviço {icon} e a Política de Privacidade {icon} do {app_pro} + Visualização de notificação + Exibir o nome do remetente e uma prévia do conteúdo da mensagem. + Exibir apenas o nome do remetente, sem o conteúdo da mensagem. Todas as Mensagens Conteúdo da notificação A informação exibida nas notificações. @@ -712,6 +846,7 @@ Será notificado sobre novas mensagens de forma consistente e imediata usando os servidores de notificação do Google. Será notificado de novas mensagens de forma fiável e imediata usando os servidores de notificação da Huawei. Ao usar os servidores de notificação da Apple, será notificado de novas mensagens de forma consistente e imediata. + Exibir uma notificação genérica do {app_name} sem o nome do remetente nem o conteúdo da mensagem. Ir para as definições de notificações do dispositivo Notificações - Todas Notificações - Apenas Menções @@ -719,6 +854,7 @@ {name} para {conversation_name} Poderá ter recebido mensagens enquanto o seu {device} reiniciava. Cor do LED + Reproduzir um som quando você receber novas mensagens. Apenas menções Notificações de mensagem Mais recente de {name} @@ -740,6 +876,12 @@ Desligado Ok Ligado + No seu dispositivo {device_type} + Abra esta conta do {app_name} num dispositivo {device_type} com sessão iniciada na {platform_account} usada na subscrição original. Em seguida, cancele o {pro} nas definições do {app_pro}. + Abra esta conta do {app_name} em um dispositivo {device_type} conectado à {platform_account} com a qual você se registrou originalmente. Em seguida, atualize seu acesso {pro} nas configurações do {app_pro}. + Num dispositivo ligado + No site da {platform_store} + No site da {platform} Criar Conta Conta Criada Já tenho uma conta @@ -764,10 +906,17 @@ Não foi possível reconhecer este ONS. Verifique e tente novamente. Não foi possível pesquisar este ONS. Tente novamente mais tarde. Abrir + Abrir site da {platform_store} + Abrir site do {platform} + Abrir Configurações Abrir questionário Outro + Palavra-passe Alterar Palavra-passe + Altere a palavra-passe necessária para desbloquear o {app_name}. + A sua palavra-passe foi alterada. Por favor, mantenha-a segura. Confirmar palavra-passe + Criar palavra-passe A sua palavra-passe atual está incorreta. Introduza a palavra-passe Por favor, insira a sua palavra-passe @@ -777,9 +926,24 @@ As palavras-passes não correspondem Falha ao definir palavra-passe Palavra-passe Incorreta + Confirme a nova palavra-passe Remover Palavra-passe + Remova a palavra-passe necessária para desbloquear {app_name} + A sua palavra-passe foi removida. Configurar palavra-passe + A sua palavra-passe foi definida. Por favor, mantenha-a segura. + Solicitar palavra-passe para desbloquear o {app_name} no arranque. + Mais de 12 caracteres + Inclui um número + Inclui uma letra minúscula + Inclui um símbolo + Inclui uma letra maiúscula + Indicador de robustez da palavra-passe + Definir uma palavra-passe robusta ajuda a proteger as suas mensagens e anexos caso o seu dispositivo seja perdido ou roubado. + Palavras-passe Colar + Erro de pagamento + O seu pagamento foi processado com sucesso, mas ocorreu um erro ao {action_type} o seu estado {pro}.\n\nVerifique a sua ligação de rede e tente novamente. Alteração de permissão {app_name} precisa de acesso a música e áudio para enviar arquivos, músicas e áudios, mas foi permanentemente negado. Toque em Configurações → Permissões e ative \"Música e áudio\". {app_name} precisa usar o Apple Music para reproduzir anexos de multimédia. @@ -791,6 +955,7 @@ Permitir acesso à câmara para chamadas de vídeo. A funcionalidade de bloqueio de ecrã {app_name} usa Face ID. Manter na Barra de Tarefas + {app_name} Continua a funcionar em segundo plano quando fecha a janela. {app_name} precisa de acesso à biblioteca de fotos para continuar. Pode permitir o acesso nas definições do iOS. É necessário acesso à rede local para permitir chamadas. Ative a permissão \"Rede local\" nas Definições para continuar. {app_name} precisa de acesso à rede local para efetuar chamadas de voz e vídeo. @@ -817,24 +982,172 @@ Fixar Conversa Desafixar Desafixar Conversa + E muito mais... + Novos recursos chegando em breve ao {pro}. Descubra o que vem por aí no Roteiro do {pro} {icon} + Preferências Pré-visualizar + Pré-visualizar notificação + Seu acesso ao {pro} está ativo!\n\nSeu acesso ao {pro} será renovado automaticamente por mais {current_plan_length} em {date}. + Seu acesso ao {pro} está ativo!\n\nSeu acesso ao {pro} será renovado automaticamente por mais \n{current_plan_length} em {date}. Quaisquer alterações feitas aqui entrarão em vigor na próxima renovação. + Erro de acesso {pro} + Seu acesso {pro} expirará em {date}. + A carregar acesso {pro} + As informações de acesso {pro} ainda estão a ser carregadas. Não é possível atualizar até que este processo esteja concluído. + a carregar acesso {pro}... + Não foi possível conectar à rede para carregar suas informações de acesso {pro}. A atualização de {pro} via {app_name} ficará desabilitada até que a conexão seja restabelecida.\n\nVerifique sua conexão com a rede e tente novamente. + Acesso {pro} Não Encontrado + O {app_name} detectou que sua conta não possui acesso {pro}. Se você acredita que isso seja um erro, entre em contato com o suporte do {app_name} para obter ajuda. + Recuperar acesso {pro} + Renovar acesso {pro} + Atualmente, o acesso ao {pro} só pode ser adquirido e renovado por meio da {platform_store} ou da {platform_store_other}. Como você está usando o {app_name} Desktop, não é possível renovar aqui.\n\nOs desenvolvedores do {app_name} estão trabalhando intensamente em opções alternativas de pagamento para permitir que os usuários adquiram o acesso ao {pro} fora da {platform_store} e da {platform_store_other}. Roteiro do {pro} {icon} + Renove seu acesso ao {pro} no site da {platform_store} usando a {platform_account} com a qual você criou sua conta {pro}. + Renove no site da {platform} usando a {platform_account} com a qual subscreveu o {pro}. + Renove seu acesso {pro} para voltar a usar os poderosos recursos Beta do {app_pro}. + Acesso {pro} Recuperado + O {app_name} detectou e recuperou o acesso {pro} da sua conta. Seu status {pro} foi restaurado! + Como você se registrou originalmente no {app_pro} pela {platform_store}, será necessário usar sua conta {platform_account} para atualizar seu acesso ao {pro}. + Atualmente, o acesso {pro} só pode ser adquirido através da {platform_store} ou da {platform_store_other}. Como você está usando o {app_name} Desktop, não é possível fazer o upgrade para {pro} aqui.\n\nOs desenvolvedores do {app_name} estão trabalhando arduamente em opções de pagamento alternativas para permitir que os usuários adquiram acesso {pro} fora da {platform_store} e {platform_store_other}. Roteiro do {pro} {icon} Ativado + a ativar + Tudo pronto! + Seu acesso ao {app_pro} foi atualizado! A cobrança será feita quando o {pro} for renovado automaticamente em {date}. Já tem Agora pode enviar GIFs e imagens WebP animadas para a sua imagem de exibição! + Obtenha imagens de exibição animadas e desbloqueie funcionalidades premium com o {app_pro} Beta Imagem de exibição animada os utilizadores podem carregar GIFs + Imagens de exibição animadas + Defina imagens animadas em GIF e WebP como sua foto de exibição. Carregue GIFs com + {pro} será renovado automaticamente em {time} + Distintivo {pro} + Mostrar o distintivo {app_pro} a outros utilizadores + Distintivos + Mostre seu apoio ao {app_name} com um emblema exclusivo ao lado do seu nome de exibição. + + %1$s emblema %2$s enviado + %1$s emblemas %2$s enviados + + Funcionalidades Beta de {pro} + {price} Cobrado Anualmente + {price} Cobrado Mensalmente + {price} Cobrado Trimestralmente + Quer enviar mensagens mais longas?\nEnvie mais texto e desbloqueie funcionalidades premium com o {app_pro} Beta + Quer fixar mais conversas?\nOrganize os seus chats e desbloqueie funcionalidades premium com o {app_pro} Beta + Quer fixar mais de {limit} conversas?\nOrganize os seus chats e desbloqueie funcionalidades premium com o {app_pro} Beta + Lamentamos que esteja a cancelar o {pro}. Eis o que deve saber antes de cancelar o seu acesso ao {pro}. + Cancelamento + Cancelar o acesso ao {pro} impedirá a renovação automática antes que o acesso ao {pro} expire. O cancelamento do {pro} não resulta em reembolso. Você continuará podendo usar os recursos do {app_pro} até que o seu acesso ao {pro} expire.\n\nComo você se inscreveu originalmente no {app_pro} usando sua conta {platform_account}, será necessário utilizar essa mesma conta {platform_account} para cancelar o {pro}. + Duas formas de cancelar seu acesso ao {pro}: + Cancelar o acesso ao {pro} impedirá a renovação automática antes que o {pro} expire.\n\nCancelar o {pro} não garante reembolso. Continuará a poder usar as funcionalidades do {app_pro} até o seu acesso ao {pro} expirar. + Escolha a opção de acesso {pro} que é ideal para você.\nAcesso por mais tempo significa maiores descontos. + Tem certeza de que deseja apagar seus dados deste dispositivo?\n\n{app_pro} não pode ser transferido para outra conta. Guarde sua palavra-passe de recuperação para garantir que poderá restaurar seu acesso ao {pro} mais tarde. + Tem certeza de que deseja apagar seus dados da rede? Se continuar, não será possível restaurar suas mensagens ou contatos.\n\n{app_pro} não pode ser transferido para outra conta. Guarde sua palavra-passe de recuperação para garantir que poderá restaurar seu acesso ao {pro} mais tarde. + Seu acesso {pro} já possui um desconto de {percent}% sobre o preço integral do {app_pro}. + Erro ao atualizar o status do {pro} + Expirado + Infelizmente, seu acesso {pro} expirou.\nRenove para reativar os benefícios e recursos exclusivos do {app_pro} Beta. + Expirando em breve + Seu acesso {pro} expirará em {time}.\nAtualize agora para continuar acessando os benefícios e recursos exclusivos do {app_pro} Beta + {pro} expira em {time} + FAQ de {pro} + Encontre respostas para perguntas frequentes na seção de FAQ do {app_pro}. Carregue imagens de exibição em GIF e WebP Conversas de grupo maiores com até 300 membros E muitas outras funcionalidades exclusivas Mensagens até 10 000 caracteres Fixe conversas ilimitadas + Quer usar o {app_name} ao máximo?\nAtualize para o {app_pro} Beta para ter acesso a diversos benefícios e recursos exclusivos. Grupo ativado Este grupo tem capacidade expandida! Pode suportar até 300 membros porque um administrador do grupo tem + + %1$s grupo melhorado + %1$s grupos melhorados + + Solicitar um reembolso é definitivo. Se aprovado, seu acesso {pro} será cancelado imediatamente e você perderá o acesso a todos os recursos {pro}. Maior tamanho de anexo Maior comprimento de mensagem + Grupos maiores + Os grupos nos quais é administrador são atualizados automaticamente para suportar 300 membros. + Chats em grupo maiores (até 300 membros) chegarão em breve para todos os usuários Pro Beta! + Mensagens mais longas + Pode enviar mensagens com até 10.000 caracteres em todas as conversas. + + %1$s mensagem longa enviada + %1$s mensagens longas enviadas + Esta mensagem utilizou as seguintes funcionalidades do {app_pro}: + Com uma nova instalação + Reinstale o {app_name} neste dispositivo via {platform_store}, restaure sua conta com sua Palavra-passe de recuperação e renove o {pro} nas definições do {app_pro}. + Reinstale o {app_name} neste dispositivo através da {platform_store}, restaure sua conta com a sua Senha de Recuperação e faça o upgrade para {pro} nas configurações do {app_pro}. + Por enquanto, há três formas de renovar: + Por enquanto, há duas maneiras de renovar: + {percent}% de desconto + + %1$s conversa fixada + %1$s conversas fixadas + + Como você se registrou originalmente no {app_pro} pela {platform_store}, será necessário usar sua conta {platform_account} para solicitar um reembolso. + Como você se registrou originalmente no {app_pro} pela {platform_store}, sua solicitação de reembolso será processada pelo Suporte do {app_name}.\n\nSolicite um reembolso clicando no botão abaixo e preenchendo o formulário de reembolso.\n\nEmbora o Suporte do {app_name} se esforce para processar as solicitações de reembolso em 24 a 72 horas, o processamento pode levar mais tempo durante períodos de alta demanda. + Seu acesso {app_pro} foi renovado! Obrigado por apoiar a {network_name}. + 1 Mês - {monthly_price} / Mês + 3 Meses - {monthly_price} / Mês + 12 Meses - {monthly_price} / Mês + a reativar + Abra esta conta do {app_name} em um dispositivo {device_type} com sessão iniciada na {platform_account} com a qual você se inscreveu originalmente. Em seguida, solicite um reembolso pelas definições do {app_pro}. + Lamentamos ver você partir. Aqui está o que você precisa saber antes de solicitar o reembolso. + O {platform} está processando sua solicitação de reembolso. Isso normalmente leva entre 24 e 48 horas. Dependendo da decisão deles, você poderá ver seu status {pro} mudar no {app_name}. + Sua solicitação de reembolso será tratada pelo Suporte da {app_name}.\n\nSolicite um reembolso clicando no botão abaixo e preenchendo o formulário de solicitação de reembolso.\n\nEmbora o Suporte da {app_name} se empenhe para processar as solicitações de reembolso em até 24–72 horas, o tempo de processamento pode ser maior em períodos de alto volume de solicitações. + Sua solicitação de reembolso será tratada exclusivamente pela {platform} através do site da {platform}.\n\nDevido às políticas de reembolso da {platform}, os desenvolvedores do {app_name} não têm meios de influenciar o resultado das solicitações de reembolso. Isso inclui a aprovação ou a recusa da solicitação, bem como se o reembolso será total ou parcial. + Entre em contato com o {platform} para atualizações sobre sua solicitação de reembolso. Devido às políticas de reembolso do {platform}, os desenvolvedores do {app_name} não têm qualquer possibilidade de influenciar o resultado dessas solicitações.\n\nSuporte de Reembolso do {platform} + Reembolsando {pro} + Reembolsos do {app_pro} são processados exclusivamente pelo {platform} por meio da {platform_store}.\n\nDevido às políticas de reembolso do {platform}, os desenvolvedores do {app_name} não têm qualquer possibilidade de influenciar o resultado de pedidos de reembolso. Isso inclui se o pedido será aprovado ou recusado, assim como se será emitido um reembolso integral ou parcial. + Quer voltar a usar imagens de exibição animadas?\nRenove seu acesso ao {pro} para desbloquear os recursos dos quais você sentiu falta. + Renovar {pro} Beta + Renove seu acesso ao {pro} nas configurações do {app_pro} em um dispositivo vinculado com o {app_name} instalado via {platform_store} ou {platform_store_other}. + Quer voltar a enviar mensagens mais longas?\nRenove seu acesso ao {pro} para desbloquear os recursos dos quais você sentiu falta. + Quer voltar a usar o {app_name} ao máximo?\nRenove seu acesso ao {pro} para desbloquear os recursos dos quais você sentiu falta. + Quer voltar a fixar mais de {limit} conversas?\nRenove seu acesso ao {pro} para desbloquear os recursos dos quais você sentiu falta. + Quer voltar a fixar mais conversas?\nRenove seu acesso ao {pro} para desbloquear os recursos dos quais você sentiu falta. + Ao renovar, você concorda com os Termos de Serviço {icon} e a Política de Privacidade {icon} do {app_pro} + a renovar + No momento, o acesso ao {pro} só pode ser adquirido e renovado através da {platform_store} ou da {platform_store_other}. Como você instalou o {app_name} usando o {build_variant}, não é possível renovar por aqui.\n\nOs desenvolvedores do {app_name} estão trabalhando intensamente em opções alternativas de pagamento para que os usuários possam adquirir acesso ao {pro} fora da {platform_store} e da {platform_store_other}. Roteiro do {pro} {icon} + Reembolso solicitado Envie mais com + Definições do {pro} + Comece a usar {pro} + As suas estatísticas de {pro} + Carregando estatísticas {pro} + Suas estatísticas {pro} estão sendo carregadas, aguarde. + As estatísticas do {pro} refletem o uso neste dispositivo e podem aparecer de forma diferente em dispositivos vinculados. + Erro no status {pro} + Não foi possível conectar à rede para verificar seu status {pro}. As informações exibidas nesta página podem estar incorretas até que a conexão seja restabelecida.\n\nVerifique sua conexão com a rede e tente novamente. + A carregar estado {pro} + As suas informações {pro} estão a ser carregadas. Algumas ações nesta página poderão não estar disponíveis até que o carregamento esteja concluído. + Carregando status do {pro} + Não foi possível conectar-se à rede para verificar seu status do {pro}. Você não pode continuar até que a conectividade seja restabelecida.\n\nVerifique sua conexão de rede e tente novamente. + Não foi possível conectar à rede para verificar seu status {pro}. Você não poderá fazer upgrade para {pro} até que a conexão seja restabelecida.\n\nVerifique sua conexão com a rede e tente novamente. + Não foi possível conectar à rede para atualizar seu status {pro}. Algumas ações nesta página serão desativadas até que a conexão seja restabelecida.\n\nVerifique sua conexão com a rede e tente novamente. + Não foi possível ligar à rede para carregar o seu acesso atual ao {pro}. A renovação do {pro} via {app_name} ficará desativada até que a ligação seja restaurada.\n\nVerifique a sua ligação de rede e tente novamente. + Precisa de ajuda com o {pro}? Envie uma solicitação à equipe de suporte. + Ao {action_type}, você está {activation_type} o {app_pro} através do Protocolo {app_name}. O {entity} facilitará essa ativação, mas não é o fornecedor do {app_pro}. O {entity} não é responsável pelo desempenho, disponibilidade ou funcionalidade do {app_pro}. + Ao atualizar, você concorda com os Termos de Serviço {icon} e a Política de Privacidade {icon} da {app_pro} + Pins ilimitados + Organize todos os seus chats com conversas fixadas ilimitadas. + Sua opção de cobrança atual concede {current_plan_length} de acesso ao {pro}. Tem certeza de que deseja alterar para a opção de cobrança {selected_plan_length_singular}?\n\nAo atualizar, seu acesso ao {pro} será renovado automaticamente em {date} por mais {selected_plan_length} de acesso ao {pro}. + Seu acesso ao {pro} expirará em {date}.\n\nAo atualizar, seu acesso ao {pro} será renovado automaticamente em {date} por mais {selected_plan_length} de acesso ao {pro}. + a atualizar + Faça o upgrade para o {app_pro} Beta para obter acesso a vários benefícios e recursos exclusivos. + Faça o upgrade para {pro} nas configurações do {app_pro} em um dispositivo vinculado onde o {app_name} foi instalado pela {platform_store} ou {platform_store_other}. + Atualmente, o acesso {pro} só pode ser adquirido através da {platform_store} ou {platform_store_other}. Como você instalou o {app_name} usando o {build_variant}, não é possível fazer o upgrade para {pro} aqui.\n\nOs desenvolvedores do {app_name} estão trabalhando arduamente em opções de pagamento alternativas para permitir que os usuários adquiram acesso {pro} fora da {platform_store} e {platform_store_other}. Roteiro do {pro} {icon} + Por enquanto, há apenas uma forma de atualizar: + Por enquanto, há duas formas de fazer o upgrade: + Você fez upgrade para {app_pro}!\nObrigado por apoiar o {network_name}. + a atualizar + Atualizando para {pro} + Ao fazer o upgrade, você concorda com os Termos de Serviço {icon} e a Política de Privacidade {icon} da {app_pro} + Quer aproveitar mais o {app_name}?\nAtualize para o {app_pro} Beta e tenha uma experiência de mensagens mais poderosa. + {platform} está processando sua solicitação de reembolso Perfil Exibir Imagem Erro ao remover a foto do perfil. @@ -842,6 +1155,11 @@ Por favor, escolha um ficheiro menor. Não foi possível atualizar o perfil. Promover + Os admins poderão ver o histórico de mensagens dos últimos 14 dias e não poderão ser despromovidos ou removidos do grupo. + + Promover membro + Promover membros + A promoção falhou As promoções falharam @@ -871,6 +1189,7 @@ Recomendado Guarde a sua chave de recuperação para garantir que não perde o acesso à sua conta. Guarde a sua chave de recuperação + Use a sua palavra-passe de recuperação para carregar a sua conta em novos dispositivos.\n\nA sua conta não pode ser recuperada sem a sua palavra-passe de recuperação. Guarde-a num local seguro — e não a partilhe com ninguém. Insira a sua chave de recuperação Ocorreu um erro ao tentar carregar a sua palavra-passe de recuperação.\n\nPor favor exporte os seus logs e depois envie o ficheiro através do Centro de Ajuda do {app_name} para ajudar a resolver este problema. Por favor, verifique a sua chave de recuperação e tente novamente. @@ -880,27 +1199,69 @@ Para carregar a sua conta, insira a sua chave de recuperação. Esconder Chave de Recuperação Permanentemente Sem a sua chave de recuperação, não pode carregar a sua conta em novos dispositivos. \n\nRecomendamos fortemente que guarde a sua chave de recuperação num lugar seguro antes de continuar. + Tem a certeza de que deseja esconder permanentemente a sua palavra-passe de recuperação neste dispositivo?\n\nEsta ação não pode ser desfeita. Ocultar Chave de Recuperação Esconder permanentemente a sua palavra-passe de recuperação neste dispositivo. Insira a sua chave de recuperação para carregar a sua conta. Se não a salvou, pode encontrá-la nas configurações da aplicação. + Ver Palavra-passe de Recuperação + Visibilidade da Palavra-passe de Recuperação Esta é a sua chave de recuperação. Se você enviá-la para alguém, essa pessoa terá acesso total à sua conta. Recriar grupo Refazer + Como você se registrou originalmente no {app_pro} usando uma {platform_account} diferente, precisará usar essa {platform_account} para atualizar seu acesso {pro}. + Duas formas de solicitar um reembolso: Reduza o comprimento da mensagem em {count} %1$d caractere restante %1$d caracteres restantes + Lembrar Depois Remover + + Remover membro + Remover membros + + + Remover membro e as suas mensagens + Remover membros e as suas mensagens + Falha ao remover a palavra-passe + Remova sua senha atual do {app_name}. Os dados armazenados localmente serão recriptografados com uma chave gerada aleatoriamente e armazenada no seu dispositivo. + + A remover membro + A remover membros + + Renovar + Renovando o {pro} Responder + Solicitar reembolso + Solicite um reembolso no site da {platform}, usando a {platform_account} com a qual você se inscreveu no {pro}. Enviar novamente + + Reenviar convite + Reenviar convites + + + Reenviar promoção + Reenviar promoções + + + A reenviar convite + A reenviar convites + + + A reenviar promoção + A reenviar promoções + A carregar lista de países... Reiniciar Ressincronizar Tentar novamente Limite de avaliações Parece que já avaliou recentemente o {app_name}, obrigado pelo seu feedback! + Executar o app em segundo plano + Executar o {app_name} em segundo plano? + Como você está usando o modo lento, recomendamos permitir que o {app_name} seja executado em segundo plano para melhorar as notificações. Isso pode melhorar a consistência das notificações, embora seu sistema ainda possa limitar automaticamente a atividade em segundo plano.\n\nVocê pode alterar isso depois em Configurações. Guardar Guardado Mensagens guardadas @@ -909,6 +1270,8 @@ Segurança de ecrã Notificações de Screenshot Receber uma notificação quando um contato captura um screenshot de uma conversa. + Ocultar a janela do {app_name} nas capturas de ecrã feitas neste dispositivo. + Proteção contra capturas de ecrã {name} fez uma captura de ecrã. Pesquisar Pesquisar contactos @@ -929,6 +1292,10 @@ A enviar A enviar oferta de chamada A enviar candidatos de ligação + + A enviar promoção de administrador + A enviar promoções de administrador + Enviado: Aparência Limpar Dados @@ -949,38 +1316,56 @@ Notificações Permissões Privacidade + {app_pro} Beta Chave de Recuperação Configurações Configurar Definir imagem de exibição da Comunidade + Defina uma senha para o {app_name}. Os dados armazenados localmente serão criptografados com essa senha. Você precisará digitá-la sempre que o {app_name} for iniciado. + Não é possível atualizar a definição Tem que reiniciar o {app_name} de modo a aplicar as novas definições. Segurança de ecrã + Inicialização Partilhar Convide os seus amigos para conversarem consigo no {app_name} partilhando o ID da sua Conta com eles. Partilhe com seus amigos onde você geralmente conversa com eles — depois mova a conversa para cá. Há um problema ao abrir a base de dados. Por favor, reinicie a aplicação e tente novamente. Ops! Parece que ainda não tem uma conta {app_name}.\n\nSerá necessário criar uma na aplicação {app_name} antes de poder partilhar. + Você gostaria de compartilhar o histórico de mensagens do grupo com este usuário? Partilhar para {app_name} + Desculpe, o {app_name} só permite compartilhar múltiplas imagens e vídeos ao mesmo tempo + O compartilhamento suporta apenas arquivos de mídia. Os arquivos que não são de mídia foram excluídos Mostrar Mostrar tudo Mostrar menos Mostrar Nota Pessoal Tem a certeza de que pretende mostrar a Nota Pessoal na sua lista de conversas? + Corretor ortográfico Autocolantes + Robustez + Está com problemas? Explore os artigos de ajuda ou abra um chamado com o Suporte do {app_name}. Ir para a página de suporte Informação do Sistema: {information} Toque para tentar novamente Continuar Pré-definição Erro + Voltar + Pré-visualização do tema O ID da Conta de {name} está visível com base nas suas interações anteriores IDs Ocultos são usados em Comunidades para reduzir spam e aumentar a privacidade + Traduzir + Área de notificação Tentar Novamente Indicadores de escrita Ver e partilhar indicadores de escrita. Indisponível Desfazer Desconhecido + CPU não suportada + Atualizar + Atualizar acesso {pro} + Duas formas de atualizar seu acesso {pro}: Atualizações da aplicação Atualizar informações da Comunidade O nome e a descrição da Comunidade são visíveis para todos os membros da Comunidade @@ -995,17 +1380,26 @@ Por favor, introduza uma descrição mais curta do grupo Uma nova versão de {app_name} está disponível, toque para atualizar Uma nova versão ({version}) de {app_name} está disponível. + Atualizar informações do perfil + O seu nome de exibição e foto de exibição estão visíveis em todas as conversas. Ir para as Notas de Lançamento Atualização do {app_name} Versão {version} Última atualização há {relative_time} + Atualizações + Atualizando... + Atualizar + Atualizar {app_name} Atualizar para Carregando Copiar URL Abrir URL Isso abrirá no seu navegador. Tem a certeza de que quer abrir este URL no seu browser?\n\n{url} + Os links serão abertos no seu navegador. Usar Modo Rápido + Altere o seu plano usando a {platform_account} com a qual se registou, através do site da {platform}. + Pelo site do {platform} Vídeo Não foi possível reproduzir o vídeo. Ver @@ -1014,7 +1408,12 @@ Este processo poderá demorar alguns minutos. Um momento por favor... Aviso + O suporte para iOS 15 foi encerrado. Atualize para o iOS 16 ou versão superior para continuar recebendo atualizações do aplicativo. Janela Sim Você + Seu processador não é compatível com as instruções SSE 4.2, que são exigidas pelo {app_name} em sistemas operacionais Linux x64 para processar imagens. Atualize para um processador compatível ou utilize outro sistema operacional. + A sua Palavra-passe de Recuperação + Fator de zoom + Ajuste o tamanho do texto e dos elementos visuais. \ No newline at end of file diff --git a/app/src/main/res/values-b+ro+RO/strings.xml b/app/src/main/res/values-b+ro+RO/strings.xml index 51fe52aa9b..a844c6287a 100644 --- a/app/src/main/res/values-b+ro+RO/strings.xml +++ b/app/src/main/res/values-b+ro+RO/strings.xml @@ -15,7 +15,14 @@ Acesta este ID-ul tău de cont. Alți utilizatori îl pot scana pentru a începe o conversație cu tine. Mărime actuală Adaugă + + Adaugă administrator + Adaugă administratori + Adaugă administratori + + Adaugă administrator Introdu ID-ul contului utilizatorului pe care îl promovezi ca administrator.\n\nPentru a adăuga mai mulți utilizatori, introduceți fiecare ID al contului separat prin virgulă. Pot fi specificate până la 20 de ID-uri de cont o dată. + Administratorii nu pot fi retrogradați sau eliminați din grup. Administratorii nu pot fi eliminați. {name} și {count} alții au fost promovați la nivel de administrator. Promovează administratorii @@ -40,13 +47,21 @@ {name} a fost eliminat ca administrator. {name} și alți {count} au fost eliminați ca administratori. {name} și {other_name} au fost eliminați ca administratori. + + %1$d Administrator selectat + %1$d Administratori selectați + %1$d administratori selectați + Se trimite promovarea la nivel de administrator Se trimit promovările la nivel de administrator Se trimit promovările la nivel de administrator Setări administrator + Nu îți poți schimba statutul de administrator. Pentru a părăsi grupul, deschide setările conversației și selectează Părăsește grupul. {name} și {other_name} au fost promovați la nivel de administrator. + Administratori + Permite +{count} Anonim Pictogramă @@ -163,6 +178,7 @@ Ești sigur/ă că dorești să deblochezi pe {name} și 1 altă persoană? {name} a fost deblocat/ă Vezi și gestionează contactele blocate. + Nu s-a găsit niciun browser pentru a deschide acel URL, încearcă să copiezi URL-ul în schimb Apelează {name} te-a apelat Nu poți iniția un apel nou. Termină mai întâi apelul actual. @@ -187,9 +203,14 @@ Apeluri (Beta) Apeluri vocale și video Apeluri vocale și video (Beta) + IP-ul tău este vizibil pentru partenerul de apel și un server {session_foundation} în timpul utilizării apelurilor beta. Activează apelurile vocale și video către și de la alți utilizatori. Ai apelat pe {name} Ai ratat un apel de la {name} pentru că nu ai activat Apeluri vocale și video în setările de confidențialitate. + {app_name} are nevoie de acces la camera ta pentru a activa apelurile video, dar această permisiune a fost refuzată. Nu poți modifica permisiunile camerei în timpul unui apel.\n\nVrei să închei apelul acum și să activezi accesul la cameră sau vrei să primești un memento după apel? + Pentru a permite accesul la cameră, deschide setările și activează permisiunea Cameră. + În timpul ultimului apel, ai încercat să folosești video, dar nu ai reușit deoarece accesul la cameră a fost refuzat anterior. Pentru a permite accesul la cameră, deschide setările și activează permisiunea Cameră. + Este necesar accesul la cameră Nu s-a găsit camera Cameră indisponibilă. Acordă acces cameră foto @@ -198,9 +219,18 @@ {app_name} are nevoie de acces la cameră pentru a scana coduri QR Anulare Anulează {pro} + Anulează pe site-ul {platform}, folosind contul {platform_account} cu care te-ai înregistrat la {pro}. + Anulează pe site-ul {platform_store}, folosind contul {platform_account} cu care te-ai înregistrat la {pro}. Schimba Eroare la modificarea parolei Modifică parola ta pentru {app_name}. Datele stocate local vor fi re-criptate cu noua ta parolă. + Modifică setarea + Verificare stare {pro} + Se verifică starea contului {pro}. Vei putea continua după finalizarea acestei verificări. + Se verifică detaliile tale {pro}. Unele acțiuni de pe această pagină ar putea fi indisponibile până la finalizarea verificării. + Se verifică statutul {pro}... + Se verifică detaliile {pro}. Nu poți reînnoi până când această verificare nu este completă. + Se verifică starea ta {pro}. Vei putea face upgrade la {pro} după finalizarea verificării. Șterge Șterge tot Șterge toate datele @@ -260,11 +290,18 @@ URL-ul comunității Copiere adresă URL comunitate Confirmă + Confirmă promovarea + Ești sigur? Administratorii nu pot fi retrogradați sau eliminați din grup. Contacte Șterge contact Ești sigur că vrei să ștergi pe {name} din contactele tale? Mesajele noi de la {name} vor ajunge ca o solicitare de mesaj. Încă nu ai contacte Selectare contacte + + %1$d Contact selectat + %1$d Contacte selectate + %1$d contacte selectate + Detalii utilizator Cameră Alegeți o acțiune pentru a începe o conversație @@ -272,6 +309,7 @@ Compunere mesaj Pictograma imaginii din mesajul citat Creează o conversație cu un nou contact + Alege conținutul afișat în notificările locale când primești un mesaj. Adaugă pe ecranul principal Adăugat pe ecranul principal Mesaje audio @@ -284,9 +322,13 @@ Conversație ștearsă Nu există mesaje în {conversation_name}. Introduceți tasta + Definește modul în care funcționează tastele Enter și Shift+Enter în conversații. + SHIFT + ENTER trimite un mesaj, ENTER începe o linie nouă. + ENTER trimite un mesaj, SHIFT + ENTER începe o linie nouă. Grupuri Scurtarea mesajelor Ajustare comunități + Șterge automat mesajele mai vechi de 6 luni din comunitățile cu peste 2000 de mesaje. Conversație nouă Încă nu ai conversații Apasă Enter pentru a trimite @@ -300,6 +342,7 @@ Copiază Creează Se creează apelul + Facturare curentă Parola curentă Decupează Mod întunecat @@ -327,6 +370,16 @@ Te rugăm să aștepți până când grupul este creat... Eroare la actualizarea grupului Nu aveți permisiunea de a șterge mesajele altora + + Șterge atașamentul selectat + Șterge atașamentele selectate + Șterge atașamentele selectate + + + Ești sigur că vrei să ștergi atașamentul selectat? Și mesajul asociat cu atașamentul va fi șters. + Ești sigur că vrei să ștergi atașamentele selectate? Mesajul asociat cu atașamentele va fi de asemenea șters. + Ești sigur că vrei să ștergi atașamentele selectate? Și mesajul asociat cu atașamentele va fi șters. + Ești sigur/ă că dorești să ștergi {name} din contactele tale?\n\nAceasta va șterge conversația ta, inclusiv toate mesajele și atașamentele. Mesajele viitoare de la {name} vor apărea ca o solicitare de mesaj. Ești sigur/ă că dorești să ștergi conversația cu {name}?\nAceasta va șterge definitiv toate mesajele și fișierele atașate. @@ -373,6 +426,7 @@ Ești sigur că vrei să ștergi aceste mesaje pentru toată lumea? Se șterge Comutare unelte dezvoltator + Setări notificări dispozitiv Începe dictarea... Mesaje temporare Mesajul va fi șters în {time_large} @@ -420,6 +474,8 @@ Numele tău de afișare este vizibil utilizatorilor, grupurilor și comunităților cu care interacționezi. Document Donează + Forțe puternice încearcă să slăbească confidențialitatea, dar nu putem duce această luptă singuri.\n\nDonațiile ajută la menținerea {app_name} sigur, independent și online. + {app_name} are nevoie de ajutorul tău Terminat Descarcă Se descarcă... @@ -450,16 +506,31 @@ Tu și {name} ați reacționat cu {emoji_name} A reacționat la mesajul tău {emoji} Activare + Activezi accesul la Cameră? + Afișează notificări când primești mesaje noi. + Încheie apelul pentru a activa Îți place {app_name}? Mai e de lucru {emoji} Este grozav {emoji} Folosești {app_name} de ceva timp, cum ți se pare? Ne-ar face mare plăcere să aflăm părerea ta. Intră + Introdu parola setată pentru {app_name} + Introdu parola pe care o folosești pentru a debloca {app_name} la pornire, nu Parola de recuperare + Eroare la verificarea stării {pro} Vă rugăm să verificați conexiunea la internet și să încercați din nou. Copiază eroare și închide aplicația Eroare de bază de date Ceva nu a mers bine. Te rugăm să încerci din nou mai târziu. + Eroare la încărcarea accesului {pro} + {app_name} nu a putut căuta acest ONS. Verifică conexiunea la rețea și încearcă din nou. O eroare neașteptată a avut loc. + Acest ONS nu este înregistrat. Verifică dacă este corect și încearcă din nou. + Eșec la retrimiterea invitației către {name} în {group_name} + Eșec la retrimiterea invitației către {name} și {count} alți în {group_name} + Eșec la retrimiterea invitației către {name} și {other_name} în {group_name} + Promovarea nu a putut fi retrimisă către {name} în {group_name} + Promovarea nu a putut fi retrimisă către {name} și {count} alți în {group_name} + Promovarea nu a putut fi retrimisă către {name} și {other_name} în {group_name} Descărcarea a eșuat Erori Feedback @@ -514,6 +585,9 @@ Ești sigur/ă că vrei să părăsești grupul {group_name}? Ești sigur/ă că vrei să părăsești {group_name}?\n\nAceastă acțiune va elimina toți membrii și va șterge tot conținutul grupului. Nu s-a putut părăsi grupul {group_name} + {name} a fost invitat(ă) să se alăture grupului. Istoricul conversațiilor din ultimele 14 zile a fost partajat. + {name} și încă {count} au fost invitați să se alăture grupului. Istoricul conversațiilor din ultimele 14 zile a fost partajat. + {name} și {other_name} au fost invitați să se alăture grupului. Istoricul conversațiilor din ultimele 14 zile a fost partajat. {name} a părăsit grupul. {name} și alți {count} au părăsit grupul. {name} și {other_name} au părăsit grupul. @@ -525,6 +599,9 @@ {name} și {other_name} au fost invitați să se alăture grupului. Tu și alți {count} ați fost invitați să vă alăturați grupului. Istoricul conversațiilor a fost partajat. Dumneavoastră și {other_name} ați fost invitați să vă alăturați grupului. Istoricul conversațiilor a fost partajat. + A eșuat eliminarea lui {name} din {group_name} + A eșuat eliminarea lui {name} și a {count} alți din {group_name} + A eșuat eliminarea lui {name} și {other_name} din {group_name} Tu ai părăsit grupul. Membrii grupului Nu există alți membri în acest grup. @@ -538,6 +615,7 @@ Nu ai mesaje din {group_name}. Trimite un mesaj pentru a începe conversația! Acest grup nu a fost actualizat de peste 30 de zile. Este posibil să întâmpinați probleme la trimiterea mesajelor sau vizualizarea informațiilor grupului. Ești singurul administrator din {group_name}.\n\nMembrii și setările grupului nu pot fi modificate fără un administrator. + Ești singurul administrator din {group_name}.\n\nMembrii și setările grupului nu pot fi modificate fără un administrator. Pentru a părăsi grupul fără să-l ștergi, te rugăm să adaugi mai întâi un nou administrator. În curs de eliminare Tu ai fost promovat/ă la nivel de administrator. Tu și alți {count} ați fost promovați la nivel de administrator. @@ -567,16 +645,19 @@ Grupul a fost actualizat. Se gestionează candidații pentru conexiune Întrebări frecvente + Consultă întrebările frecvente {app_name} pentru răspunsuri la întrebări comune. Ajută-ne să traducem {app_name} Raportează o eroare Împărtășește câteva detalii pentru a ne ajuta să rezolvăm problema. Exportă jurnalele, apoi încarcă fișierul prin Serviciul de asistență {app_name}. Exportare jurnale Exportă jurnalele, apoi încarcă fișierul prin Serviciul de asistență {app_name}. Salvează pe desktop + Salvează acest fișier, apoi partajează-l cu dezvoltatorii {app_name}. Asistență Ajută la traducerea aplicației {app_name} în peste 80 de limbi! Ne-ar plăcea feedback-ul tău Ascunde + Comută vizibilitatea barei de meniu a sistemului. Ești sigur/ă că dorești să ascunzi Notă personală din lista ta de conversații? Ascunde altele Imagine @@ -586,6 +667,11 @@ Solicită modul incognito, dacă este disponibil. În funcție de tastatura pe care o folosești, este posibil ca tastatura ta să ignore această solicitare. Info Scurtătură incorectă + + Invită persoana de contact + Invită persoanele de contact + Invită persoanele de contact + Invitație eșuată Invitații eșuate @@ -596,8 +682,18 @@ Invitațiile nu au putut fi trimise. Doriți să încercați din nou? Invitațiile nu au putut fi trimise. Doriți să încercați din nou? + + Invită ca membru + Invită ca membri + Invită membri + + Invită un nou membru în grup introducând ID-ul de cont al prietenului tău, ONS sau scanând codul său QR {icon} + Invită un nou membru în grup introducând ID-ul de cont, ONS-ul prietenului tău sau scanând codul său QR Alătură-te Mai târziu + Lansează {app_name} automat când computerul pornește. + Lansare la pornire + Această setare este gestionată de sistemul dvs. în Linux. Pentru a activa pornirea automată, adăugați {app_name} în aplicațiile de pornire din setările sistemului. Află mai mult Părăsește Se părăsește... @@ -612,6 +708,8 @@ Tu și {other_name} v-ați alăturat grupului. {name} și {other_name} s-au alăturat grupului. Te-ai alăturat grupului. + Limitezi activitatea în fundal? + Permiți în prezent ca {app_name} să ruleze în fundal pentru a îmbunătăți fiabilitatea notificărilor. Modificarea acestei setări ar putea duce la notificări mai puțin fiabile. Previzualizări ale linkurilor Afișează previzualizări ale linkurilor pentru URL-urile acceptate. Activează previzualizarea linkurilor @@ -636,9 +734,17 @@ Atingeți pentru a debloca {app_name} este deblocată Loguri + Gestionează administratori Gestionează membri + Administrează {pro} Maximum + Poate mai târziu Media + + %1$d Membru selectat + %1$d Membri selectați + %1$d membri selectați + %1$d membru %1$d membri @@ -650,7 +756,9 @@ %1$d membri activi Adaugă ID-ul contului sau ONS + Membrii pot fi promovați doar după ce acceptă invitația de a se alătura grupului. Invită contacte + Nu ai niciun contact de invitat în acest grup.\nÎntoarce-te și invită membri folosind ID-ul de cont sau ONS-ul lor. Trimite invitația Trimite invitațiile @@ -660,8 +768,10 @@ Doriți să partajați istoricul mesajelor de grup cu {name} și alți {count}? Doriți să partajați istoricul mesajelor de grup cu {name} și {other_name}? Distribuie istoricul mesajelor + Partajează istoricul mesajelor din ultimele 14 zile Distribuie doar mesajele noi Invită + Membri (non-administratori) Bara de meniu Mesaj Citește mai mult @@ -681,6 +791,7 @@ Începe o conversație nouă introducând ID-ul de cont sau ONS al prietenului tău. Începe o conversație nouă introducând ID-ul de cont sau ONS al prietenului tău, sau scanează codul său QR. + Începe o conversație nouă introducând ID-ul de cont, ONS-ul prietenului tău sau scanând codul său QR {icon} Ai primit un mesaj nou. Ai %1$d mesaje noi. @@ -741,14 +852,20 @@ Elimină pseudonimul Setează pseudonim Nu + Nu există membri non-administratori în acest grup. Fără sugestii + Poți trimite mesaje de până la 10.000 de caractere în toate conversațiile. + Organizează conversațiile prin fixarea unui număr nelimitat de chaturi. Fără Nu acum Notă personală Nu ai mesaje în Notă personală. Ascunde Notă personală Ești sigur/ă că dorești ascunderea Notei personale? + VĂ RUGĂM SĂ REȚINEȚI: Prin {action_type}, sunteți de acord cu Termenii și condițiile {icon} și Politica de confidențialitate {icon} ale {app_pro} Vizualizare notificări + Afișează numele expeditorului și o previzualizare a conținutului mesajului. + Afișează doar numele expeditorului fără niciun conținut de mesaj. Toate mesajele Conținut notificări Informaţiile prezentate în notificări. @@ -759,6 +876,7 @@ Veți fi notificat de mesaje noi imediat și în siguranță folosind serverele de notificare Google. Vei fi notificat în legătură cu noile mesaje imediat și în mod fiabil folosind serverele de notificări Huawei. Veți fi notificat de mesaje noi imediat și în siguranță folosind serverele de notificare Apple. + Afișează o notificare generică {app_name} fără numele expeditorului sau conținutul mesajului. Mergi la setările de notificare ale dispozitivului Notificări - Toate Notificări - Doar mențiuni @@ -766,6 +884,7 @@ {name} către {conversation_name} Este posibil să fi primit mesaje în timp ce {device} a fost repornit. Culoare LED + Redă un sunet când primești mesaje noi. Doar mențiuni Notificări mesaje Cel mai recent de la: {name} @@ -787,6 +906,12 @@ Dezactivat OK Activat + Pe dispozitivul tău {device_type} + Deschide acest cont {app_name} pe un dispozitiv {device_type} conectat la contul {platform_account} cu care te-ai înregistrat inițial. Apoi, anulează {pro} din setările {app_pro}. + Deschide acest cont {app_name} pe un dispozitiv {device_type} conectat la {platform_account} cu care te-ai înregistrat inițial. Apoi, actualizează accesul tău {pro} din setările {app_pro}. + Pe un dispozitiv conectat + Pe site-ul {platform_store} + Pe site-ul {platform} Creează un cont Cont creat Am deja un cont @@ -811,6 +936,9 @@ Nu am putut recunoaște acest ONS. Vă rugăm să verificați și să încercați din nou. Nu am putut căuta acest ONS. Vă rugăm să încercați din nou mai târziu. Deschide + Deschide site-ul {platform_store} + Deschide site-ul {platform} + Deschide setările Deschide sondajul Altele Parolă @@ -834,14 +962,18 @@ Parola ta a fost ștearsă. Setează parola Parola ta a fost setata. Securizați-va parola. + Solicită parolă pentru a debloca {app_name} la pornire. Mai mare de 12 caractere Include un număr Include o literă mică + Include un simbol Include o literă mare Indicator de parolă puternică Setarea unei parole puternice ajută la protejarea mesajelor și fișierelor în cazul pierderii sau furtului dispozitivului dumneavoastră. Parole Lipire + Eroare de plată + Plata a fost procesată cu succes, însă a apărut o eroare la {action_type} statutului dvs. {pro}.\n\nVă rugăm să verificați conexiunea la rețea și să încercați din nou. Modificare permisiune {app_name} are nevoie de acces la funcția de muzică și bibliotecă audio pentru a trimite fișiere, muzică și înregistrări audio, dar accesul a fost refuzat definitiv. Mergi la Setări → Permisiuni și activează funcția „Muzică și audio”. {app_name} are nevoie de acces la Apple Music pentru a reda atașamente media. @@ -888,31 +1020,67 @@ Accesul tău {pro} este activ!\n\nAccesul tău {pro} se va reînnoi automat pentru încă {current_plan_length} pe data de {date}. Accesul tău {pro} va expira pe {date}.\n\n Reînnoiește accesul tău {pro} acum pentru a te asigura că se va reînnoi automat înainte de expirarea accesului {pro}. Accesul tău {pro} este activ!\n\nAccesul {pro} se va reînnoi automat pentru încă\n{current_plan_length} în data de {date}. Orice modificare făcută aici va intra în vigoare la următoarea reînnoire. + Eroare de acces {pro} Accesul tău {pro} va expira pe {date}. + Accesul {pro} se încarcă + Informațiile tale de acces {pro} sunt încă în curs de încărcare. Nu poți face actualizări până la finalizarea acestui proces. + acces {pro} se încarcă... + Nu s-a putut realiza conexiunea la rețea pentru a încărca informațiile despre accesul {pro}. Actualizarea {pro} prin {app_name} va fi dezactivată până când conexiunea este restabilită.\n\nVă rugăm să verificați conexiunea la rețea și să încercați din nou. Accesul la {pro} nu putut fi găsit + {app_name} a detectat că contul tău nu are acces {pro}. Dacă crezi că este o greșeală, te rugăm să contactezi asistența {app_name} pentru ajutor. Recuperează accesul {pro} Reînnoiește accesul {pro} + În prezent, accesul {pro} poate fi achiziționat și reînnoit doar prin {platform_store} sau {platform_store_other}. Deoarece folosești {app_name} Desktop, nu îl poți reînnoi aici.\n\nDezvoltatorii {app_name} lucrează intens la opțiuni alternative de plată pentru a permite utilizatorilor să achiziționeze accesul {pro} în afara {platform_store} și {platform_store_other}. Foaia de parcurs {pro} {icon} + Reînnoiește-ți accesul la {pro} pe site-ul {platform_store} folosind contul {platform_account} cu care te-ai înregistrat pentru {pro}. + Reînnoiește pe site-ul {platform} folosind contul {platform_account} cu care te-ai înregistrat la {pro}. + Reînnoiește-ți accesul {pro} pentru a începe din nou să folosești funcționalitățile beta avansate {app_pro}. Acces {pro} recuperat {app_name} a detectat și a readus accesul {pro} pentru contul tău. Statutul tău {pro} a fost restabilit! + Deoarece te-ai înregistrat inițial la {app_pro} prin {platform_store}, va trebui să folosești contul tău {platform_account} pentru a-ți actualiza accesul la {pro}. Activat + activare Totul este gata! Accesul tău la {app_pro} a fost actualizat! Vei fi facturat când {pro} se va reînnoi automat pe {date}. Deja ai Mergi mai departe și încarcă GIF-uri și imagini WebP animate pentru imaginea ta de profil! + Obține imagini de profil animate și deblochează funcționalități premium cu {app_pro} Beta Poză de profil animată utilizatorii pot încărca GIF-uri Poze de profil animate Setează GIF-uri animate și imagini WebP animate ca imagine de profil. Încarcă GIF-uri cu {pro} se va reînnoi automat în {time} + Insigna {pro} Afișează insigna {app_pro} altor utilizatori Insigne Arată susținerea ta pentru {app_name} cu o insignă exclusivă afișată lângă numele tău. + + %1$s Insignă %2$s trimisă + %1$s Insigne %2$s trimise + %1$s insigne %2$s trimise + Caracteristici {pro} Beta + {price} Facturat anual + {price} Facturat lunar + {price} Facturat trimestrial + Vrei să trimiți mesaje mai lungi?\nTrimite mai mult text și deblochează funcții premium cu {app_pro} Beta + Vrei mai multe fixări?\nOrganizează-ți conversațiile și deblochează funcționalități premium cu {app_pro} Beta + Vrei mai mult de {limit} fixări?\nOrganizează-ți conversațiile și deblochează funcții premium cu {app_pro} Beta + Ne pare rău că renunți la {pro}. Iată ce trebuie să știi înainte de a anula accesul la {pro}. + Anulare + Anularea accesului {pro} va împiedica reînnoirea automată înainte ca accesul {pro} să expire. Anularea {pro} nu include o rambursare. Vei putea continua să folosești funcțiile {app_pro} până când accesul tău {pro} va expira.\n\nPentru că te-ai înregistrat inițial la {app_pro} cu contul tău {platform_account}, trebuie să folosești același {platform_account} pentru a anula {pro}. + Două moduri de a anula accesul {pro}: + Anularea accesului {pro} va împiedica reînnoirea automată înainte ca {pro} să expire.\n\nAnularea {pro} nu implică rambursarea sumei. Vei putea folosi în continuare funcțiile {app_pro} până la expirarea accesului {pro}. + Alege opțiunea de acces {pro} potrivită pentru tine.\nAccesul pe durată mai lungă înseamnă reduceri mai mari. + Ești sigur că vrei să ștergi datele tale de pe acest dispozitiv?\n\n{app_pro} nu poate fi transferat către un alt cont. Te rugăm să salvezi Parola de recuperare pentru a te asigura că poți restabili mai târziu accesul tău {pro}. + Ești sigur că vrei să ștergi datele tale din rețea? Dacă continui, nu vei putea recupera mesajele sau contactele.\n\n{app_pro} nu poate fi transferat către un alt cont. Te rugăm să salvezi Parola de recuperare pentru a te asigura că poți restabili mai târziu accesul tău {pro}. Accesul tău {pro} are deja o reducere de {percent}% din prețul complet al {app_pro}. + Eroare la reîmprospătarea statutului {pro} Expirat Din păcate, accesul tău {pro} a expirat. Reînnoiește-l pentru a reactiva beneficiile și funcționalitățile exclusive ale {app_pro}. Expiră în curând + Accesul tău {pro} expiră în {time}.\nActualizează acum pentru a continua să beneficiezi de avantajele și funcționalitățile exclusive ale {app_pro} Beta + {pro} va expira în {time} Întrebări frecvente {pro} Găsește răspunsuri la întrebările frecvente în secțiunea {app_pro} FAQ. Încarcă imagini de profil GIF și WebP @@ -920,26 +1088,100 @@ Și multe alte funcționalități exclusive Mesaje de până la 10.000 de caractere Fixează un număr nelimitat de conversații + Vrei să folosești {app_name} la întregul său potențial?\nFă upgrade la {app_pro} Beta pentru a avea acces la o mulțime de beneficii și funcții exclusive. Grup activat Acest grup are capacitate extinsă! Poate susține până la 300 de membri deoarece un administrator de grup are + + %1$s Grup upgradat + %1$s Grupuri upgradate + %1$s grupuri upgradate + Solicitarea unei rambursări este definitivă. Dacă este aprobată, accesul tău {pro} va fi anulat imediat și vei pierde accesul la toate funcționalitățile {pro}. Dimensiune mărită a atașamentului Lungime extinsă a mesajului Grupuri mai mari Grupurile în care ești administrator sunt actualizate automat pentru a permite 300 de membri. + Discuțiile de grup mai mari (până la 300 de membri) vor fi disponibile în curând pentru toți utilizatorii Pro Beta! Mesaje mai lungi Poți trimite mesaje de până la 10.000 de caractere în toate conversațiile. + + %1$s Mesaj mai lung trimis + %1$s Mesaje mai lungi trimise + %1$s mesaje mai lungi trimise + Acest mesaj a folosit următoarele funcționalități {app_pro}: + Cu o instalare nouă + Reinstalează {app_name} pe acest dispozitiv prin intermediul {platform_store}, restaurează-ți contul folosind Parola de Recuperare și reînnoiește {pro} din setările {app_pro}. + Reinstalează {app_name} pe acest dispozitiv prin {platform_store}, recuperează-ți contul folosind Parola de recuperare și upgradează la {pro} din setările {app_pro}. + Deocamdată, există trei moduri de a reînnoi: + Momentan, există două moduri de reînnoire: + Reducere de {percent}% + + %1$s Conversație fixată + %1$s Conversații fixate + %1$s conversații fixate + + Deoarece te-ai înregistrat inițial la {app_pro} prin {platform_store}, va trebui să folosești contul tău {platform_account} pentru a solicita o rambursare. + Pentru că v-ați înregistrat inițial pentru {app_pro} prin intermediul {platform_store}, solicitarea dvs. de rambursare va fi procesată de Asistența {app_name}.\n\nSolicitați o rambursare apăsând butonul de mai jos și completând formularul de cerere de rambursare.\n\nDeși Asistența {app_name} depune eforturi pentru a procesa solicitările de rambursare în 24–72 de ore, procesarea poate dura mai mult în perioadele cu volum mare de cereri. + Accesul tău {app_pro} a fost reînnoit! Îți mulțumim că susții {network_name}. + 1 lună - {monthly_price} / lună + 3 luni - {monthly_price} / lună + 12 luni - {monthly_price} / lună + reactivare + Deschide acest cont {app_name} pe un dispozitiv {device_type} conectat la contul {platform_account} cu care te-ai înregistrat inițial. Apoi, solicită o rambursare din setările {app_pro}. Ne pare rău că pleci. Iată ce trebuie să știi înainte de a solicita o rambursare. + {platform} procesează acum solicitarea ta de rambursare. De obicei, poate dura între 24 și 48 de ore. În funcție de decizia lor, este posibil să observi o modificare a statutului {pro} în {app_name}. + Cererea ta de rambursare va fi gestionată de echipa de asistență {app_name}.\n\nSolicită o rambursare apăsând butonul de mai jos și completând formularul de cerere de rambursare.\n\nDeși asistența {app_name} depune eforturi pentru a procesa cererile de rambursare în 24–72 de ore, procesarea poate dura mai mult în perioadele cu volum ridicat de solicitări. + Cererea ta de rambursare va fi gestionată exclusiv de {platform} prin intermediul site-ului {platform}.\n\nDin cauza politicilor de rambursare ale {platform}, dezvoltatorii {app_name} nu pot influența rezultatul cererilor de rambursare. Acest lucru include dacă cererea este aprobată sau respinsă, precum și dacă se acordă o rambursare completă sau parțială. + Te rugăm să contactezi {platform} pentru actualizări suplimentare privind solicitarea ta de rambursare. Din cauza politicilor de rambursare ale {platform}, dezvoltatorii {app_name} nu au nicio posibilitate de a influența rezultatul solicitărilor de rambursare.\n\nAsistență rambursare {platform} Se rambursează {pro} + Rambursările pentru {app_pro} sunt gestionate exclusiv de {platform} prin intermediul {platform_store}.\n\nDin cauza politicilor de rambursare ale {platform}, dezvoltatorii {app_name} nu pot influența rezultatul solicitărilor de rambursare. Aceasta include dacă solicitarea este aprobată sau refuzată, precum și dacă se acordă o rambursare integrală sau parțială. + Vrei să folosești din nou poze de profil animate?\nReînnoiește-ți accesul {pro} pentru a debloca funcțiile de care ai fost privat. + Reînnoiește {pro} Beta + Reînnoiește-ți accesul la {pro} din setările {app_pro} de pe un dispozitiv asociat care are instalată aplicația {app_name} prin {platform_store} sau {platform_store_other}. + Vrei să trimiți din nou mesaje mai lungi?\nReînnoiește-ți accesul {pro} pentru a debloca funcțiile de care ai fost privat. + Vrei să folosești din nou {app_name} la potențialul său maxim?\nReînnoiește accesul {pro} pentru a debloca funcționalitățile care ți-au lipsit. + Vrei să fixezi din nou mai mult de {limit} conversații?\nReînnoiește accesul {pro} pentru a debloca funcționalitățile care ți-au lipsit. + Vrei să fixezi din nou mai multe conversații?\nReînnoiește accesul {pro} pentru a debloca funcționalitățile care ți-au lipsit. + Prin reînnoire, ești de acord cu Termenii de utilizare {icon} și Politica de confidențialitate {icon} ale {app_pro} + reînnoire + În prezent, accesul {pro} poate fi achiziționat și reînnoit doar prin intermediul {platform_store} sau {platform_store_other}. Deoarece ai instalat {app_name} folosind {build_variant}, nu poți reînnoi de aici.\n\nDezvoltatorii {app_name} lucrează intens la opțiuni alternative de plată, pentru a permite utilizatorilor să cumpere accesul {pro} în afara {platform_store} și {platform_store_other}. Planul {pro} {icon} Rambursare solicitată Trimite mai mult cu + Setări {pro} + Începe să folosești {pro} Statisticile tale {pro} + Statistici {pro} se încarcă + Statistica ta {pro} se încarcă, te rugăm să aștepți. Statistica {pro} reflectă utilizarea pe acest dispozitiv și poate apărea diferit pe alte dispozitive conectate + Eroare stare {pro} + Nu s-a putut realiza conexiunea la rețea pentru a verifica statutul {pro}. Informațiile afișate pe această pagină pot fi inexacte până când conexiunea este restabilită.\n\nVă rugăm să verificați conexiunea la rețea și să încercați din nou. + Stare {pro} se încarcă + Informațiile tale {pro} sunt în curs de încărcare. Unele acțiuni de pe această pagină ar putea fi indisponibile până la finalizarea încărcării. + Se încarcă statutul {pro} + Conexiunea la rețea nu a reușit pentru a verifica starea contului {pro}. Nu poți continua până când conexiunea nu este restabilită.\n\nTe rugăm să verifici conexiunea la rețea și să încerci din nou. + Nu s-a putut realiza conexiunea la rețea pentru verificarea stării {pro}. Nu poți face upgrade la {pro} până când conexiunea nu este restabilită.\n\nVerifică conexiunea la rețea și încearcă din nou. + Nu s-a putut realiza conexiunea la rețea pentru a reîmprospăta statutul {pro}. Unele acțiuni de pe această pagină vor fi dezactivate până când conexiunea este restabilită.\n\nVă rugăm să verificați conexiunea la rețea și să încercați din nou. + Nu se poate realiza conexiunea la rețea pentru a încărca accesul tău {pro} curent. Reînnoirea {pro} prin {app_name} va fi dezactivată până la restabilirea conexiunii.\n\nVerifică conexiunea la rețea și încearcă din nou. Ai nevoie de ajutor cu {pro}? Trimite o solicitare către Echipa de Suport. + Prin {action_type}, sunteți {activation_type} {app_pro} prin protocolul {app_name}. {entity} va facilita această activare, dar nu este furnizorul {app_pro}. {entity} nu este responsabil pentru performanța, disponibilitatea sau funcționalitatea {app_pro}. Prin actualizare, sunteți de acord cu Termenii și condițiile {icon} și Politica de confidențialitate {icon} ale {app_pro} Pin-uri nelimitate Organizează-ți toate conversațiile prin fixarea unui număr nelimitat de chaturi. + Opțiunea dvs. actuală de facturare oferă {current_plan_length} de acces {pro}. Sigur doriți să treceți la opțiunea de facturare pe {selected_plan_length_singular}?\n\nPrin actualizare, accesul dvs. la {pro} se va reînnoi automat pe {date} pentru încă {selected_plan_length} de acces {pro}. + Accesul dvs. {pro} va expira pe {date}.\n\nPrin actualizare, accesul dvs. la {pro} se va reînnoi automat pe {date} pentru încă {selected_plan_length} de acces {pro}. + actualizare + Upgradează la {app_pro} Beta pentru a avea acces la numeroase beneficii și funcționalități exclusive. + Upgradează la {pro} din setările {app_pro} de pe un dispozitiv asociat, unde {app_name} este instalat prin {platform_store} sau {platform_store_other}. + În prezent, accesul {pro} poate fi achiziționat doar prin {platform_store} sau {platform_store_other}. Deoarece ai instalat {app_name} utilizând {build_variant}, nu poți face upgrade la {pro} de aici.\n\nDezvoltatorii {app_name} lucrează intens la opțiuni alternative de plată pentru a permite utilizatorilor să achiziționeze accesul la {pro} în afara {platform_store} și {platform_store_other}. Foaie de parcurs {pro} {icon} + Deocamdată, există o singură modalitate de a face upgrade: + Deocamdată, există două modalități de a face upgrade: + Ai făcut upgrade la {app_pro}!\nÎți mulțumim că susții {network_name}. + actualizare + Upgradează la {pro} + Prin actualizare, ești de acord cu Termenii și condițiile {icon} și Politica de confidențialitate {icon} ale {app_pro} + Vrei să profiți mai mult de {app_name}?\nFă upgrade la {app_pro} Beta pentru o experiență de mesagerie mai puternică. + {platform} îți procesează solicitarea de rambursare Profil Afișează imaginea Nu s-a putut elimina imaginea de profil. @@ -947,6 +1189,12 @@ Vă rugăm alegeți un fișier mai mic. Eroare la actualizarea profilului. Promovează + Administratorii vor putea vedea istoricul mesajelor din ultimele 14 zile și nu pot fi retrogradați sau eliminați din grup. + + Promovează membrul + Promovează membrii + Promovează membrii + Promovare eșuată Promovări eșuate @@ -978,6 +1226,7 @@ Recomandat Salvați parola de recuperare pentru a vă asigura că nu pierdeți accesul la contul dumneavoastră. Salvează parola de recuperare + Folosește parola de recuperare pentru a încărca contul tău pe dispozitive noi.\n\nContul nu poate fi recuperat fără parola de recuperare. Asigură-te că este stocată într-un loc sigur și securizat — și nu o împărtăși cu nimeni. Introduceți parola de recuperare A apărut o eroare la încărcarea parolei de recuperare.\n\nTe rugăm să exporți jurnalele, apoi să încarci fișierul prin intermediul Biroului de asistență {app_name} pentru a ajuta la soluționarea acestei probleme. Vă rugăm să verificați parola de recuperare și să încercați din nou. @@ -996,25 +1245,68 @@ Aceasta este parola ta de recuperare. Dacă o trimiți cuiva, acea persoană va avea acces complet la contul tău. Recreează grup Repetă + Pentru că te-ai înregistrat inițial la {app_pro} folosind un alt {platform_account}, va trebui să folosești acel {platform_account} pentru a-ți actualiza accesul {pro}. + Două moduri de a solicita o rambursare: Redu lungimea mesajului cu {count} %1$d caracter rămas %1$d caractere rămase %1$d de caractere rămase + Amintește-mi mai târziu Elimină + + Elimină membrul + Elimină membrii + Elimină membrii + + + Elimină membrul și mesajele acestuia + Elimină membrii și mesajele acestora + Elimină membrii și mesajele acestora + Eroare la eliminarea parolei Elimină parola actuală pentru {app_name}. Datele stocate local vor fi re-criptate cu o cheie generată aleatoriu, stocată pe dispozitivul tău. + + Se elimină membrul + Se elimină membrii + Se elimină membrii + Reînnoiește + Reînnoire {pro} Răspunde Solicită rambursare + Solicită o rambursare pe site-ul {platform}, folosind contul {platform_account} cu care te-ai înregistrat la {pro}. Retrimite + + Retrimite invitația + Retrimite invitațiile + Retrimite invitațiile + + + Retrimite promovarea + Retrimite promovările + Retrimite promovările + + + Se retrimite invitația + Se retrimit invitațiile + Se retrimit invitațiile + + + Se retrimite promovarea + Se retrimit promovările + Se retrimit promovările + Se încarcă informațiile despre țară... Repornește Resincronizează Reîncearcă Limită de recenzii Se pare că ai evaluat deja recent {app_name}, îți mulțumim pentru feedback! + Rulează aplicația în fundal + Rulezi {app_name} în fundal? + Deoarece folosești Modul Lent, îți recomandăm să permiți rularea {app_name} în fundal pentru a îmbunătăți notificările. Acest lucru poate îmbunătăți consistența notificărilor, deși sistemul tău poate limita automat activitatea în fundal.\n\nPoți modifica această opțiune mai târziu din Setări. Salvează Salvat Mesaje salvate @@ -1023,6 +1315,8 @@ Securitate ecran Notificări captură ecran Primește o notificare atunci când un contact face o captură de ecran a unei conversații unu-la-unu. + Ascunde fereastra {app_name} în capturile de ecran realizate pe acest dispozitiv. + Protecție captură ecran {name} a făcut o captură de ecran. Căutați Cauta contacte @@ -1044,6 +1338,11 @@ Trimitere Se trimite oferta de apel Se trimit candidații pentru conexiune + + Se trimite promovarea + Se trimit promovări + Se trimit promovări + Trimis: Aspect Șterge datele @@ -1070,14 +1369,19 @@ Setează Setează imaginea afișată de Comunitate Setează o parolă pentru {app_name}. Datele stocate local vor fi criptate cu această parolă. Ți se va cere să introduci această parolă de fiecare dată când pornește {app_name}. + Setarea nu poate fi actualizată Trebuie să reporniți {app_name} pentru a aplica noile setări. Securitate ecran + Pornire Distribuie Invită-ți prietenul să converseze cu tine pe {app_name} partajându-i ID-ul contului tău. Împărtășește cu prietenii tăi oriunde comunici de obicei cu ei — apoi mută conversația aici. A apărut o eroare la deschiderea bazei de date. Te rugăm să repornești aplicația și să încerci din nou. Ups! Se pare că nu ai încă un cont {app_name}.\n\nVa trebui să creezi unul în aplicația {app_name} înainte de a putea partaja. + Doriți să partajați istoricul mesajelor de grup cu acest utilizator? Distribuie către {app_name} + Ne pare rău, {app_name} acceptă doar partajarea simultană a mai multor imagini și videoclipuri + Partajarea acceptă doar fișiere media. Fișierele non-media au fost excluse Afișează Afișează tot Afișează mai puțin @@ -1098,12 +1402,15 @@ ID-ul contului {name} este vizibil în baza interacțiunilor anterioare ID-urile cenzurate sunt utilizate în comunități pentru a reduce mesajele spam și a crește confidențialitatea Traducere + Tavă de sistem Încercați din nou Indicatori tastare Vizualizează și distribuie indicatorii de tastare. Indisponibil Anulează Necunoscut + CPU incompatibil + Actualizează Actualizează accesul {pro} Două moduri de a actualiza accesul {pro}: Actualizări aplicație @@ -1128,6 +1435,8 @@ Ultima actualizare acum {relative_time} Actualizări Actualizare... + Actualizează + Upgrade {app_name} Actualizează la Încărcare Copiați adresa URL @@ -1136,6 +1445,8 @@ Ești sigur/ă că dorești să deschizi acest URL în browserul tău?\n\n{url} Linkurile se vor deschide în browserul tău. Folosește modul rapid + Schimbă-ți planul utilizând contul {platform_account} cu care te-ai înregistrat, prin intermediul site-ului {platform}. + Prin intermediul site-ului {platform} Video Videoclipul nu poate fi redat. Vizualizare @@ -1144,9 +1455,12 @@ Poate dura câteva minute. Un moment, vă rog... Atenţie + Asistența pentru iOS 15 s-a încheiat. Actualizează la iOS 16 sau o versiune mai nouă pentru a continua să primești actualizări ale aplicației. Fereastră Da Tu + Procesorul dumneavoastră nu suportă instrucțiunile SSE 4.2, necesare pentru ca {app_name} să proceseze imagini pe sistemele de operare Linux x64. Vă rugăm să faceți upgrade la un procesor compatibil sau să utilizați un alt sistem de operare. Parola de recuperare + Factor de mărire Ajustează dimensiunea textului și a elementelor vizuale. \ No newline at end of file diff --git a/app/src/main/res/values-b+ru+RU/strings.xml b/app/src/main/res/values-b+ru+RU/strings.xml index 78bfc4f34b..4e7ade60b7 100644 --- a/app/src/main/res/values-b+ru+RU/strings.xml +++ b/app/src/main/res/values-b+ru+RU/strings.xml @@ -15,7 +15,15 @@ Это ваш ID аккаунта. Другие пользователи могут сканировать его, чтобы начать беседу с вами. Фактический размер Добавить + + Добавить Админа + Добавить Админов + Добавить Админов + Добавить Админов + + Добавить администратора Введите ID аккаунта пользователя, которого вы повышаете до администратора.\n\nЧтобы добавить нескольких пользователей, введите ID каждого аккаунта через запятую. Одновременно можно указать до 20 идентификаторов учётных записей. + Администраторов нельзя понизить в должности или удалить из группы. Администраторов нельзя удалить. {name} и {count} других пользователей назначены администраторами. Назначать администраторов @@ -40,6 +48,12 @@ {name} был(а) снят(а) с должности администратора. {name} и {count} других пользователей были удалены админом. Пользователи {name} и {other_name} были удалены админом. + + Выбран %1$d Админ + Выбрано %1$d Админа + Выбрано %1$d Админов + Выбрано %1$d Админов + Отправление запроса в админы Отправление запросов в админы @@ -47,7 +61,10 @@ Отправление запросов в админы Настройки администратора + Вы не можете изменить свой статус администратора. Чтобы покинуть группу, откройте настройки беседы и выберите «Покинуть группу». {name} и {other_name} назначены администраторами. + Администраторы + Разрешить +{count} Анонимно Иконка Приложения @@ -67,6 +84,7 @@ Заметки Акции Погода + Значок {app_pro} Автоматический тёмный режим Спрятать системное меню Язык @@ -163,6 +181,7 @@ Вы уверены, что хотите разблокировать {name} и ещё одного пользователя? {name} разблокирован(а) Просматривайте и управляйте списком заблокированных контактов. + Не найдено приложение браузера для открытия ссылки, попробуйте скопировать URL Вызов {name} звонил(а) вам Вы не можете начать новый звонок. Сначала завершите текущий звонок. @@ -191,6 +210,10 @@ Включает голосовые и видеозвонки для общения с другими пользователями. Вы позвонили {name} Вы пропустили звонок от {name}, потому что не включили Голосовые и видеозвонки в настройках конфиденциальности. + {app_name} требуется доступ к вашей камере для включения видеозвонков, но разрешение было отклонено. Вы не можете изменить разрешения камеры во время звонка.\n\nХотите завершить звонок сейчас и включить доступ к камере или получить напоминание после звонка? + Чтобы разрешить доступ к камере, откройте настройки и включите разрешение Камера. + Во время вашего последнего звонка вы пытались использовать видео, но это не удалось, так как доступ к камере был ранее запрещён. Чтобы разрешить доступ к камере, откройте настройки и включите разрешение Камера. + Требуется доступ к камере Камера не найдена Камера недоступна. Предоставить доступ к камере @@ -198,9 +221,19 @@ {app_name} требуется доступ к камере для съемки фото, видео, а также сканирования QR-кодов. {app_name} требуется доступ к камере для сканирования QR-кодов Отмена + Отменить {pro} + Отмените на сайте {platform}, используя учётную запись {platform_account}, с которой вы оформили подписку на {pro}. + Отмените на сайте {platform_store}, используя учётную запись {platform_account}, с которой вы оформили подписку на {pro}. Изменить Не удалось изменить пароль Измените пароль для {app_name}. Локально сохранённые данные будут повторно зашифрованы с использованием нового пароля. + Изменить настройку + Проверка статуса {pro} + Проверяется ваш статус {pro}. Вы сможете продолжить, как только проверка завершится. + Проверяются ваши данные {pro}. Некоторые действия на этой странице могут быть недоступны до завершения этой проверки. + Проверка статуса {pro}... + Проверяем данные {pro}. Вы не сможете продлить доступ, пока проверка не будет завершена. + Проверяется ваш статус {pro}. Вы сможете перейти на {pro} после завершения проверки. Очистить Очистить все Очистить все данные @@ -261,11 +294,19 @@ URL сообщества Копировать ссылку сообщества Подтвердить + Подтвердить назначение + Вы уверены? Администраторов нельзя понизить или удалить из группы. Контакты Удалить контакт Вы уверены, что хотите удалить {name} из ваших контактов? Новые сообщения от {name} будут поступать как запросы сообщений. У вас еще нет контактов Выбрать контакты + + Выбран %1$d контакт + Выбрано %1$d контакта + Выбрано %1$d контактов + Выбрано %1$d контактов + Сведения о пользователе Камера Выберите действие для начала беседы @@ -306,6 +347,8 @@ Скопировать Создать Создание вызова + Текущая система оплаты + Текущий Пароль Вырезать Тёмный режим Вы уверены, что хотите удалить все сообщения, вложения и данные учетной записи с этого устройства и создать новую учетную запись? @@ -332,6 +375,18 @@ Пожалуйста, подождите, пока группа будет создана... Ошибка при обновлении группы У вас недостаточно прав для удаления других сообщений + + Удалить Выбранное Вложение + Удалить Выбранные Вложения + Удалить Выбранные Вложения + Удалить Выбранные Вложения + + + Вы действительно хотите удалить выбранное вложение? Связанное с ним сообщение также будет удалено. + Вы действительно хотите удалить выбранное вложения? Связанное с ними сообщение также будет удалено. + Вы действительно хотите удалить выбранное вложения? Связанное с ними сообщение также будет удалено. + Вы действительно хотите удалить выбранное вложения? Связанное с ними сообщение также будет удалено. + Вы уверены, что хотите удалить {name} из контактов?\n\nВаша переписка будет удалена, включая все сообщения и вложения. Последующие сообщения от {name} будут появляться в виде запроса на общение. Вы уверены, что хотите удалить свою беседу с {name}?\nЭто приведет к безвозвратному удалению всех сообщений и вложений. @@ -385,6 +440,7 @@ Вы уверены, что хотите удалить эти сообщения для всех? Удаление Включить инструменты разработчика + Настройки уведомлений устройства Начать диктовку... Исчезающие сообщения Сообщение будет удалено через {time_large} @@ -432,6 +488,8 @@ Ваше отображаемое имя видно пользователям, группам и сообществам, с которыми вы взаимодействуете. Документ Донат + Могущественные силы пытаются ослабить конфиденциальность, но мы не можем продолжать эту борьбу в одиночку.\n\nВаши пожертвования помогают сохранять безопасность, независимость и доступность {app_name}. + {app_name} нуждается в вашей помощи Готово Скачать Скачивание... @@ -463,17 +521,31 @@ Вы и {name} поставили {emoji_name} Отреагировали на ваше сообщение {emoji} Включить + Включить доступ к камере? Показывать уведомления при получении новых сообщений. + Завершить звонок для включения Нравится {app_name}? Требуется доработка {emoji} Отлично {emoji} Вы уже некоторое время пользуетесь {app_name}, как оно? Нам действительно интересно узнать ваше мнение. Войти + Введите пароль, который вы задали для {app_name} + Введите пароль, который вы используете для разблокировки {app_name} при запуске, а не ваш пароль восстановления + Ошибка при проверке статуса {pro} Пожалуйста, проверьте подключение к интернету и повторите попытку. Скопировать ошибку и выйти Ошибка базы данных Что-то пошло не так. Попробуйте ещё раз позже. + Ошибка при загрузке доступа к {pro} + {app_name} не удалось выполнить поиск по этому ONS. Пожалуйста, проверьте сетевое подключение и попробуйте снова. Произошла неизвестная ошибка. + Этот ONS не зарегистрирован. Пожалуйста, проверьте правильность и попробуйте снова. + Не удалось повторно отправить приглашение {name} в {group_name} + Не удалось повторно отправить приглашение {name} и {count} другим в {group_name} + Не удалось повторно отправить приглашение {name} и {other_name} в {group_name} + Не удалось повторно отправить повышение для {name} в группе {group_name} + Не удалось повторно отправить повышение для {name} и ещё {count} в группе {group_name} + Не удалось повторно отправить повышение для {name} и {other_name} в группе {group_name} Не удалось загрузить Сбои Отзыв @@ -529,6 +601,9 @@ Вы уверены, что хотите покинуть {group_name}? Вы уверены, что хотите покинуть {group_name}?\n\nЭто удалит всех участников и всё содержимое группы. Не удалось выйти из {group_name} + {name} был(а) приглашён(а) в группу. История чата за последние 14 дней была передана. + {name} и ещё {count} были приглашены в группу. История чата за последние 14 дней была передана. + {name} и {other_name} были приглашены в группу. История чата за последние 14 дней была передана. {name} покинул(а) группу. {name} и {count} других человек покинули группу. {name} и {other_name} покинули группу. @@ -540,6 +615,9 @@ {name} и {other_name} были приглашены в группу. Вы и {count} других пользователей приглашены вступить в группу. История чата была передана. Вы и {other_name} были приглашены в группу. История чата была передана. + Не удалось удалить {name} из {group_name} + Не удалось удалить {name} и {count} других из {group_name} + Не удалось удалить {name} и {other_name} из {group_name} Вы покинули группу. Участники группы В этой группе нет других участников. @@ -553,6 +631,7 @@ У вас нет сообщений от {group_name}. Отправьте сообщение, чтобы начать беседу! Эта группа не обновлялась более 30 дней. У вас могут возникнуть проблемы с отправкой сообщений или просмотром информации о группе. Вы единственный администратор в {group_name}.\n\nУчастники группы и настройки не могут быть изменены без администратора. + Вы единственный администратор в {group_name}.\n\nУчастники группы и настройки не могут быть изменены без администратора. Чтобы покинуть группу без её удаления, сначала добавьте нового администратора. Ожидание удаления Вы назначены администратором. Вы и {count} других человек назначены администраторами. @@ -601,10 +680,17 @@ Скрыть других Изображение изображения + Важно Клавиатура «Инкогнито» Запросить режим «Инкогнито», если доступно. В зависимости от клавиатуры, которую вы используете, она может проигнорировать этот запрос. Информация Недопустимый ярлык + + Пригласить Контакт + Пригласить Контакты + Пригласить Контакты + Пригласить Контакты + Ошибка отправки приглашения Ошибка отправки приглашений @@ -617,8 +703,19 @@ Приглашения не были отправлены. Повторить попытку? Приглашения не были отправлены. Повторить попытку? + + Пригласить Участника + Пригласить Участников + Пригласить Участников + Пригласить Участников + + Пригласите нового участника в группу, введя Account ID, ONS друга или отсканировав их QR-код {icon} + Пригласите нового участника в группу, введя ID аккаунта вашего друга, ONS, или отсканировав его QR-код Присоединиться Позже + Автоматически запускать {app_name} при включении компьютера. + Запускать при старте системы + Этот параметр управляется вашей системой в Linux. Чтобы включить автозапуск, добавьте {app_name} в приложения автозагрузки в системных настройках. Узнать больше Покинуть Выход... @@ -633,6 +730,8 @@ Вы и пользователь {other_name} присоединились к группе. {name} и {other_name} присоединились к группе. Вы присоединились к группе. + Ограничить фоновую активность? + В данный момент вы разрешили {app_name} работать в фоновом режиме для повышения надёжности уведомлений. Изменение этой настройки может снизить надёжность уведомлений. Предпросмотры ссылок Активировать предпросмотр ссылок для поддерживаемых URL. Включить Предпросмотр Ссылок @@ -643,6 +742,7 @@ Ваши метаданные не будут полностью защищены при отправке ссылок с предпросмотром. Предпросмотры ссылок выключены {app_name} необходимо посетить сайты, на которые даются отправляемые и получаемые ссылки, чтобы создать для них превью.\n\nВы можете включить эту функцию в настройках {app_name}. + Ссылки Загрузить аккаунт Загрузка вашего аккаунта Загрузка... @@ -655,9 +755,19 @@ Статус блокировки Разблокировать Приложение {app_name} разблокировано + Отчёты + Управление администраторами Управление участниками + Управление {pro} Максимальный + Может быть, позже Медиа + + Выбран %1$d Участник + Выбрано %1$d Участника + Выбрано %1$d Участников + Выбрано %1$d Участников + %1$d Участник %1$d участника @@ -671,7 +781,9 @@ %1$d активных участников Добавить Account ID или ONS + Участников можно повысить только после принятия приглашения в группу. Пригласить друзей в Session + У вас нет контактов для приглашения в эту группу.\nВернитесь назад и пригласите участников, используя их Account ID или ONS. Отправить приглашение Отправить приглашения @@ -682,8 +794,10 @@ Хотите поделиться историей сообщений группы с {name} и {count} другими? Хотите поделиться историей сообщений группы с {name} и {other_name}? Поделиться историей сообщений + Поделиться историей сообщений за последние 14 дней Поделиться только новыми сообщениями Пригласить + Участники (не администраторы) Панель меню Сообщение Читать далее @@ -704,6 +818,7 @@ Начните новую беседу, введя ID аккаунта вашего друга или ONS. Начните новую беседу, введя ID аккаунта вашего друга, ONS или отсканировав их QR-код. + Начните новую беседу, введя идентификатор аккаунта друга, ONS или отсканировав его QR-код {icon} У вас новое сообщение. У вас %1$d новых сообщения. @@ -758,20 +873,26 @@ Сообщение слишком длинное Пожалуйста, сократите свое сообщение до {limit} символов. Сообщение слишком длинное + Новый Пароль Далее + Следующие Шаги Выберите псевдоним для {name}. Он будет отображаться в индивидуальных и групповых беседах. Введите ник Пожалуйста, введите более короткий псевдоним Удалить имя пользователя Задать имя пользователя Нет + В этой группе нет участников, не являющихся администраторами. Нет предложений + Отправляйте сообщения до 10 000 символов во всех беседах. + Организуйте чаты с неограниченным количеством закреплённых бесед. Нет Не сейчас Заметки для Себя У вас нет сообщений в Заметках для Себя. Скрыть Заметки для Себя Вы уверены, что хотите скрыть Заметки для Себя? + ОБРАТИТЕ ВНИМАНИЕ: Выполняя {action_type}, вы соглашаетесь с Условиями обслуживания {icon} и Политикой конфиденциальности {icon} {app_pro} Отображение уведомлений Показывать имя отправителя и предварительный просмотр содержимого сообщения. Отображать только имя отправителя без содержимого сообщения. @@ -815,6 +936,12 @@ Выключено ОК Вкл + На вашем устройстве {device_type} + Откройте эту учётную запись {app_name} на устройстве {device_type}, где выполнен вход в {platform_account}, с которым вы изначально зарегистрировались. Затем отмените {pro} через настройки {app_pro}. + Откройте этот аккаунт {app_name} на устройстве {device_type}, в котором выполнен вход в аккаунт {platform_account}, использованный при регистрации. Затем обновите доступ к {pro} через настройки {app_pro}. + На привязанном устройстве + На сайте {platform_store} + На сайте {platform} Создать аккаунт Аккаунт создан У меня есть аккаунт @@ -839,6 +966,9 @@ Мы не смогли распознать этот ONS. Пожалуйста, проверьте его и попробуйте снова. Мы не смогли выполнить поиск по этому ONS. Пожалуйста, попробуйте снова позже. Открыть + Открыть сайт {platform_store} + Открыть сайт {platform} + Открыть настройки Открытый опрос Другое Пароль @@ -866,11 +996,14 @@ Длина больше 12 символов Содержит цифру Содержит строчную букву + Содержит символ Содержит заглавную букву Индикатор надёжности пароля Надёжный пароль помогает защитить ваши сообщения и вложения в случае утери или кражи устройства. Пароли Вставить + Ошибка платежа + Ваш платёж был успешно обработан, но произошла ошибка при {action_type} статуса {pro}.\n\nПожалуйста, проверьте сетевое подключение и повторите попытку. Изменение разрешений {app_name} требуется доступ для отправки музыки, аудио и файлов, но доступ был запрещен. Перейдите в Настройки → Разрешения, и включите \"Музыка и аудио\". {app_name} требуется доступ к Apple Music для воспроизведения медиафайлов. @@ -909,26 +1042,181 @@ Закрепить беседу Открепить Открепить беседу + И многое другое... + Новые функции в {pro} скоро. Узнай о них первым в {pro} Плане Развития {icon} Предпочтения Предварительный просмотр Предпросмотр уведомления + Ваш доступ к {pro} активен!\n\n{pro} автоматически продлевается {date} на {current_plan_length}. + Срок действия вашего доступа к {pro} истечет {date}.\n\nОбновите свой доступ к {pro} сейчас, чтобы гарантировать автоматическое продление до истечения срока действия вашего доступа к {pro}. + Ваш доступ к {pro} активен!\n\n{pro} будет автоматически продлён {date} ещё на\n{current_plan_length}. Все внесённые здесь изменения вступят в силу при следующем продлении. + Ошибка доступа к {pro} + Ваш доступ {pro} истекает {date}. + Загрузка доступа {pro} + Информация о доступе к {pro} всё ещё загружается. Обновление будет доступно после завершения этого процесса. + Загрузка доступа к {pro}... + Не удалось подключиться к сети для загрузки информации о доступе к {pro}. Обновление {pro} через {app_name} будет недоступно, пока соединение не будет восстановлено.\n\nПожалуйста, проверьте сетевое подключение и повторите попытку. + Доступ к {pro} не найден + {app_name} не обнаружил у вас доступа к {pro}. Если это ошибка, пожалуйста, обратитесь в службу поддержки {app_name} за помощью. + Восстановить доступ к {pro} + Возобновить доступ к {pro} + В настоящее время доступ к {pro} можно приобрести и продлить только через {platform_store} или {platform_store_other}. Так как вы используете {app_name} для компьютеров, продление здесь невозможно.\n\nРазработчики {app_name} активно работают над альтернативными вариантами оплаты, чтобы пользователи могли приобретать доступ к {pro} вне {platform_store} и {platform_store_other}. Дорожная карта {pro} {icon} + Продлите свой доступ к {pro} на сайте {platform_store}, используя {platform_account}, с которым вы оформили подписку на {pro}. + Продлите на сайте {platform}, используя учётную запись {platform_account}, с которой вы оформили подписку на {pro}. + Продлите доступ к {pro}, чтобы снова начать использовать мощные функции бета-версии {app_pro}. + Доступ к {pro} Восстановлен + {app_name} обнаружил и восстановил доступ вашего аккаунта к {pro}. Ваш статус {pro} был восстановлен! + Поскольку вы изначально оформили подписку на {app_pro} через {platform_store}, для обновления доступа к {pro} необходимо использовать учётную запись {platform_account}. + В настоящее время доступ к {pro} можно приобрести только через {platform_store} или {platform_store_other}. Поскольку вы используете {app_name} Desktop, вы не можете выполнить обновление до {pro} здесь.\n\nРазработчики {app_name} активно работают над альтернативными способами оплаты, чтобы пользователи могли приобрести доступ к {pro} вне {platform_store} и {platform_store_other}. Дорожная карта {pro} {icon} Активирован + активация + Готово! + Ваш доступ к {app_pro} возобновлён! Списание средств произойдёт автоматически при продлении {pro} {date}. У вас уже есть Вперёд! И загружай анимированные GIF и WebP для вашего изображения! + Получите анимированный профиль и другие премиум функции с {app_pro} Beta Анимированное изображение профиля пользователи могут загружать GIF-файлы + Анимированный Картинки Профиля + Устанавливайте GIF и WebP картинки в свой профиль. Загружайте GIF с + {pro} автопродление через {time} + Значок {pro} + Показывать значок {app_pro} другим пользователям + Значки + Покажите свою поддержку {app_name}, добавив эксклюзивный значок рядом с вашим именем. + + %1$s %2$s Значок отправлен + %1$s %2$s Значки отправлены + %1$s %2$s Значки отправлены + %1$s %2$s Значки отправлены + + Функции {pro} Beta + {price} в год + {price} в месяц + {price} — оплата раз в квартал + Хотите отправлять сообщения подлиннее?\nОтправляй больше текста и открой премиум функции с {app_pro} Beta + Хотите больше закреплений?\nОрганизовывайте свои чаты и получите доступ к премиум функциям с {app_pro} Beta + Нужно больше, чем {limit} закреплений?\nОрганизовывайте свои чаты и получите доступ к премиум функциям с {app_pro} Beta + Жаль, что вы отменяете {pro}. Вот что нужно знать перед отменой доступа {pro}. + Отмена + Отмена доступа к {pro} отключит автоматическое продление до истечения срока действия доступа к {pro}. Отмена {pro} не предусматривает возврат средств. Вы сможете продолжать использовать функции {app_pro} до истечения срока действия доступа к {pro}.\n\nТак как вы изначально оформили доступ к {app_pro}, используя {platform_account}, для отмены {pro} необходимо воспользоваться тем же аккаунтом {platform_account}. + Два способа отменить доступ к {pro}: + Отмена доступа {pro} предотвратит автоматическое продление до истечения срока действия {pro}.\n\nОтмена {pro} не означает возврат средств. Вы сможете продолжать использовать функции {app_pro} до окончания срока доступа {pro}. + Выберите подходящий вариант доступа {pro}.\nЧем дольше срок — тем больше скидка. + Вы уверены, что хотите удалить свои данные с этого устройства?\n\n{app_pro} не может быть перенесён на другую учётную запись. Пожалуйста, сохраните ваш пароль восстановления, чтобы позже вы могли восстановить доступ к {pro}. + Вы уверены, что хотите удалить свои данные из сети? После продолжения вы не сможете восстановить свои сообщения или контакты.\n\n{app_pro} не может быть перенесён на другой аккаунт. Обязательно сохраните ваш Пароль для восстановления, чтобы позже вы смогли восстановить доступ к {pro}. + Ваш доступ к {pro} уже со скидкой в {percent}% от полной стоимости {app_pro}. + Ошибка обновления статуса {pro} + Срок действия истёк + К сожалению, ваш доступ к {pro} истёк.\nПродлите его, чтобы снова получить эксклюзивные преимущества и функции {app_pro} Beta. + Срок Действия Истекает + Ваш доступ {pro} истечёт через {time}.\nПродлите сейчас, чтобы сохранить доступ к эксклюзивным привилегиям и функциям {app_pro} Beta + {pro} истекает через {time} + {pro} Вопросы + Найдите ответы на часто задаваемые вопросы в разделе справки {app_pro}. Загрузка изображений в формате GIF и WebP Групповые чаты до 300 участников + множество эксклюзивных функций Сообщения до 10 тыс. символов Закрепление неограниченного количества бесед + Хотите использовать {app_name} на полную?\nПерейдите на {app_pro} Beta, чтобы получить доступ ко множеству эксклюзивных преимуществ и функций. Группа активирована У этой группы увеличена вместимость! Теперь она поддерживает до 300 участников, потому что администратор группы активировал + + %1$s Группа Улучшена + %1$s Группы Улучшены + %1$s Групп Улучшены + %1$s Групп Улучшены + + Запрос на возврат средств является окончательным. В случае одобрения ваш доступ к {pro} будет немедленно аннулирован, и вы потеряете доступ ко всем функциям {pro}. Увеличенный размер вложений Увеличенная длина сообщения + Большие Группы + Группы, в которых вы являетесь админом, автоматически поддерживают до 300 участников. + Скоро для всех пользователей Pro Beta станут доступны расширенные групповые чаты (до 300 участников)! + Большие Сообщения + Вы можете отправлять сообщения длиной до 10 000 символов во всех переписках. + + Отправлено %1$s Длинное Сообщение + Отправлено %1$s Длинных Сообщения + Отправлено %1$s Длинных Сообщений + Отправлено %1$s Длинных Сообщений + Это сообщение использовало следующие функции {app_pro}: + С новой установкой + Переустановите {app_name} на этом устройстве через {platform_store}, восстановите свою учётную запись с помощью пароля восстановления и продлите {pro} через настройки {app_pro}. + Переустановите {app_name} на этом устройстве через {platform_store}, восстановите свою учетную запись с помощью пароля восстановления и обновите до {pro} в настройках {app_pro}. + На данный момент есть три способа продления: + На данный момент есть два способа продления: + Скидка {percent}% + + %1$s Закреплённый Чат + %1$s Закреплённых Чатов + %1$s Закреплённых Чатов + %1$s Закреплённых Чатов + + Поскольку вы изначально оформили подписку на {app_pro} через {platform_store}, чтобы запросить возврат средств, необходимо использовать учётную запись {platform_account}. + Поскольку вы изначально оформили подписку на {app_pro} через {platform_store}, запрос на возврат средств будет обрабатываться службой поддержки {app_name}.\n\nЗапросите возврат, нажав кнопку ниже и заполнив форму возврата.\n\nСлужба поддержки {app_name} стремится обрабатывать запросы на возврат в течение 24–72 часов, но в периоды высокой нагрузки обработка может занять больше времени. + Ваш доступ к {app_pro} был продлен! Спасибо за поддержку {network_name}. + 1 месяц — {monthly_price} / месяц + 3 месяца — {monthly_price} / месяц + 12 месяцев - {monthly_price} / месяц + повторная активация + Откройте этот аккаунт {app_name} на устройстве {device_type}, где выполнен вход в учётную запись {platform_account}, с которой вы изначально зарегистрировались. Затем запросите возврат средств через настройки {app_pro}. + Нам жаль, что вы уходите. Вот что необходимо знать перед запросом возврата средств. + {platform} сейчас обрабатывает ваш запрос на возврат. Обычно это занимает 24–48 часов. В зависимости от принятого решения, статус {pro} может измениться в {app_name}. + Ваш запрос на возврат средств будет рассмотрен службой поддержки {app_name}.\n\nОтправьте запрос, нажав на кнопку ниже и заполнив форму возврата.\n\nХотя служба поддержки {app_name} стремится обрабатывать запросы на возврат в течение 24–72 часов, время обработки может увеличиться при большом объёме обращений. + Ваш запрос на возврат средств будет обрабатываться исключительно через веб-сайт {platform} самой платформой {platform}.\n\nВ соответствии с политикой возврата {platform}, разработчики {app_name} не могут повлиять на результат обработки запроса. Это касается как его одобрения или отклонения, так и вопроса о полном или частичном возврате. + Пожалуйста, свяжитесь с {platform} для получения дополнительной информации о статусе вашего запроса на возврат. Из-за политики возвратов {platform} разработчики {app_name} не могут повлиять на результат рассмотрения запроса.\n\nСлужба поддержки возвратов {platform} + Возврат средств {pro} + Возвраты за {app_pro} обрабатываются исключительно {platform} через {platform_store}.\n\nИз-за политики возвратов {platform} разработчики {app_name} не могут повлиять на результат рассмотрения запроса на возврат. Это касается как одобрения или отклонения запроса, так и решения о полном или частичном возврате. + Хотите снова использовать анимированные изображения профиля?\nПродлите {pro}, чтобы получить доступ к функциям, которые вы упустили. + Продлить {pro} Beta + Продлите доступ к {pro} через настройки {app_pro} на связанное устройство, на котором установлен {app_name} через {platform_store} или {platform_store_other}. + Хотите снова отправлять более длинные сообщения?\nПродлите доступ к {pro}, чтобы разблокировать функции, которые были недоступны. + Хотите снова использовать {app_name} на полную мощность?\nПродлите {pro}, чтобы получить доступ к функциям, которые вы упустили. + Хотите снова закреплять более {limit} бесед?\nПродлите {pro}, чтобы получить доступ к функциям, которые вы упустили. + Хотите снова закреплять больше бесед?\nПродлите {pro}, чтобы получить доступ к функциям, которые вы упустили. + Продлевая подписку, вы соглашаетесь с Условиями использования {icon} и Политикой конфиденциальности {icon} {app_pro} + продление + В настоящее время доступ к {pro} можно приобрести и продлить только через {platform_store} или {platform_store_other}. Поскольку вы установили {app_name} с использованием сборки {build_variant}, продление здесь недоступно.\n\nРазработчики {app_name} активно работают над альтернативными способами оплаты, чтобы пользователи могли приобретать доступ к {pro} вне {platform_store} и {platform_store_other}. Дорожная карта {pro} {icon} + Возврат Средств Запрошен Отправить еще с + Настройки {pro} + Начать использовать {pro} + Вашы {pro} Статы + Загрузка статистики {pro} + Загружается ваша статистика {pro}, пожалуйста, подождите. + Статы {pro} отражают использование на этом устройстве и может отображаться иначе на подключённых устройствах + Ошибка статуса {pro} + Не удалось подключиться к сети для проверки вашего статуса {pro}. Информация, отображаемая на этой странице, может быть неточной, пока соединение не будет восстановлено.\n\nПожалуйста, проверьте сетевое подключение и повторите попытку. + Загрузка статуса {pro} + Информация о вашем статусе {pro} загружается. Некоторые действия на этой странице могут быть недоступны до завершения загрузки. + Загрузка статуса {pro} + Не удалось подключиться к сети для проверки вашего статуса {pro}. Вы не можете продолжить, пока подключение не будет восстановлено.\n\nПожалуйста, проверьте сетевое подключение и повторите попытку. + Не удалось подключиться к сети для проверки вашего статуса {pro}. Обновление до {pro} невозможно, пока соединение не будет восстановлено.\n\nПожалуйста, проверьте сетевое подключение и повторите попытку. + Не удалось подключиться к сети для обновления статуса {pro}. Некоторые действия на этой странице будут недоступны, пока соединение не будет восстановлено.\n\nПожалуйста, проверьте сетевое подключение и повторите попытку. + Не удалось подключиться к сети для загрузки текущего доступа {pro}. Продление {pro} через {app_name} будет недоступно до восстановления соединения.\n\nПожалуйста, проверьте сетевое подключение и попробуйте снова. + Нужна помощь с {pro}? Отправьте запрос в службу поддержки. + Выполняя {action_type}, вы {activation_type} {app_pro} через протокол {app_name}. {entity} обеспечивает активацию, но не является поставщиком {app_pro}. {entity} не несёт ответственности за производительность, доступность или функциональность {app_pro}. + Подтверждая, вы соглашаетесь с Условиями Использования {icon} и Политикой Конфиденциальности {icon} от {app_pro} + Неограниченные Закрепления + Организовывайте все чаты с неограниченными закреплениями. + Ваша текущая опция оплаты предусматривает {current_plan_length} доступа к {pro}. Вы уверены, что хотите переключиться на вариант оплаты {selected_plan_length_singular}?\n\nПосле обновления доступ к {pro} будет автоматически продлён {date} на ещё {selected_plan_length} доступа к {pro}. + Ваш доступ к {pro} истекает {date}.\n\nПосле обновления доступ к {pro} будет автоматически продлён {date} на ещё {selected_plan_length} доступа к {pro}. + обновление + Обновитесь до {app_pro} Beta, чтобы получить доступ к множеству эксклюзивных преимуществ и функций. + Обновите до {pro} в настройках {app_pro} на подключённом устройстве, где {app_name} установлен через {platform_store} или {platform_store_other}. + В настоящее время доступ к {pro} можно приобрести только через {platform_store} или {platform_store_other}. Поскольку вы установили {app_name} с использованием {build_variant}, вы не можете выполнить обновление до {pro} здесь.\n\nРазработчики {app_name} активно работают над альтернативными способами оплаты, чтобы пользователи могли приобрести доступ к {pro} вне {platform_store} и {platform_store_other}. Дорожная карта {pro} {icon} + На данный момент есть только один способ обновления: + На данный момент есть два способа обновления: + Вы перешли на {app_pro}!\nСпасибо за поддержку {network_name}. + обновление + Обновление до {pro} + Обновляя, вы соглашаетесь с Условиями обслуживания {icon} и Политикой конфиденциальности {icon} от {app_pro} + Хотите больше от {app_name}?\nПереходите на {app_pro} Beta, чтобы получить улучшенный опыт обмена сообщениями. + {platform} обрабатывает ваш запрос на возврат средств Профиль Изображение профиля Не удалось удалить изображение профиля. @@ -936,6 +1224,13 @@ Пожалуйста, выберите файл меньшего размера. Ошибка обновления профиля. Повысить + Администраторы смогут просматривать историю сообщений за последние 14 дней и не могут быть понижены или удалены из группы. + + Повысить Участника + Повысить Участников + Повысить Участников + Повысить Участников + Перевод не удался Переводы не удались @@ -988,6 +1283,8 @@ Это ваш Пароль Восстановления. Если вы отправите его кому-либо, у них будет полный доступ к вашей учетной записи. Пересоздать группу Вернуть + Поскольку вы изначально оформили подписку на {app_pro} через другой аккаунт {platform_account}, вам необходимо использовать этот {platform_account}, чтобы обновить доступ к {pro}. + Два способа запросить возврат средств: Уменьшить длину на {count} %1$d символ остался @@ -995,17 +1292,67 @@ %1$d символов осталось %1$d символов осталось + Напомнить позже Удалить + + Удалить участника + Удалить участников + Удалить участника + Удалить участников + + + Удалить участника и его сообщения + Удалить участников и их сообщения + Удалить участников и их сообщения + Удалить участников и их сообщения + Не удалось удалить пароль Удалите текущий пароль для {app_name}. Локально сохранённые данные будут повторно зашифрованы с использованием случайно сгенерированного ключа, хранящегося на вашем устройстве. + + Удаление участника + Удаление участников + Удаление участников + Удаление участников + + Обновить + Продление {pro} Ответить + Запросить Возврат + Запросите возврат на сайте {platform}, используя учётную запись {platform_account}, с которой вы оформили подписку на {pro}. Отправить повторно + + Повторно отправить приглашение + Отправить приглашения повторно + Отправить приглашения повторно + Отправить приглашения повторно + + + Повторить Повышение + Повторить Повышения + Повторить Повышения + Повторить Повышения + + + Повторная отправка приглашения + Повторная отправка приглашений + Повторная отправка приглашений + Повторная отправка приглашений + + + Повтор Повышения + Повтор Повышений + Повтор Повышений + Повтор Повышений + Загружаем страны... Перезапуск Ресинхронизировать Повторить попытку Ограничение на отзыв Похоже, вы недавно уже оставляли отзыв о {app_name}, спасибо за ваш отзыв! + Запуск приложения в фоновом режиме + Запустить {app_name} в фоновом режиме? + Вы используете замедленный режим, и мы рекомендуем разрешить {app_name} работу в фоновом режиме для улучшения уведомлений. Это может повысить стабильность уведомлений, хотя система всё равно может автоматически ограничивать фоновую активность.\n\nВы можете изменить это позже в настройках. Сохранить Сохранено Сохраненные сообщения @@ -1014,6 +1361,8 @@ Защита экрана Уведомления о скриншотах Получать уведомление, когда контакт делает скриншот личного чата. + Скрывать окно {app_name} на скриншотах, сделанных на этом устройстве. + Защита от скриншотов {name} сделал(а) снимок экрана. Поиск Поиск контактов @@ -1036,6 +1385,12 @@ Отправка Отправка Предложения о Звонке Отправка Кандидатов на Подключение + + Отправка повышения + Отправка повышений + Отправка повышений + Отправка повышений + Отправлено: Внешний вид Очистить данные @@ -1056,20 +1411,26 @@ Уведомления Разрешения Конфиденциальность + {app_pro} Beta Пароль восстановления Настройки Установить Установить картинку для сообщества Установите пароль для {app_name}. Локально сохранённые данные будут зашифрованы с использованием этого пароля. При каждом запуске {app_name} вам потребуется вводить этот пароль. + Не удалось обновить настройку Вы должны перезапустить {app_name}, чтобы применить новые настройки. Защита экрана + Автозапуск Поделиться Пригласите друга пообщаться с вами в {app_name}, поделившись с ним своим ID аккаунта. Поделитесь с друзьями любым удобным способом — и продолжите беседу здесь. Возникла проблема с открытием базы данных. Пожалуйста, перезапустите приложение и попробуйте снова. Ой! Кажется у вас нет аккаунта {app_name}.\n \n Вам нужно создать новый в приложении {app_name}, чтобы поделиться. + Вы хотите поделиться историей сообщений группы с этим пользователем? Поделиться в {app_name} + К сожалению, {app_name} поддерживает только одновременную отправку нескольких изображений и видео + Отправка поддерживает только медиафайлы. Немедийные файлы были исключены Показать Показать все Свернуть @@ -1085,6 +1446,7 @@ Продолжить По умолчанию Ошибка + Вернуться Предпросмотр темы ID аккаунта {name} виден\nна основе ваших предыдущих взаимодействий Скрытые ID используются в сообществах\nдля уменьшения спама и повышения конфиденциальности @@ -1096,6 +1458,10 @@ Недоступно Отменить Неизвестно + Неподдерживаемый процессор + Обновить + Обновить {pro} Привилегии + Два способа обновить доступ к {pro}: Обновления приложения Обновить информацию о сообществе Название и описание Community видны всем участникам Community @@ -1117,13 +1483,19 @@ Версия {version} Последнее обновление {relative_time} Обновления + Обновление... + Обновить + Обновить {app_name} Обновить до Загрузка Копировать ссылку Открыть ссылку Откроется в вашем браузере. Вы уверены, что хотите открыть эту ссылку в вашем браузере?\n\n{url} + Ссылки откроются в вашем браузере. Использовать быстрый режим + Измените свой план, используя {platform_account}, с которым вы зарегистрировались, через веб-сайт {platform}. + Через сайт {platform} Видео Невозможно воспроизвести видео. Просмотреть @@ -1132,9 +1504,11 @@ Это может занять несколько минут. Пожалуйста, подождите... Предупреждение + Поддержка iOS 15 завершена. Обновитесь до iOS 16 или новее, чтобы продолжать получать обновления приложения. Окно Да Вы + Ваш процессор не поддерживает инструкции SSE 4.2, необходимые для обработки изображений в {app_name} на операционных системах Linux x64. Пожалуйста, обновите процессор на совместимый или используйте другую операционную систему. Ваш Пароль Восстановления Масштабирование приложения Настройте размер текста и визуальных элементов. diff --git a/app/src/main/res/values-b+sq+AL/strings.xml b/app/src/main/res/values-b+sq+AL/strings.xml index 19e5a2d205..7219c5b3a1 100644 --- a/app/src/main/res/values-b+sq+AL/strings.xml +++ b/app/src/main/res/values-b+sq+AL/strings.xml @@ -274,6 +274,7 @@ Mesazhi u fshi + Messages deleted Ky mesazh u fshi Ky mesazh u fshi në këtë pajisje diff --git a/app/src/main/res/values-b+sv+SE/strings.xml b/app/src/main/res/values-b+sv+SE/strings.xml index 539f4bf52e..0bdf7406af 100644 --- a/app/src/main/res/values-b+sv+SE/strings.xml +++ b/app/src/main/res/values-b+sv+SE/strings.xml @@ -15,7 +15,13 @@ Detta är ditt konto-ID. Andra användare kan skanna det för att starta en konversation med dig. Aktuella storlek Lägg till + + Lägg till administratör + Lägg till administratörer + + Lägg till administratör Ange Account ID för användaren du gör till administratör.\n\nFör att lägga till flera användare, ange varje Account ID separerat med ett kommatecken. Upp till 20 Account ID:er kan anges åt gången. + Administratörer kan inte nedgraderas eller tas bort från gruppen. Administratörer kan inte tas bort. {name} och {count} andra blev befordrade till Admin. Uppgradera administratörer @@ -40,12 +46,19 @@ {name} blev borttagen som Admin. {name} och {count} andra togs bort som Admin. {name} och {other_name} togs bort som Admin. + + %1$d administratör vald + %1$d administratörer valda + Sänder administratörsbehörighet Sänder administratörsbehörigheter Admin Settings + Du kan inte ändra din administratörsstatus. För att lämna gruppen, öppna konversationsinställningarna och välj Lämna gruppen. {name} och {other_name} blev befordrade till Admin. + Administratörer + Tillåt +{count} Anonym App-ikon @@ -65,6 +78,7 @@ Anteckningar Aktier Väder + {app_pro}-märke Automatisk mörkt läge Dölj menyfältet Språk @@ -161,6 +175,7 @@ Är du säker på att du vill avblockera {name} och en annan? Avblockerad {name} Visa och hantera blockerade kontakter. + Ingen webbläsare hittades för att öppna webbadressen, försök kopiera webbadressen istället Samtal {name} ringde dig Du kan inte starta ett nytt samtal. Avsluta ditt nuvarande samtal först. @@ -189,6 +204,10 @@ Möjliggör röst- och videosamtal till och från andra användare. Du ringde {name} Du missade ett samtal från {name} eftersom du inte har aktiverat Röst- och videosamtal i Sekretessinställningar. + {app_name} behöver åtkomst till din kamera för att möjliggöra videosamtal, men detta tillstånd har nekats. Du kan inte ändra dina kamerabehörigheter under ett samtal.\n\nVill du avsluta samtalet nu och aktivera kameraåtkomst, eller vill du bli påmind efter samtalet? + För att tillåta åtkomst till kameran, öppna inställningarna och slå på behörigheten för kamera. + Under ditt senaste samtal försökte du använda video men kunde inte det eftersom kameråtkomst tidigare nekades. Öppna inställningarna och aktivera kamerabehörigheten för att tillåta kameråtkomst. + Åtkomst till kamera krävs Ingen kamera hittades Kameran är inte tillgänglig. Tillåt kameraåtkomst @@ -196,9 +215,19 @@ {app_name} behöver åtkomst till kameran för att kunna fotografera och filma eller skanna QR-koder. {app_name} behöver kameraåtkomst för att skanna QR-koder Avbryt + Avsluta {pro} + Avsluta på webbplatsen för {platform} med det {platform_account} du använde för att registrera dig för {pro}. + Avsluta på webbplatsen för {platform_store} med det {platform_account} du använde för att registrera dig för {pro}. Ändra Misslyckades att ändra lösenordet Ändra ditt lösenord för {app_name}. Lokalt lagrad data kommer att krypteras om med ditt nya lösenord. + Ändra inställning + Kontrollerar status för {pro} + Kontrollerar din {pro}-status. Du kan fortsätta när kontrollen är klar. + Kontrollerar dina uppgifter om {pro}. Vissa åtgärder på denna sida kan vara otillgängliga tills kontrollen är klar. + Kontrollerar status för {pro}... + Kontrollerar dina {pro}-uppgifter. Du kan inte förnya förrän denna kontroll är slutförd. + Kontrollerar din status för {pro}. Du kommer att kunna uppgradera till {pro} när denna kontroll är klar. Rensa Rensa alla Rensa all data @@ -257,11 +286,17 @@ Community URL Kopiera community-URL Bekräfta + Bekräfta befordran + Är du säker? Administratörer kan inte nedgraderas eller tas bort från gruppen. Kontakter Radera kontakt Är du säker på att du vill radera {name} från dina kontakter? Nya meddelanden från {name} kommer att anlända som en meddelandeförfrågan. Du har inga kontakter än Välj Kontakter + + %1$d kontakt vald + %1$d kontakter valda + Visa användardetaljer Kamera Välj en åtgärd för att starta en konversation @@ -302,6 +337,7 @@ Kopiera Skapa Skapar samtalet + Aktuell fakturering Nuvarande lösenord Klipp ut Mörkt läge @@ -329,6 +365,14 @@ Vänligen vänta medans gruppen skapas... Misslyckades att uppdatera grupp Du har inte behörighet att ta bort andras meddelanden + + Ta bort markerad bilaga + Ta bort markerade bilagor + + + Är du säker på att du vill ta bort den markerade bilagan? Meddelandet som är kopplat till bilagan kommer också att raderas. + Är du säker på att du vill ta bort de valda bilagorna? Meddelandet som är kopplat till bilagorna kommer också att raderas. + Är du säker på att du vill ta bort {name} från dina kontakter?\n\nDetta kommer att radera din konversation, inklusive alla meddelanden och bilagor. Framtida meddelanden från {name} kommer att visas som en meddelandeförfrågan. Är du säker på att du vill radera din konversation med {name}?\nDetta kommer permanent radera alla meddelanden och bilagor. @@ -368,6 +412,7 @@ Är du säker på att du vill radera dessa meddelanden för alla? Raderar Slå på utvecklingsverktyg + Enhetens aviseringsinställningar Starta diktering... Försvinnande meddelanden Meddelande kommer att raderas om {time_large} @@ -415,6 +460,8 @@ Ditt visningsnamn är synligt för användare, grupper och samhällen du interagerar med. Dokument Donera + Starka krafter försöker försvaga den personliga integriteten, men vi kan inte fortsätta kampen ensamma.\n\nAtt donera hjälper till att hålla {app_name} säker, oberoende och online. + {app_name} behöver din hjälp Klar Hämta Hämtar... @@ -444,17 +491,31 @@ Du och {name} reagerade med {emoji_name} Reagerade på ditt meddelande {emoji} Aktivera + Aktivera kameratillgång? Visa aviseringar när du får nya meddelanden. + Avsluta samtal för att aktivera Gillar du {app_name}? Behöver förbättras {emoji} Det är fantastiskt {emoji} Du har använt {app_name} ett tag, hur går det? Vi skulle uppskatta om du delar dina tankar. Enter + Ange lösenordet du skapade för {app_name} + Ange lösenordet du använder för att låsa upp {app_name} vid start – inte ditt återställningslösenord + Fel vid kontroll av {pro}-status Kontrollera din internetanslutning och försök igen. Kopiera felmeddelande och avsluta Databasfel Något gick fel. Försök igen senare. + Fel vid inläsning av åtkomst till {pro} + {app_name} kunde inte söka efter denna ONS. Kontrollera din nätverksanslutning och försök igen. Ett okänt fel har uppstått. + Den här ONS är inte registrerad. Kontrollera att den är korrekt och försök igen. + Det gick inte att skicka inbjudan igen till {name} i {group_name} + Det gick inte att skicka inbjudan igen till {name} och {count} andra i {group_name} + Det gick inte att skicka inbjudan igen till {name} och {other_name} i {group_name} + Det gick inte att skicka befordran på nytt till {name} i {group_name} + Det gick inte att skicka befordran på nytt till {name} och {count} andra i {group_name} + Det gick inte att skicka befordran på nytt till {name} och {other_name} i {group_name} Nedladdningen misslyckades Misslyckanden Feedback @@ -508,6 +569,9 @@ Är du säker på att du vill lämna {group_name}? Är du säker på att du vill lämna {group_name}?\n\nDetta kommer att ta bort alla medlemmar och radera allt gruppinnehåll. Misslyckades med att lämna {group_name} + {name} blev inbjuden att gå med i gruppen. Chatt historik från de senaste 14 dagarna delades. + {name} och {count} andra blev inbjudna att gå med i gruppen. Chatt historik från de senaste 14 dagarna delades. + {name} och {other_name} blev inbjudna att gå med i gruppen. Chatt historik från de senaste 14 dagarna delades. {name} lämnade gruppen. {name} och {count} andra lämnade gruppen. {name} och {other_name} lämnade gruppen. @@ -519,6 +583,9 @@ {name} och {other_name} bjöds in att gå med i gruppen. Du och {count} andra bjöds in att gå med i gruppen. Chatt historik delades. Duoch{other_name}blev inbjudna för att delta i gruppen. Chatt historiken är delad. + Det gick inte att ta bort {name} från {group_name} + Det gick inte att ta bort {name} och {count} andra från {group_name} + Det gick inte att ta bort {name} och {other_name} från {group_name} Du lämnade gruppen. Gruppmedlemmar Det finns inga andra medlemmar i denna grupp. @@ -532,6 +599,7 @@ Du har inga meddelanden från {group_name}. Skicka ett meddelande för att starta konversationen! Denna grupp har inte uppdaterats på över 30 dagar. Du kan uppleva problem med att skicka meddelanden eller visa gruppinformation. Du är den enda administratören i {group_name}.\n\nGruppmedlemmar och inställningar kan inte ändras utan en administratör. + Du är den enda administratören i {group_name}.\n\nGruppmedlemmar och inställningar kan inte ändras utan en administratör. För att lämna gruppen utan att radera den, lägg först till en ny administratör. Avvaktar radering Du blev befordrad till Admin. Du och {count} andra blev befordrade till Admin. @@ -576,10 +644,15 @@ Dölj andra Bild bilder + Viktigt Inkognito-tangentbord Begär inkognitoläge om tillgängligt. Beroende på tangentbordet du använder kanske ditt tangentbord ignorerar denna begäran. Info Ogiltig genväg + + Bjud in kontakt + Bjud in kontakter + Inbjudningen misslyckades Inbjudningarna misslyckades @@ -588,8 +661,17 @@ Inbjudningen kunde inte skickas. Vill du försöka igen? Inbjudningarna kunde inte skickas. Vill du försöka igen? + + Bjud in medlem + Bjud in medlemmar + + Bjud in en ny medlem till gruppen genom att ange din väns Account ID, ONS eller skanna deras QR-kod {icon} + Bjud in en ny medlem till gruppen genom att ange din väns Account ID, ONS eller skanna deras QR-kod Anslut Senare + Starta {app_name} automatiskt när datorn startas. + Starta vid uppstart + Denna inställning hanteras av ditt system på GNU/Linux. För att aktivera automatisk start, lägg till {app_name} i dina startprogram i systeminställningarna. Läs mer Lämna Lämnar... @@ -604,6 +686,8 @@ Du och {other_name} gick med i gruppen. {name} och {other_name} gick med i gruppen. Du gick med i gruppen. + Begränsa bakgrundsaktivitet? + Du tillåter för närvarande att {app_name} körs i bakgrunden för att förbättra aviseringarnas tillförlitlighet. Att ändra denna inställning kan leda till mindre tillförlitliga aviseringar. Förhandsgranskning av länkar Visa länkförhandsvisningar för stödda URL:er. Aktivera förhandsgranskningar av länkar @@ -628,9 +712,16 @@ Tryck om du vill låsa upp {app_name} är upplåst Loggar + Hantera administratörer Hantera medlemmar + Hantera {pro} Max + Kanske senare Media + + %1$d medlem vald + %1$d medlemmar valda + %1$d medlem %1$d medlemmar @@ -640,7 +731,9 @@ %1$d aktiva medlemmar Lägg till Account ID eller ONS + Medlemmar kan endast befordras efter att de har accepterat en inbjudan att gå med i gruppen. Bjud in kontakter + Du har inga kontakter att bjuda in till den här gruppen.\nGå tillbaka och bjud in medlemmar med deras Account ID eller ONS. Skicka inbjudan Skicka inbjudningar @@ -649,8 +742,10 @@ Vill du dela gruppmeddelandehistorik med {name} och {count} andra? Vill du dela gruppmeddelandehistorik med {name} och {other_name}? Dela meddelandehistorik + Dela meddelandehistorik från de senaste 14 dagarna Dela endast nya meddelanden Bjud in + Medlemmar (icke-administratörer) Menyrad Meddelande Läs mer @@ -669,6 +764,7 @@ Starta en ny konversation genom att ange din väns Account ID eller ONS. Starta en ny konversation genom att ange din väns Account ID, ONS eller skanna deras QR-kod. + Starta en ny konversation genom att ange din väns Account ID, ONS eller skanna deras QR-kod {icon} Du har ett nytt meddelande. Du har %1$d nya meddelanden. @@ -719,19 +815,24 @@ Meddelandet är för långt Nytt Lösenord Nästa + Nästa steg Välj ett smeknamn för {name}. Detta kommer att visas för dig i dina en-till-en- och gruppkonversationer. Ange smeknamn Vänligen ange ett kortare smeknamn Ta bort smeknamn Ange smeknamn Nej + Det finns inga icke-administratörer i den här gruppen. Inga förslag + Du kan skicka meddelanden med upp till 10 000 tecken i alla konversationer. + Organisera chattar med obegränsade fastnålade konversationer. Inga Inte nu Påminnelse till mig själv Du har inga meddelanden i Note to Self. Göm Notera till mig själv Är du säker på att du vill dölja Påminnelse till mig själv? + OBS: Genom att {action_type} godkänner du {app_pro} Användarvillkoren {icon} och Sekretesspolicyn {icon} Aviseringsvisning Visa avsändarens namn och en förhandsvisning av meddelandets innehåll. Visa endast avsändarens namn utan något meddelandeinnehåll. @@ -775,6 +876,12 @@ Av Okej + På din {device_type}-enhet + Öppna detta {app_name}-konto på en {device_type}-enhet som är inloggad på det {platform_account} du ursprungligen registrerade dig med. Avsluta sedan {pro} via inställningarna i {app_pro}. + Öppna detta {app_name}-konto på en {device_type}-enhet som är inloggad på det {platform_account} du ursprungligen registrerade dig med. Uppdatera sedan din åtkomst till {pro} via inställningarna i {app_pro}. + På en länkad enhet + På webbplatsen för {platform_store} + På webbplatsen för {platform} Skapa konto Konto Skapat Jag har ett konto @@ -799,6 +906,9 @@ Vi kunde inte känna igen denna ONS. Vänligen kontrollera det och försök igen. Vi kunde inte söka efter denna ONS. Vänligen försök igen senare. Öppna + Öppna webbplatsen för {platform_store} + Öppna webbplatsen för {platform} + Öppna inställningar Öppna undersökning Övrigt Lösenord @@ -826,11 +936,14 @@ Längre än 12 tecken Inkluderar en siffra Inkluderar en liten bokstav + Innehåller en symbol Inkluderar en stor bokstav Indikator för lösenordsstyrka Att skapa ett starkt lösenord hjälper till att skydda dina meddelanden och bilagor om din enhet skulle gå förlorad eller bli stulen. Lösenord Klistra in + Betalningsfel + Din betalning har behandlats, men ett fel inträffade vid {action_type} av din {pro}-status.\n\nKontrollera nätverksanslutningen och försök igen. Ändra tillåtelse {app_name} behöver åtkomst till musik och ljud för att skicka filer, musik och ljud, men åtkomsten har nekats permanent. Tryck på Inställningar → Behörigheter, och slå på \'Musik och ljud\'. {app_name} behöver åtkomst till Apple Music för att spela upp bifogade mediafiler. @@ -869,26 +982,171 @@ Fäst konversation Lossa Lossa konversation + Plus mycket mer... + Nya funktioner kommer snart till {pro}. Upptäck vad som är på gång på {pro} Roadmap {icon} Inställningar Förhandsgranska Förhandsgranska avisering + Din åtkomst till {pro} är aktiv!\n\nDin åtkomst till {pro} kommer att förnyas automatiskt med ytterligare {current_plan_length} den {date}. + Din åtkomst till {pro} är aktiv!\n\nDin åtkomst till {pro} kommer att förnyas automatiskt med ytterligare\n{current_plan_length} den {date}. Alla ändringar du gör här kommer att träda i kraft vid din nästa förnyelse. + Åtkomstfel för {pro} + Din åtkomst till {pro} upphör {date}. + Åtkomst till {pro} läses in + Uppgifter om din åtkomst till {pro} laddas fortfarande. Du kan inte uppdatera förrän processen är klar. + åtkomst till {pro} läses in... + Det går inte att ansluta till nätverket för att läsa in uppgifter om åtkomst till {pro}. Uppdatering av {pro} via {app_name} kommer att inaktiveras tills anslutningen har återställts.\n\nKontrollera din nätverksanslutning och försök igen. + {pro} Åtkomst hittades inte + {app_name} har upptäckt att ditt konto inte har tillgång till {pro}. Om du tror att detta är ett misstag, vänligen kontakta {app_name}s support för hjälp. + Återställ åtkomst till {pro} + Förnya åtkomst till {pro} + För närvarande kan {pro}-åtkomst endast köpas och förnyas via {platform_store} eller {platform_store_other}. Eftersom du använder {app_name} Desktop kan du inte förnya här.\n\nUtvecklarna av {app_name} arbetar hårt med alternativa betalningslösningar för att göra det möjligt för användare att köpa {pro}-åtkomst utanför {platform_store} och {platform_store_other}. {pro}-utvecklingsplan {icon} + Förnya din {pro}-åtkomst på {platform_store}-webbplatsen med det {platform_account} du använde för att registrera dig för {pro}. + Förnya via {platform}s webbplats med det {platform_account} du registrerade dig för {pro} med. + Förnya din åtkomst till {pro} för att börja använda kraftfulla {app_pro} Beta-funktioner igen. + {pro} Åtkomst återställd + {app_name} upptäckte och återskapade åtkomst till {pro} för ditt konto. Din {pro}-status har återställts! + Eftersom du ursprungligen registrerade dig för {app_pro} via {platform_store}, måste du använda ditt {platform_account} för att uppdatera din åtkomst till {pro}. + För närvarande kan åtkomst till {pro} endast köpas via {platform_store} eller {platform_store_other}. Eftersom du använder {app_name} Desktop, kan du inte uppgradera till {pro} här.\n\nUtvecklarna av {app_name} arbetar hårt med alternativa betalningslösningar för att möjliggöra köp av åtkomst till {pro} utanför {platform_store} och {platform_store_other}. {pro} utvecklingsplan {icon} Aktiverat + aktiverar + Allt är klart! + Din åtkomst till {app_pro} har uppdaterats! Du kommer att debiteras när {pro} automatiskt förnyas {date}. Du har redan Fortsätt och ladda upp GIF:ar och animerade WebP-bilder som visningsbild! + Skaffa animerade visningsbilder och lås upp premiumfunktioner med {app_pro} Beta Animerad visningsbild användare kan ladda upp GIF:ar + Animerade visningsbilder + Ställ in animerade GIF-bilder och WebP-bilder som din visningsbild. Ladda upp GIF:ar med + {pro} förnyas automatiskt om {time} + {pro}-märke + Visa märket {app_pro} för andra användare + Märken + Visa ditt stöd för {app_name} med ett exklusivt märke bredvid ditt visningsnamn. + + %1$s %2$s-märken skickade + %1$s %2$s-märken skickade + + {pro} beta-funktioner + {price} Faktureras årligen + {price} Faktureras månadsvis + {price} Faktureras kvartalsvis + Vill du skicka längre meddelanden?\nSkicka mer text och lås upp premiumfunktioner med {app_pro} Beta + Vill du ha fler fästen?\nOrganisera dina chattar och lås upp premiumfunktioner med {app_pro} Beta + Vill du ha mer än {limit} fästisar?\nOrganisera dina chattar och lås upp premiumfunktioner med {app_pro} Beta + Tråkigt att se att du avbryter {pro}. Här är vad du behöver veta innan du avbryter din åtkomst till {pro}. + Uppsägning + Att avsluta åtkomsten till {pro} kommer att förhindra automatisk förnyelse innan åtkomsten till {pro} löper ut. Att avsluta {pro} leder inte till någon återbetalning. Du kommer fortfarande att kunna använda {app_pro}-funktioner tills din {pro}-åtkomst löper ut.\n\nEftersom du ursprungligen registrerade dig för {app_pro} med ditt {platform_account}, måste du använda samma {platform_account} för att avsluta {pro}. + Två sätt att avsluta din åtkomst till {pro}: + Välj det {pro}-abonnemang som passar dig.\nLängre tillgång ger större rabatter. + Är du säker på att du vill ta bort dina data från den här enheten?\n\n{app_pro} kan inte överföras till ett annat konto. Spara ditt återställningslösenord för att säkerställa att du kan återställa din åtkomst till {pro} senare. + Är du säker på att du vill ta bort dina data från nätverket? Om du fortsätter kommer du inte att kunna återställa dina meddelanden eller kontakter.\n\n{app_pro} kan inte överföras till ett annat konto. Spara ditt återställningslösenord för att säkerställa att du kan återställa din åtkomst till {pro} senare. + Din åtkomst till {pro} är redan rabatterad med {percent}% av fullpriset för {app_pro}. + Fel vid uppdatering av {pro}-status + Utgången + Tyvärr har din åtkomst till {pro} upphört.\nFörnya den för att återaktivera de exklusiva förmånerna och funktionerna i {app_pro} Beta. + Löper ut snart + Din åtkomst till {pro} löper ut om {time}.\nUppdatera nu för fortsatt åtkomst till de exklusiva förmånerna och funktionerna i {app_pro} Beta + {pro} upphör om {time} + {pro} Vanliga frågor + Hitta svar på vanliga frågor i {app_pro} FAQ. Ladda upp GIF- och WebP-visningsbilder Större gruppchattar upp till 300 medlemmar Plus många fler exklusiva funktioner Meddelanden upp till 10 000 tecken Fäst obegränsat antal konversationer + Vill du använda {app_name} till sin fulla potential?\nUppgradera till {app_pro} Beta för att få tillgång till massor av exklusiva förmåner och funktioner. Grupp aktiverad Den här gruppen har utökad kapacitet! Den kan ha upp till 300 medlemmar eftersom en gruppadministratör har + + %1$s grupp uppgraderad + %1$s Grupper uppgraderade + + Att begära återbetalning är slutgiltigt. Om den godkänns kommer din åtkomst till {pro} att avbrytas omedelbart och du kommer att förlora åtkomst till alla {pro}-funktioner. Större bilagegräns Förlängd meddelandelängd + Större grupper + Grupper där du är administratör uppgraderas automatiskt för att stödja 300 medlemmar. + Större gruppchattar (upp till 300 medlemmar) kommer snart för alla Pro Beta-användare! + Längre meddelanden + Du kan skicka meddelanden med upp till 10 000 tecken i alla konversationer. + + %1$s Längre meddelande skickat + %1$s längre meddelanden skickade + Detta meddelande använde följande funktioner från {app_pro}: + Med en ny installation + Installera om {app_name} på den här enheten via {platform_store}, återställ ditt konto med ditt återställningslösenord och förnya {pro} från inställningarna i {app_pro}. + Installera om {app_name} på den här enheten via {platform_store}, återställ ditt konto med ditt återställningslösenord och uppgradera till {pro} från inställningarna i {app_pro}. + För närvarande finns det tre sätt att förnya: + För tillfället finns det två sätt att förnya: + {percent}% rabatt + + %1$s fastnålad konversation + %1$s fastnålade konversationer + + Eftersom du ursprungligen registrerade dig för {app_pro} via {platform_store}så måste du använda din {platform_account} för att begära återbetalning. + Eftersom du ursprungligen registrerade dig för {app_pro} via {platform_store}, så kommer din begäran om återbetalning att behandlas av {app_name} Support.\n\nBegär återbetalning genom att trycka på knappen nedan och fyll i formuläret för återbetalning.\n\nÄven om {app_name} Support strävar efter att behandla en begäran om återbetalning inom 24–72 timmar, så kan det ta längre tid vid tillfällen med många förfrågningar om återbetalning. + Din tillgång till {app_pro} har förnyats! Tack för att du stöttar {network_name}. + 1 månad – {monthly_price} / månad + 3 månader – {monthly_price} / månad + 12 månader – {monthly_price} / månad + återaktiverar + Öppna detta {app_name}-konto på en {device_type}-enhet som är inloggad på det {platform_account} du ursprungligen registrerade dig med. Begär sedan återbetalning via inställningarna i {app_pro}. + Vi är ledsna att du vill avsluta. Här är vad du behöver veta innan du begär en återbetalning. + {platform} behandlar nu din återbetalningsförfrågan. Detta tar vanligtvis 24–48 timmar. Beroende på deras beslut kan din {pro}-status ändras i {app_name}. + Din återbetalningsbegäran kommer att hanteras av {app_name} Support.\n\nBegär återbetalning genom att trycka på knappen nedan och fylla i formuläret för återbetalning.\n\nÄven om {app_name} Support strävar efter att behandla återbetalningsbegäran inom 24–72 timmar kan det ta längre tid vid hög belastning. + Din återbetalningsbegäran hanteras uteslutande av {platform} via {platform} webbplats.\n\nPå grund av {platform}s återbetalningspolicyer har utvecklarna av {app_name} ingen möjlighet att påverka resultatet av återbetalningsförfrågningar. Detta inkluderar om begäran godkänns eller nekas, samt om en fullständig eller delvis återbetalning ges. + Vänligen kontakta {platform} för ytterligare uppdateringar om din återbetalningsförfrågan. På grund av {platform}s återbetalningspolicyer har utvecklarna av {app_name} ingen möjlighet att påverka resultatet av återbetalningsförfrågningar.\n\n{platform} Återbetalningssupport + Återbetalning av {pro} + Återbetalningar för {app_pro} hanteras uteslutande av {platform} via {platform_store}.\n\nPå grund av {platform}s återbetalningspolicyer har utvecklarna av {app_name} ingen möjlighet att påverka resultatet av återbetalningsförfrågningar. Detta inkluderar om begäran godkänns eller nekas, samt om en fullständig eller delvis återbetalning ges. + Vill du använda animerade visningsbilder igen?\nFörnya din {pro}-åtkomst för att låsa upp funktioner du har gått miste om. + Förnya {pro} Beta + Förnya din {pro}-åtkomst från {app_pro}-inställningarna på en länkad enhet med {app_name} installerad via {platform_store} eller {platform_store_other}. + Vill du skicka längre meddelanden igen?\nFörnya din {pro}-åtkomst för att låsa upp funktioner du har gått miste om. + Vill du använda {app_name} till dess fulla potential igen?\nFörnya din {pro}-åtkomst för att låsa upp funktioner du har gått miste om. + Vill du fästa fler än {limit} konversationer igen?\nFörnya din {pro}-åtkomst för att låsa upp funktioner du har gått miste om. + Vill du fästa fler konversationer igen?\nFörnya din {pro}-åtkomst för att låsa upp funktioner du har gått miste om. + Genom att förnya godkänner du {app_pro} Användarvillkoren {icon} och Sekretesspolicyn {icon} + förnyar + För närvarande kan åtkomst till {pro} endast köpas och förnyas via {platform_store} eller {platform_store_other}. Eftersom du installerade {app_name} med hjälp av {build_variant}, så kan du inte förnya här.\n\nUtvecklarna av {app_name} arbetar hårt med alternativa betalningslösningar för att göra det möjligt för användare att köpa åtkomst till {pro} utanför {platform_store} och {platform_store_other}. Utvecklingsplan för {pro} {icon} + Återbetalning begärd Skicka mer med + {pro}-inställningar + Börja använda {pro} + Din {pro}-statistik + {pro}-statistik läses in + Din {pro}-statistik laddas, var god vänta. + {pro}-statistik återspeglar användning på den här enheten och kan visas annorlunda på länkade enheter + Statusfel för {pro} + Det går inte att ansluta till nätverket för att kontrollera din status för {pro}. Informationen som visas på den här sidan kan vara felaktig tills anslutningen har återställts.\n\nKontrollera din nätverksanslutning och försök igen. + {pro}-status läses in + Uppgifterna om {pro} läses in. Vissa åtgärder på denna sida kan vara otillgängliga tills inläsningen är klar. + {pro}-status läses in + Det går inte att ansluta till nätverket för att kontrollera din {pro}-status. Du kan inte fortsätta förrän anslutningen har återställts.\n\nKontrollera din nätverksanslutning och försök igen. + Det går inte att ansluta till nätverket för att kontrollera din status för {pro}. Du kan inte uppgradera till {pro} förrän anslutningen har återställts.\n\nKontrollera din nätverksanslutning och försök igen. + Det går inte att ansluta till nätverket för att uppdatera din {pro}-status. Vissa åtgärder på den här sidan kommer att inaktiveras tills anslutningen har återställts.\n\nKontrollera din nätverksanslutning och försök igen. + Det gick inte att ansluta till nätverket för att läsa in din nuvarande åtkomst till {pro}. Att förnya {pro} via {app_name} kommer att inaktiveras tills anslutningen har återställts.\n\nKontrollera din nätverksanslutning och försök igen. + Behöver du hjälp med {pro}? Skicka en förfrågan till supportteamet. + Genom att {action_type} håller du på att {activation_type} {app_pro} via {app_name}-protokollet. {entity} underlättar denna aktivering men är inte leverantör av {app_pro}. {entity} ansvarar inte för prestanda, tillgänglighet eller funktionalitet för {app_pro}. + Genom att uppdatera godkänner du {app_pro} Användarvillkoren {icon} och Sekretesspolicyn {icon} + Obegränsat med nålar + Organisera alla dina chattar med obegränsade fastnålade konversationer. + Ditt nuvarande faktureringsalternativ ger {current_plan_length} åtkomst till {pro}. Är du säker på att du vill byta till faktureringsalternativet {selected_plan_length_singular}?\n\nGenom att uppdatera kommer din åtkomst till {pro} att automatiskt förnyas den {date} för ytterligare {selected_plan_length} åtkomst till {pro}. + Din åtkomst till {pro} upphör den {date}.\n\nGenom att uppdatera kommer din åtkomst till {pro} att automatiskt förnyas den {date} för ytterligare {selected_plan_length} åtkomst till {pro}. + uppdaterar + Uppgradera till {app_pro} Beta för att få tillgång till massor av exklusiva förmåner och funktioner. + Uppgradera till {pro} från {app_pro}-inställningarna på en länkad enhet med {app_name} installerad via {platform_store} eller {platform_store_other}. + För närvarande kan åtkomst till {pro} endast köpas via {platform_store} eller {platform_store_other}. Eftersom du installerade {app_name} med hjälp av {build_variant}, kan du inte uppgradera till {pro} här.\n\nUtvecklarna av {app_name} arbetar hårt med alternativa betalningslösningar för att möjliggöra köp av åtkomst till {pro} utanför {platform_store} och {platform_store_other}. {pro} utvecklingsplan {icon} + För tillfället finns det endast ett sätt att uppgradera: + För tillfället finns det två sätt att uppgradera: + Du har uppgraderat till {app_pro}!\nTack för att du stöttar {network_name}. + uppgraderar + Uppgraderar till {pro} + Genom att uppdatera godkänner du {app_pro} Användarvillkoren {icon} och Sekretesspolicyn {icon} + Vill du få ut mer av {app_name}?\nUppgradera till {app_pro} Beta för en mer avancerad meddelandeupplevelse. + {platform} behandlar din återbetalningsbegäran Profil Visa bild Misslyckades med att ta bort visningsbild. @@ -896,6 +1154,11 @@ Vänligen välj en mindre fil. Misslyckades att uppdatera profilen. Främja + Administratörer kommer att kunna se de senaste 14 dagarnas meddelandehistorik och kan inte nedgraderas eller tas bort från gruppen. + + Befordra medlem + Befordra medlemmar + Befordran Misslyckades Befordringar Misslyckades @@ -921,6 +1184,7 @@ Mottaget: Mottog svar Inkommande erbjudande för samtal + Receiving Pre Offer Rekommenderat Spara ditt återställningslösenord så att du inte förlorar tillgången till ditt konto. Spara ditt återställningslösenord @@ -943,23 +1207,60 @@ Detta är ditt återställningslösenord. Om du skickar det till någon kommer de att ha full tillgång till ditt konto. Skapa gruppen igen Gör om + Eftersom du ursprungligen registrerade dig för {app_pro} via en annan {platform_account}, så måste du använda {platform_account} för att uppdatera din åtkomst till {pro}. + Två sätt att begära återbetalning: Förkorta meddelandet med {count} tecken %1$d tecken kvar %1$d tecken kvar + Påminn mig senare Ta bort + + Ta bort medlem + Ta bort medlemmar + + + Ta bort medlem och dess meddelanden + Ta bort medlemmar och deras meddelanden + Misslyckades med att ta bort lösenord Ta bort ditt nuvarande lösenord för {app_name}. Lokalt lagrad data kommer att krypteras om med en slumpmässigt genererad nyckel som lagras på din enhet. + + Tar bort medlem + Tar bort medlemmar + + Förnya + Förnyelse av {pro} Svara Begär återbetalning + Begär en återbetalning på {platform}s webbplats med det {platform_account} du registrerade dig för {pro} med. Skicka på nytt + + Skicka inbjudan igen + Skicka inbjudningar igen + + + Skicka befordran igen + Skicka befordringar igen + + + Skickar inbjudan igen + Skicka inbjudningar igen + + + Skickar befordran + Skickar befordringar + Läser in landinformation ... Starta om Synkronisera Försök igen Betygsättningsgräns Det verkar som att du redan betygsatt {app_name} nyligen – tack för din feedback! + Kör appen i bakgrunden + Köra {app_name} i bakgrunden? + Eftersom du använder Slow Mode rekommenderar vi att du tillåter att {app_name} körs i bakgrunden för att förbättra aviseringarna. Det kan förbättra aviseringarnas konsekvens, även om ditt system fortfarande kan begränsa bakgrundsaktivitet automatiskt.\n\nDu kan ändra detta senare i Inställningar. Spara Sparad Sparade meddelanden @@ -968,6 +1269,8 @@ Skärmsäkerhet Aviseringar för skärmdump Kräv en avisering när en kontakt tar en skärmdump av en enskild chatt. + Dölj {app_name}-fönstret i skärmdumpar som tas på den här enheten. + Skärmdumpsskydd {name} tog en skärmbild. Sök Sök kontakter @@ -988,6 +1291,10 @@ Skickar Skickar erbjudande för samtal Skickar kontakt kandidater + + Sänder befordran + Sänder administratörsbehörigheter + Skickat: Utseende Rensa data @@ -1008,19 +1315,25 @@ Aviseringar Behörigheter Integritet + {app_pro} Beta Återställningslösenord Inställningar Ange Ange Community-visningsbild Ställ in ett lösenord för {app_name}. Lokalt lagrade data kommer att krypteras med detta lösenord. Du kommer att bli ombedd att ange detta lösenord varje gång {app_name} startas. + Kan inte uppdatera inställning Du måste starta om {app_name} för att tillämpa dina nya inställningar. Skärmsäkerhet + Uppstart Dela Bjud in din vän att chatta med dig på {app_name} genom att dela ditt Account ID med dem. Dela med dina vänner var du än brukar prata med dem — flytta sedan konversationen hit. Det finns ett problem med att öppna databasen. Starta om appen och försök igen. Oops! Ser ut som att du inte har ett {app_name} konto ännu.\n\nDu behöver skapa ett först hos {app_name} innan du kan dela. + Vill du dela gruppens meddelandehistorik med den här användaren? Dela till {app_name} + Tyvärr stöder {app_name} endast delning av flera bilder och videor samtidigt + Delning stöder endast media. Icke-mediefiler har exkluderats Visa Visa alla Visa färre @@ -1036,6 +1349,7 @@ Fortsätt Standard Fel + Tillbaka Förhandsvisning av tema {name}:s Account ID är synligt baserat på dina tidigare interaktioner Maskerade ID används i Communitys för att minska spam och öka sekretessen @@ -1047,6 +1361,10 @@ Otillgänglig Ångra Okänd + Processorn stöds ej + Uppdatera + Uppdatera {pro}-åtkomst + Två sätt att uppdatera din åtkomst till {pro}: App-uppdateringar Uppdatera communityinformation Communitynamn och beskrivning är synliga för alla communitymedlemmar @@ -1069,13 +1387,18 @@ Senast uppdaterad för {relative_time} sedan Uppdateringar Uppdaterar... + Uppgradera + Uppgradera {app_name} Uppgradera till Laddar upp Kopiera URL Öppna URL Detta kommer att öppna i din webbläsare. Är du säker på att du vill öppna denna URL i din webbläsare?\n\n{url} + Länkar kommer att öppnas i din webbläsare. Använd snabbläge + Ändra din plan med det {platform_account} du använde vid registrering, via {platform}s webbplats. + Via webbplatsen för {platform} Video Kunde inte spela upp video. Visa @@ -1084,9 +1407,11 @@ Detta kan dröja några minuter. Ett ögonblick... Varning + Stödet för iOS 15 har avslutats. Uppdatera till iOS 16 eller senare för att fortsätta få appuppdateringar. Fönster Ja Du + Din processor stöder inte SSE 4.2-instruktioner, vilket krävs av {app_name} på GNU/Linux x64-operativsystem för att bearbeta bilder. Uppgradera till en kompatibel processor eller använd ett annat operativsystem. Ditt återställningslösenord Zoomfaktor Justera storleken på text och visuella element. diff --git a/app/src/main/res/values-b+ta+IN/strings.xml b/app/src/main/res/values-b+ta+IN/strings.xml index 9abdd1c3bf..72372e8946 100644 --- a/app/src/main/res/values-b+ta+IN/strings.xml +++ b/app/src/main/res/values-b+ta+IN/strings.xml @@ -487,8 +487,8 @@ உங்கள் கணக்கை ஏற்றுகின்றன ஏற்றப்பட்டுள்ளன... பயன்பாட்டு பூட்டு - {app_name}யை திறக்க { ஒப்பந்த இனிய சக்தையுடன்} உடன் அடைவுகளிருந்து வேண்டுகின்றேன். - {app_name}க்கு திறிட { குறியீடு } பதுக்குகடைதிறன் ஒப்புகக வேண்டும்.. + {app_name} ஐ திறக்க கைரேகை, பின், பேட்டர்ன் அல்லது கடவுச்சொல் தேவை. + {app_name} ஐத் திறக்க, Touch ID, Face ID அல்லது உங்கள் கடவுக்குறியீட்டை தேவை. ஸ்க்ரீன் லாக்கைப் பயன்படுத்த iOS அமைப்புகளில் கடவுச்சொல் வைப்பது தேவை. {app_name} பாதுகாத்து உள்ளது Quick response unavailable when {app_name} is locked! @@ -726,7 +726,7 @@ ஸ்கான் திரை பாதுகாப்பு ஸ்கிரீன்ஷாட் அறிவிப்புகள் - ஆரா எச்சரிக்கையை { சிலை வகையான } குவைத்தால் சேப தீங்கு பிண்ட + ஒரு தொடர்பு ஒன்றுக்கு ஒன்று அரட்டையின் ஸ்கிரீன் ஷாட்டை எடுக்கும்போது அறிவிப்பைக் கோருங்கள். {name} திரைப் பிடிப்பு எடுத்தார். தேடு தொடர்புகளை தேடு diff --git a/app/src/main/res/values-b+tr+TR/strings.xml b/app/src/main/res/values-b+tr+TR/strings.xml index 86103f9a3c..ccdff98c07 100644 --- a/app/src/main/res/values-b+tr+TR/strings.xml +++ b/app/src/main/res/values-b+tr+TR/strings.xml @@ -15,7 +15,13 @@ Bu sizin Hesap Kimliğiniz. Diğer kullanıcılar, sizinle bir oturum başlatmak için tarayabilir. Normal Boyut Ekle + + Yönetici Ekle + Yönetici Ekle + + Yönetici Ekle Yönetici olarak atadığınız kullanıcının Hesap Kimliğini girin.\n\nBirden fazla kullanıcı eklemek için her Hesap Kimliğini virgülle ayırarak girin. Tek seferde en fazla 20 Hesap Kimliği belirtilebilir. + Yöneticiler gruptan çıkarılamaz veya görevden alınamaz. Yöneticiler kaldırılamaz. {name} ve {count} diğer yönetici olarak terfi etti. Yöneticileri Terfi Ettir @@ -40,12 +46,19 @@ {name} yönetici olarak kaldırıldı. {name} ve {count} üye Yönetici seviyesinden düşürüldü. {name} ve {other_name} Yönetici seviyesinden düşürüldü. + + %1$d Yönetici Seçildi + %1$d Yönetici Seçildi + Yönetici ayrıcalığı gönderiliyor Yönetici ayrıcalıkları gönderiliyor Yönetici Ayarları + Yönetici durumunuzu değiştiremezsiniz. Gruptan ayrılmak için, sohbet ayarlarını açın ve \"Gruptan Ayrıl\" öğesini seçin. {name} ve {other_name} yönetici olarak terfi etti. + Yöneticiler + İzin Ver +{count} Anonim Uygulama ikonu @@ -161,6 +174,8 @@ {name} ve {count} diğerinin engelini kaldırmak istediğinizden emin misiniz? {name} ve 1 diğerinin engelini kaldırmak istediğinizden emin misiniz? Engel kaldırıldı {name} + Engellenen kişileri görüntüleyin ve yönetin. + Bu URL\'yi açmak için tarayıcı bulunamadı, bunun yerine URL\'yi kopyalamayı deneyin Ara {name} seni aradı Yeni bir çağrı başlatamazsınız. Önce mevcut çağrınızı bitirin. @@ -189,6 +204,10 @@ Diğer kullanıcılara ve diğer kullanıcılardan sesli ve görüntülü arama yapılmasını sağlar. {name} kullanıcısını aradınız {name} kişisinden gelen bir çağrıyı, Ses ve Video Görüşmeleri özelliğini Gizlilik Ayarlarında etkinleştirmediğiniz için kaçırdınız. + {app_name}, görüntülü aramaları etkinleştirmek için kameranıza erişim gerektiriyor, ancak bu izin reddedildi. Arama sırasında kamera izinlerini güncelleyemezsiniz.\n\nŞimdi aramayı sonlandırıp kamera erişimini etkinleştirmek ister misiniz, yoksa aramadan sonra hatırlatılmak mı istersiniz? + Kamera erişimine izin vermek için ayarları açın ve Kamera iznini etkinleştirin. + Son aramanız sırasında video kullanmayı denediniz ancak önceden kamera erişimi reddedildiği için bu mümkün olmadı. Kamera erişimine izin vermek için ayarları açın ve Kamera iznini etkinleştirin. + Kamera Erişimi Gerekli Kamera bulunamadı Kamera kullanılamıyor. Kamera Erişimine İzin Verin @@ -196,8 +215,19 @@ {app_name}, fotoğraf ve video çekmek veya QR kodları taramak için kamera erişimine ihtiyaç duyar. {app_name}, QR kodlarını taramak için kamera erişimine ihtiyaç duyar. İptal + {pro}\'yu İptal Et + {pro} için kaydolduğunuz {platform_account} hesabını kullanarak {platform} web sitesinde iptal edin. + {pro} için kaydolduğunuz {platform_account} hesabını kullanarak {platform_store} web sitesinde iptal edin. Değiştir Parola değiştirilemedi + {app_name} için parolanızı değiştirin. Yerel olarak saklanan veriler yeni parolanızla yeniden şifrelenecektir. + Ayarı Değiştir + {pro} Durumu Kontrol Ediliyor + {pro} durumunuz kontrol ediliyor. Bu kontrol tamamlandıktan sonra devam edebileceksiniz. + {pro} bilgileriniz kontrol ediliyor. Bu kontrol tamamlanana kadar bu sayfadaki bazı işlemler kullanılamayabilir. + {pro} Durumu Kontrol Ediliyor... + {pro} bilgileriniz kontrol ediliyor. Bu kontrol tamamlanmadan yenileme yapılamaz. + {pro} durumunuz kontrol ediliyor. Bu kontrol tamamlandıktan sonra {pro} sürümüne geçebileceksiniz. Temizle Hepsini Temizle Tüm Veriyi Temizle @@ -256,11 +286,17 @@ Topluluk URL\'si Topluluk URL\'sini Kopyala Onayla + Terfiyi Onayla + Emin misiniz? Yöneticiler gruptan çıkarılamaz veya görevden alınamaz. Kişiler Kişiyi Sil {name}\'i kişilerinizden silmek istediğinizden emin misiniz? {name}\'den gelen yeni iletiler ileti isteği olarak gelecektir. Henüz herhangi bir kişi yok Kişileri Seçin + + %1$d Kişi Seçildi + %1$d Kişi Seçildi + Kullanıcı Detayları Kamera Bir sohbet başlatmak için bir eylem seçin @@ -268,6 +304,7 @@ İleti oluştur Alıntılanmış iletideki görüntünün önizlemesi Yeni bir kişiyle görüşme oluşturma + Gelen bir mesaj alındığında yerel bildirimlerde gösterilecek içeriği seçin. Ana ekrana ekle Ana ekrana eklendi Sesli İletiler @@ -280,6 +317,7 @@ Konuşma silindi {conversation_name}\'de ileti yok. Giriş Tuşu + Sohbetlerde Enter ve Shift+Enter tuşlarının nasıl çalışacağını tanımlayın. SHIFT + ENTER mesaj gönderir, ENTER yeni bir satıra geçer. ENTER tuşu mesaj gönderir, SHIFT + ENTER yeni bir satıra geçer. Gruplar @@ -290,6 +328,7 @@ Henüz herhangi bir konuşmanız yok Enter tuşu ile gönder Enter tuşuna basmak yeni bir satıra geçmek yerine ileti gönderir. + Shift+Enter ile gönder Tüm Medya Yazım Denetimi İleti yazarken yazım denetimini etkinleştirin. @@ -298,7 +337,10 @@ Kopyala Oluştur Arama Oluşturuluyor + Mevcut Faturalandırma + Mevcut Parola Kes + Karanlık Mod Tüm mesajları, ekleri ve hesap verilerini bu cihazdan silip yeni bir hesap oluşturmak istediğinizden emin misiniz? Veritabanında bir sorun oluştu.\n\nSorun giderme için uygulama günlüklerinizi dışa aktarın. Eğer başarısız olunursa {app_name} uygulamasını yeniden yükleyip, hesabınızı geri yükleyin. Tüm mesajları, ekleri ve hesap verilerini bu cihazdan silip hesabınızı ağdan geri yüklemek istediğinizden emin misiniz? @@ -327,6 +369,10 @@ Seçili Eki Sil Seçilen ekleri sil + + Seçilen eki silmek istediğinizden emin misiniz? Ek ile ilişkili mesaj da silinecektir. + Seçilen ekleri silmek istediğinizden emin misiniz? Eklerle ilişkili mesaj da silinecektir. + {name} kişisini kişilerinizden silmek istediğinizden emin misiniz?\n\nBu işlem, tüm mesajlar ve ekler dahil olmak üzere sohbetinizi silecektir. {name} kişisinden gelen gelecekteki mesajlar, mesaj isteği olarak görünecektir. {name} ile olan sohbetinizi silmek istediğinizden emin misiniz?\nBu işlem, tüm mesajları ve ekleri kalıcı olarak silecektir. @@ -366,6 +412,7 @@ İletileri herkes için silmek istediğinizden emin misiniz? Siliniyor Geliştirici Araçlarını Aç/Kapat + Cihaz Bildirim Ayarları Dikteyi Başlat... Kaybolan İletiler İleti, {time_large} içerisinde silinecek @@ -401,6 +448,7 @@ {admin_name}, kaybolan ileti ayarlarını güncelledi. Sen kaybolan ileti ayarlarını güncelledin. Reddet + Görünüm Gerçek adınız, takma adınız veya istediğiniz herhangi bir şey olabilir - ve istediğiniz zaman değiştirebilirsiniz. Görünecek adınızı girin Lütfen bir görünen ad girin @@ -412,6 +460,8 @@ Görünen Adınız, etkileşimde bulunduğunuz kullanıcılar, gruplar ve topluluklar tarafından görülebilir. Belge Bağış yap + Gizliliği zayıflatmaya çalışan güçlü güçler var, ancak bu mücadeleyi tek başımıza sürdüremeyiz.\n\nBağış yapmak {app_name} uygulamasının güvenli, bağımsız ve çevrim içi kalmasına yardımcı olur. + {app_name} Yardımınıza İhtiyaç Duyuyor Tamamlandı İndir İndiriliyor... @@ -441,17 +491,35 @@ Siz ve {name} {emoji_name} ile tepki verdiniz İletinize {emoji} ile tepki verdi Etkinleştir + Kamera Erişimi Etkinleştirilsin mi? + Yeni mesajlar aldığınızda bildirimleri göster. + Etkinleştirmek için Aramayı Sonlandır {app_name}\'i beğendiniz mi? Geliştirilmesi Gerekiyor {emoji} Harika {emoji} Bir süredir {app_name} uygulamasını kullanıyorsunuz, nasıl gidiyor? Düşüncelerinizi duymaktan çok memnun oluruz. + Giriş + {app_name} için belirlediğiniz parolayı girin + Başlangıçta {app_name} kilidini açmak için kullandığınız parolayı girin, Kurtarma Parolanızı değil. + {pro} durumu kontrol edilirken hata oluştu Lütfen internet bağlantınızı kontrol edin ve tekrar deneyin. Hata Kopyala ve Çık Veritabanı Hatası Bir hata oluştu. Lütfen daha sonra tekrar deneyin. + {pro} erişimi yüklenirken hata oluştu + {app_name} bu ONS için arama yapamadı. Lütfen ağ bağlantınızı kontrol ederek tekrar deneyin. Bilinmeyen bir hata oluştu. + Bu ONS kayıtlı değil. Lütfen doğru olduğunu kontrol ederek tekrar deneyin. + {group_name} grubundaki {name} kişisine davet yeniden gönderilemedi + {group_name} grubundaki {name} ve {count} diğer kişiye davet yeniden gönderilemedi + {group_name} grubundaki {name} ve {other_name} kişilerine davet yeniden gönderilemedi + {name} adlı kullanıcıya {group_name} grubunda yetki yeniden gönderilemedi. + {name} ve {count} diğer kişiye {group_name} grubunda yetki yeniden gönderilemedi. + {name} ve {other_name} adlı kullanıcılara {group_name} grubunda yetki yeniden gönderilemedi. İndirme başarısız Hatalar + Geri Bildirim + {app_name} deneyiminizi kısa bir anket doldurarak paylaşın. Dosya Dosyalar Sistem ayarlarını kullan. @@ -501,6 +569,9 @@ {group_name} grubundan ayrılmak istediğinizden emin misiniz? {group_name} adlı gruptan ayrılmak istediğinizden emin misiniz?\n\nBu, tüm üyeleri kaldıracak ve tüm grup içeriğini silecektir. {group_name} çıkış yapılamadı + {name} gruba katılmak için davet edildi. Son 14 günün sohbet geçmişi paylaşıldı. + {name} ve {count} kişi gruba katılmak üzere davet edildi. Son 14 güne ait sohbet geçmişi paylaşıldı. + {name} ve {other_name} gruba katılmak için davet edildi. Son 14 günün sohbet geçmişi paylaşıldı. {name} gruptan ayrıldı. {name} ve {count} diğer gruptan ayrıldı. {name} ve {other_name} gruptan ayrıldı. @@ -512,6 +583,9 @@ {name} ve {other_name} gruba katılmak üzere davet edildi. Sen ve {count} diğerleri gruba katılmaya davet edildiniz. Sohbet geçmişi paylaşıldı. Siz ve {other_name} bir gruba katılmaya davet edildiniz. Sohbet geçmişi paylaşıldı. + {name}, {group_name} grubundan kaldırılamadı + {name} ve {count} diğer kişi, {group_name} grubundan kaldırılamadı + {name} ve {other_name}, {group_name} grubundan kaldırılamadı Sen gruptan ayrıldın. Grup Üyeleri Bu grupta başka üye yok. @@ -525,6 +599,7 @@ {group_name} kullanıcısından herhangi bir iletiniz yok. Sohbeti başlatmak için bir ileti gönderin! Bu grup 30 günden daha fazladır güncellenmedi. Mesaj gönderirken veya grup bilgilerini görüntülerken sorun yaşayabilirsiniz. {group_name} grubunda tek adminsiniz.\n\nAdmin olmadan grup üyeleri ve ayarları değiştirilemez. + {group_name} grubunda yalnızca siz yöneticisiniz.\n\nGrup üyeleri ve ayarlar bir yönetici olmadan değiştirilemez. Grubu silmeden ayrılmak için önce yeni bir yönetici ekleyin. Kaldırma bekleniyor Sen yönetici olarak terfi ettin. Sen ve {count} diğer yönetici olarak terfi ettiniz. @@ -552,6 +627,7 @@ Grup güncellendi Bağlantı Adayları İşleniyor SSS + Sıkça sorulan soruların yanıtları için {app_name} SSS bölümüne göz atın. {app_name}\'yi çevirmemize yardımcı olun Hata Bildir Sorununuzu çözmemize yardımcı olmak için bazı ayrıntılar paylaşın. Günlüklerinizi dışa aktarın ve ardından dosyayı {app_name}\'in Destek Merkezi\'ne yükleyin. @@ -560,6 +636,7 @@ Masaüstüne kaydet Bu dosyayı kaydedin, ardından {app_name} geliştiricileriyle paylaşın. Destek + {app_name} uygulamasını 80\'den fazla dile çevirmemize yardım edin! Görüşlerinizi almak isteriz Gizle Sistem menü çubuğu görünürlüğünü değiştirin. @@ -572,6 +649,10 @@ Gizli mod iste. Kullandığınız klavyeye bağlı olarak klavyeniz bu isteği göz ardı edebilir. Bilgi Geçersiz kısayol + + Kişiyi Davet Et + Kişileri Davet Et + Davet Başarısız Oldu Davetler Başarısız Oldu @@ -580,8 +661,17 @@ Davet gönderilemedi. Yeniden denemek ister misiniz? Davetler gönderilemedi. Yeniden denemek ister misiniz? + + Üye Davet Et + Üyeleri Davet Et + + Arkadaşınızın Hesap Kimliğini, ONS\'yi girerek veya QR kodunu tarayarak gruba yeni bir üye davet edin {icon} + Arkadaşınızın Hesap Kimliğini, ONS\'yi girerek ya da QR kodunu tarayarak gruba yeni bir üye davet edin Katıl Sonra + Bilgisayarınız açıldığında {app_name} uygulamasını otomatik olarak başlat. + Başlangıçta Başlat + Bu ayar Linux sisteminiz tarafından yönetilmektedir. Otomatik başlatmayı etkinleştirmek için sistem ayarlarından {app_name} uygulamasını başlangıç uygulamalarınıza ekleyin. Daha fazla bilgi edinin Ayrıl Çıkılıyor... @@ -596,6 +686,8 @@ Siz ve {other_name} gruba katıldınız. {name} ve {other_name} gruba katıldı. Siz gruba katıldınız. + Arka Plan Etkinliği Sınırlandırılsın mı? + Şu anda, bildirimlerin güvenilirliğini artırmak için {app_name} uygulamasının arka planda çalışmasına izin veriyorsunuz. Bu ayarın değiştirilmesi, bildirimlerin daha az güvenilir olmasına neden olabilir. Bağlantı Önizlemeleri Desteklenen URL\'ler için bağlantı önizlemeleri göster. Bağlantı Önizlemeleri Etkinleştirilsin mi @@ -606,6 +698,7 @@ Bağlantı önizlemeleri gönderirken tamamıyla metaveri korumasına sahip olmazsınız. Bağlantı Önizlemeleri Kapalı {app_name} gönderdiğiniz ve aldığınız bağlantıların önizlemelerini oluşturmak için bağlantılı web siteleriyle iletişime geçmelidir.\n\nOnları {app_name} ayarlarından açabilirsiniz. + Bağlantılar Hesap Yükle Hesabın yükleniyor Yükleniyor... @@ -618,9 +711,17 @@ Kilit durumu Açmak için dokun {app_name} kilidi açıldı + Kayıtlar + Yöneticileri Yönet Üyeleri Yönet + {pro} Yönet En Yüksek + Belki Daha Sonra Medya + + %1$d Üye Seçildi + %1$d Üye Seçildi + %1$d üye %1$d üye @@ -630,7 +731,9 @@ %1$d aktif üyeler Account ID veya ONS Ekle + Üyeler yalnızca grup davetini kabul ettikten sonra yönetici olarak atanabilir. Kişileri Davet Et + Bu gruba davet edilecek hiçbir kişiniz yok.\nGeri dönün ve Hesap Kimliği ya da ONS kullanarak üyeleri davet edin. Davet Et Davet Et @@ -639,10 +742,14 @@ {name} ve {count} diğerleri ile grup ileti geçmişini paylaşmak ister misiniz? {name} ve {other_name} ile grup ileti geçmişini paylaşmak ister misiniz? İleti geçmişini paylaş + Son 14 güne ait mesaj geçmişini paylaş Yalnızca yeni iletileri paylaş Davet et + Üyeler (Yönetici olmayanlar) + Menü Çubuğu İleti Devamını oku + Mesajı Kopyala Bu ileti boş. İleti teslimi başarısız oldu İleti sınırı aşıldı @@ -657,6 +764,7 @@ Yeni bir sohbet başlatmak için arkadaşınızın Hesap Kimliğini veya ONS\'yi girin. Yeni bir sohbet başlatmak için arkadaşınızın Hesap Kimliğini, ONS\'yi girin veya onların QR kodunu tarayın. + Yeni bir sohbet başlatmak için arkadaşınızın Hesap Kimliğini, ONS’yi girin veya QR kodunu tarayın {icon} Yeni bir iletiniz var. %1$d Adet Yeni İletiniz Var. @@ -705,20 +813,29 @@ Mesaj çok uzun Lütfen mesajınızı {limit} karakter veya daha az olacak şekilde kısaltın. Mesaj çok uzun + Yeni Parola İleri + Sonraki Adımlar {name} için bir takma ad seçin. Bu, bire bir ve grup sohbetlerinizde size görünecektir. Bir kullanıcı adı girin Lütfen daha kısa bir takma ad girin Takma adı kaldır Takma Ad Belirle Hayır + Bu grupta yönetici olmayan hiç üye yok. Öneri Yok + Tüm sohbetlerde 10.000 karaktere kadar mesaj gönderebilirsiniz. + Sınırsız sabitlenmiş sohbetle sohbetlerinizi düzenleyin. Hiçbiri Şimdi değil Kendime Not Note to Self\'de iletiniz yok. Kendime Notu Gizle Kendime Not\'u gizlemek istediğinizden emin misiniz? + LÜTFEN DİKKAT: {action_type} işlemiyle, {app_pro} Hizmet Koşulları {icon} ve Gizlilik Politikası {icon}’nı kabul etmiş olursunuz + Bildirim Görünümü + Gönderenin adını ve mesaj içeriğinin önizlemesini göster. + Mesaj içeriği olmadan yalnızca gönderenin adını göster. Tüm İletiler Bildirim İçeriği Bildirimlerde gösterilen bilgiler. @@ -729,6 +846,7 @@ Google\'ın bildirim sunucularını kullanarak yeni iletilerden güvenilir ve anında haberdar olacaksınız. Huawei\'nin bildirim sunucuları kullanılarak yeni mesajlardan güvenilir bir şekilde ve anında haberdar edileceksiniz. Apple\'ın bildirim sunucularını kullanarak yeni iletilerden güvenilir ve anında haberdar olacaksınız. + Gönderenin adı veya mesaj içeriği olmadan genel bir {app_name} bildirimi göster. Cihaz bildirim ayarlarına git Bildirimler - Tümü Bildirimler - Yalnızca Bahsedildiğinde @@ -736,6 +854,7 @@ {name} tarafından {conversation_name}\'ya {device} yeniden başlatılırken iletiler almış olabilirsiniz. LED rengi + Yeni mesajlar aldığınızda bir ses çal. Sadece bahsetmeler İleti Bildirimleri En son: {name} @@ -757,6 +876,12 @@ Kapalı Tamam Açık + {device_type} cihazınızda + Bu {app_name} hesabını, ilk kayıt olduğunuz {platform_account} hesabıyla oturum açılmış bir {device_type} cihazında açın. Ardından, {pro} aboneliğini {app_pro} ayarlarından iptal edin. + Başlangıçta kayıt olduğunuz {platform_account} hesabına giriş yapılmış bir {device_type} cihazda bu {app_name} hesabını açın. Ardından, {app_pro} ayarlarını kullanarak {pro} erişiminizi güncelleyin. + Bağlı bir cihazda + {platform_store} web sitesinde + {platform} web sitesinde Hesap oluştur Hesap Oluşturuldu Hesabım var @@ -781,6 +906,9 @@ Bu ONS\'u tanıyamadık. Lütfen kontrol edin ve tekrar deneyin. Bu ONS\'u arayamadık. Lütfen daha sonra tekrar deneyin. + {platform_store} Web Sitesini Aç + {platform} İnternet Sitesini Aç + Ayarları Aç Anketi Aç Diğer Şifre @@ -800,14 +928,22 @@ Yanlış şifre Yeni Şifreyi Onayla Parolayı Kaldır + {app_name} kilidini açmak için gereken parolayı kaldır Şifreniz kaldırıldı. Şifre Belirle Şifreniz ayarlandı. Lütfen güvenle saklayınız. {app_name} kilidini açmak için şifre iste. + 12 karakterden uzun + Bir rakam içeriyor + Bir küçük harf içeriyor + Bir sembol içeriyor + Bir büyük harf içeriyor Şifre Güç Göstergesi Güçlü bir şifre ayarlamak, cihazınız kaybolur veya çalınırsa mesajlarınızı ve eklerinizi korumanıza yardımcı olur. Şifreler Yapıştır + Ödeme Hatası + Ödemeniz başarıyla işlendi, ancak {pro} statünüz {action_type} edilirken bir hata oluştu.\n\nLütfen ağ bağlantınızı kontrol edip tekrar deneyin. İzin Değişimi {app_name} dosya, müzik ve ses gönderimi için müzik ve ses erişimine ihtiyaç duyuyor, ancak bu erişim kalıcı olarak reddedildi. Ayarlar → İzinler üzerine dokunun ve \"Müzik ve ses\" seçeneğini açın. {app_name}, medya eklerini çalmak için Apple Music\'i kullanmak zorunda. @@ -850,15 +986,42 @@ {pro} için yakında yeni özellikler geliyor. {pro} Yol Haritası\'nda gelecek yenilikleri keşfedin {icon} Tercihler Ön İzleme + Bildirim Önizlemesi + {pro} erişiminiz aktif!\n\n{pro} erişiminiz {date} tarihinde otomatik olarak {current_plan_length} süreyle yenilenecek. + {pro} erişiminiz {date} tarihinde sona erecek.\n\n{pro} erişiminizin sona ermeden önce otomatik yenilenmesini sağlamak için şimdi {pro} erişiminizi güncelleyin. + {pro} erişiminiz aktif!\n\n{pro} erişiminiz {date} tarihinde otomatik olarak\n{current_plan_length} süreyle yenilenecektir. Burada yaptığınız tüm değişiklikler bir sonraki yenilemede geçerli olacaktır. + {pro} erişim hatası + {pro} erişiminiz {date} tarihinde sona erecek. + {pro} Erişimi Yükleniyor + {pro} erişim bilgileriniz hâlâ yükleniyor. Bu işlem tamamlanmadan güncelleme yapamazsınız. + {pro} erişimi yükleniyor... + {pro} erişim bilgilerinizi yüklemek için ağa bağlanılamadı. Bağlantı sağlanana kadar {app_name} üzerinden {pro} güncellemesi devre dışı bırakılacaktır.\n\nLütfen ağ bağlantınızı kontrol edin ve tekrar deneyin. + {pro} Erişimi Bulunamadı + {app_name}, hesabınızın {pro} erişimine sahip olmadığını tespit etti. Bunun bir hata olduğunu düşünüyorsanız, lütfen yardım için {app_name} destek ekibiyle iletişime geçin. + {pro} Erişimini Geri Yükle + {pro} Erişimini Yenile + Şu anda {pro} erişimi yalnızca {platform_store} veya {platform_store_other} üzerinden satın alınabilir ve yenilenebilir. {app_name} Masaüstü uygulamasını kullandığınız için yenileme işlemi buradan yapılamıyor.\n\n{app_name} geliştiricileri, {pro} erişiminin {platform_store} ve {platform_store_other} dışından da satın alınabilmesi için alternatif ödeme seçenekleri geliştirmek üzere yoğun şekilde çalışıyor. {pro} Yol Haritası {icon} + {pro} erişiminizi, {pro} için kaydolurken kullandığınız {platform_account} ile {platform_store} web sitesi üzerinden yenileyin. + {pro} için kaydolduğunuz {platform_account} hesabını kullanarak {platform} web sitesinde yenileyin. + Güçlü {app_pro} Beta özelliklerini tekrar kullanmaya başlamak için {pro} erişiminizi yenileyin. + {pro} Erişimi Geri Yüklendi + {app_name}, hesabınız için {pro} erişimini algıladı ve geri yükledi. {pro} durumunuz geri yüklendi! + {app_pro}\'ya ilk olarak {platform_store} üzerinden kaydolduğunuz için, {pro} erişiminizi güncellemek üzere {platform_account} hesabınızı kullanmanız gerekir. + Şu anda {pro} erişimi yalnızca {platform_store} veya {platform_store_other} üzerinden satın alınabilir. {app_name} Masaüstü\'nü kullandığınız için buradan {pro} sürümüne yükseltme yapamazsınız.\n\n{app_name} geliştiricileri, {platform_store} ve {platform_store_other} dışında {pro} erişimi satın alınabilmesini sağlamak üzere alternatif ödeme seçenekleri üzerinde yoğun şekilde çalışıyor. {pro} Yol Haritası {icon} Etkinleştirildi + etkinleştiriliyor + Her şey hazır! + {app_pro} erişiminiz güncellendi! {pro}, {date} tarihinde otomatik olarak yenilendiğinde ücretlendirme yapılacaktır. Zaten sahipsiniz Hadi, profil resminiz için GIF\'ler ve animasyonlu WebP görselleri yükleyin! + Animasyonlu profil resimleri edinin ve {app_pro} Beta ile premium özelliklerin kilidini açın Profil Onur Seçin kullanıcılar GIF yükleyebilir Animasyonlu Profil Resimleri GIF ve WebP görsellerini profil resminiz olarak ayarlayın. ile GIF Yükleyin {pro} {time} içinde otomatik olarak yenileniyor + {pro} Rozet {app_pro} rozetini diğer kullanıcılara göster Rozetler Görünen adınızın yanında özel bir rozetle {app_name} uygulamasını desteklediğinizi gösterin. @@ -867,22 +1030,47 @@ %1$s %2$s Gönderilen Rozetler {pro} Beta Özellikler + Yıllık {price} faturalandırılır + Aylık {price} faturalandırılır + Üç Aylık {price} faturalandırılır + Daha uzun mesajlar mı göndermek istiyorsunuz?\n{app_pro} Beta ile daha fazla metin gönderin ve premium özelliklerin kilidini açın + Daha fazla sabitleme mi istiyorsunuz?\nSohbetlerinizi düzenleyin ve {app_pro} Beta ile premium özelliklerin kilidini açın + {limit} sabitlemeden fazlasını mı istiyorsunuz?\nSohbetlerinizi düzenleyin ve {app_pro} Beta ile premium özelliklerin kilidini açın + {pro} aboneliğinizi iptal ettiğiniz için üzgünüz. {pro} erişiminizi iptal etmeden önce bilmeniz gerekenler: + İptal + {pro} erişiminin iptal edilmesi, {pro} süresi dolmadan önce otomatik yenilemenin gerçekleşmesini engeller. {pro}\'yu iptal etmek geri ödeme sağlamaz. {pro} erişiminiz sona erene kadar {app_pro} özelliklerini kullanmaya devam edebileceksiniz.\n\n{app_pro} hizmetine ilk olarak {platform_account} hesabınızı kullanarak kaydolduğunuz için, {pro}\'yu iptal etmek için aynı {platform_account} hesabını kullanmanız gerekir. + {pro} erişiminizi iptal etmenin iki yolu var: + {pro} erişimini iptal etmek, {pro} süresi dolmadan önce otomatik yenilemeyi engeller.\n\n{pro} iptali geri ödeme sağlamaz. {pro} erişiminiz sona erene kadar {app_pro} özelliklerini kullanmaya devam edebilirsiniz. + Size uygun {pro} erişim seçeneğini seçin.\nDaha uzun erişim, daha büyük indirimler demektir. + Bu cihazdan verilerinizi silmek istediğinizden emin misiniz?\n\n{app_pro} başka bir hesaba aktarılamaz. {pro} erişiminizi daha sonra geri yükleyebilmek için Kurtarma Parolanızı kaydettiğinizden emin olun. + Verilerinizi ağdan silmek istediğinizden emin misiniz? Devam ederseniz, mesajlarınızı veya kişilerinizi geri getiremezsiniz.\n\n{app_pro} başka bir hesaba aktarılamaz. Lütfen {pro} erişiminizi daha sonra geri yükleyebilmek için Kurtarma Parolanızı kaydedin. + {pro} erişiminiz, tam {app_pro} fiyatı üzerinden % {percent} indirimlidir. + {pro} durumu yenilenirken hata oluştu + Süresi Doldu + Maalesef, {pro} erişiminizin süresi doldu.\n{app_pro} Beta\'nın ayrıcalıklı avantajlarını ve özelliklerini yeniden etkinleştirmek için yenileyin. + Yakında Sona Eriyor + {pro} erişiminiz {time} içinde sona eriyor.\n{app_pro} Beta\'nın ayrıcalıklı avantajlarına ve özelliklerine erişmeye devam etmek için şimdi güncelleyin. + {pro}, {time} içinde sona eriyor + {pro} SSS {app_pro} SSS bölümünde sık sorulan soruların yanıtlarını bulun. GIF ve WebP profil resmi yükleme 300 üyeye kadar daha büyük grup sohbetleri Ayrıca daha birçok özel özellik 10.000 karaktere kadar mesajlar Sınırsız sohbet sabitleme + {app_name} uygulamasını tüm potansiyeliyle kullanmak ister misiniz?\nBirçok özel ayrıcalık ve özelliğe erişmek için {app_pro} Beta\'ya yükseltin. Grup Etkinleştirildi Bu grubun kapasitesi artırıldı! Bir grup yöneticisi sayesinde artık 300 üyeye kadar destekleyebilir %1$s Grup Yükseltildi %1$s Grup Yükseltildi + Geri ödeme talebi kesindir. Onaylanması durumunda, {pro} erişiminiz hemen iptal edilecek ve tüm {pro} özelliklerine erişiminizi kaybedeceksiniz. Artırılmış Ek Boyutu Artırılmış Mesaj Uzunluğu Daha Büyük Gruplar Yöneticisi olduğunuz gruplar otomatik olarak 300 üyeye kadar destekleyecek şekilde yükseltilir. + Daha büyük grup sohbetleri (300\'e kadar üye) yakında tüm Pro Beta kullanıcıları için geliyor! Uzun Mesajlar Tüm sohbetlerde 10.000 karaktere kadar mesaj gönderebilirsiniz. @@ -890,16 +1078,74 @@ %1$s Uzun Mesaj Gönderildi Bu mesajda aşağıdaki {app_pro} özellikleri kullanıldı: + Yeni bir kurulumla + {app_name} uygulamasını bu cihaza {platform_store} üzerinden yeniden yükleyin, Kurtarma Parolanız ile hesabınızı geri yükleyin ve {pro} aboneliğinizi {app_pro} ayarlarından yenileyin. + {app_name} uygulamasını bu cihaza {platform_store} üzerinden yeniden yükleyin, Kurtarma Parolanız ile hesabınızı geri yükleyin ve {app_pro} ayarlarından {pro} sürümüne geçin. + Şimdilik, yenilemenin üç yolu var: + Şimdilik iki yenileme seçeneği var: %1$s Sabitlenmiş Sohbet %1$s Sabitlenmiş Sohbetler + {app_pro}\'ya ilk olarak {platform_store} üzerinden kaydolduğunuz için, geri ödeme talebinde bulunmak üzere {platform_account} hesabınızı kullanmanız gerekir. + {app_pro}\'ya ilk olarak {platform_store} üzerinden kaydolduğunuz için, geri ödeme talebiniz {app_name} Destek tarafından işlenecektir.\n\nAşağıdaki düğmeye tıklayarak ve geri ödeme talep formunu doldurarak geri ödeme isteğinde bulunun.\n\n{app_name} Destek, geri ödeme taleplerini genellikle 24–72 saat içinde işlemeye çalışır; ancak taleplerin yoğun olduğu dönemlerde bu süre uzayabilir. + {app_pro} erişiminiz yenilendi! {network_name} desteğiniz için teşekkür ederiz. + 1 Ay - Aylık {monthly_price} + 3 Ay - Aylık {monthly_price} + 12 Ay - Aylık {monthly_price} + yeniden etkinleştiriliyor Seni kaybettiğimize üzüldük. İşte para iadesi talep etmeden önce bilmen gerekenler. + {platform} şu anda iade talebinizi işliyor. Bu işlem genellikle 24-48 saat sürer. Kararlarına bağlı olarak, {app_name} içindeki {pro} durumunuzda bir değişiklik görebilirsiniz. + Geri ödeme talebiniz {app_name} Destek ekibi tarafından işleme alınacaktır.\n\nAşağıdaki düğmeye tıklayarak ve geri ödeme talep formunu doldurarak geri ödemenizi isteyebilirsiniz.\n\n{app_name} Destek ekibi, geri ödeme taleplerini genellikle 24-72 saat içinde işleme almaya çalışır ancak yoğun taleplerin olduğu dönemlerde bu süre uzayabilir. + Geri ödeme talebiniz yalnızca {platform} web sitesi üzerinden {platform} tarafından işlenecektir.\n\n{platform}’un geri ödeme politikaları nedeniyle, {app_name} geliştiricileri bu taleplerin sonucunu etkileyemez. Bu, talebin onaylanıp onaylanmaması ve tam ya da kısmi geri ödeme yapılması durumlarını da kapsamaktadır. + İade talebinizle ilgili daha fazla güncelleme için lütfen {platform} ile iletişime geçin. {platform} iade politikaları nedeniyle, {app_name} geliştiricileri iade taleplerinin sonucunu etkileme yetkisine sahip değildir.\n\n{platform} İade Desteği + {pro} Geri Ödeme + {app_pro} için geri ödemeler yalnızca {platform} tarafından {platform_store} aracılığıyla gerçekleştirilir.\n\n{platform} iade politikaları nedeniyle, {app_name} geliştiricileri iade taleplerinin sonucunu etkileme yetkisine sahip değildir. Bu, talebin onaylanıp onaylanmaması ve tam mı yoksa kısmi mi iade yapılacağı gibi durumları da içerir. + Animasyonlu profil resimlerini yeniden kullanmak mı istiyorsunuz?\nKaçırdığınız özellikleri açmak için {pro} erişiminizi yenileyin. + {pro} Beta\'yı yenile + {pro} erişiminizi, {platform_store} veya {platform_store_other} üzerinden {app_name} yüklü bağlı bir cihazda {app_pro} ayarlarını kullanarak yenileyin. + Tekrar uzun mesajlar göndermek mi istiyorsunuz?\n{pro} erişiminizi yenileyerek kaçırdığınız özelliklerin kilidini açın. + {app_name} uygulamasını tekrar tam potansiyeliyle kullanmak mı istiyorsunuz?\n{pro} erişiminizi yenileyerek kaçırdığınız özelliklerin kilidini açın. + Yine {limit}’den fazla sohbeti sabitlemek mi istiyorsunuz?\nKaçırdığınız özellikleri açmak için {pro} erişiminizi yenileyin. + Yine daha fazla sohbet sabitlemek mi istiyorsunuz?\nKaçırdığınız özellikleri açmak için {pro} erişiminizi yenileyin. + Yenileyerek, {app_pro} Hizmet Şartları {icon} ve Gizlilik Politikası {icon}’nı kabul etmiş olursunuz. + yenileniyor + Şu anda, {pro} erişimi yalnızca {platform_store} veya {platform_store_other} üzerinden satın alınabilir ve yenilenebilir. {app_name} uygulamasını {build_variant} kullanarak yüklediğiniz için burada yenileme işlemi yapamazsınız.\n\n{app_name} geliştiricileri, kullanıcıların {platform_store} ve {platform_store_other} dışında {pro} erişimi satın alabilmesi için alternatif ödeme seçenekleri üzerinde yoğun şekilde çalışıyor. {pro} Yol Haritası {icon} + Geri Ödeme Talep Edildi ile daha fazlasını gönderin + {pro} Ayarları + {pro} kullanmaya başla {pro} İstatistikleriniz + {pro} İstatistikleri Yükleniyor + {pro} istatistikleriniz yükleniyor, lütfen bekleyin. {pro} istatistikleri bu cihazdaki kullanımı yansıtır ve bağlanan diğer cihazlarda farklı görünebilir + {pro} Durum Hatası + {pro} durumunuzu kontrol etmek için ağa bağlanılamadı. Bağlantı yeniden kurulana kadar bu sayfada gösterilen bilgiler hatalı olabilir.\n\nLütfen ağ bağlantınızı kontrol edin ve tekrar deneyin. + {pro} Durumu Yükleniyor + {pro} bilgileriniz yükleniyor. Yükleme tamamlanana kadar bu sayfadaki bazı işlemler kullanılamayabilir. + {pro} durumu yükleniyor + {pro} durumunuzu kontrol etmek için ağa bağlanılamıyor. Bağlantı yeniden kurulana kadar devam edemezsiniz.\n\nLütfen ağ bağlantınızı kontrol edin ve tekrar deneyin. + {pro} durumunuzu yenilemek için ağa bağlanılamadı. Bağlantı yeniden kurulana kadar bu sayfadaki bazı işlemler devre dışı bırakılacaktır.\n\nLütfen ağ bağlantınızı kontrol edin ve tekrar deneyin. + Mevcut {pro} erişiminizi yüklemek için ağa bağlanılamıyor. Bağlantı yeniden sağlanana kadar {pro} yenileme işlemi {app_name} üzerinden devre dışı bırakılacaktır.\n\nLütfen ağ bağlantınızı kontrol edin ve tekrar deneyin. + {pro} ile ilgili yardıma mı ihtiyacınız var? Destek ekibine bir talep gönderin. + {action_type} işlemiyle, {app_pro} uygulamasını {app_name} Protokolü aracılığıyla {activation_type} oluyorsunuz. Bu aktivasyon {entity} tarafından kolaylaştırılacaktır ancak {app_pro} sağlayıcısı değildir. {entity}, {app_pro}’nun performansı, kullanılabilirliği veya işlevselliğinden sorumlu değildir. + Güncelleyerek, {app_pro} Hizmet Şartları {icon} ve Gizlilik Politikası {icon}’nı kabul etmiş olursunuz. Sınırsız Sabitleme Sınırsız sohbet sabitleme özelliğiyle tüm sohbetlerinizi organize edin. + Mevcut faturalandırma seçeneğiniz size {current_plan_length} {pro} erişimi sağlar. {selected_plan_length_singular} faturalandırma seçeneğine geçmek istediğinizden emin misiniz?\n\nGüncelleme ile birlikte, {pro} erişiminiz {date} tarihinde otomatik olarak yenilenerek ek {selected_plan_length} {pro} erişimi sağlayacaktır. + {pro} erişiminiz {date} tarihinde sona erecek.\n\nGüncelleme ile birlikte, {pro} erişiminiz {date} tarihinde otomatik olarak yenilenerek ek {selected_plan_length} {pro} erişimi sağlayacaktır. + güncelleniyor + {app_pro} Beta sürümüne geçerek birçok ayrıcalıklı avantaja ve özelliğe erişin. + {pro} sürümüne yükseltmek için {app_name} uygulamasının {platform_store} veya {platform_store_other} üzerinden yüklü olduğu bağlı bir cihazdaki {app_pro} ayarlarını kullanın. + Şu anda {pro} erişimi yalnızca {platform_store} veya {platform_store_other} üzerinden satın alınabilir. {build_variant} kullanılarak {app_name} uygulamasını yüklediğiniz için buradan {pro} yükseltmesi yapamazsınız.\n\n{app_name} geliştiricileri, {platform_store} ve {platform_store_other} dışında {pro} erişimi satın alınabilmesini sağlamak üzere alternatif ödeme seçenekleri üzerinde yoğun bir şekilde çalışıyor. {pro} Yol Haritası {icon} + Şu anda yükseltmenin yalnızca bir yolu var: + Şu anda yükseltmenin iki yolu var: + {app_pro} sürümüne yükseltildiniz!\n{network_name} desteğiniz için teşekkürler. + yükseltiliyor + {pro} sürümüne yükseltiliyor + Yükselterek, {app_pro} Hizmet Şartları {icon} ve Gizlilik Politikası {icon}’nı kabul etmiş olursunuz. + {app_name}\'den daha fazla yararlanmak mı istiyorsunuz?\nDaha güçlü bir mesajlaşma deneyimi için {app_pro} Beta\'ya yükseltin. + {platform}, geri ödeme talebinizi işliyor. Profil Profil Resmini Seçin Profil resmi kaldırılamadı. @@ -907,6 +1153,11 @@ Lütfen daha küçük bir dosya seçin. Profil güncellenemedi. Yükselt + Yöneticiler son 14 güne ait mesaj geçmişini görebilir ve gruptan çıkarılamaz veya görevden alınamaz. + + Üyeyi Terfi Ettir + Üyeleri Terfi Ettir + Ayrıcalık Başarısız Oldu Ayrıcalıklar Başarısız Oldu @@ -950,23 +1201,65 @@ Kurtarma Şifresini Gizle Kurtarma parolanızı bu cihazda kalıcı olarak gizleyin. Hesabınızı yüklemek için kurtarma parolanızı girin. Kaydetmediyseniz, uygulama ayarlarınızda bulabilirsiniz. + Kurtarma Şifresini Görüntüle + Kurtarma Şifresi Görünürlüğü Bu sizin kurtarma ifadenizdir. Birine gönderirseniz, hesabınızda tam erişime sahip olurlar. Grubu Yeniden Oluştur Yinele + {app_pro} için başlangıçta farklı bir {platform_account} kullanılarak kayıt olduğunuzdan, {pro} erişiminizi güncellemek için o {platform_account} hesabını kullanmanız gerekir. + Geri ödeme istemek için iki yöntem: Mesaj uzunluğunu {count} karakter azaltın %1$d karakter kaldı %1$d karakter kaldı + Daha Sonra Hatırlat Kaldır + + Üye Kaldır + Üyeleri Kaldır + + + Üye ve iletilerini kaldır + Üyeleri ve iletilerini kaldır + Parola kaldırma başarısız oldu + {app_name} için mevcut parolanızı kaldırın. Yerel olarak saklanan veriler, cihazınızda saklanan rastgele oluşturulmuş bir anahtarla yeniden şifrelenecektir. + + Üye kaldırılıyor + Üyeler kaldırılıyor + + Yenile + {pro} yenileniyor Yanıtla + Geri Ödeme Talep Et + {pro} için kaydolduğunuz {platform_account} hesabını kullanarak {platform} web sitesinden geri ödeme isteyin. Tekrar gönder + + Davetiyeyi Tekrar Gönder + Davetleri Yeniden Gönder + + + Terfi Yeniden Gönderiliyor + Terfiler Yeniden Gönderiliyor + + + Davet yeniden gönderiliyor + Davetler yeniden gönderiliyor + + + Terfi yeniden gönderiliyor + Terfiler yeniden gönderiliyor + Ülke bilgileri yükleniyor... Yeniden başlat Yeniden Senkronize Et Yeniden Dene + İnceleme Limiti Görünüşe göre {app_name} uygulamasını yakın zamanda zaten değerlendirmişsiniz, geri bildiriminiz için teşekkürler! + Uygulamayı Arka Planda Çalıştır + {app_name} Arka Planda Çalışsın mı? + Yavaş Mod kullandığınız için, bildirimleri iyileştirmek amacıyla {app_name} uygulamasının arka planda çalışmasına izin vermenizi öneririz. Bu, bildirimlerin tutarlılığını artırabilir, ancak sisteminiz arka plan etkinliğini yine de otomatik olarak sınırlayabilir.\n\nBu ayarı daha sonra Ayarlar bölümünden değiştirebilirsiniz. Kaydet Kaydedildi Kaydedilen iletiler @@ -975,6 +1268,8 @@ Ekran Güvenliği Ekran Görüntüsü Bildirimleri Bir kişi bire bir sohbetin ekran görüntüsünü aldığında bildirim alın. + Bu cihazda alınan ekran görüntülerinde {app_name} penceresini gizle. + Ekran Görüntüsü Koruması {name} ekran görüntüsü aldı. Ara Kişileri Bul @@ -995,6 +1290,10 @@ Gönderiliyor Arama Teklifi Gönderiliyor Bağlantı Adayları Gönderiliyor + + Yönetici ataması gönderiliyor + Yönetici atamaları gönderiliyor + Gönderildi: Görünüm Verileri Temizle @@ -1015,39 +1314,56 @@ Bildirimler İzinler Gizlilik + {app_pro} Beta Recovery Password Ayarlar Set Topluluk Görünen Resmini Ayarla + {app_name} için bir parola belirleyin. Yerel olarak saklanan veriler bu parola ile şifrelenecektir. {app_name} her başlatıldığında bu parolayı girmeniz istenecektir. + Ayar Güncellenemiyor Yeni ayarların uygulanması için {app_name}\'i yeniden başlatmanız gerekiyor. Ekran Güvenliği + Başlangıç Paylaş Arkadaşını {app_name} üzerinde seninle konuşmaya davet etmek için onunla Hesap ID\'ni paylaşabilirsin. Arkadaşlarınızla genellikle nerede konuştuğunuzu paylaşın ve ardından sohbeti buraya taşıyın. Veritabanını açarken bir sorun oluştu. Lütfen uygulamayı yeniden başlatıp tekrar deneyin. Olamaz! {app_name} için bir hesaba sahip değilsiniz. \n\nBir şeyler paylaşmadan önce {app_name} için bir hesap oluşturmanız gerekiyor. + Bu kullanıcıyla grup iletilerinin geçmişini paylaşmak ister misiniz? {app_name}\'ya paylaşın + Üzgünüz, {app_name} yalnızca birden fazla görüntü ve videonun aynı anda paylaşılmasını destekler + Paylaşım yalnızca medya dosyalarını destekler. Medya olmayan dosyalar dışarıda bırakıldı Göster Hepsini Göster Daha az göster Kendime Notu Göster Kendime Not\'u sohbet listenizde göstermek istediğinizden emin misiniz? + Yazım Denetimi Çıkartmalar Güç + Sorun mu yaşıyorsunuz? Yardım makalelerini inceleyin ya da {app_name} Destek ile bir talep oluşturun. Destek Sayfasına Git Sistem Bilgisi: {information} Tekrar denemek için tıkla Devam et Varsayılan Hata + Geri Dön + Tema Önizlemesi {name} adlı kişinin Hesap Kimliği, önceki etkileşimlerinize dayanarak görünür durumdadır Körleştirilmiş Kimlikler, istenmeyen mesajları (spam) azaltmak ve gizliliği artırmak için topluluklarda kullanılır + Çeviri + Sistem Tepsisi Tekrar Dene Yazım Belirtileri Yazım göstergelerini görün ve paylaşın. Mevcut Değil Geri al Bilinmeyen + Desteklenmeyen CPU + Güncelle + {pro} Erişimini Güncelle + {pro} erişiminizi güncellemenin iki yolu: Uygulama güncellemeleri Grup Bilgilerini Güncelle Grup adı ve açıklaması tüm grup üyeleri tarafından görülebilir @@ -1068,13 +1384,20 @@ {app_name} Güncellemesi Sürüm {version} En son {relative_time} önce güncellendi + Güncellemeler + Güncelleniyor... + Yükselt + {app_name} uygulamasını yükselt Yükselt Karşıya yükleniyor URL\'yi Kopyala URL açılsın mı Bu tarayıcınızda açılacak. Bu URL\'yi tarayıcınızda açmak istediğinizden emin misiniz?\n\n{url} + Bağlantılar tarayıcınızda açılacaktır. Hızlı Modu Kullan + Kayıt olurken kullandığınız {platform_account} hesabını kullanarak {platform} web sitesi üzerinden planınızı değiştirin. + {platform} internet sitesi üzerinden Video Video oynatılamıyor. Görünüm @@ -1083,7 +1406,12 @@ Bu işlem birkaç dakika sürebilir. Bir dakika lütfen... Uyarı + iOS 15 desteği sona erdi. Uygulama güncellemelerini almaya devam etmek için iOS 16 veya daha yeni bir sürüme güncelleyin. Pencere Evet Siz + CPU\'nuz SSE 4.2 komutlarını desteklemiyor. Bu komutlar, {app_name}\'in Linux x64 işletim sistemlerinde görselleri işleyebilmesi için gereklidir. Lütfen uyumlu bir CPU\'ya geçiş yapın ya da farklı bir işletim sistemi kullanın. + Kurtarma Şifreniz + Yakınlaştırma Faktörü + Metin ve görsel öğelerin boyutunu ayarlayın. \ No newline at end of file diff --git a/app/src/main/res/values-b+uk+UA/strings.xml b/app/src/main/res/values-b+uk+UA/strings.xml index ee44db3887..08eb701885 100644 --- a/app/src/main/res/values-b+uk+UA/strings.xml +++ b/app/src/main/res/values-b+uk+UA/strings.xml @@ -15,7 +15,15 @@ Це ваш Account ID. Інші користувачі можуть просканувати його, щоб почати розмову з вами. Актуальний розмір Додати + + Додати адміністратора + Додати адміністраторів + Додати адміністраторів + Додати адміністраторів + + Додати адміністратора Введіть ідентифікатор облікового запису користувача, якого ви призначаєте адміністратором.\n\nЩоб додати кількох користувачів, введіть ідентифікатори їхніх облікових записів, розділяючи їх комами. Одночасно можна вказати до 20 ідентифікаторів облікових записів. + Адміністраторів не можна понизити або видалити з групи. Адміністратори не можуть бути видалені. {name} та ще {count} інших було підвищено до адміністраторів. Підвищити адміністратора @@ -40,6 +48,12 @@ {name} було вилучено із групи. {name} та ще {count} інших було вилучено з переліку адміністраторів. {name} та {other_name} було вилучено з переліку адміністраторів. + + Вибрано %1$d адміністратора + Вибрано %1$d адміністратора + Вибрано %1$d адміністраторів + Вибрано %1$d адміністраторів + Надсилання прав адміністратора Надсилання прав адміністратора @@ -47,7 +61,10 @@ Надсилання прав адміністраторів Адміністраторські налаштування + Ви не можете змінити свій статус адміністратора. Щоб вийти з групи, відкрийте налаштування розмови та виберіть «Вийти з групи». {name} та {other_name} було підвищено до адміністраторів. + Адміністратори + Дозволити +{count} Анонімно Значок застосунку @@ -164,6 +181,7 @@ Ви впевнені, що бажаєте розблокувати {name} і ще одного? {name} розблоковано Переглядайте та керуйте заблокованими контактами. + Не знайдено браузер для відкриття цієї URL-адреси, спробуйте натомість скопіювати її Дзвінок {name} дзвонив вам Не можна розпочати новий дзвінок. Спочатку закінчіть поточний дзвінок. @@ -204,13 +222,18 @@ {app_name} потрібен дозвіл до камери для сканування QR-кодів Скасувати Скасувати {pro} - Скасувати план {pro} + Скасуйте підписку на вебсайті {platform}, використовуючи обліковий запис {platform_account}, з яким ви оформили {pro}. + Скасуйте підписку на вебсайті {platform_store}, використовуючи обліковий запис {platform_account}, з яким ви оформили {pro}. Змінити Не вдалося змінити пароль Змінити ваш пароль для {app_name}. Локально збережені дані будуть наново шифровані з застосуванням нового паролю. + Змінити параметр Перевірка статусу {pro} + Перевіряємо ваш статус {pro}. Після завершення перевірки ви зможете продовжити. + Перевіряємо ваші дані {pro}. Деякі дії на цій сторінці можуть бути недоступні, поки перевірка не завершена. Перевірка статусу {pro}... Перевіряємо ваші дані {pro}. Ви не можете продовжити передплату, доки ця перевірка не буде завершена. + Перевіряємо ваш статус {pro}. Ви зможете перейти на {pro}, щойно перевірка завершиться. Очистити Очистити всі Очистити всі дані @@ -271,11 +294,19 @@ URL спільноти Копіювати URL спільноти Підтвердити + Підтвердити підвищення + Ви впевнені? Адміністратора не можна буде понизити або видалити з групи. Контакти Видалити контакт Ви впевнені, що хочете видалити {name} з ваших контактів? Нові повідомлення від {name} будуть приходити як запит на повідомлення. У вас ще немає жодних контактів Обрати контакти + + Вибрано %1$d контакт + Вибрано %1$d контакти + Вибрано %1$d контактів + Вибрано %1$d контактів + Деталі користувача Камера Виберіть дію, щоб розпочати розмову @@ -316,6 +347,7 @@ Копіювати Створити Викликаємо + Поточна оплата Поточний пароль Вирізати Темний режим @@ -343,6 +375,18 @@ Будь ласка, зачекайте поки створюється група... Не вдалося оновити групу У вас немає прав на видалення інших повідомлень + + Видалити вибране вкладення + Видалити вибрані вкладення + Видалити вибрані вкладення + Видалити вибрані вкладення + + + Ви впевнені, що хочете видалити вибране вкладення? Повідомлення, пов\'язане з цим вкладенням, також буде видалено. + Ви впевнені, що хочете видалити вибрані вкладення? Повідомлення, пов\'язане з цим вкладенням, також буде видалено. + Ви впевнені, що хочете видалити вибрані вкладення? Повідомлення, пов\'язане з цим вкладенням, також буде видалено. + Ви впевнені, що хочете видалити вибрані вкладення? Повідомлення, пов\'язане з цим вкладенням, також буде видалено. + Ви впевнені, що хочете видалити {name} зі своїх контактів?\n\nЦе призведе до видалення вашої розмови, включно з усіма повідомленнями та вкладеннями. Майбутні повідомлення від {name} відображатимуться як запит на повідомлення. Ви дійсно хочете видалити розмову з {name}?\n Це назавжди видалить усі повідомлення та вкладення. @@ -396,6 +440,7 @@ Ви впевнені, що хочете видалити ці повідомлення для всіх? Видалення Відкрити засоби розробника + Налаштування сповіщень пристрою Почати диктування... Зникаючі повідомлення Повідомлення буде видалено через {time_large} @@ -443,6 +488,8 @@ Ваше відображуване ім\'я видно користувачам, групам і спільнотам, з якими ви взаємодієте. Документ Підтримати + Потужні сили намагаються послабити конфіденційність, але ми не можемо продовжувати цю боротьбу наодинці.\n\nВаші пожертви допомагають зберегти {app_name} захищеним, незалежним і доступним онлайн. + {app_name} потребує вашої допомоги Готово Завантажити Завантаження... @@ -482,12 +529,23 @@ Крутяк {emoji} Ви вже деякий час користуєтесь {app_name}, які у вас враження? Нам би дуже хотілося дізнатися вашу думку. Увійти + Введіть пароль, який ви встановили для {app_name} + Введіть пароль, який ви використовуєте для розблокування {app_name} під час запуску. Це не ваш пароль відновлення. + Помилка під час перевірки статусу {pro} Будь ласка, перевірте підключення до Інтернету та спробуйте ще раз. Скопіювати помилку та вийти Помилка бази даних Щось пішло не так. Будь ласка, спробуйте пізніше. Помилка завантаження доступу до {pro} + {app_name} не вдалося знайти цей ONS. Перевірте підключення до мережі й повторіть спробу. Невідома помилка + Цей ONS не зареєстровано. Перевірте правильність і повторіть спробу. + Не вдалося повторно надіслати запрошення користувачу {name} у групі {group_name} + Не вдалося повторно надіслати запрошення користувачу {name} та ще {count} учасникам у групі {group_name} + Не вдалося повторно надіслати запрошення користувачам {name} та {other_name} у групі {group_name} + Не вдалося повторно надіслати права адміністратора для {name} у {group_name} + Не вдалося повторно надіслати права адміністратора для {name} і {count} інших у {group_name} + Не вдалося повторно надіслати права адміністратора для {name} і {other_name} у {group_name} Не вдалося завантажити Відмови Відгук @@ -543,6 +601,9 @@ Чи дійсно ви бажаєте вийти з {group_name}? Ви впевнені, що хочете вийти з {group_name}?\n\nЦе видалить усіх учасників та вміст групи. Не вдалося вийти з {group_name} + {name} був запрошений приєднатися до групи. Історію чатів за останні 14 днів надано. + {name} і ще {count} осіб були запрошені приєднатися до групи. Історію чатів за останні 14 днів надано. + {name} та {other_name} були запрошені приєднатися до групи. Історію чатів за останні 14 днів надано. {name} покинув групу. {name} та ще {count} інших покинули групу. {name} та {other_name} покинули групу. @@ -554,6 +615,9 @@ {name} та {other_name} були запрошені приєднатися до групи. Ви та {count} інших були запрошені приєднатися до групи. Було надано спільний доступ до історії чату. Вас та {other_name} запросили до групи з наданням доступу до історії листування. + Не вдалося видалити {name} з {group_name} + Не вдалося видалити {name} і {count} інших з {group_name} + Не вдалося видалити {name} і {other_name} з {group_name} Ви покинули групу. Учасники групи Відсутні учасники у цій групі. @@ -567,6 +631,7 @@ У вас немає повідомлень від {group_name}. Надішліть повідомлення, щоб розпочати розмову! Ця група не оновлювалася понад 30 днів. У вас можуть виникнути проблеми з надсиланням повідомлень або переглядом інформації про групу. Ви — єдиний адміністратор у {group_name}.\n\nУчасники групи та налаштування не можуть бути змінені без адміністратора. + Ви — єдиний адміністратор у {group_name}.\n\nУчасники групи та налаштування не можуть бути змінені без адміністратора. Щоб вийти з групи, не видаливши її, спочатку додайте нового адміністратора. Очікує видалення Вас підвищили до адміністратора. Ви та ще {count} інших були підвищені до адміністраторів. @@ -620,6 +685,12 @@ Запитувати режим інкогніто, якщо доступний. Залежно від клавіатури, яку ви використовуєте, ваша клавіатура може ігнорувати цей запит. Інформація Недопустимий ярлик + + Запросити контакт + Запросити контакти + Запросити контакти + Запросити контакти + Помилка запрошення Помилка запрошень @@ -632,6 +703,14 @@ Неможливо надіслати запрошення. Бажаєте спробувати ще раз? Неможливо надіслати запрошення. Бажаєте спробувати ще раз? + + Запросити учасника + Запросити учасників + Запросити учасників + Запросити учасників + + Запросіть нового учасника до групи, ввівши Account ID, ONS вашого друга або сканувавши їх QR-код {icon} + Запросіть нового учасника до групи, ввівши Account ID або ONS вашого друга чи відсканувавши їхній QR-код Приєднатися Пізніше Автоматично запускати {app_name} під час увімкнення компʼютера. @@ -651,6 +730,8 @@ Ви та {other_name} приєдналися до групи. {name} та {other_name} приєдналися до групи. Ви приєдналися до групи. + Обмежити фонову активність? + Наразі ви дозволяєте {app_name} працювати у фоновому режимі для підвищення надійності сповіщень. Зміна цього параметра може призвести до менш надійних сповіщень. Попередній перегляд посилань Показувати попередні перегляди посилань для підтримуваних URL. Ввімкнути попередній перегляд посилань @@ -675,9 +756,11 @@ Торкніться, щоб розблокувати {app_name} розблоковано Журнали + Керувати адміністраторами Керувати учасниками Налаштування {pro} Максимум + Можливо, пізніше Медіа %1$d учасник @@ -692,7 +775,9 @@ %1$d активних учасників Додати ID облікового запису або ONS + Учасників можна підвищити лише після того, як вони приймуть запрошення до групи. Запросити з контактів + У вас немає контактів, яких можна запросити до цієї групи.\nПоверніться назад і запросіть учасників, використовуючи їхній Account ID або ONS. Надіслати запрошення Надіслати запрошення @@ -703,8 +788,10 @@ Бажаєте поділитися історією повідомлень групи з {name} і {count} іншими? Бажаєте поділитися історією повідомлень групи з {name} і {other_name}? Поділитися історією повідомлень + Надати доступ до історії повідомлень за останні 14 днів Ділитися тільки новими повідомленнями Запросити + Учасники (не адміністратори) Панель меню Повідомлення Читати далі @@ -725,6 +812,7 @@ Розпочніть нову розмову, ввівши Account ID або ONS вашого друга. Розпочніть нову розмову, ввівши Account ID, ONS вашого друга або скануючи їх QR-код. + Розпочніть нову розмову, ввівши Account ID, ONS вашого друга або сканувавши їх QR-код {icon} Ви отримали нове повідомлення. Ви отримали %1$d нових повідомлення @@ -788,6 +876,7 @@ Видалити нікнейм Встановити псевдонім Ні + У цій групі немає учасників, які не є адміністраторами. Немає припущень Надсилайте повідомлення до 10 000 символів у всіх розмовах. Організовуйте вікно розмов з необмеженою кількістю закріплених бесід. @@ -797,6 +886,7 @@ У вас немає повідомлень в Нотатках для себе. Приховати нотатку для себе Ви справді бажаєте приховати Нотатку для себе? + УВАГА: Здійснюючи {action_type}, ти погоджуєшся з Правилами користування {icon} та Політикою конфіденційності {icon} {app_pro} Сповіщення Показувати ім’я відправника та стислий вміст повідомлення. Показувати лише ім\'я відправника без вмісту повідомлення. @@ -841,6 +931,8 @@ Добре Увімк. На вашому пристрої {device_type} + Відкрийте цей обліковий запис {app_name} на пристрої типу {device_type}, увійденому до облікового запису {platform_account}, з яким ви зареєструвалися. Далі скасуйте {pro} через налаштування {app_pro}. + Відкрийте цей обліковий запис {app_name} на пристрої {device_type}, що увійшов у {platform_account}, з яким ви реєструвались. Потім оновіть доступ до {pro} через налаштування {app_pro}. На зв\'язаному пристрої На вебсайті {platform_store} На вебсайті {platform} @@ -868,6 +960,7 @@ Не вдалося розпізнати цей ONS. Будь ласка, перевірте його та спробуйте ще раз. Не вдалося виконати пошук цього ONS. Спробуйте пізніше. Відкрити + Відкрити вебсайт {platform_store} Відкрити {platform} вебсайт Відкрити налаштування Пройти опитування @@ -903,6 +996,8 @@ Встановлення надійного пароля допомагає захистити ваші повідомлення та вкладення у разі втрати або крадіжки пристрою. Паролі Вставити + Помилка оплати + Твій платіж було успішно оброблено, але сталася помилка під час {action_type} стану {pro}.\n\nБудь ласка, перевір з\'єднання з мережею та повтори спробу. Потрібна зміна дозволів {app_name} потребує доступу до музики та аудіо, щоб надсилати файли, музику та аудіо, але доступ було постійно відхилено. Натисніть Налаштування → Дозволи та увімкніть «Музика та аудіо». {app_name} потребує використовувати Apple Music для відтворення медіавкладень. @@ -947,11 +1042,30 @@ Попередній перегляд Попередній перегляд сповіщень Помилка доступу {pro} + Ваш доступ до {pro} спливає {date}. + Завантажується доступ до {pro} + Ваші дані доступу до {pro} все ще завантажуються. Ви не можете оновити, поки цей процес не буде завершено. Доступ до {pro} завантажується... + Не вдалося встановити з\'єднання з мережею, щоб завантажити інформацію про ваш доступ {pro}. Оновлення {pro} через {app_name} буде вимкнено, доки не буде відновлено з\'єднання.\n\nБудь ласка, перевірте своє мережеве з\'єднання та повторіть спробу. + Доступ до {pro} не знайдено + {app_name} виявив, що ваш обліковий запис не має доступу {pro}. Якщо ви вважаєте, що це помилка, зверніться до служби підтримки {app_name} для отримання допомоги. + Відновити доступ до {pro} + Поновити доступ до {pro} + На даний момент доступ до {pro} можна придбати або поновити лише через {platform_store} або {platform_store_other}. Оскільки ви використовуєте {app_name} для компʼютера, поновити доступ тут неможливо.\n\nРозробники {app_name} активно працюють над альтернативними методами оплати, щоб дозволити користувачам купувати доступ до {pro} поза межами {platform_store} та {platform_store_other}. Дорожня карта {pro} {icon} + Поновіть доступ до {pro} на вебсайті {platform_store}, використовуючи обліковий запис {platform_account}, з яким ви зареєструвалися у {pro}. + Поновіть підписку на вебсайті {platform}, використовуючи обліковий запис {platform_account}, з яким ви оформили {pro}. + Поновіть свій доступ до {pro}, щоб знову почати використовувати потужні функції {app_pro} Beta. + Доступ до {pro} відновлено + {app_name} виявив і відновив доступ до {pro} для вашого облікового запису. Ваш статус {pro} було відновлено! + Оскільки ви спочатку зареєструвалися в {app_pro} через {platform_store}, для оновлення доступу до {pro} потрібно використати ваш обліковий запис {platform_account}. + Наразі доступ до {pro} можна придбати лише через {platform_store} або {platform_store_other}. Оскільки ви використовуєте {app_name} Desktop, оновлення до {pro} тут недоступне.\n\nРозробники {app_name} наполегливо працюють над альтернативними способами оплати, щоб дозволити придбання доступу до {pro} поза межами {platform_store} та {platform_store_other}. Дорожня карта {pro} {icon} активовано + активація Готово! + Ваш доступ до {app_pro} оновлено! З вас буде стягнуто плату, коли {pro} автоматично поновиться {date}. У вас вже є Не зволікайте і завантажуйте GIF та анімовані WebP картинки для свого аватара! + Отримайте анімовані аватари та розблокуйте преміальні функції з {app_pro} Beta Анімоване зображення профілю користувачі можуть завантажувати GIF Анімовані зображення облікового запису @@ -962,14 +1076,32 @@ Показувати значок {app_pro} іншим користувачам Позначки Продемонструйте свою підтримку {app_name} з ексклюзивним значком поруч з власним іменем. + + %1$s значок %2$s надіслано + %1$s значки %2$s надіслано + %1$s значків %2$s надіслано + %1$s значків %2$s надіслано + Можливості {pro} {price} сплата щорічно {price} сплата щомісячно {price} сплата щоквартально + Хочете надсилати довші повідомлення?\nНадсилайте більше тексту та розблокуйте преміальні функції з {app_pro} Beta + Потрібно більше закріплень?\nВпорядкуйте свої чати та розблокуйте преміальні функції з {app_pro} Beta + Хочете більше ніж {limit} закріплень?\nВпорядкуйте свої чати та розблокуйте преміальні функції з {app_pro} Beta + Шкода, що ви скасовуєте {pro}. Ось що вам слід знати перед скасуванням вашого доступу {pro}. Скасування + Два способи скасувати доступ {pro}: + Скасування доступу до {pro} запобіжить автоматичному поновленню перед завершенням терміну дії {pro}.\n\nСкасування {pro} не передбачає повернення коштів. Ви й надалі зможете користуватися функціями {app_pro} до закінчення терміну дії вашого доступу {pro}. + Оберіть варіант доступу до {pro}, який найбільше вам підходить.\nЧим довший доступ — тим більша знижка. + Ви впевнені, що хочете видалити свої дані з цього пристрою?\n\n{app_pro} не можна перенести на інший обліковий запис. Збережіть свій пароль відновлення, щоб мати змогу пізніше відновити доступ до {pro}. + Ви впевнені, що бажаєте видалити свої дані з мережі? Якщо продовжите, ви не зможете відновити свої повідомлення або контакти.\n\n{app_pro} не можна перенести на інший обліковий запис. Будь ласка, збережіть свій пароль відновлення, щоб мати змогу пізніше відновити доступ до {pro}. + Ваш доступ до {pro} вже має знижку {percent}% від повної ціни {app_pro}. Помилка оновлення {pro} Підписка сплила + На жаль, ваш доступ до {pro} завершився.\nПоновіть його, щоб знову активувати ексклюзивні переваги та функції {app_pro} Beta. Невдовзі спливе підписка + Ваш доступ {pro} спливає через {time}.\nОновіть зараз, щоб зберегти доступ до ексклюзивних переваг і функцій {app_pro} Beta {pro} спливає за {time} {pro} ЧАП Відповіді на загальні запитання знайдеш у ЧаПи {app_pro}. @@ -978,8 +1110,16 @@ Та велика кількість ексклюзивних можливостей Повідомлення до 10 000 символів Закріплюйте необмежену кількість бесід + Бажаєте використовувати {app_name} на повну?\nОновіться до {app_pro} Beta, щоб отримати доступ до безлічі ексклюзивних переваг та функцій. Групу активовано У цієї групи розширено можливості! Тепер вона може вміщати до 300 учасників, тому що адміністратор групи має + + %1$s групу покращено + %1$s групи покращено + %1$s груп покращено + %1$s груп покращено + + Запит на повернення коштів є остаточним. Якщо його буде схвалено, ваш доступ до {pro} буде негайно скасовано, і ви втратите доступ до всіх функцій {pro}. Збільшений розмір вкладення Збільшена довжина повідомлень Більші групи @@ -987,20 +1127,54 @@ Незабаром усі користувачі Pro Beta зможуть створювати більші групові чати (до 300 учасників)! Довші повідомлення Ви можете надсилати повідомлення до 10 000 символів у всіх розмовах. - У цьому повідомленні наявні наступні функції Session Pro: + + %1$s довге повідомлення надіслано + %1$s довгих повідомлення надіслано + %1$s довгих повідомлень надіслано + %1$s довгих повідомлень надіслано + + У цьому повідомленні наявні наступні функції {app_pro}: + За допомогою нової інсталяції + Переінсталюйте {app_name} на цьому пристрої через {platform_store}, відновіть свій обліковий запис за допомогою пароля відновлення і поновіть {pro} через налаштування {app_pro}. + Перевстановіть {app_name} на цьому пристрої через {platform_store}, відновіть свій обліковий запис за допомогою Пароля відновлення доступу та оновіть до {pro} із налаштувань {app_pro}. + Поки що є три способи оновлення: Наразі є два способи поновлення доступу: {percent}% Знижки + + %1$s закріплена бесіда + %1$s закріплені бесіди + %1$s закріплених бесід + %1$s закріплених бесід + + Оскільки ви спочатку зареєструвалися в {app_pro} через {platform_store}, вам потрібно використати ваш обліковий запис {platform_account}, щоб подати запит на повернення коштів. + Оскільки ви спочатку зареєструвалися в {app_pro} через {platform_store}, ваш запит на повернення коштів буде оброблений Підтримкою {app_name}.\n\nПодайте запит на повернення, натиснувши кнопку нижче та заповнивши форму запиту на повернення коштів.\n\nПідтримка {app_name} прагне обробляти запити на повернення протягом 24–72 годин, проте в періоди високого навантаження це може зайняти більше часу. + Ваш доступ до {app_pro} було поновлено! Дякуємо за підтримку {network_name}. 1 місяць — {monthly_price} / місяць 3 місяці — {monthly_price} / місяць 12 місяців – {monthly_price} / місяць + повторна активація + Відкрийте цей обліковий запис {app_name} на пристрої типу {device_type}, увійденому до облікового запису {platform_account}, з яким ви зареєструвалися. Потім подайте запит на повернення коштів через налаштування {app_pro}. Шкода, же ти передумав(ла). Перед вимогою повернення грошей ти мусиш знати ось що. + {platform} наразі обробляє ваш запит на повернення коштів. Зазвичай це займає 24–48 годин. Залежно від їх рішення, ви можете побачити зміну статусу {pro} у {app_name}. + Ваш запит на повернення коштів буде оброблений Службою підтримки {app_name}.\n\nНадішліть запит на повернення натиснувши кнопку нижче та заповнивши форму повернення.\n\nСлужба підтримки {app_name} зазвичай обробляє запити на повернення коштів упродовж 24–72 годин, однак у періоди високої завантаженості обробка може займати більше часу. + Ваш запит на повернення коштів буде оброблятись виключно через вебсайт {platform} платформою {platform}.\n\nЧерез політику повернення коштів {platform}, розробники {app_name} не мають змоги вплинути на результат запиту. Це стосується як схвалення або відхилення запиту, так і видачі повного або часткового повернення. + Будь ласка, зверніться до {platform} для отримання подальших оновлень щодо вашого запиту на повернення коштів. Через політику повернення коштів {platform}, розробники {app_name} не мають можливості впливати на результат запитів на повернення коштів.\n\nПідтримка повернень коштів {platform} Повернення грошей за {pro} + Повернення коштів за {app_pro} здійснюється виключно через {platform} через {platform_store}.\n\nЧерез політику повернення коштів {platform}, розробники {app_name} не мають можливості впливати на результат запитів на повернення. Це стосується як схвалення або відхилення запиту, так і надання повного або часткового повернення коштів. + Хочете знову використовувати анімовані зображення профілю?\nПоновіть свій доступ до {pro}, щоб розблокувати функції, яких вам бракувало. + Оновити {pro} Beta + Поновіть доступ до {pro} у налаштуваннях {app_pro} на повʼязаному пристрої з встановленим {app_name} через {platform_store} або {platform_store_other}. + Хочете знову надсилати довші повідомлення?\nПоновіть свій доступ до {pro}, щоб розблокувати функції, яких вам бракувало. Хочете знову використати {app_name} на повну?\nПоновіть доступ до {pro}, щоб розблокувати функції, яких вам бракувало. + Бажаєте знову закріпити більше ніж {limit} розмов?\nПоновіть свій доступ до {pro}, щоб отримати функції, яких вам бракувало. Хочете знову закріпити більше розмов?\nПоновіть свій доступ до {pro}, щоб розблокувати функції, яких вам бракувало. - Помилка поновлення Pro, повторна спроба незабаром + Поновлюючи, ви погоджуєтеся з Умовами надання послуг {icon} та Політикою конфіденційності {icon} сервісу {app_pro}. + поновлення + На даний момент доступ {pro} можна придбати та оновити лише через {platform_store} або {platform_store_other}. Оскільки ви встановили {app_name}, використовуючи {build_variant}, ви не можете поновити доступ тут.\n\nРозробники {app_name} активно працюють над альтернативними способами оплати, щоб надати змогу користувачам купувати доступ {pro} поза межами {platform_store} та {platform_store_other}. Дорожня карта {pro} {icon} Вимогу повернення грошей надіслано Надсилайте довші повідомлення з Налаштування {pro} + Почати користуватися {pro} Ваша статистика {pro} Завантажується статистика плану {pro} Ваша статистика плану {pro} завантажується, зачекайте, будь ласка. @@ -1008,12 +1182,28 @@ Помилка статусу {pro} Не вдалося під\'єднатися до мережі для перевірки вашого статусу {pro}. Інформація, демонстрована на цій сторінці, може бути неточною, доки не буде відновлено з\'єднання.\n\nБудь ласка, перевірте з\'єднання з мережею та спробуйте ще раз. Завантажується статус плану {pro} + Завантажується ваша інформація {pro}. Деякі дії на цій сторінці можуть бути недоступні, поки завантаження не завершено. Завантаження {pro} + Не вдалося підключитися до мережі для перевірки статусу {pro}. Ви не можете продовжити, доки з\'єднання не буде відновлено.\n\nПеревірте з\'єднання з мережею та повторіть спробу. + Не вдалося під\'єднатися до мережі для перевірки вашого статусу {pro}. Ви не можете перейти на {pro}, поки з\'єднання не буде відновлено.\n\nБудь ласка, перевірте з\'єднання з мережею та спробуйте ще раз. + Не вдалося під\'єднатися до мережі для оновлення вашого статусу {pro}. Деякі дії на цій сторінці буде вимкнено, доки не буде відновлено з\'єднання.\n\nБудь ласка, перевірте з\'єднання з мережею та спробуйте ще раз. + Не вдалося з\'єднатися з мережею для завантаження вашого поточного доступу {pro}. Поновлення {pro} через {app_name} буде вимкнено, доки з\'єднання не буде відновлено.\n\nПеревірте підключення до мережі та спробуйте ще раз. Потрібна допомога з {pro}? Надішліть запит до служби підтримки. + Здійснюючи {action_type}, ти здійснюєш {activation_type} {app_pro} через протокол {app_name}. {entity} забезпечує активацію, але не є постачальником {app_pro}. {entity} не несе відповідальності за продуктивність, доступність чи функціональність {app_pro}. Цією дією ти надаси згоду щодо дотримання Правил послуги {app_pro} {icon} і Ставлення до особистих відомостей {icon} Необмежена кількість закріплених бесід Закріплення необмеженої кількості співрозмовників в головному переліку. + оновлення + Оновіться до {app_pro} Beta, щоб отримати доступ до великої кількості ексклюзивних переваг і функцій. + Оновіть до {pro} через налаштування {app_pro} на прив’язаному пристрої, на якому встановлено {app_name} через {platform_store} або {platform_store_other}. + На даний момент доступ до {pro} можна придбати лише через {platform_store} або {platform_store_other}. Оскільки ви встановили {app_name} за допомогою {build_variant}, оновлення до {pro} тут недоступне.\n\nРозробники {app_name} активно працюють над альтернативними способами оплати, щоб дати змогу користувачам придбати доступ до {pro} поза межами {platform_store} та {platform_store_other}. Дорожня карта {pro} {icon} Наразі єдиний шлях підвищити рівень: + Наразі є два способи оновлення доступу. + Ви оновилися до {app_pro}!\nДякуємо за підтримку мережі {network_name}. + покращення + Оновлення до {pro} + Оновлюючись, ви погоджуєтесь з Умовами надання послуг {icon} та Політикою конфіденційності {icon} {app_pro} + Хочете отримати більше від {app_name}?\nОновіться до {app_pro} Beta, щоб мати потужніший досвід обміну повідомленнями. {platform} опрацьовує ваш запит на відшкодування Профіль Аватар @@ -1022,6 +1212,13 @@ Будь ласка, виберіть файл меншого розміру. Не вдалося оновити профіль. Підвищити + Адміністратори зможуть бачити історію повідомлень за останні 14 днів і не можуть бути понижені або видалені з групи. + + Підвищити учасника + Підвищити учасників + Підвищити учасників + Підвищити учасників + Підвищення прав не відбулось Підвищення прав не відбулось @@ -1074,6 +1271,8 @@ Це ваш Recovery password. Якщо ви надішлете його комусь, він матиме повний доступ до вашого облікового запису. Оновити групу Вперед + Оскільки ви спочатку зареєструвалися на {app_pro} через інший обліковий запис {platform_account}, вам потрібно використовувати саме цей {platform_account}, щоб оновити доступ до {pro}. + Два способи подати запит на повернення коштів: Скоротіть довжину повідомлення на {count} %1$d символ залишився @@ -1083,18 +1282,65 @@ Нагадати пізніше Видалити + + Видалити учасника + Видалити учасників + Видалити учасників + Видалити учасників + + + Видалити учасника та його повідомлення + Видалити учасників та їхні повідомлення + Видалити учасників та їхні повідомлення + Видалити учасників та їхні повідомлення + Не вдалося видалити пароль Видаліть свій поточний пароль для {app_name}. Локально збережені дані буде повторно зашифровано випадково згенерованим ключем, який зберігатиметься на вашому пристрої. + + Видалення учасника + Видалення учасників + Видалення учасників + Видалення учасників + Поновити + Оновлення {pro} Відповісти Запит на повернення коштів + Подайте запит на повернення коштів на вебсайті {platform}, використовуючи обліковий запис {platform_account}, з яким ви оформили {pro}. Надіслати повторно + + Надіслати запрошення повторно + Надіслати запрошення повторно + Надіслати запрошення повторно + Надіслати запрошення повторно + + + Надіслати повторно підвищення + Надіслати повторно підвищення + Надіслати повторно підвищення + Надіслати повторно підвищення + + + Повторне надсилання запрошення + Повторне надсилання запрошень + Повторне надсилання запрошень + Повторне надсилання запрошень + + + Повторна відправка прав адміністратора + Повторна відправка прав адміністратора + Повторна відправка прав адміністратора + Повторна відправка прав адміністратора + Завантаження інформації про країни... Перезапустити Синхронізувати повторно Спробувати знову Ліміт на відгуки Здається, ви нещодавно вже залишали відгук про {app_name}. Дякуємо за ваш зворотний зв\'язок! + Запускати застосунок у фоні + Запустити {app_name} у фоновому режимі? + Оскільки ви використовуєте повільний режим, ми рекомендуємо дозволити {app_name} працювати у фоновому режимі для покращення сповіщень. Це може підвищити стабільність отримання сповіщень, хоча ваша система все одно може автоматично обмежувати фонову активність.\n\nЦе можна змінити пізніше в налаштуваннях. Зберегти Збережено Збережені повідомлення @@ -1127,6 +1373,12 @@ Надсилання Надіслання запиту Надіслання можливих з\'єднань + + Надсилання прав адміністратора + Надсилання прав адміністратора + Надсилання прав адміністратора + Надсилання прав адміністратора + Надіслано: Зовнішній вигляд Очистити дані @@ -1162,7 +1414,10 @@ Поширте друзям у застосунках, де ви зазвичай спілкуєтеся, а потім перенесіть розмову сюди. Виникла проблема при відкритті бази даних. Будь ласка, перезапустіть застосунок і повторіть спробу. Овва! Здається, у вас ще немає облікового запису у {app_name}.\n\nСпочатку необхідно створити обліковий запис у {app_name}, а потім зможете поділитись. + Бажаєте поділитися історією повідомлень групи з цим користувачем? Поділитись з {app_name} + Вибачте, {app_name} підтримує лише спільний доступ до кількох зображень і відео одночасно + Спільний доступ підтримує лише медіафайли. Немедійні файли було виключено Перегляд Показати все Показати менше @@ -1192,6 +1447,8 @@ Невідомо Непідтримуваний ЦП Оновити + Оновити доступ до {pro} + Два способи оновити ваш доступ до {pro}: Оновлення застосунку Оновити інформацію про спільноту Назву та опис спільноти бачать усі учасники @@ -1224,6 +1481,7 @@ Ви впевнені, що хочете відкрити цю URL-адресу у своєму браузері?\n\n{url} За ланкою перейде твоє оглядало мережців за промовчання. Використовувати швидкий режим + Змініть тариф, використовуючи обліковий запис {platform_account}, з яким ви зареєструвалися, через вебсайт {platform}. Через вебсайт {platform} Відео Не вдається відтворити відео. diff --git a/app/src/main/res/values-b+zh+CN/strings.xml b/app/src/main/res/values-b+zh+CN/strings.xml index 86bef95069..d30c0dc940 100644 --- a/app/src/main/res/values-b+zh+CN/strings.xml +++ b/app/src/main/res/values-b+zh+CN/strings.xml @@ -15,7 +15,12 @@ 这是您的账户ID。其他用户可以扫描它来与您开始会话。 实际尺寸 添加 + + 添加管理者 + + 添加管理员 请输入您正在授权为管理员的用户的帐户 ID。\n\n要添加多个用户,请输入用逗号分隔的每个帐户 ID。一次最多可以指定20个帐户 ID。 + 无法将管理员降级或从群组中移除。 管理员无法被移除。 {name}和其他{count}名成员被设置为管理员。 授权为管理员 @@ -40,11 +45,17 @@ {name}被移除了管理员身份。 {name}和其他{count}人的管理身份被移除。 {name}{other_name}的管理身份被移除。 + + %1$d 管理者已选中 + 发送管理员推广信息 管理员设置 + 您无法更改自己的管理员状态。如需退出群组,请打开会话设置并选择“退出群组”。 {name}{other_name}被设置为管理员。 + 管理员 + 允许 +{count} 匿名用戶 应用图标 @@ -64,6 +75,7 @@ 笔记 股票 天气 + {app_pro} 徽章 自动深色模式 隐藏菜单栏 语言 @@ -160,6 +172,7 @@ 您确定要取消屏蔽{name}和其他1人吗? 取消屏蔽{name} 查看和管理已屏蔽的联系人。 + 找不到可打开该网址的浏览器,请尝试复制该网址 语音通话 {name}呼叫过您 您无法开始新的通话。请先结束当前的通话。 @@ -184,9 +197,14 @@ 语音通话 (测试版) 语音和视频通话 语音和视频通话(测试版) + 在使用测试版通话时,您的 IP 会显示给通话对象和 {session_foundation} 服务器。 允许来自其它用户的语音和视频通话。 您呼叫了{name} 您未在隐私设置中启用语音和视频通话,因此错过了来自{name}的通话。 + {app_name} 需要访问您的相机以启用视频通话,但该权限已被拒绝。通话期间无法更改相机权限。\n\n您是否希望现在结束通话并启用相机访问,或在通话后再提醒您? + 如需允许访问相机,请打开设置并启用相机权限。 + 上一次通话时,您尝试使用视频功能,但由于之前拒绝了相机访问权限,未能成功。要启用相机访问,请打开设置并启用相机权限。 + 需要相机访问权限 找不到摄像头 摄像头不可用。 授予摄像头访问权限 @@ -194,8 +212,19 @@ {app_name}需要相机权限来拍摄照片和视频,或扫描二维码。 {app_name}需要相机访问权限才能扫描二维码 取消 + 取消 {pro} + 请使用您注册 {pro} 所用的 {platform_account},前往 {platform} 网站进行取消。 + 请使用您注册 {pro} 所用的 {platform_account},前往 {platform_store} 网站进行取消。 更改 更改密码失败 + 更改 {app_name} 的密码。本地存储的数据将使用您的新密码重新加密。 + 更改设置 + 正在检查 {pro} 状态 + 正在检查您的 {pro} 状态。完成检查后即可继续。 + 正在检查您的 {pro} 详情。此检查完成之前,本页面上的某些操作可能无法使用。 + 正在检查 {pro} 状态... + 正在检查您的 {pro} 详情。完成此检查之前,无法进行续订。 + 正在检查您的 {pro} 状态。完成检查后,您将可以升级为 {pro}。 清除 清除所有 清除所有数据 @@ -253,11 +282,16 @@ 社群链接 复制社群链接 确认 + 确认提升 + 您确定吗?管理员无法被降级或移除。 联系人 删除联系人 您确定要删除联系人{name}吗?来自{name}的新消息将被视为消息请求。 您还没有任何联系人 选择联系人 + + %1$d 已选择联系人 + 用户详情 相机 选择一个方式开始会话 @@ -265,6 +299,7 @@ 消息编辑框 引用消息图片的缩略图 与新联系人开始会话 + 选择在接收传入消息时应在本地通知中显示的内容。 添加到主屏幕 已添加到主屏幕 语音消息 @@ -277,11 +312,16 @@ 会话已删除 {conversation_name}中没有消息 回车键 + 定义在对话中 Enter 和 Shift+Enter 键的功能。 + SHIFT+回车键发送消息,回车键换行。 + 按回车键发送消息,SHIFT+回车键换行。 群组 消息整理 清理群组旧消息 + 在包含 2000 条以上消息的群组中自动删除 6 个月前的消息。 新建会话 您还没有任何会话 + 使用回车键发送 按回车键发送消息而非换行 使用 Shift + Enter 键发送 所有媒体 @@ -292,6 +332,7 @@ 复制 创建 正在创建通话 + 当前账单 当前密码 剪切 深色模式 @@ -319,6 +360,12 @@ 正在创建群组,请稍候... 更新群组失败 您无权删除他人的消息 + + 删除选定的附件 + + + 您确定要删除选定的附件吗?与附件关联的消息也将被删除。 + 您确定要删除联系人{name}吗?\n\n该操作将删除你们的会话,包括所有消息和附件。来自{name}的新消息将被视为消息请求。 您确定要删除您与{name}的会话吗?\n该操作将永久删除所有消息和附件。 @@ -351,6 +398,7 @@ 您确定要为所有人删除这些消息吗? 正在删除 开发者工具 + 设备通知设置 开始语音输入... 阅后即焚消息 消息将在{time_large}后自动焚毁 @@ -398,6 +446,8 @@ 您的显示名称对与您互动的用户、群组和社区可见。 文档 捐赠 + 有强大的势力正试图削弱隐私保护,但我们无法独自应对这场战斗。\n\n捐赠有助于保持 {app_name} 的安全性、独立性和在线状态。 + {app_name} 需要您的帮助 完成 下载 下载中… @@ -426,16 +476,31 @@ 您使用{emoji_name}回应了{name} 对您的消息作出反应{emoji} 启用 + 启用摄像头访问? + 收到新消息时显示通知。 + 结束通话以启用 喜欢使用 {app_name} 吗? 需要改进 {emoji} 很棒 {emoji} 您已使用 {app_name} 一段时间了,感觉如何?非常希望能听到您的反馈。 进入 + 请输入您为 {app_name} 设置的密码 + 请输入用于启动时解锁 {app_name} 的密码,不是您的恢复密码 + 检查 {pro} 状态时出错 请检查您的网络连接并重试。 复制错误并退出 数据库错误 出现问题。请稍后再试。 + 加载 {pro} 访问时出错 + {app_name} 无法搜索该 ONS。请检查您的网络连接并重试。 发生了未知错误。 + 该 ONS 尚未注册。请确认其是否正确然后重试。 + 重新发送给 {name}{group_name} 的邀请失败 + 重新发送给 {name} 和另外 {count} 人{group_name} 的邀请失败 + 重新发送给 {name}{other_name}{group_name} 的邀请失败 + 无法将管理员邀请重新发送给 {name}(群组 {group_name} + 无法将管理员邀请重新发送给 {name} 和另外 {count} 人(群组 {group_name} + 无法将管理员邀请重新发送给 {name}{other_name}(群组 {group_name} 下载失败 失败 反馈 @@ -488,6 +553,9 @@ 您确定要退出{group_name}吗? 您确定要离开{group_name}吗?\n\n该操作将移除所有成员并删除所有群组内容。 离开{group_name}失败 + {name} 已被邀请加入群组。已共享过去 14 天的聊天记录。 + {name} 和另外 {count} 人被邀请加入群组。最近14天的聊天记录已共享。 + {name}{other_name} 已被邀请加入群组。已共享过去 14 天的聊天记录。 {name}离开了群组。 {name}和其他{count}名成员离开了群组。 {name}{other_name}离开了群组。 @@ -499,6 +567,9 @@ {name}{other_name}被邀请加入了群组。 和其他{count}人被邀请加入群组。聊天记录已共享。 {other_name}被邀请加入了群组。 聊天记录已共享。 + 无法将 {name}{group_name} 中移除 + 无法将 {name} 和其他 {count} 人从 {group_name} 中移除 + 无法将 {name}{other_name}{group_name} 中移除 离开了群组。 群成员 此群组没有其他成员。 @@ -512,6 +583,7 @@ 您没有来自{group_name}的消息。发送一条消息开始会话! 此群组已超过 30 天未更新。您可能在发送消息或查看群组信息时遇到问题。 您是{group_name}中唯一的管理员。\n\n没有管理员,群组成员和设置将无法被更改。 + 您是 {group_name} 中唯一的管理员。\n\n在没有管理员的情况下无法更改群组成员和设置。如需在不删除群组的情况下退出,请先添加新管理员。 待移除 被设置为管理员。 和其他{count}人被授权为管理员。 @@ -537,11 +609,14 @@ 群组已更新 处理连接候选人 常见问题 + 查看 {app_name} 的常见问题解答以获取常见问题的答案。 帮助我们本地化{app_name} + Bug 反馈 分享一些细节以帮助我们解决您的问题。导出您的日志,然后通过{app_name}的帮助台上传文件。 导出日志 导出您的日志,然后通过{app_name}的帮助服务台上传日志。 保存到桌面 + 保存此文件,然后与 {app_name} 开发者分享。 支持 帮助将 {app_name} 本地化翻译成超过 80 种语言! 感谢您的反馈 @@ -551,15 +626,30 @@ 隐藏其它 图片 图片 + 重要 无痕键盘 启用隐身模式(如果可用)。您正在使用的键盘可能会忽略此请求。 信息 无效的快捷方式 + + 邀请联系人 + + + Invites Failed + 无法发送邀请。您想重试吗? + + 邀请成员 + + 通过输入朋友的账户 ID、ONS 或扫描其二维码邀请新成员加入群组 {icon} + 通过输入您朋友的账户ID、ONS或扫描其二维码,将新成员邀请加入群组 加入 稍后 + 当您的计算机启动时,自动启动 {app_name}。 + 开机时启动 + 此设置由您的 Linux 系统管理。要启用自动启动,请在系统设置中将 {app_name} 添加到启动应用程序中。 了解更多 离开 退出中... @@ -574,6 +664,8 @@ {other_name}加入了群组。 {name}{other_name}加入了群组。 加入了群组。 + 限制后台活动? + 您当前已允许 {app_name} 在后台运行,以提高通知的可靠性。更改此设置可能会导致通知不可靠。 链接预览 为支持的链接生成预览。 是否启用链接预览 @@ -598,9 +690,15 @@ 点击解锁 {app_name}已解锁 日志 + 管理管理员 管理成员 + 管理 {pro} 最大 + 稍后再说 媒体 + + %1$d 成员已选中 + %1$d位成员 @@ -608,7 +706,9 @@ %1$d名活跃成员 添加账户ID或ONS + 成员接受加入群组的邀请后才可被提升为管理员。 邀请联系人 + 您没有可以邀请加入此群组的联系人。\n请返回并通过账户 ID 或 ONS 邀请成员。 发送邀请 @@ -616,8 +716,11 @@ 您希望与{name}和其他{count}人共享群组消息历史吗? 您希望与{name}{other_name}共享群组消息历史吗? 共享消息历史 + 共享过去 14 天的消息记录 仅共享新消息 邀请 + 成员(非管理员) + 菜单栏 消息 了解更多 复制消息 @@ -634,6 +737,7 @@ 通过输入朋友的账户ID或ONS来开始新的对话。 通过输入朋友的账户ID、ONS或扫描他们的二维码来开始新的对话。 + 通过输入朋友的账户 ID、ONS 或扫描他们的二维码开始一段新的对话 {icon} 您有%1$d条新消息。 @@ -681,20 +785,27 @@ 消息太长 新密码 下一步 + 后续步骤 {name}选择一个昵称。该昵称将在您的一对一和群组对话中显示。 输入昵称 请输入较短的昵称 移除昵称 设置昵称 + 该群组中没有非管理员成员。 没有拼写建议 + 在所有对话中最多可发送 10,000 个字符的消息。 + 通过无限量置顶对话来整理聊天内容。 下次再说 备忘录 您在备忘录中没有消息。 隐藏备忘录 您确定要隐藏备忘录吗? + 请注意:通过 {action_type},您同意 {app_pro} 的服务条款 {icon} 以及隐私政策 {icon} 通知显示 + 显示发送者名称和消息内容预览。 + 仅显示发送者名称,不显示任何消息内容。 所有信息 通知内容 在通知中显示的信息。 @@ -705,6 +816,7 @@ 您将会收到由Google的通知服务器发出的即时可靠的新消息通知。 您将会收到由华为的通知服务器发出的即时可靠的新消息通知。 您将会收到由Apple的通知服务器发出的即时可靠的新消息通知。 + 显示一条通用的 {app_name} 通知,不包含发送者名称或消息内容。 跳转到设备通知设置 通知 - 所有 通知 - 仅提及我的消息 @@ -712,6 +824,7 @@ {name}向{conversation_name}发送了消息 您可能在{device}重启时收到了新消息。 LED颜色 + 收到新消息时播放提示音。 仅当被提及时 消息通知 最近来自{name}的消息 @@ -733,6 +846,12 @@ 确定 开启 + 在您的 {device_type} 设备上 + 请在登录了您最初注册所用的 {platform_account} 的 {device_type} 设备上打开此 {app_name} 账户。然后通过 {app_pro} 设置取消 {pro}。 + 在登录了您最初注册使用的 {platform_account} 的 {device_type} 设备上打开此 {app_name} 账户。然后通过 {app_pro} 设置更新您的 {pro} 访问权限。 + 在已关联的设备上 + 在 {platform_store} 网站上 + 在 {platform} 网站上 创建账号 账户已创建 我已有账号 @@ -757,11 +876,15 @@ 我们无法识别此ONS。请检查并重试。 我们无法查询到此ONS。请稍后再试。 打开 + 打开 {platform_store} 网站 + 打开 {platform} 网站 + 打开设置 打开调查问卷 其它 密码 更改密码 更改 {app_name} 的解锁密码。 + 您的密码已经更改。请妥善保管。 确认密码 创建密码 您当前的密码不正确。 @@ -775,13 +898,22 @@ 密码不正确 确认新密码 移除密码 + 删除用于解锁 {app_name} 的密码 您的密码已被移除。 设置密码 + 您的密码已设定。请妥善保管。 + 启动时需要密码才能解锁 {app_name}。 长于12个字符 + 包含一个数字 + 包含一个小写字母 + 包含符号 + 包含一个大写字母 密码强度指标 设置强密码有助于在设备丢失或被盗时保护您的消息和附件。 密码 粘贴 + 付款错误 + 您的付款已成功处理,但在 {action_type} 您的 {pro} 状态时发生错误。\n\n请检查您的网络连接并重试。 授权变更 {app_name}需要音乐和音频权限才能发送文件、音乐和音频,但该权限已被永久拒绝。请进入应用程序设置→权限,打开“音乐和音频”权限。 {app_name}需要使用Apple Music来播放媒体附件。 @@ -820,27 +952,165 @@ 置顶会话 取消置顶 取消置顶会话 + 还有更多功能…… + {pro} 即将推出新功能。点击 {pro} 功能路线图 {icon} 了解接下来将推出的内容 偏好设置 通知效果预览 预览通知 + 你的 {pro} 访问权限已激活!\n\n你的 {pro} 访问将在 {date} 自动续订 {current_plan_length} + 你的 {pro} 访问权限已激活!\n\n你的 {pro} 访问将在 {date} 自动续订 \n{current_plan_length}。你在此所做的更改将在下次续订时生效。 + {pro} 访问错误 + 您的 {pro} 访问权限将于 {date} 到期。 + {pro} 访问权限加载中 + 您的 {pro} 访问信息仍在加载。加载完成前无法进行更新。 + {pro} 访问加载中... + 无法连接网络以加载您的 {pro} 访问信息。在连接恢复之前,将无法通过 {app_name} 更新 {pro}。\n\n请检查您的网络连接并重试。 + 未找到 {pro} 访问权限 + {app_name} 检测到您的账户没有 {pro} 访问权限。如果您认为这是一个错误,请联系 {app_name} 客服寻求帮助。 + 恢复 {pro} 访问权限 + 续订 {pro} 访问权限 + 目前,{pro} 访问权限只能通过 {platform_store} 或 {platform_store_other} 购买和续订。由于您使用的是 {app_name} 桌面版,无法在此续订。\n\n{app_name} 开发者正在积极开发支付替代选项,以便用户可以在 {platform_store} 和 {platform_store_other} 以外购买 {pro} 访问权限。{pro} 路线图 {icon} + 请使用您注册 {pro} 时使用的 {platform_account},在 {platform_store} 网站 上续订您的 {pro} 访问权限。 + 请使用您注册 {pro} 所用的 {platform_account},前往 {platform} 网站 进行续订。 + 续订您的 {pro} 访问权限以重新使用强大的 {app_pro} Beta 功能。 + 已恢复 {pro} 访问权限 + {app_name} 检测并已为您的账户恢复 {pro} 访问权限。您的 {pro} 状态已恢复! + 由于您最初是通过 {platform_store} 注册 {app_pro} 的,因此需要使用您的 {platform_account} 来更新 {pro} 访问权限。 + 目前,只能通过 {platform_store} 或 {platform_store_other} 购买 {pro} 访问权限。由于您正在使用 {app_name} 桌面版,您无法在此处升级至 {pro}。\n\n{app_name} 的开发者正在努力提供替代的付款方式,以便用户能够在 {platform_store} 和 {platform_store_other} 之外购买 {pro} 访问权限。 {pro} 开发规划 {icon} 已激活 + 正在激活 + 一切就绪! + 您的 {app_pro} 访问权限已更新!当 {pro} 于 {date} 自动续订时,您将被扣费。 您已拥有 快去为头像上传 GIF 或动画 WebP 图片吧! + 获取动画头像并使用 {app_pro} Beta 解锁高级功能 动画头像 用户可上传 GIF + 动画头像 + 可设置动画 GIF 和 WebP 图像作为你的头像。 使用 PRO 上传 GIF + {pro} 将于 {time} 自动续订 + {pro} 徽章 + 向其他用户显示 {app_pro} 徽章 + 徽章 + 通过你的显示名称旁边的专属徽章,展示你对 {app_name} 的支持。 + + %1$s %2$s 已发送徽章 + + {pro} Beta 功能 + {price} 每年计费 + {price} 每月计费 + {price} 每季度计费 + 想发送更长的消息?\n使用 {app_pro} Beta 发送更多文本并解锁高级功能 + 想要固定更多对话?\n使用 {app_pro} Beta 整理您的对话并解锁高级功能 + 想要固定超过 {limit} 个对话?\n使用 {app_pro} Beta 整理您的对话并解锁高级功能 + 很遗憾看到您取消 {pro}。在取消您的 {pro} 访问之前,请了解以下信息。 + 取消 + 取消 {pro} 访问的两种方式: + 选择适合您的 {pro} 访问选项。\n访问时间越长,折扣越大。 + 您确定要从此设备中删除您的数据吗?\n\n{app_pro} 无法转移至其他账户。请务必保存您的恢复密码,以确保您之后可以恢复对 {pro} 的访问权限。 + 您确定要从网络中删除您的数据吗?继续操作将无法恢复您的消息或联系人。\n\n{app_pro} 无法转移至其他账户。请务必保存您的恢复密码,以确保您之后可以恢复对 {pro} 的访问权限。 + 您的 {pro} 访问权限已经享受 {percent}% 的 {app_pro} 原价优惠。 + 刷新 {pro} 状态时出错 + 已过期 + 您的 {pro} 访问权限已过期。\n续订以重新启用 {app_pro} Beta 的独家福利和功能。 + 即将到期 + 您的 {pro} 访问权限将在 {time} 后到期。\n现在就更新,以继续使用 {app_pro} Beta 的专属福利和功能 + {pro} 将在 {time} 后到期 + {pro} 常见问题 + 在 {app_pro} 常见问题中查找常见问题的答案。 上传 GIF 和 WebP 头像 更大的群组聊天,最多可容纳 300 名成员 还有更多专属功能等你解锁 消息长度上限为 10,000 个字符 无限固定对话 + 想要发挥 {app_name} 的全部潜力吗?\n升级至 {app_pro} Beta,即可解锁大量专属福利与功能。 群组已激活 该群组已扩容!因管理员升级为 PRO,现支持最多 300 名成员 + + %1$s 群组升级 + + 申请退款后将无法撤销。一旦通过,您的 {pro} 访问权限将立即取消,您将无法再使用所有 {pro} 功能。 附件大小已增加 消息长度已增加 + 更大的群组 您作为管理员的群组自动升级成支持300名成员。 + 更大的群聊(最多 300 名成员)即将面向所有 Pro Beta 用户推出! + 更长消息 + 你可以在所有会话中发送最多 10,000 个字符的消息。 + + %1$s 发送更长的消息 + 此消息使用了以下 {app_pro} 功能: + 通过全新安装 + 通过 {platform_store} 在此设备上重新安装 {app_name},使用恢复密码恢复您的账户,并在 {app_pro} 设置中续订 {pro}。 + 请通过 {platform_store} 在此设备上重新安装 {app_name},使用“恢复密码”恢复您的账户,然后在 {app_pro} 设置中升级至 {pro}。 + 目前,有三种续订方式: + 目前有两种方式可以续订: + {percent}% 折扣 + + %1$s 个置顶聊天 + + 由于您最初是通过 {platform_store} 注册 {app_pro} 的,因此需要使用您的 {platform_account} 来申请退款。 + 由于您最初是通过 {platform_store} 注册 {app_pro} 的,因此您的退款请求将由 {app_name} 支持团队处理。\n\n点击下方按钮并填写退款申请表即可申请退款。\n\n{app_name} 支持团队会尽力在 24-72 小时内处理退款请求,但在请求量较大时可能需要更长时间。 + 您的 {app_pro} 访问权限已续订!感谢您支持 {network_name}。 + 1 个月 - 每月 {monthly_price} + 3 个月 - 每月 {monthly_price} + 12 个月 - 每月 {monthly_price} + 重新激活 + 请在登录了您最初注册所用的 {platform_account} 的 {device_type} 设备上打开此 {app_name} 账户。然后通过 {app_pro} 设置申请退款。 + 很遗憾看到您离开。在申请退款前,请先了解以下信息。 + {platform} 正在处理您的退款请求。通常需要 24 到 48 小时。根据其决定,您在 {app_name} 中的 {pro} 状态可能会发生变化。 + 您的退款请求将由 {app_name} 支持团队处理。\n\n点击下面的按钮并填写退款请求表格以申请退款。\n\n{app_name} 支持团队会尽力在 24-72 小时内处理您的请求,但在请求量大的时期,处理时间可能会延长。 + 您的退款请求将由 {platform} 独家通过 {platform} 网站 处理。\n\n根据 {platform} 的退款政策,{app_name} 开发者无法影响退款请求的结果。这包括请求是被批准还是被拒绝,以及是否发放全额或部分退款。 + 如需获取退款请求的进一步更新,请联系 {platform}。由于 {platform} 的退款政策,{app_name} 开发者无法影响退款请求的结果。\n\n{platform} 退款支持 + 正在退款 {pro} + {app_pro} 的退款处理仅由 {platform} 通过 {platform_store} 进行。\n\n由于 {platform} 的退款政策,{app_name} 开发者无法影响退款请求的处理结果。这包括请求是否被批准或拒绝,以及是否发放全额或部分退款。 + 想再次使用动态头像吗?\n请续费您的 {pro} 访问权限以解锁您错过的功能。 + 续订 {pro} Beta + 请通过在已安装了 {app_name},并与之关联的设备上的 {app_pro} 设置中续订您的 {pro} 访问权限,安装方式需为通过 {platform_store} 或 {platform_store_other}。 + 想再次发送更长的消息吗?\n请续费您的 {pro} 访问权限以解锁您错过的功能。 + 想再次充分发挥 {app_name} 的使用潜力吗?\n请续费您的 {pro} 访问权限以解锁您错过的功能。 + 想再次固定超过 {limit} 个对话吗?\n请续费您的 {pro} 访问权限以解锁您错过的功能。 + 想再次固定更多对话吗?\n请续费您的 {pro} 访问权限以解锁您错过的功能。 + 通过续费,即表示您同意 {app_pro} 的服务条款{icon} 和 隐私政策{icon} + 正在续订 + 目前,{pro} 访问只能通过 {platform_store} 或 {platform_store_other} 进行购买和续订。由于您是使用 {build_variant} 安装的 {app_name},因此无法在此进行续订。\n\n{app_name} 的开发者正在积极开发其他付款选项,以便用户可在不使用 {platform_store} 和 {platform_store_other} 的情况下购买 {pro} 访问。 {pro} 路线图 {icon} + 已申请退款 发送更多内容,体验 + {pro} 设置 + 开始使用 {pro} + 你的 {pro} 统计数据 + {pro} 统计数据加载中 + 您的 {pro} 统计数据正在加载,请稍候。 + 你的 {pro} 统计数据反映的是本设备的使用情况,在其他关联设备上可能会有所不同 + {pro} 状态错误 + 无法连接网络以检查您的 {pro} 状态。在连接恢复之前,页面显示的信息可能不准确。\n\n请检查您的网络连接并重试。 + {pro} 状态加载中 + 您的 {pro} 信息正在加载。在加载完成之前,本页面上的某些操作可能无法执行。 + 正在加载 {pro} 状态 + 无法连接到网络以检查您的 {pro} 状态。在恢复连接之前,您无法继续。\n\n请检查您的网络连接并重试。 + 无法连接网络以检查您的 {pro} 状态。在恢复连接之前,您无法升级到 {pro}。\n\n请检查您的网络连接并重试。 + 无法连接网络以刷新您的 {pro} 状态。在连接恢复之前,此页面上的某些操作将被禁用。\n\n请检查您的网络连接并重试。 + 无法连接网络以加载您当前的 {pro} 访问权限。在连接恢复之前,无法通过 {app_name} 续订 {pro}。\n\n请检查您的网络连接并重试。 + 需要 {pro} 的帮助?提交请求联系支持团队。 + 通过 {action_type},您正在通过 {app_name} 协议 {activation_type} {app_pro}。{entity} 将协助该激活过程,但并非 {app_pro} 的提供方。{entity} 不对 {app_pro} 的性能、可用性或功能负责。 + 通过更新,即表示您同意 {app_pro} 的 服务条款 {icon} 和 隐私政策 {icon} + 无限置顶消息 + 使用无限置顶消息,整理你的所有聊天。 + 您当前的账单选项包括 {current_plan_length} 的 {pro} 访问权限。您确定要切换到 {selected_plan_length_singular} 的账单选项吗?\n\n更新后,您的 {pro} 访问权限将在 {date} 自动续订,新增 {selected_plan_length} 的 {pro} 访问时长。 + 正在更新 + 升级至 {app_pro} Beta,获取大量专属福利和功能。 + 请在通过 {platform_store} 或 {platform_store_other} 安装了 {app_name} 的关联设备上的 {app_pro} 设置中升级至 {pro}。 + 目前,只能通过 {platform_store} 或 {platform_store_other} 购买 {pro} 访问权限。由于您是通过 {build_variant} 安装的 {app_name},因此无法在此处升级至 {pro}。\n\n{app_name} 的开发者正在努力提供替代的付款方式,以便用户能够在 {platform_store} 和 {platform_store_other} 之外购买 {pro} 访问权限。{pro} 开发规划 {icon} + 目前只有一种升级方式: + 目前有两种升级方式: + 您已升级至 {app_pro}!\n感谢您支持 {network_name}。 + 正在升级 + 正在升级至 {pro} + 通过升级,即表示您同意 {app_pro} 的服务条款 {icon} 和隐私政策 {icon} + 想充分体验 {app_name}?\n升级到 {app_pro} Beta,享受更强大的消息体验。 + {platform} 正在处理您的退款请求 个人资料 头像 移除头像失败。 @@ -848,6 +1118,10 @@ 请选择一个更小的文件。 更新资料失败。 授权 + 管理员将能够查看最近14天的消息记录,且无法被降级或从群组中移除。 + + 提升成员 + 授权失败 @@ -875,6 +1149,7 @@ 推荐选项 保存您的恢复密码以确保您不会失去对账户的访问权限。 保存您的恢复密码 + 使用您的恢复密码在新设备上加载您的帐户。\n\n没有您的恢复密码,无法恢复您的帐户。请确保将其存储在安全的地方,并且不要与任何人共享 输入您的恢复密码 尝试加载您的恢复密码时发生错误。\n\n请导出您的日志,然后通过{app_name}帮助服务台上传文件以帮助解决此问题。 请检查您的恢复密码并重试。 @@ -884,26 +1159,64 @@ 要加载您的账户,请输入您的恢复密码。 永久隐藏恢复密码 没有您的恢复密码,您将不能在新设备上加载您的账户。\n\n我们强烈建议您在继续之前将恢复密码保存在一个安全的地方。 + 您确定要在此设备上永久隐藏您的恢复密码吗?\n\n此操作无法撤销。 隐藏恢复密码 在此设备上永久隐藏您的恢复密码。 输入您的恢复密码以加载您的账户。如果您没有保存它,您可以在应用设置中找到该恢复密码。 + 查看恢复密码 + 恢复密码可见性 这是您的恢复密码。如果您发送给他人,他们将能够完全访问您的账户。 重新创建群组 重做 + 由于您最初是通过不同的 {platform_account} 注册 {app_pro},因此您需要使用该 {platform_account} 来更新您的 {pro} 访问权限。 + 申请退款的两种方式: 将消息长度减少 {count} 个字符 还剩 %1$d 个字符 + 稍后提醒我 移除 + + 移除成员 + + + 移除成员 + + + 移除成员及其消息 + 移除密码失败 + 删除 {app_name} 的当前密码。本地存储的数据将使用设备上存储的随机生成密钥重新加密。 + + 正在移除成员 + + 续订 + 正在续订 {pro} 回复 + 申请退款 + 请在 {platform} 官网 使用您注册 {pro} 时的 {platform_account} 申请退款。 重新发送 + + 重新发送邀请 + + + 重新发送管理员邀请 + + + 重新发送邀请 + + + 正在重新发送管理员邀请 + 正在读取国家列表... 重新启动 重新同步 重试 评价次数已达上限 您最近似乎已经评价过 {app_name},感谢您的反馈! + 在后台运行应用 + 在后台运行 {app_name}? + 您当前正在使用慢速模式,我们建议允许 {app_name} 在后台运行,以改善通知的可靠性。这可提高通知的一致性,但您的系统可能仍会自动限制后台活动。\n\n您可以稍后在设置中更改此项。 保存 已保存 已保存的消息 @@ -912,6 +1225,8 @@ 屏幕安全性 屏幕截图通知 当联系人在一对一聊天中截屏时通知您。 + 在此设备截图中隐藏 {app_name} 窗口。 + 屏幕截图保护 {name}进行了截图。 搜索 搜索联系人 @@ -931,6 +1246,9 @@ 发送中 正在发送通话邀请 发送连接候选人 + + 正在发送管理员邀请 + 发送: 外观 清除数据 @@ -951,23 +1269,31 @@ 通知设置 权限 隐私 + {app_pro} Beta 恢复密码 设置 设置 设置社群头像 + 为 {app_name} 设置密码。本地存储的数据将使用此密码加密。每次启动 {app_name} 时,您都需要输入该密码。 + 无法更新设置 您必须重新启动{app_name}以应用您的新设置。 屏幕安全性 + 启动 分享 通过与好友分享您的账号ID来邀请他们与您在{app_name}上聊天 随时随地和朋友分享,并将会话移到此处。 打开数据库时出现问题。请重新启动应用程序并重试。 哎呀!您似乎还没有{app_name}帐户。\n\n您需要先在{app_name}应用中创建一个帐户,然后才能分享。 + 您希望与该用户共享群组消息历史吗? 分享到{app_name} + 很抱歉,{app_name} 仅支持同时共享多张图片和视频 + 仅支持共享媒体文件。非媒体文件已被排除 显示 全部显示 收起 显示备忘录 你确定要在对话列表中显示 Note to Self吗? + 拼写检查 贴图 强度 遇到问题?浏览帮助文章或向 {app_name} 支持提交工单。 @@ -977,15 +1303,22 @@ 继续 默认 错误 + 返回 主题预览 基于您与 {name} 之前的互动,其 Account ID 对您可见 盲化 ID 在社区中用于减少垃圾信息并提高隐私性 + 翻译 + 托盘 重试 “正在输入”提示 接收并发送”正在输入“提示。 不可用 撤销 未知 + 不支持的 CPU + 更新 + 更新 {pro} 访问权限 + 有两种方法可以更新您的 {pro} 访问权限: 应用更新 更新社群信息 社群名称与描述对所有社群成员可见 @@ -1001,17 +1334,25 @@ 有新版本的{app_name}可用,点击更新 {app_name}有新版本({version})可用。 更新个人资料信息 + 您的显示名称和头像将在所有对话中可见。 跳转到版本信息 {app_name}更新 版本 {version} 最近更新于 {relative_time} 前 + 更新 + 正在更新... + 升级 + 升级 {app_name} 升级到 正在上传 复制链接 打开链接 该链接将在您的浏览器中打开。 您确定要在浏览器中打开此链接吗?\n\n{url} + 链接将在您的浏览器中打开。 使用快速模式 + 请使用您注册所用的 {platform_account},通过 {platform} 网站 更改套餐。 + 通过 {platform} 网站 视频 无法播放视频。 查看 @@ -1020,9 +1361,12 @@ 这可能需要几分钟的时间。 请稍候... 警告 + iOS 15 的支持已结束。请升级至 iOS 16 或更高版本以继续接收应用更新。 窗口 + 您的 CPU 不支持 SSE 4.2 指令,而 {app_name} 需要该指令才能在 Linux x64 操作系统中处理图像。请升级至兼容的 CPU 或使用其他操作系统。 + 您的恢复密码 缩放系数 调整文本和视觉元素的大小。 \ No newline at end of file diff --git a/app/src/main/res/values-b+zh+TW/strings.xml b/app/src/main/res/values-b+zh+TW/strings.xml index e9b6932cc6..deca8c8017 100644 --- a/app/src/main/res/values-b+zh+TW/strings.xml +++ b/app/src/main/res/values-b+zh+TW/strings.xml @@ -801,7 +801,7 @@ 您可以為您的顯示圖片上傳 GIF 或動畫 WebP 圖片了! 動畫顯示圖片 用戶可以上傳 GIF - 使用 {app_pro} 上傳 GIF 圖片 + 上傳 GIF 上傳 GIF 和 WebP 顯示圖片 最大支援 300 位成員的大型群組聊天室 以及更多獨家功能 diff --git a/app/src/main/res/values-sw400dp/dimens.xml b/app/src/main/res/values-sw400dp/dimens.xml index 7dc4f477fd..c6cfd2d5d7 100644 --- a/app/src/main/res/values-sw400dp/dimens.xml +++ b/app/src/main/res/values-sw400dp/dimens.xml @@ -3,8 +3,6 @@ 44dp - 48dp - 64dp 72dp diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index fd66584015..9e7712cd9d 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -61,6 +61,7 @@ + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 680ed067c3..924b88fd80 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,13 +1,11 @@ - #D8D8D8 #353535 #161616 #36383C #333132 #1B1B1B #141414 - #FFCE3A #0C0C0C #ff31F196 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 8fd8c34d2f..1f3f2a743e 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -14,28 +14,20 @@ 18sp - 60dp - 34dp 38dp 54dp 22dp 4dp 26dp - 36dp 46dp - 80dp 128dp 190dp 14dp 1dp - 36dp - 8dp 10dp - 64dp 12dp 11dp 8dp - 8dp 56dp 8dp 16dp @@ -53,15 +45,9 @@ 35dp 64dp 56dp - 40dp - 50dp - 220dp - 110dp - 170dp 48dp 16sp - 64dp 210dp 105dp @@ -73,42 +59,16 @@ 18dp 4dp - 2dp - 1.5dp - 24dp - 24dp 210dp 150dp 175dp 85dp - 5dp - 4dp - - 120dp - - 40dp - 4 10dp - 13sp - - - - - - - - 14dp - 24dp 16dp 56dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a4e8bb6015..19f87708fc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -20,6 +20,7 @@ Add Admin Add Admins + Add Admin Enter the Account ID of the user you are promoting to admin.\n\nTo add multiple users, enter each Account ID separated by a comma. Up to 20 Account IDs can be specified at a time. Admins cannot be demoted or removed from the group. Admins cannot be removed. @@ -216,7 +217,6 @@ {app_name} needs camera access to scan QR codes Cancel Cancel {pro} - Cancel {pro} Plan Cancel on the {platform} website, using the {platform_account} you signed up for {pro} with. Cancel on the {platform_store} website, using the {platform_account} you signed up for {pro} with. Change @@ -266,6 +266,7 @@ Commit Hash: {hash} This will ban the selected user from this Community and delete all their messages. Are you sure you want to continue? This will ban the selected user from this Community. Are you sure you want to continue? + Are you sure you want to ban {name} from this Community? Enter a community description Enter Community URL Invalid URL @@ -283,6 +284,7 @@ Failed to leave {community_name} Enter a community name Please enter a community name + Are you sure you want to unban {name} from this Community? Unknown Community Community URL Copy Community URL @@ -600,8 +602,9 @@ You have no messages from {group_name}. Send a message to start the conversation! This group has not been updated in over 30 days. You may experience issues sending messages or viewing group information. You are the only admin in {group_name}.\n\nGroup members and settings cannot be changed without an admin. - You are the only admin in {group_name}.\n\nGroup members and settings cannot be changed without an admin. To leave the group without deleting it, please add a new admin first + You are the only admin in {group_name}.\n\nGroup members and settings cannot be changed without an admin. To leave the group without deleting it, please add a new admin first. Pending removal + This group is read-only You were promoted to Admin. You and {count} others were promoted to Admin. You and {other_name} were promoted to Admin. @@ -1112,7 +1115,8 @@ Want to pin more than {limit} conversations again?\nRenew your {pro} access to unlock the features you’ve been missing out on. Want to pin more conversations again?\nRenew your {pro} access to unlock the features you’ve been missing out on. By renewing, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon} - Pro renewal unsuccessful, retrying soon + {pro} renewal unsuccessful, retrying soon + {pro} Renewal Unsuccessful renewing Currently, {pro} access can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you\'re not able to renew here.\n\n{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} Refund Requested @@ -1137,7 +1141,8 @@ By updating, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon} Unlimited Pins Organize all your chats with unlimited pinned conversations. - Your current billing option grants {current_plan_length} of {pro} access. Are you sure you want to switch to the {selected_plan_length_singular} billing option?\n\nBy updating, your {pro} access will automatically renew on {date} for an additional {selected_plan_length} of {pro} access. + Your {pro} access couldn’t be renewed due to a payment issue with your {platform_account}. Please check the status of your payment on the {platform_store}.\n\nYour {pro} access will remain active and you may continue to make billing changes while the {platform_store} retries your payment. + Your current billing option will grant an additional {current_plan_length} of {pro} access when your next renewal occurs. Are you sure you want to switch to the {selected_plan_length_singular} billing option?\n\nBy updating, your {pro} access will automatically renew on {date} for an additional {selected_plan_length} of {pro} access. Your {pro} access will expire on {date}.\n\nBy updating, your {pro} access will automatically renew on {date} for an additional {selected_plan_length} of {pro} access. updating Upgrade to {app_pro} Beta to get access to loads of exclusive perks and features. @@ -1182,6 +1187,8 @@ Rate {app_name}? Rate App We\'re glad you\'re enjoying {app_name}, if you have a moment, rating us in the {storevariant} helps others discover private, secure messaging! + {app_name} runs no ads, sells no data, and answers only to its users. Your {storevariant} rating helps more people find messaging that actually respects their privacy. + Rate Us Read Read Receipts Show read receipts for all messages you send and receive. @@ -1224,6 +1231,10 @@ Remove Member Remove Members + + Remove member + Remove members + Remove member and their messages Remove members and their messages @@ -1336,6 +1347,8 @@ Oops! Looks like you don\'t have a {app_name} account yet.\n\nYou\'ll need to create one in the {app_name} app before you can share. Would you like to share group message history with this user? Share to {app_name} + Sorry, {app_name} only supports sharing multiple images and videos at once + Sharing only supports media. Non-media files have been excluded Show Show All Show Less @@ -1401,7 +1414,7 @@ Use Fast Mode Change your plan using the {platform_account} you used to sign up with, via the {platform} website . Via the {platform} website - Update your {pro} access using the {platform_account} you used to sign up with, via the {platform_store} website. + Update your {pro} access using the {platform_account} you used to sign up with, via the {platform_store} website. Video Unable to play video. View diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index c73762f95f..b552d54691 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -28,7 +28,8 @@ ?android:textColorPrimary ?backgroundSecondary @drawable/ic_trash_2 - @drawable/ic_ban + @drawable/ic_user_round_x + @drawable/ic_user_round_tick @drawable/ic_arrow_down_to_line= @drawable/ic_copy @drawable/ic_reply diff --git a/app/src/main/res/xml/preferences_notifications.xml b/app/src/main/res/xml/preferences_notifications.xml index 076149f257..d2d5ec2486 100644 --- a/app/src/main/res/xml/preferences_notifications.xml +++ b/app/src/main/res/xml/preferences_notifications.xml @@ -1,6 +1,7 @@ - + @@ -10,8 +11,13 @@ android:summary="@string/notificationsFastModeDescription" android:defaultValue="false" /> + + + android:key="system_notifications" /> diff --git a/app/src/test/java/org/session/libsession/messaging/file_server/FileServerApiTest.kt b/app/src/test/java/org/session/libsession/messaging/file_server/FileServerApiTest.kt index ba148e2e26..924ffd554b 100644 --- a/app/src/test/java/org/session/libsession/messaging/file_server/FileServerApiTest.kt +++ b/app/src/test/java/org/session/libsession/messaging/file_server/FileServerApiTest.kt @@ -2,9 +2,9 @@ package org.session.libsession.messaging.file_server import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test -import org.mockito.kotlin.mock class FileServerApiTest { @@ -12,18 +12,16 @@ class FileServerApiTest { private data class Case( val name: String, val url: HttpUrl, - val successfulParseResult: FileServerApi.URLParseResult?, + val successfulParseResult: FileServerApis.URLParseResult?, ) @Test fun `can build and parse attachment url`() { - val api = FileServerApi(storage = mock()) - val testCases = listOf( Case( name = "With deterministic flag", url = "http://fileserver/file/id1#d&p=1234".toHttpUrl(), - successfulParseResult = FileServerApi.URLParseResult( + successfulParseResult = FileServerApis.URLParseResult( fileId = "id1", usesDeterministicEncryption = true, fileServer = FileServer( @@ -35,7 +33,7 @@ class FileServerApiTest { Case( name = "With deterministic flag variant1", url = "http://fileserver/file/id1#d=&p=1234".toHttpUrl(), - successfulParseResult = FileServerApi.URLParseResult( + successfulParseResult = FileServerApis.URLParseResult( fileId = "id1", usesDeterministicEncryption = true, fileServer = FileServer( @@ -47,7 +45,7 @@ class FileServerApiTest { Case( name = "Without deterministic flag", url = "http://fileserver/file/id1#p=1234".toHttpUrl(), - successfulParseResult = FileServerApi.URLParseResult( + successfulParseResult = FileServerApis.URLParseResult( fileId = "id1", usesDeterministicEncryption = false, fileServer = FileServer( @@ -58,22 +56,22 @@ class FileServerApiTest { ), Case( name = "Official server without public key", - url = "http://${FileServerApi.DEFAULT_FILE_SERVER.url.host}/file/id1".toHttpUrl(), - successfulParseResult = FileServerApi.URLParseResult( + url = "http://${FileServerApis.DEFAULT_FILE_SERVER.url.host}/file/id1".toHttpUrl(), + successfulParseResult = FileServerApis.URLParseResult( fileId = "id1", usesDeterministicEncryption = false, - fileServer = FileServerApi.DEFAULT_FILE_SERVER + fileServer = FileServerApis.DEFAULT_FILE_SERVER ), ), Case( name = "Alt official server without public key", url = "http://fileabc.getsession.org/file/id1".toHttpUrl(), - successfulParseResult = FileServerApi.URLParseResult( + successfulParseResult = FileServerApis.URLParseResult( fileId = "id1", usesDeterministicEncryption = false, fileServer = FileServer( "http://fileabc.getsession.org", - FileServerApi.DEFAULT_FILE_SERVER.ed25519PublicKeyHex + FileServerApis.DEFAULT_FILE_SERVER.ed25519PublicKeyHex ) ), ), @@ -93,13 +91,13 @@ class FileServerApiTest { for (case in testCases) { try { - val result = runCatching { api.parseAttachmentUrl(case.url) } + val result = runCatching { FileServerApis.parseAttachmentUrl(case.url) } if (case.successfulParseResult != null) { val actual = result.getOrThrow() assertEquals("Parse result differs!",case.successfulParseResult, actual) - val url = api.buildAttachmentUrl(actual.fileId, actual.fileServer, actual.usesDeterministicEncryption) - val reversed = api.parseAttachmentUrl(url) + val url = FileServerApis.buildAttachmentUrl(actual.fileId, actual.fileServer, actual.usesDeterministicEncryption) + val reversed = FileServerApis.parseAttachmentUrl(url) assertEquals("Build URL differs!", actual, reversed) } else { diff --git a/app/src/test/java/org/session/libsignal/utilities/SnodeTest.kt b/app/src/test/java/org/session/libsignal/utilities/SnodeTest.kt deleted file mode 100644 index 8a1aaa1301..0000000000 --- a/app/src/test/java/org/session/libsignal/utilities/SnodeTest.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.session.libsignal.utilities - -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.core.IsEqual.equalTo -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.Parameterized -import org.robolectric.ParameterizedRobolectricTestRunner - -@RunWith(ParameterizedRobolectricTestRunner::class) -class SnodeVersionTest( - private val v1: String, - private val v2: String, - private val expectedEqual: Boolean, - private val expectedLessThan: Boolean -) { - companion object { - @JvmStatic - @ParameterizedRobolectricTestRunner.Parameters(name = "{index}: testVersion({0},{1}) = (equalTo: {2}, lessThan: {3})") - fun data(): Collection> = listOf( - arrayOf("1", "1", true, false), - arrayOf("1", "2", false, true), - arrayOf("2", "1", false, false), - arrayOf("1.0", "1", true, false), - arrayOf("1.0", "1.0.0", true, false), - arrayOf("1.0", "1.0.0.0", true, false), - arrayOf("1.0", "1.0.0.0.0.0", true, false), - arrayOf("2.0", "1.2", false, false), - arrayOf("1.0.0.0", "1.0.0.1", false, true), - // Snode.Version only considers the first 4 integers, so these are equal - arrayOf("1.0.0.0", "1.0.0.0.1", true, false), - arrayOf("1.0.0.1", "1.0.0.1", true, false), - // parts can be up to 16 bits, around 65,535 - arrayOf("65535.65535.65535.65535", "65535.65535.65535.65535", true, false), - // values higher than this are coerced to 65535 (: - arrayOf("65535.65535.65535.65535", "65535.65535.65535.99999", true, false), - ) - } - - @Test - fun testVersionEqual() { - val version1 = Snode.Version(v1) - val version2 = Snode.Version(v2) - assertThat(version1 == version2, equalTo(expectedEqual)) - } - - @Test - fun testVersionOnePartLessThan() { - val version1 = Snode.Version(v1) - val version2 = Snode.Version(v2) - assertThat(version1 < version2, equalTo(expectedLessThan)) - } -} \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/api/onion/OnionSessionApiExecutorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/api/onion/OnionSessionApiExecutorTest.kt new file mode 100644 index 0000000000..f913e93ab4 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/api/onion/OnionSessionApiExecutorTest.kt @@ -0,0 +1,442 @@ +package org.thoughtcrime.securesms.api.onion + +import com.google.common.truth.Truth.assertThat +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.session.libsession.network.model.FailureDecision +import org.session.libsession.network.model.OnionError +import org.session.libsession.network.onion.OnionBuilder +import org.session.libsession.network.onion.PathManager +import org.session.libsession.network.snode.SnodeDirectory +import org.session.libsession.utilities.AESGCM +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.SessionApiRequest +import org.thoughtcrime.securesms.api.SessionApiResponse +import org.thoughtcrime.securesms.api.direct.DirectSessionApiExecutor +import org.thoughtcrime.securesms.api.error.ErrorWithFailureDecision +import org.thoughtcrime.securesms.api.execute +import org.thoughtcrime.securesms.api.http.HttpApiExecutor +import org.thoughtcrime.securesms.api.http.HttpBody +import org.thoughtcrime.securesms.api.http.HttpResponse +import org.thoughtcrime.securesms.api.snode.SnodeJsonRequest +import org.thoughtcrime.securesms.util.MockLoggingRule +import org.thoughtcrime.securesms.util.NetworkConnectivity +import org.thoughtcrime.securesms.util.findCause +import java.io.IOException +import kotlin.test.assertIs + +class OnionSessionApiExecutorTest { + + @get:Rule + val logRule = MockLoggingRule() + + lateinit var httpExecutor: HttpApiExecutor + lateinit var pathManager: PathManager + lateinit var directSessionApiExecutor: DirectSessionApiExecutor + lateinit var snodeDirectory: SnodeDirectory + lateinit var connectivity: NetworkConnectivity + + + private lateinit var executor: OnionSessionApiExecutor + + private val path1 = listOf( + snode("guard1"), + snode("middle1"), + snode("exit1"), + ) + + @Before + fun setUp() { + pathManager = mockk(relaxed = true) + httpExecutor = mockk() + directSessionApiExecutor = mockk() + snodeDirectory = mockk() + connectivity = mockk() + + executor = OnionSessionApiExecutor( + httpApiExecutor = httpExecutor, + pathManager = pathManager, + json = Json.Default, + onionSessionApiErrorManager = OnionSessionApiErrorManager( + pathManager = pathManager, + connectivity = connectivity, + ), + onionBuilder = mockk { + every { + build(any(), any(), any(), any()) + } answers { + OnionBuilder.BuiltOnion( + guard = snode("guard"), + ciphertext = ByteArray(0), + ephemeralPublicKey = ByteArray(0), + destinationSymmetricKey = ByteArray(0), + ) + } + }, + onionRequestEncryption = mockk { + every { + encryptPayloadForDestination(any(), any(), any()) + } returns AESGCM.EncryptionResult( + ciphertext = ByteArray(0), + ephemeralPublicKey = ByteArray(0), + symmetricKey = ByteArray(0), + ) + + every { + encode(any(), any()) + } returns ByteArray(0) + } + ) + } + + private fun snode(id: String) = + Snode( + address = "https://$id.example", + port = 443, + publicKeySet = Snode.KeySet(ed25519Key = "ed_$id", x25519Key = "x_$id"), + ) + + private suspend fun runExecutor( + target: Snode = snode("target"), + ctx: ApiExecutorContext = ApiExecutorContext() + ): Result { + return runCatching { + executor.execute( + SessionApiRequest.SnodeJsonRPC( + snode = target, + SnodeJsonRequest("test", JsonObject(emptyMap())) + ), + ctx = ctx + ) + } + } + + @Test + fun `IOException on guard node while having network should strike guard node and retry`() = runTest { + coEvery { pathManager.getPath(any()) } returns path1 + coEvery { + httpExecutor.send(any(), any()) + } throws IOException("Failed to connect") + every { connectivity.networkAvailable } returns MutableStateFlow(true) + + val result = runExecutor() + + val exception = + checkNotNull(result.exceptionOrNull()?.findCause()) + + assertThat(exception.failureDecision).isEqualTo(FailureDecision.Retry) + + assertIs(exception.cause) + + // Should strike the guard node + coVerify(exactly = 1) { pathManager.handleBadSnode(path1[0], forceRemove = false) } + + // Should not punish the whole path + coVerify(exactly = 0) { pathManager.handleBadPath(any()) } + } + + @Test + fun `IOException on guard node while having no network should just fail`() = runTest { + coEvery { pathManager.getPath(any()) } returns path1 + coEvery { + httpExecutor.send(any(), any()) + } throws IOException("Failed to connect") + every { connectivity.networkAvailable } returns MutableStateFlow(false) + + val result = runExecutor() + + val exception = + checkNotNull(result.exceptionOrNull()?.findCause()) + + assertThat(exception.failureDecision).isEqualTo(FailureDecision.Fail) + + assertIs(exception.cause) + + // Should not strike any node + coVerify(exactly = 0) { pathManager.handleBadSnode(any(), any()) } + + // Should not punish the whole path + coVerify(exactly = 0) { pathManager.handleBadPath(any()) } + } + + @Test + fun `Invalid onion response from destination should just fail`() = runTest { + coEvery { pathManager.getPath(any()) } returns path1 + coEvery { + httpExecutor.send(any(), any()) + } returns HttpResponse( + statusCode = 200, + body = HttpBody.Text("OK"), + headers = emptyMap(), + ) + + val result = runExecutor() + val exception = + checkNotNull(result.exceptionOrNull()?.findCause()) + + assertThat(exception.failureDecision).isEqualTo(FailureDecision.Fail) + assertIs(exception.cause) + + // Should not strike any node + coVerify(exactly = 0) { pathManager.handleBadSnode(any(), any()) } + + // Should not punish any path + coVerify(exactly = 0) { pathManager.handleBadPath(any()) } + } + + @Test + fun `Unknown error response should just fail`() = runTest { + coEvery { pathManager.getPath(any()) } returns path1 + coEvery { + httpExecutor.send(any(), any()) + } returns HttpResponse( + statusCode = 500, + body = HttpBody.Text("Internal server error"), + headers = emptyMap(), + ) + + val result = runExecutor() + + val exception = + checkNotNull(result.exceptionOrNull()?.findCause()) + + assertThat(exception.failureDecision).isEqualTo(FailureDecision.Fail) + + assertIs(exception.cause) + + // Should not strike any node + coVerify(exactly = 0) { pathManager.handleBadSnode(any(), any()) } + + // Should not punish any path + coVerify(exactly = 0) { pathManager.handleBadPath(any()) } + } + + @Test + fun `502 next node not found as destination should strike snode and retry`() = runTest { + val dest = snode("target") + coEvery { pathManager.getPath(any()) } returns path1 + coEvery { + httpExecutor.send(any(), any()) + } returns HttpResponse( + statusCode = 502, + body = HttpBody.Text("Next node not found: ${dest.ed25519Key}"), + headers = emptyMap(), + ) + + val result = runExecutor(dest) + + val exception = + checkNotNull(result.exceptionOrNull()?.findCause()) + + assertThat(exception.failureDecision).isEqualTo(FailureDecision.Retry) + + assertIs(exception.cause) + + // Should strike snode + coVerify(exactly = 1) { pathManager.handleBadSnode(dest, forceRemove = true) } + + // Should not punish the whole path + coVerify(exactly = 0) { pathManager.handleBadPath(any()) } + } + + @Test + fun `502 next node not found should strike snode and retry`() = runTest { + coEvery { pathManager.getPath(any()) } returns path1 + coEvery { + httpExecutor.send(any(), any()) + } returns HttpResponse( + statusCode = 502, + body = HttpBody.Text("Next node not found: ${path1[1].ed25519Key}"), + headers = emptyMap(), + ) + + val result = runExecutor() + + val exception = + checkNotNull(result.exceptionOrNull()?.findCause()) + + assertThat(exception.failureDecision).isEqualTo(FailureDecision.Retry) + + assertIs(exception.cause) + + // Should strike snode + coVerify(exactly = 1) { pathManager.handleBadSnode(path1[1], forceRemove = true) } + + // Should not punish the whole path + coVerify(exactly = 0) { pathManager.handleBadPath(any()) } + } + + @Test + fun `503 snode not ready should strike snode and retry`() = runTest { + coEvery { pathManager.getPath(any()) } returns path1 + + coEvery { + httpExecutor.send(any(), any()) + } returns HttpResponse( + statusCode = 503, + body = HttpBody.Text("Snode not ready: ${path1[1].ed25519Key}"), + headers = emptyMap(), + ) + + val result = runExecutor() + + val exception = + checkNotNull(result.exceptionOrNull()?.findCause()) + + assertThat(exception.failureDecision).isEqualTo(FailureDecision.Retry) + + assertIs(exception.cause) + + // Should strike the snode that timed out + coVerify(exactly = 1) { pathManager.handleBadSnode(snode = path1[1], forceRemove = false) } + + // Should not punish the whole path for a single snode timeout + coVerify(exactly = 0) { pathManager.handleBadPath(any()) } + } + + @Test + fun `503 service node not ready should strike guard node and retry`() = runTest { + coEvery { pathManager.getPath(any()) } returns path1 + + coEvery { + httpExecutor.send(any(), any()) + } returns HttpResponse( + statusCode = 503, + body = HttpBody.Text("Service node is not ready:"), + headers = emptyMap(), + ) + + val result = runExecutor() + + val exception = + checkNotNull(result.exceptionOrNull()?.findCause()) + + assertThat(exception.failureDecision).isEqualTo(FailureDecision.Retry) + + assertIs(exception.cause) + + // Should strike the snode that timed out + coVerify(exactly = 1) { pathManager.handleBadSnode(snode = path1[0], forceRemove = false) } + + // Should not punish the whole path for a single snode timeout + coVerify(exactly = 0) { pathManager.handleBadPath(any()) } + } + + @Test + fun `503 server busy should strike guard node and retry`() = runTest { + coEvery { pathManager.getPath(any()) } returns path1 + + coEvery { + httpExecutor.send(any(), any()) + } returns HttpResponse( + statusCode = 503, + body = HttpBody.Text("Server busy, try again later"), + headers = emptyMap(), + ) + + val result = runExecutor() + + val exception = + checkNotNull(result.exceptionOrNull()?.findCause()) + + assertThat(exception.failureDecision).isEqualTo(FailureDecision.Retry) + + assertIs(exception.cause) + + // Should strike the snode that timed out + coVerify(exactly = 1) { pathManager.handleBadSnode(snode = path1[0], forceRemove = false) } + + // Should not punish the whole path for a single snode timeout + coVerify(exactly = 0) { pathManager.handleBadPath(any()) } + } + + @Test + fun `504 request timeout should strike path and retry`() = runTest { + coEvery { pathManager.getPath(any()) } returns path1 + coEvery { + httpExecutor.send(any(), any()) + } returns HttpResponse( + statusCode = 504, + body = HttpBody.Text("Request time out"), + headers = emptyMap(), + ) + + val result = runExecutor() + + val exception = + checkNotNull(result.exceptionOrNull()?.findCause()) + + assertThat(exception.failureDecision).isEqualTo(FailureDecision.Retry) + + assertIs(exception.cause) + + // Should not strike any specific snode + coVerify(exactly = 0) { pathManager.handleBadSnode(any(), any()) } + + // Should punish the whole path for a timeout + coVerify(exactly = 1) { pathManager.handleBadPath(path1) } + } + + @Test + fun `504 request timeout with path override should not strike path and fail`() = runTest { + coEvery { + httpExecutor.send(any(), any()) + } returns HttpResponse( + statusCode = 504, + body = HttpBody.Text("Request time out"), + headers = emptyMap(), + ) + + val result = runExecutor(ctx = ApiExecutorContext().also { it.set(OnionSessionApiExecutor.OnionPathOverridesKey, path1) }) + + val exception = + checkNotNull(result.exceptionOrNull()?.findCause()) + + assertThat(exception.failureDecision).isEqualTo(FailureDecision.Fail) + + assertIs(exception.cause) + + // Should not strike any specific snode + coVerify(exactly = 0) { pathManager.handleBadSnode(any(), any()) } + + // Should not punish the whole path for a timeout + coVerify(exactly = 0) { pathManager.handleBadPath(any()) } + } + + @Test + fun `500 invalid response from snode should strike the path and retry`() = runTest { + coEvery { pathManager.getPath(any()) } returns path1 + coEvery { + httpExecutor.send(any(), any()) + } returns HttpResponse( + statusCode = 500, + body = HttpBody.Text("Invalid response from snode"), + headers = emptyMap(), + ) + + val result = runExecutor() + + val exception = + checkNotNull(result.exceptionOrNull()?.findCause()) + + assertThat(exception.failureDecision).isEqualTo(FailureDecision.Retry) + + assertIs(exception.cause) + + // Should not strike any specific snode + coVerify(exactly = 0) { pathManager.handleBadSnode(any(), any()) } + + // Should punish the whole path for invalid hop response + coVerify(exactly = 1) { pathManager.handleBadPath(path1) } + } +} \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/api/swarm/SwarmApiExecutorImplTest.kt b/app/src/test/java/org/thoughtcrime/securesms/api/swarm/SwarmApiExecutorImplTest.kt new file mode 100644 index 0000000000..d25484a93b --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/api/swarm/SwarmApiExecutorImplTest.kt @@ -0,0 +1,117 @@ +package org.thoughtcrime.securesms.api.swarm + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.session.libsession.network.model.FailureDecision +import org.session.libsession.network.snode.SwarmDirectory +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.api.ApiExecutorContext +import org.thoughtcrime.securesms.api.error.ErrorWithFailureDecision +import org.thoughtcrime.securesms.api.error.UnhandledStatusCodeException +import org.thoughtcrime.securesms.api.snode.SnodeApi +import org.thoughtcrime.securesms.api.snode.SnodeApiExecutor +import org.thoughtcrime.securesms.api.snode.SnodeApiRequest +import org.thoughtcrime.securesms.api.snode.SnodeApiResponse +import org.thoughtcrime.securesms.util.MockLoggingRule +import org.thoughtcrime.securesms.util.findCause +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class SwarmApiExecutorImplTest { + + @get:Rule + val loggingRule = MockLoggingRule() + + private lateinit var swarmDirectory: SwarmDirectory + private lateinit var snodeApiExecutor: SnodeApiExecutor + + private lateinit var swarmSnodeSelector: SwarmSnodeSelector + + private lateinit var executor: SwarmApiExecutorImpl + + @Before + fun setUp() { + swarmDirectory = mockk(relaxed = true) + snodeApiExecutor = mockk(relaxed = true) + swarmSnodeSelector = mockk(relaxed = true) + + executor = SwarmApiExecutorImpl( + snodeApiExecutor = snodeApiExecutor, + swarmDirectory = swarmDirectory, + swarmSnodeSelector = swarmSnodeSelector + ) + } + + private val testSnodes = List(10) { + Snode( + url = "https://snode$it.example".toHttpUrl(), + publicKeySet = Snode.KeySet("k1$it", "k2$it") + ) + } + + @Test + fun `should perform a successful request`() = runTest { + val expectResponse = "Success" + + coEvery { swarmSnodeSelector.selectSnode("test") } returns testSnodes[0] + coEvery { snodeApiExecutor.send(any(), any()) } returns expectResponse + + val snodeApi: SnodeApi = mockk(relaxed = true) + + val actualResponse = executor.execute(SwarmApiRequest(swarmPubKeyHex = "test", api = snodeApi)) + + // Validate that the response is as expected + assertEquals(expectResponse, actualResponse) + + // Validate that the underlying snodeApiExecutor was called with correct parameters + coVerify { + snodeApiExecutor.send(any(), eq(SnodeApiRequest(testSnodes[0], snodeApi))) + } + } + + @Test + fun `421 should remove snode from swarm and retry with different snode`() = runTest { + val callCount = AtomicInteger(0) + + coEvery { swarmSnodeSelector.selectSnode("test") } answers { + testSnodes[callCount.getAndIncrement() % testSnodes.size] + } + coEvery { swarmDirectory.updateSwarmFromResponse(any(), any()) } returns false + + coEvery { snodeApiExecutor.send(any(), any()) } throws UnhandledStatusCodeException(code = 421, origin = "snode") + + val context = ApiExecutorContext() + + val api = mockk>() + + val result = runCatching { executor.execute(SwarmApiRequest(swarmPubKeyHex = "test", api = api), ctx = context) } + + // Validated that we have have a retry decision + val failureDecision = assertNotNull(result.exceptionOrNull()?.findCause()?.failureDecision) + assertEquals(FailureDecision.Retry, failureDecision) + + // Validated that the snode was removed from the swarm + verify { + swarmDirectory.updateSwarmFromResponse(eq("test"), any()) + } + verify { + swarmDirectory.dropSnodeFromSwarmIfNeeded(testSnodes[0], "test") + } + + // Retry the request + runCatching { executor.execute(SwarmApiRequest(swarmPubKeyHex = "test", api = api), ctx = context) } + + // The second retry should use a different snode now + coVerify { + snodeApiExecutor.send(any(), eq(SnodeApiRequest(testSnodes[1], api))) + } + } +} \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/api/swarm/SwarmSnodeSelectorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/api/swarm/SwarmSnodeSelectorTest.kt new file mode 100644 index 0000000000..be438a14cd --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/api/swarm/SwarmSnodeSelectorTest.kt @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.api.swarm + +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.session.libsession.network.snode.SwarmDirectory +import org.session.libsignal.utilities.Snode + +class SwarmSnodeSelectorTest { + private lateinit var swarmDirectory: SwarmDirectory + private lateinit var selector: SwarmSnodeSelector + + private val swarmPool = List(5) { + Snode( + url = "https://snode$it.example".toHttpUrl(), + publicKeySet = Snode.KeySet("ed25519Key$it", "x25519Key$it") + ) + } + + @Before + fun setUp() { + swarmDirectory = mockk(relaxed = true) { + coEvery { getSwarm(any()) } returns swarmPool + } + + selector = SwarmSnodeSelector(swarmDirectory) + } + + @Test + fun `should select different node in a swarm in random order`() = runTest { + val firstSelectedNodes = List(swarmPool.size) { + selector.selectSnode("swarmPubKey") + } + + val secondSelectedNodes = List(swarmPool.size) { + selector.selectSnode("swarmPubKey") + } + + // Validate that the selected nodes are from the swarm pool but in different order + assertNotEquals(firstSelectedNodes, swarmPool) + assertEquals(firstSelectedNodes.toHashSet(), swarmPool.toHashSet()) + + // Validate that two selection rounds produce different orders + assertNotEquals(firstSelectedNodes, secondSelectedNodes) + assertEquals(firstSelectedNodes.toHashSet(), secondSelectedNodes.toHashSet()) + } +} \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt index 1c6b3b18df..15fa3a619f 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt @@ -107,7 +107,8 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { destroyed = false, joinedAtSecs = System.currentTimeMillis() / 1000L, ), - proData = null, + //todo LARGE GROUP hiding group pro status until we enable large groups + //proData = null, members = listOf(), description = null, firstMember = Recipient( @@ -182,7 +183,8 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { destroyed = false, joinedAtSecs = System.currentTimeMillis() / 1000L, ), - proData = null, + //todo LARGE GROUP hiding group pro status until we enable large groups + //proData = null, members = listOf(), description = null, firstMember = Recipient( diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt index 11565309b0..94f2fb8e71 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt @@ -87,11 +87,12 @@ class ConversationViewModelTest : BaseViewModelTest() { private fun createViewModel(recipient: Recipient): ConversationViewModel { return ConversationViewModel( repository = repository, - storage = storage, + storage = mock{ + on { getThreadId(recipient.address) } doReturn threadId + }, groupDb = mock(), threadDb = mock { on { getOrCreateThreadIdFor(recipient.address) } doReturn threadId - on { getThreadIdIfExistsFor(recipient.address) } doReturn threadId on { updateNotifications } doAnswer { emptyFlow() } @@ -110,7 +111,6 @@ class ConversationViewModelTest : BaseViewModelTest() { }, expiredGroupManager = mock(), avatarUtils = avatarUtils, - lokiAPIDb = mock(), dateUtils = mock(), proStatusManager = mock(), upmFactory = mock(), @@ -130,7 +130,10 @@ class ConversationViewModelTest : BaseViewModelTest() { on { changesNotification } doReturn MutableSharedFlow() }, openGroupManager = mock(), - attachmentDownloadJobFactory = mock() + attachmentDownloadJobFactory = mock(), + communityApiExecutor = mock(), + deleteAllReactionsApiFactory = mock(), + loginStateRepository = mock(), ) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionViewModelTest.kt index ffc4c63815..67a4492961 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionViewModelTest.kt @@ -87,13 +87,13 @@ class MentionViewModelTest : BaseViewModelTest() { mentionViewModel = MentionViewModel( threadDatabase = mock { on { getRecipientForThreadId(threadID) } doReturn communityRecipient.address - on { getThreadIdIfExistsFor(communityRecipient.address) } doReturn threadID }, groupDatabase = mock { }, storage = mock { on { getUserBlindedAccountId(any()) } doReturn myId on { getUserPublicKey() } doReturn myId.hexString + on { getThreadId(communityRecipient.address) } doReturn threadID }, application = InstrumentationRegistry.getInstrumentation().context as android.app.Application, mmsSmsDatabase = mock { diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/SnodeDatabaseTest.kt b/app/src/test/java/org/thoughtcrime/securesms/database/SnodeDatabaseTest.kt new file mode 100644 index 0000000000..64c535f3af --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/database/SnodeDatabaseTest.kt @@ -0,0 +1,315 @@ +package org.thoughtcrime.securesms.database + +import android.content.Context +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.core.app.ApplicationProvider +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.session.libsignal.utilities.Snode + +@RunWith(RobolectricTestRunner::class) +@Config(minSdk = 36) // Setting min sdk 36 to use recent sqlite version as we use some modern features in the app code +class SnodeDatabaseTest { + lateinit var db: SnodeDatabase + + @Before + fun setUp() { + db = createInMemorySnodeDatabase() + } + + companion object { + fun createInMemorySnodeDatabase(): SnodeDatabase { + val context = ApplicationProvider.getApplicationContext() + val config = SupportSQLiteOpenHelper.Configuration.builder(context) + .name(null) + .callback(object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) { + SnodeDatabase.createTableAndMigrateData(db, migrateOldData = false) + } + + override fun onUpgrade( + db: SupportSQLiteDatabase, + oldVersion: Int, + newVersion: Int + ) {} + + override fun onOpen(db: SupportSQLiteDatabase) { + super.onOpen(db) + + db.execSQL("PRAGMA foreign_keys=ON") + } + }) + .build() + + val openHelper = FrameworkSQLiteOpenHelperFactory().create(config) + + return SnodeDatabase( + helper = { openHelper }, + json = Json + ) + } + } + + private val snodes = List(20) { idx -> + Snode("https://1.2.3.1$idx", 5000, Snode.KeySet("edKey$idx", "xkey$idx")) + } + + @Test + fun `should persist snode pool`() { + assertEquals(0, db.getSnodePool().size) + val expected = snodes + + db.setSnodePool(expected) + assertEquals(expected, db.getSnodePool()) + } + + @Test + fun `should persist paths`() { + assertEquals(0, db.getOnionRequestPaths().size) + + db.setSnodePool(snodes) + + val paths = listOf( + listOf(snodes[0], snodes[1], snodes[2]), + listOf(snodes[3], snodes[4]) + ) + + db.setOnionRequestPaths(paths) + assertEquals(paths, db.getOnionRequestPaths()) + + // Changing the order of paths while adding a new one + val newPaths = listOf( + listOf(snodes[3], snodes[4]), + listOf(snodes[5], snodes[6], snodes[7]), + ) + + db.setOnionRequestPaths(newPaths) + assertEquals(newPaths, db.getOnionRequestPaths()) + } + + @Test + fun `should persist path while retaining strikes`() { + db.setSnodePool(snodes) + + val path1 = listOf(snodes[0], snodes[1], snodes[2]) + + db.setOnionRequestPaths(listOf(path1)) + assertEquals(1, db.increaseOnionRequestPathStrike(path1, 1)) + + val path2 = listOf(snodes[3], snodes[4]) + db.setOnionRequestPaths(listOf(path1, path2)) + assertEquals(listOf(path1, path2), db.getOnionRequestPaths()) + assertEquals(1, db.increaseOnionRequestPathStrike(path1, 0)) + assertEquals(0, db.increaseOnionRequestPathStrike(path2, 0)) + } + + @Test + fun `should not persist paths that contain node not in snode pool`() { + db.setSnodePool(snodes.take(2).toSet()) + + val paths = listOf( + listOf(snodes[0], snodes[1], snodes[2]), + ) + + assertThrows(RuntimeException::class.java) { + db.setOnionRequestPaths(paths) + } + } + + @Test + fun `should not persist paths that overlap`() { + db.setSnodePool(snodes) + + val paths = listOf( + listOf(snodes[0], snodes[1], snodes[2]), + listOf(snodes[2], snodes[3]) + ) + + assertThrows(RuntimeException::class.java) { + db.setOnionRequestPaths(paths) + } + } + + @Test + fun `should persist swarm`() { + db.setSnodePool(snodes) + + val swarmNodes = listOf(snodes[0], snodes[1], snodes[2]) + + assertEquals(0, db.getSwarm("key1").size) + db.setSwarm("key1", swarmNodes) + assertEquals(swarmNodes, db.getSwarm("key1")) + } + + @Test + fun `increase path strike works`() { + db.setSnodePool(snodes) + + val path1 = listOf(snodes[0], snodes[1], snodes[2]) + val path2 = listOf(snodes[3], snodes[4]) + db.setOnionRequestPaths(listOf(path1, path2)) + + assertEquals(1, db.increaseOnionRequestPathStrike(path1, 1)) + assertEquals(1, db.increaseOnionRequestPathStrike(path2, 1)) + assertEquals(0, db.increaseOnionRequestPathStrike(path1, -1)) + } + + @Test + fun `increase path strikes with snode works`() { + db.setSnodePool(snodes) + + val path1 = listOf(snodes[0], snodes[1], snodes[2]) + val path2 = listOf(snodes[3], snodes[4]) + db.setOnionRequestPaths(listOf(path1, path2)) + } + + @Test + fun `increase snode strike works`() { + db.setSnodePool(snodes) + + assertEquals(1, db.increaseSnodeStrike(snodes[0], 1)) + assertEquals(1, db.increaseSnodeStrike(snodes[1], 1)) + } + + @Test + fun `drop snode works`() { + db.setSnodePool(snodes) + + db.setSwarm("swarm1", listOf(snodes[0], snodes[1])) + val paths = listOf( + listOf(snodes[1], snodes[2]), + listOf(snodes[3], snodes[4]) + ) + db.setOnionRequestPaths(paths) + + val expectingRemaining = snodes.drop(1) + assertNotNull(db.removeSnode(snodes[0].publicKeySet!!.ed25519Key)) + assertEquals(expectingRemaining, db.getSnodePool()) + + // Snode was in swarm, so it should be removed from there too + assertEquals(listOf(snodes[1]), db.getSwarm("swarm1")) + + // Since snode is not in any path, paths should remain unchanged + assertEquals(paths, db.getOnionRequestPaths()) + } + + @Test + fun `drop snode with min strikes works`() { + db.setSnodePool(snodes) + + assertEquals(4, db.increaseSnodeStrike(snodes[0], 4)) + assertEquals(3, db.increaseSnodeStrike(snodes[1], 3)) + + db.removeSnodesWithStrikesGreaterThan(2) + val expectingRemaining = snodes.drop(2) + assertEquals(expectingRemaining, db.getSnodePool()) + } + + @Test + fun `clear onion request paths works`() { + db.setSnodePool(snodes) + + val paths = listOf( + listOf(snodes[0], snodes[1]), + listOf(snodes[2], snodes[3]) + ) + db.setOnionRequestPaths(paths) + + db.clearOnionRequestPaths() + assertEquals(0, db.getOnionRequestPaths().size) + } + + @Test + fun `should be able to find random unused snodes for path`() { + db.setSnodePool(snodes) + + val paths = listOf( + listOf(snodes[0], snodes[1]), + listOf(snodes[2], snodes[3]) + ) + db.setOnionRequestPaths(paths) + + val found = db.findRandomUnusedSnodesForNewPath(2) + assertEquals(2, found.size) + assertFalse(paths.flatMap { it }.any { it in found }) + assertTrue(found.all { it in snodes }) + } + + @Test + fun `replace path works`() { + db.setSnodePool(snodes) + + val oldPath = listOf(snodes[0], snodes[1]) + val newPath = listOf(snodes[2], snodes[3]) + + db.setOnionRequestPaths(listOf(oldPath)) + assertEquals(2, db.increaseOnionRequestPathStrike(oldPath, 2)) + db.replaceOnionRequestPath(oldPath, newPath) + + assertEquals(2, db.increaseOnionRequestPathStrike(newPath, 0)) + } + + @Test + fun `should not be able to remove snode used in paths`() { + db.setSnodePool(snodes) + + val paths = listOf( + listOf(snodes[0], snodes[1]), + listOf(snodes[2], snodes[3]) + ) + db.setOnionRequestPaths(paths) + + assertThrows(RuntimeException::class.java) { + db.removeSnode(snodes[0].publicKeySet!!.ed25519Key) + } + } + + @Test + fun `replacing snode pool should reset their strikes`() { + db.setSnodePool(snodes) + + assertEquals(2, db.increaseSnodeStrike(snodes[0], 2)) + assertEquals(3, db.increaseSnodeStrike(snodes[1], 3)) + + db.setSnodePool(snodes) + + assertEquals(0, db.increaseSnodeStrike(snodes[0], 0)) + assertEquals(0, db.increaseSnodeStrike(snodes[1], 0)) + } + + @Test + fun `replacing snode pool should remove path that contains non-exist snode`() { + db.setSnodePool(snodes) + + val path1 = listOf(snodes[0], snodes[1]) + val path2 = listOf(snodes[2], snodes[3]) + db.setOnionRequestPaths(listOf(path1, path2)) + + val newPool = snodes.drop(1).toSet() + db.setSnodePool(newPool) + + assertEquals(listOf(path2), db.getOnionRequestPaths()) + } + + @Test + fun `drop snode from swarm works`() { + db.setSnodePool(snodes) + + db.setSwarm("swarm1", setOf(snodes[0], snodes[1], snodes[2])) + + val expectingRemaining = listOf(snodes[1], snodes[2]) + db.dropSnodeFromSwarm("swarm1", snodes[0].publicKeySet!!.ed25519Key) + assertEquals(expectingRemaining, db.getSwarm("swarm1")) + } +} \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/network/PathManagerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/network/PathManagerTest.kt new file mode 100644 index 0000000000..9fbfffa55d --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/network/PathManagerTest.kt @@ -0,0 +1,126 @@ +package org.thoughtcrime.securesms.network + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.session.libsession.network.model.Path +import org.session.libsession.network.onion.PathManager +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.database.SnodeDatabase +import org.thoughtcrime.securesms.database.SnodeDatabaseTest +import org.thoughtcrime.securesms.util.MockLoggingRule + +@RunWith(RobolectricTestRunner::class) +@Config(minSdk = 36) // Setting min sdk 36 to use recent sqlite version as we use some modern features in the app code +class PathManagerTest { + + @get:Rule + val logRule = MockLoggingRule() + + lateinit var snodeDb: SnodeDatabase + + @Before + fun setUp() { + snodeDb = SnodeDatabaseTest.createInMemorySnodeDatabase() + } + + private fun snode(id: String): Snode = + Snode( + address = "https://$id.example", + port = 443, + publicKeySet = Snode.KeySet(ed25519Key = "ed_$id", x25519Key = "x_$id"), + ) + + + @Test + fun `getPath excludes node when possible`() = runTest { + val a = snode("a"); val b = snode("b"); val c = snode("c") + val d = snode("d"); val e = snode("e"); val f = snode("f") + + val p1: Path = listOf(a, b, c) + val p2: Path = listOf(d, e, f) + + snodeDb.setSnodePool(setOf(a,b,c,d,e,f)) + snodeDb.setOnionRequestPaths(listOf(p1, p2)) + + val pm = PathManager( + scope = backgroundScope, + directory = mock(), + storage = snodeDb, + snodePoolStorage = snodeDb, + prefs = mock(), + snodeApiExecutor = { mock() }, + getInfoApi = { mock() }, + ) + + val chosen = pm.getPath(exclude = b) + assertThat(chosen).isEqualTo(p2) + } + + @Test + fun `forceRemove drops snode from pool and swarm and repairs path when possible`() = runTest { + val a = snode("a"); val b = snode("b"); val c = snode("c") + val d = snode("d"); val e = snode("e"); val f = snode("f") + val x = snode("x") // replacement candidate + + val p1: Path = listOf(a, b, c) + val p2: Path = listOf(d, e, f) + + snodeDb.setSnodePool(setOf(a,b,c,d,e,f,x)) + snodeDb.setOnionRequestPaths(listOf(p1, p2)) + + val pm = PathManager( + scope = backgroundScope, + directory = mock(), + storage = snodeDb, + snodePoolStorage = snodeDb, + prefs = mock(), + snodeApiExecutor = { mock() }, + getInfoApi = { mock() }, + ) + + pm.handleBadSnode(snode = b, forceRemove = true) + + val newPaths = pm.paths.value + assertThat(newPaths).hasSize(2) + assertThat(newPaths.flatten()).doesNotContain(b) + + // disjoint invariant + val flat = newPaths.flatten() + assertThat(flat.toSet().size).isEqualTo(flat.size) + } + + @Test + fun `forceRemove drops path when no replacement candidate exists`() = runTest { + val a = snode("a"); val b = snode("b"); val c = snode("c") + val d = snode("d"); val e = snode("e"); val f = snode("f") + + val p1: Path = listOf(a, b, c) + val p2: Path = listOf(d, e, f) + + snodeDb.setSnodePool(setOf(a,b,c,d,e,f)) + snodeDb.setOnionRequestPaths(listOf(p1, p2)) + + val pm = PathManager( + scope = backgroundScope, + directory = mock(), + storage = snodeDb, + snodePoolStorage = snodeDb, + prefs = mock(), + snodeApiExecutor = { mock() }, + getInfoApi = { mock() }, + ) + + pm.handleBadSnode(snode = b, forceRemove = true) + + val newPaths = pm.paths.value + assertThat(newPaths.flatten()).doesNotContain(b) + assertThat(newPaths.size).isLessThan(2) // irreparable path dropped :contentReference[oaicite:11]{index=11} + } +} \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/network/ServerApiErrorManagerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/network/ServerApiErrorManagerTest.kt new file mode 100644 index 0000000000..54f79d56d8 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/network/ServerApiErrorManagerTest.kt @@ -0,0 +1,79 @@ +package org.thoughtcrime.securesms.network + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.session.libsession.network.SnodeClock +import org.session.libsession.network.model.FailureDecision +import org.thoughtcrime.securesms.api.server.ServerApiErrorManager +import org.thoughtcrime.securesms.api.server.ServerClientFailureContext +import org.thoughtcrime.securesms.util.MockLoggingRule + +class ServerApiErrorManagerTest { + + @get:Rule + val logRule = MockLoggingRule() + + private val snodeClock = mock() + private val manager = ServerApiErrorManager(snodeClock = snodeClock) + + @Test + fun `COS 425 first time - resync true to Retry`() = runTest { + whenever(snodeClock.resyncClock()).thenReturn(true) + + val (_, decision) = manager.onFailure( + ctx = ServerClientFailureContext(previousErrorCode = null), + errorCode = 425, + bodyAsText = null, + serverBaseUrl = "" + ) + + assertThat(decision).isEqualTo(FailureDecision.Retry) + verify(snodeClock).resyncClock() + } + + @Test + fun `COS 425 first time - resync false to Fail`() = runTest { + whenever(snodeClock.resyncClock()).thenReturn(false) + + val (_, decision) = manager.onFailure( + ctx = ServerClientFailureContext(previousErrorCode = null), + errorCode = 425, + bodyAsText = null, + serverBaseUrl = "" + ) + + assertThat(decision).isInstanceOf(FailureDecision.Fail::class.java) + verify(snodeClock).resyncClock() + } + + @Test + fun `COS 425 second time to Fail (no more remediation)`() = runTest { + val (_, decision) = manager.onFailure( + ctx = ServerClientFailureContext(previousErrorCode = 425), + errorCode = 425, + bodyAsText = null, + serverBaseUrl = "" + ) + + assertThat(decision).isInstanceOf(FailureDecision.Fail::class.java) + verify(snodeClock, never()).resyncClock() + } + + @Test + fun `default - non COS DestinationError to Fail`() = runTest { + val (_, decision) = manager.onFailure( + ctx = ServerClientFailureContext(previousErrorCode = null), + errorCode = 500, + bodyAsText = null, + serverBaseUrl = "" + ) + + assertThat(decision).isEqualTo(null) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/network/SnodeApiErrorManagerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/network/SnodeApiErrorManagerTest.kt new file mode 100644 index 0000000000..05d11f0fbc --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/network/SnodeApiErrorManagerTest.kt @@ -0,0 +1,104 @@ +package org.thoughtcrime.securesms.network + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.session.libsession.network.SnodeClock +import org.session.libsession.network.model.FailureDecision +import org.session.libsession.network.onion.PathManager +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.api.snode.SnodeApiErrorManager +import org.thoughtcrime.securesms.api.snode.SnodeClientFailureContext +import org.thoughtcrime.securesms.util.MockLoggingRule + +class SnodeApiErrorManagerTest { + + @get:Rule + val logRule = MockLoggingRule() + + private val pathManager = mock() + private val snodeClock = mock() + + private val manager = SnodeApiErrorManager( + pathManager = pathManager, + snodeClock = snodeClock + ) + + private fun snode(id: String) = + Snode( + address = "https://$id.example", + port = 443, + publicKeySet = Snode.KeySet(ed25519Key = "ed_$id", x25519Key = "x_$id"), + ) + + @Test + fun `COS 406 first time - resync true to Retry`() = runTest { + val target = snode("target") + whenever(snodeClock.resyncClock()).thenReturn(true) + + val (_, decision) = manager.onFailure( + snode = target, + errorCode = 406, + bodyText = null, + ctx = SnodeClientFailureContext() + ) + + assertThat(decision).isEqualTo(FailureDecision.Retry) + verify(snodeClock).resyncClock() + verifyNoInteractions(pathManager) + } + + @Test + fun `COS 406 first time - resync false to Fail`() = runTest { + val target = snode("target") + whenever(snodeClock.resyncClock()).thenReturn(false) + + val (_, decision) = manager.onFailure( + snode = target, + errorCode = 406, + bodyText = null, + ctx = SnodeClientFailureContext() + ) + + assertThat(decision).isInstanceOf(FailureDecision.Fail::class.java) + verify(snodeClock).resyncClock() + verifyNoInteractions(pathManager) + } + + @Test + fun `COS 406 second time to forceRemove target snode and Retry`() = runTest { + val target = snode("target") + + val (_, decision) = manager.onFailure( + snode = target, + errorCode = 406, + bodyText = null, + ctx = SnodeClientFailureContext(previousErrorCode = 406) + ) + + assertThat(decision).isEqualTo(FailureDecision.Retry) + verify(pathManager).handleBadSnode(snode = target, forceRemove = true) + verify(snodeClock, never()).resyncClock() + } + + @Test + fun `502 unparsable data to forceRemove target snode and Retry`() = runTest { + val target = snode("target") + + val (_, decision) = manager.onFailure( + snode = target, + errorCode = 502, + bodyText = "oxend returned unparsable data", + ctx = SnodeClientFailureContext() + ) + + assertThat(decision).isEqualTo(FailureDecision.Retry) + verify(pathManager).handleBadSnode(snode = target, forceRemove = true) + } +} diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index c838f896de..75c7d2b90d 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -31,5 +31,10 @@ gradlePlugin { id = "rename-apk" implementationClass = "RenameApkPlugin" } + + create("local-snode-pool") { + id = "local-snode-pool" + implementationClass = "LocalSnodePoolPlugin" + } } } \ No newline at end of file diff --git a/build-logic/src/main/kotlin/LocalSnodePoolPlugin.kt b/build-logic/src/main/kotlin/LocalSnodePoolPlugin.kt new file mode 100644 index 0000000000..d79b96dbb2 --- /dev/null +++ b/build-logic/src/main/kotlin/LocalSnodePoolPlugin.kt @@ -0,0 +1,206 @@ +import com.android.build.api.variant.ApplicationAndroidComponentsExtension +import com.android.build.api.variant.HasUnitTest +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import org.gradle.internal.extensions.stdlib.capitalized +import org.gradle.process.ExecOperations +import java.io.ByteArrayOutputStream +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import javax.inject.Inject + +class LocalSnodePoolPlugin : Plugin { + override fun apply(project: Project) { + project.plugins.withId("com.android.application") { + val androidComponents = project.extensions.getByType(ApplicationAndroidComponentsExtension::class.java) + + androidComponents.onVariants { variant -> + val shouldRun = variant.buildType !in setOf("debug") + if (!shouldRun) return@onVariants + + val task = project.tasks.register( + "generate${variant.name.capitalized()}LocalSnodePool", + GenerateLocalSnodePoolTask::class.java + ) { + // build/generated//snodes/snode_pool.json + outputDir.set(project.layout.buildDirectory.dir("generated/${variant.name}")) + seedUrls.set( + listOf( + "https://seed1.getsession.org/json_rpc", + "https://seed2.getsession.org/json_rpc", + "https://seed3.getsession.org/json_rpc", + ) + ) + } + + // Add generated assets directory + variant.sources.assets?.addGeneratedSourceDirectory( + task, + GenerateLocalSnodePoolTask::outputDir + ) + + // Also add the generated dir to unit test resources so Gradle wires task deps correctly + (variant as? HasUnitTest)?.unitTest?.sources?.resources?.addGeneratedSourceDirectory( + task, + GenerateLocalSnodePoolTask::outputDir + ) + } + } + } +} + +abstract class GenerateLocalSnodePoolTask : DefaultTask() { + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @get:Input + abstract val seedUrls: ListProperty + + @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 + outDirFile.mkdirs() + + val outFile = outDirFile.resolve("snodes").resolve("snode_pool.json") + outFile.parentFile.mkdirs() + + val requestBody = """ + { + "method": "get_service_nodes", + "params": { + "active_only": true, + "fields": { + "public_ip": true, + "storage_port": true, + "pubkey_ed25519": true, + "pubkey_x25519": true + } + } + } + """.trimIndent() + + val seeds = seedUrls.get().shuffled() + var lastError: Throwable? = null + var body: String? = null + var usedSeed: String? = null + + for (seed in seeds) { + try { + val candidate = fetchWithCurl(seed, requestBody) + + if (candidate.isBlank()) { + throw IllegalStateException("Empty response from $seed") + } + + val count = validateSnodePoolJson(candidate) + + body = candidate + usedSeed = seed + logger.lifecycle("Validated snode pool JSON ($count nodes) from $seed") + break + } catch (t: Throwable) { + lastError = t + logger.warn("Failed to fetch/validate snode pool from $seed: ${t.message}") + } + } + + if (body == null) { + throw GradleException( + "Failed to generate local snode pool JSON from all seeds", + lastError + ) + } + + val tmp = Files.createTempFile(outFile.parentFile.toPath(), "snode_pool", ".json.tmp") + try { + Files.writeString(tmp, body, Charsets.UTF_8) + Files.move(tmp, outFile.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + } finally { + runCatching { Files.deleteIfExists(tmp) } + } + + logger.lifecycle("Wrote generated asset: ${outFile.absolutePath} (source=$usedSeed)") + } + + private fun fetchWithCurl(seed: String, requestBody: String): String { + val stdout = ByteArrayOutputStream() + val stderr = ByteArrayOutputStream() + + val result = execOps.exec { + commandLine( + "curl", + "--fail", + "--silent", + "--show-error", + "-X", "POST", + "-H", "Content-Type: application/json", + "--data", requestBody, + seed + ) + standardOutput = stdout + errorOutput = stderr + isIgnoreExitValue = true + } + + if (result.exitValue != 0) { + throw IllegalStateException( + "curl failed (exit=${result.exitValue}): ${stderr.toString("UTF-8").trim()}" + ) + } + + return stdout.toString("UTF-8").trim() + } + + /** + * Validates JSON format and returns node count. + * + * Expected shape: + * { "result": { "service_node_states": [ { public_ip, storage_port, pubkey_ed25519, pubkey_x25519 }, ... ] } } + */ + private fun validateSnodePoolJson(json: String): Int { + val root = groovy.json.JsonSlurper().parseText(json) + + val result = (root as? Map<*, *>)?.get("result") as? Map<*, *> + ?: throw IllegalStateException("Missing top-level 'result' object") + + val states = result["service_node_states"] as? List<*> + ?: throw IllegalStateException("Missing 'service_node_states' array") + + val parsed = states.mapNotNull { it as? Map<*, *> }.map { m -> + val ip = m["public_ip"] as? String ?: throw IllegalStateException("Missing public_ip") + val portNum = m["storage_port"] as? Number ?: throw IllegalStateException("Missing storage_port") + val ed = m["pubkey_ed25519"] as? String ?: throw IllegalStateException("Missing pubkey_ed25519") + val x = m["pubkey_x25519"] as? String ?: throw IllegalStateException("Missing pubkey_x25519") + + val port = portNum.toInt() + if (ip.isBlank()) throw IllegalStateException("Blank public_ip") + if (ed.length < 32) throw IllegalStateException("pubkey_ed25519 too short") + if (x.length < 32) throw IllegalStateException("pubkey_x25519 too short") + + // Minimal “Snode-like” representation for validation + Quad(ip, port, ed, x) + } + + if (parsed.isEmpty()) throw IllegalStateException("service_node_states was empty") + + return parsed.size + } + + private data class Quad(val ip: String, val port: Int, val ed: String, val x: String) +} + diff --git a/build-logic/src/main/kotlin/RenameApkPlugin.kt b/build-logic/src/main/kotlin/RenameApkPlugin.kt index 75517ea366..5d4d87a94c 100644 --- a/build-logic/src/main/kotlin/RenameApkPlugin.kt +++ b/build-logic/src/main/kotlin/RenameApkPlugin.kt @@ -20,6 +20,10 @@ class RenameApkPlugin : Plugin { project.plugins.withId("com.android.application") { val androidComponents = project.extensions.getByType(ApplicationAndroidComponentsExtension::class.java) androidComponents.onVariants { variant -> + if (variant.buildType == "debug") { + return@onVariants + } + val taskProvider = project.tasks.register( "rename${variant.name.capitalized()}Apk", RenameApkTask::class.java, diff --git a/build.gradle.kts b/build.gradle.kts index f949e4cfa1..01ef9ad70d 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.3.302") + classpath("com.huawei.agconnect:agcp:1.9.4.300") } } } diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index d0982ac176..6c5fb2850f 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -126,6 +126,7 @@ hide-nts-menu-option copy-community-url-menu-option leave-community-menu-option + manage-admins-menu-option manage-members-menu-option group-members-menu-option invite-contacts-menu-option @@ -135,6 +136,8 @@ hide-nts-cancel-button show-nts-confirm-button show-nts-cancel-button + whitelist-confirm-button + whitelist-cancel-button block-user-confirm-button block-user-cancel-button unblock-user-confirm-button @@ -148,6 +151,7 @@ delete-group-confirm-button delete-group-cancel-button leave-group-confirm-button + add-admin-button leave-group-cancel-button clear-all-messages-confirm-button clear-all-messages-cancel-button @@ -270,6 +274,17 @@ pro-settings-faq pro-settings-support + + invite-contacts-menu-option + invite-accountid-menu-option + promote-members-menu-option + + + remove-member-option + remove-member-messages-option + share-message-history-option + share-new-messages-option + Navigate back Close Dialog diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f11a63dbd7..679a6d2125 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,57 +4,55 @@ androidTargetSdkVersion = "35" androidCompileSdkVersion = "35" accompanistPermissionsVersion = "0.37.3" activityKtxVersion = "1.10.1" -androidImageCropperVersion = "4.6.0" +androidImageCropperVersion = "4.7.0" androidVersion = "137.7151.04" assertjCoreVersion = "3.27.6" biometricVersion = "1.1.0" -cameraCamera2Version = "1.5.1" +cameraCamera2Version = "1.5.2" cardviewVersion = "1.0.0" -composeBomVersion = "2025.11.01" +composeBomVersion = "2026.01.00" conscryptAndroidVersion = "2.5.3" conscryptJavaVersion = "2.5.2" constraintlayoutVersion = "2.2.1" copperFlowVersion = "1.0.0" coreTestingVersion = "2.2.0" espressoCoreVersion = "3.7.0" -exifinterfaceVersion = "1.4.1" +exifinterfaceVersion = "1.4.2" firebaseMessagingVersion = "25.0.1" flexboxVersion = "3.0.0" fragmentKtxVersion = "1.8.9" -gradlePluginVersion = "8.13.1" +gradlePluginVersion = "8.13.2" dependenciesAnalysisVersion = "3.1.0" googleServicesVersion = "4.4.4" junit = "1.3.0" -kotlinVersion = "2.2.21" +kotlinVersion = "2.3.0" kryoVersion = "5.6.2" kspVersion = "2.3.3" legacySupportV13Version = "1.0.0" -libsessionUtilAndroidVersion = "1.1.0-2-g39d4ac8" +libsessionUtilAndroidVersion = "1.1.0" media3ExoplayerVersion = "1.8.0" -mockitoCoreVersion = "5.20.0" -navVersion = "2.9.5" +mockitoCoreVersion = "5.21.0" +navVersion = "2.9.6" appcompatVersion = "1.7.1" coreVersion = "1.16.0" coroutinesVersion = "1.10.2" -daggerHiltVersion = "2.57.2" +daggerHiltVersion = "2.58" androidxHiltVersion = "1.3.0" glideVersion = "5.0.5" jacksonDatabindVersion = "2.9.8" junitVersion = "4.13.2" kotlinxJsonVersion = "1.9.0" -kovenantVersion = "3.3.0" opencsvVersion = "5.12.0" orchestratorVersion = "1.6.1" photoviewVersion = "2.3.0" phraseVersion = "1.2.0" -lifecycleVersion = "2.9.4" +lifecycleVersion = "2.10.0" materialVersion = "1.13.0" mockitoKotlinVersion = "6.1.0" -okhttpVersion = "5.3.0" +okhttpVersion = "5.3.2" preferenceVersion = "1.2.1" -protobufVersion = "4.33.0" recyclerviewVersion = "1.4.0" -robolectricVersion = "4.14.1" +robolectricVersion = "4.16" roundedimageviewVersion = "2.1.0" runnerVersion = "1.7.0" rxbindingVersion = "3.1.0" @@ -65,13 +63,13 @@ subsamplingScaleImageViewVersion = "3.10.0" testCoreVersion = "1.7.0" truthVersion = "1.4.5" turbineVersion = "1.2.1" -uiTestJunit4Version = "1.9.4" -workRuntimeKtxVersion = "2.10.5" +uiTestJunit4Version = "1.10.1" +workRuntimeKtxVersion = "2.11.0" zxingVersion = "3.5.4" huaweiPushVersion = "6.13.0.300" googlePlayReviewVersion = "2.0.2" coilVersion = "3.3.0" -billingVersion = "8.0.0" +billingVersion = "8.3.0" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissionsVersion" } @@ -100,6 +98,7 @@ androidx-orchestrator = { module = "androidx.test:orchestrator", version.ref = " androidx-rules = { module = "androidx.test:rules", version.ref = "testCoreVersion" } androidx-runner = { module = "androidx.test:runner", version.ref = "runnerVersion" } androidx-sqlite-ktx = { module = "androidx.sqlite:sqlite-ktx", version.ref = "sqliteKtxVersion" } +androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqliteKtxVersion" } androidx-truth = { module = "androidx.test.ext:truth", version.ref = "testCoreVersion" } androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "uiTestJunit4Version" } @@ -110,7 +109,6 @@ agpApi = { module = "com.android.tools.build:gradle-api", version.ref = "gradleP assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertjCoreVersion" } conscrypt-openjdk-uber = { module = "org.conscrypt:conscrypt-openjdk-uber", version.ref = "conscryptJavaVersion" } copper-flow = { module = "app.cash.copper:copper-flow", version.ref = "copperFlowVersion" } -kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinVersion" } kryo = { module = "com.esotericsoftware:kryo", version.ref = "kryoVersion" } libsession-util-android = { module = "org.sessionfoundation:libsession-util-android", version.ref = "libsessionUtilAndroidVersion" } zxing-core = { module = "com.google.zxing:core", version.ref = "zxingVersion" } @@ -147,8 +145,6 @@ kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines- kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutinesVersion" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxJsonVersion" } kotlinx-coroutines-testing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesVersion" } -kovenant-android = { module = "nl.komponents.kovenant:kovenant-android", version.ref = "kovenantVersion" } -kovenant = { module = "nl.komponents.kovenant:kovenant", version.ref = "kovenantVersion" } material = { module = "com.google.android.material:material", version.ref = "materialVersion" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoCoreVersion" } mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlinVersion" } @@ -156,11 +152,9 @@ okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttpVersion" opencsv = { module = "com.opencsv:opencsv", version.ref = "opencsvVersion" } photoview = { module = "com.github.chrisbanes:PhotoView", version.ref = "photoviewVersion" } phrase = { module = "com.squareup.phrase:phrase", version.ref = "phraseVersion" } -protobuf-java = { module = "com.google.protobuf:protobuf-java", version.ref = "protobufVersion" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectricVersion" } roundedimageview = { module = "com.makeramen:roundedimageview", version.ref = "roundedimageviewVersion" } rxbinding = { module = "com.jakewharton.rxbinding3:rxbinding", version.ref = "rxbindingVersion" } -robolectric-shadows-multidex = { module = "org.robolectric:shadows-multidex", version.ref = "robolectricVersion" } sqlcipher-android = { module = "net.zetetic:sqlcipher-android", version.ref = "sqlcipherAndroidVersion" } stream = { module = "com.annimon:stream", version.ref = "streamVersion" } subsampling-scale-image-view = { module = "com.davemorrissey.labs:subsampling-scale-image-view", version.ref = "subsamplingScaleImageViewVersion" } @@ -170,11 +164,12 @@ huawei-push = { module = 'com.huawei.hms:push', 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" } -protoc = { module = "com.google.protobuf:protoc", version = "4.32.1" } 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" } android-billing-ktx = { module = "com.android.billingclient:billing-ktx", version.ref = "billingVersion" } +mockk = { module = "io.mockk:mockk", version = "1.14.9" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlinVersion" } [plugins] android-application = { id = "com.android.application", version.ref = "gradlePluginVersion" } @@ -187,4 +182,3 @@ ksp = { id = "com.google.devtools.ksp", version.ref = "kspVersion" } hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "daggerHiltVersion" } google-services = { id = "com.google.gms.google-services", version.ref = "googleServicesVersion" } dependency-analysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependenciesAnalysisVersion" } -protobuf-compiler = { id = "com.google.protobuf", version = "0.9.5" } \ No newline at end of file diff --git a/libsession/.gitignore b/libsession/.gitignore deleted file mode 100644 index 42afabfd2a..0000000000 --- a/libsession/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/libsession/build.gradle.kts b/libsession/build.gradle.kts deleted file mode 100644 index f951095391..0000000000 --- a/libsession/build.gradle.kts +++ /dev/null @@ -1,84 +0,0 @@ -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.plugin.serialization) - alias(libs.plugins.kotlin.plugin.parcelize) - alias(libs.plugins.ksp) - alias(libs.plugins.hilt.android) -} - -android { - defaultConfig { - compileSdk = libs.versions.androidCompileSdkVersion.get().toInt() - minSdk = libs.versions.androidMinSdkVersion.get().toInt() - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - - // The following argument makes the Android Test Orchestrator run its - // "pm clear" command after each test invocation. This command ensures - // that the app's state is completely cleared between tests. - testInstrumentationRunnerArguments["clearPackageData"] = "true" - testOptions { - execution = "ANDROIDX_TEST_ORCHESTRATOR" - } - - sourceSets { - getByName("test").java.srcDirs("src/AndroidTest/java/org/session/libsession") - } - } - - buildFeatures { - buildConfig = true - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - - kotlinOptions { - jvmTarget = "11" - } - - namespace = "org.session.libsession" -} - -dependencies { - implementation(project(":libsignal")) - implementation(project(":liblazysodium")) - - implementation(libs.hilt.android) - ksp(libs.dagger.hilt.compiler) - ksp(libs.androidx.hilt.compiler) - - api(libs.libsession.util.android) - - - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.appcompat) - implementation(libs.material) - implementation(libs.protobuf.java) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - implementation(libs.glide) - implementation(libs.stream) - implementation(libs.jackson.databind) - implementation(libs.okhttp) - implementation(libs.phrase) - implementation(libs.kotlin.reflect) - implementation(libs.kotlinx.coroutines.android) - implementation(libs.kotlinx.serialization.json) - implementation(libs.kovenant) - - implementation(libs.kotlinx.datetime) - - testImplementation(libs.junit) - testImplementation(libs.assertj.core) - testImplementation(libs.mockito.core) - testImplementation(libs.mockito.kotlin) - testImplementation(libs.androidx.core) - testImplementation(libs.androidx.core.testing) - testImplementation(libs.kotlinx.coroutines.testing) - testImplementation(libs.conscrypt.openjdk.uber) - implementation(libs.eventbus) -} \ No newline at end of file diff --git a/libsession/src/androidTest/java/org/session/libsession/LocalisedTimeStringTests.kt b/libsession/src/androidTest/java/org/session/libsession/LocalisedTimeStringTests.kt deleted file mode 100644 index 9a769ae4d5..0000000000 --- a/libsession/src/androidTest/java/org/session/libsession/LocalisedTimeStringTests.kt +++ /dev/null @@ -1,414 +0,0 @@ -package org.session.libsession - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import java.util.Locale -import junit.framework.TestCase -import kotlin.time.Duration.Companion.days -import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds -import org.junit.Test -import org.junit.runner.RunWith -import org.session.libsession.LocalisedTimeUtil.toShortTwoPartString -import org.session.libsignal.utilities.Log - -import android.text.format.DateUtils -import kotlin.math.abs - - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class LocalisedTimeStringTests { - private val TAG = "LocalisedTimeStringsTest" - - // Whether or not to print debug info during the test - can be useful - private val printDebug = true - - val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext - - // Test durations - private val oneSecond = 1.seconds - private val twoSeconds = 2.seconds - private val oneMinute = 1.minutes - private val twoMinutes = 2.minutes - private val oneHour = 1.hours - private val twoHours = 2.hours - private val oneDay = 1.days - private val twoDays = 2.days - private val oneDaySevenHours = 1.days.plus(7.hours) - private val fourDaysTwentyThreeHours = 4.days.plus(23.hours) - private val oneWeekTwoDays = 9.days - private val twoWeekTwoDays = 16.days - - // List of the above for each loop-based comparisons - private val allDurations = listOf( - oneSecond, - twoSeconds, - oneMinute, - twoMinutes, - oneHour, - twoHours, - oneDay, - twoDays, - oneDaySevenHours, - fourDaysTwentyThreeHours, - oneWeekTwoDays, - twoWeekTwoDays - ) - - // Method to get the localised time as the single largest time unit in the duration, e.g., - // - 90.minutes -> 1 hour - // - 30.hours -> 1 day - // - 170.hours -> 1 week - private fun performSingleTimeUnitStringComparison(expectedOutputsList: List) { - for (i in 0 until allDurations.count()) { - var txt = LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit(context, allDurations[i]) - if (printDebug) println("$i: Single time unit - expected: ${expectedOutputsList[i]} - got: $txt") - TestCase.assertEquals(expectedOutputsList[i], txt) - } - } - - // Method to get the localised time as the two largest time units in the duration, e.g., - // - 90.minutes -> 1 hour 30 minutes - // - 30.hours -> 1 day 6 hours - // - 170.hours -> 1 week 0 days - private fun performDualTimeUnitStringComparison(expectedOutputsList: List) { - for (i in 0 until allDurations.count()) { - val txt = LocalisedTimeUtil.getDurationWithDualTimeUnits(context, allDurations[i]) - if (printDebug) println("$i: Dual time units - expected: ${expectedOutputsList[i]} - got: $txt") - TestCase.assertEquals(expectedOutputsList[i], txt) - } - } - - @Test - fun testShortTimeDurations() { - // Non-localised short time descriptions. Note: We never localise shortened durations like these. - val shortTimeDescriptions = listOf( - "0m 1s", - "0m 2s", - "1m 0s", - "2m 0s", - "1h 0m", - "2h 0m", - "1d 0h", - "2d 0h", - "1d 7h", - "4d 23h", - "1w 2d", - "2w 2d" - ) - - for (i in 0 until shortTimeDescriptions.count()) { - val txt = allDurations[i].toShortTwoPartString() - if (printDebug) println("Short time strings - expected: ${shortTimeDescriptions[i]} - got: $txt") - TestCase.assertEquals(shortTimeDescriptions[i], txt) - } - } - - fun getRelativeTimeLocalized(timestampMS: Long): String { - // Get the current system time - val nowMS = System.currentTimeMillis() - - // Calculate the time difference in milliseconds - this value will be negative if it's in the - // future or positive if it's in the past. - val timeDifferenceMS = nowMS - timestampMS - - // Choose a desired time resolution based on the time difference. - // Note: We do this against the absolute time difference so this function can still work for - // both future/past times without having separate future/past cases. - val desiredResolution = when (abs(timeDifferenceMS)) { - in 0..DateUtils.MINUTE_IN_MILLIS -> DateUtils.SECOND_IN_MILLIS - in DateUtils.MINUTE_IN_MILLIS..DateUtils.HOUR_IN_MILLIS -> DateUtils.MINUTE_IN_MILLIS - in DateUtils.HOUR_IN_MILLIS..DateUtils.DAY_IN_MILLIS -> DateUtils.HOUR_IN_MILLIS - in DateUtils.DAY_IN_MILLIS..DateUtils.WEEK_IN_MILLIS -> DateUtils.DAY_IN_MILLIS - - // We don't do months or years, so if the result is 53 weeks then so be it - also, the - // getRelativeTimeSpanString method's resolution maxes out at weeks! - else -> DateUtils.WEEK_IN_MILLIS - } - - // Get the system locale - val locale = Locale.getDefault() - - // Use DateUtils to get the relative time span string - return DateUtils.getRelativeTimeSpanString( - timestampMS, - nowMS, - desiredResolution, - DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_ABBREV_RELATIVE // Try this w/ just FORMAT_ABBREV_RELATIVE - ).toString() - } - - @Test - fun testSystemGeneratedRelativeTimes() { - var t = 0L - - // 1 and 2 seconds ago - t = System.currentTimeMillis() - 1.seconds.inWholeMilliseconds - print(getRelativeTimeLocalized(t)) - t = System.currentTimeMillis() - 2.seconds.inWholeMilliseconds - print(getRelativeTimeLocalized(t)) - - // 1 and 2 minutes ago - t = System.currentTimeMillis() - 1.minutes.inWholeMilliseconds - print(getRelativeTimeLocalized(t)) - t = System.currentTimeMillis() - 2.minutes.inWholeMilliseconds - print(getRelativeTimeLocalized(t)) - - // 1 and 2 hours ago - t = System.currentTimeMillis() - 1.hours.inWholeMilliseconds - print(getRelativeTimeLocalized(t)) - t = System.currentTimeMillis() - 2.hours.inWholeMilliseconds - print(getRelativeTimeLocalized(t)) - - assert(true) - } - - // Unit test for durations in the English language. Note: We can pre-load the time-units string - // map via `LocalisedTimeUtil.loadTimeStringMap`, or alternatively they'll get loaded for the - // current context / locale on first use. - @Test - fun timeSpanStrings_EN() { - // Expected single largest time unit outputs for English - // Note: For all single largest time unit durations we may discard smaller time unit information as appropriate. - val expectedOutputsSingleEN = listOf( - "1 second", // 1 second - "2 seconds", // 2 seconds - "1 minute", // 1 minute - "2 minutes", // 2 minutes - "1 hour", // 1 hour - "2 hours", // 2 hours - "1 day", // 1 day - "2 days", // 2 days - "1 day", // 1 day 7 hours as single unit is: 1 day - "4 days", // 4 days 23 hours as single unit is: 4 days - "1 week", // 1 week 2 days as single unit is: 1 week - "2 weeks" // 2 weeks 2 days as single unit is: 2 weeks - ) - - // Expected dual largest time unit outputs for English - val expectedOutputsDualEN = listOf( - "0 minutes 1 second", // 1 second - "0 minutes 2 seconds", // 2 seconds - "1 minute 0 seconds", // 1 minute - "2 minutes 0 seconds", // 2 minutes - "1 hour 0 minutes", // 1 hour - "2 hours 0 minutes", // 2 hours - "1 day 0 hours", // 1 day - "2 days 0 hours", // 2 days - "1 day 7 hours", // 1 day 7 hours - "4 days 23 hours", // 4 days 23 hours - "1 week 2 days", // 1 week 2 days - "2 weeks 2 days" // 2 weeks 2 days - ) - - Locale.setDefault(Locale.ENGLISH) - if (printDebug) Log.w(TAG, "EN tests - current locale is: " + Locale.getDefault()) - performSingleTimeUnitStringComparison(expectedOutputsSingleEN) - performDualTimeUnitStringComparison(expectedOutputsDualEN) - } - - // Unit test for durations in the French language - @Test - fun timeSpanStrings_FR() { - // Expected single largest time unit outputs for French - val expectedOutputsSingle_FR = listOf( - "1 seconde", // 1 second - "2 secondes", // 2 seconds - "1 minute", // 1 minute - "2 minutes", // 2 minutes - "1 heure", // 1 hour - "2 heures", // 2 hours - "1 jour", // 1 day - "2 jours", // 2 days - "1 jour", // 1 day 7 hours as single unit is: 1 day - "4 jours", // 4 days 23 hours as single unit is: 4 days - "1 semaine", // 1 week 2 days as single unit is: 1 week - "2 semaines" // 2 weeks 2 days as single unit is: 2 weeks - ) - - // Expected dual largest time unit outputs for French - val expectedOutputsDual_FR = listOf( - "0 minutes 1 seconde", // 1 second - "0 minutes 2 secondes", // 2 seconds - "1 minute 0 secondes", // 1 minute - "2 minutes 0 secondes", // 2 minutes - "1 heure 0 minutes", // 1 hour - "2 heures 0 minutes", // 2 hours - "1 jour 0 heures", // 1 day - "2 jours 0 heures", // 2 days - "1 jour 7 heures", // 1 day 7 hours - "4 jours 23 heures", // 4 days 23 hours - "1 semaine 2 jours", // 1 week 2 days - "2 semaines 2 jours" // 2 weeks 2 days - ) - - Locale.setDefault(Locale.FRENCH) - if (printDebug) Log.w(TAG, "FR tests - current locale is: " + Locale.getDefault()) - performSingleTimeUnitStringComparison(expectedOutputsSingle_FR) - performDualTimeUnitStringComparison(expectedOutputsDual_FR) - } - - // Method to reverse the order of words in a string, separating on spaces. - // It is genuinely easier to do this with the RTL strings that fight through the chaos that is - // trying to use a mixed LTR/RTL mode in Android Studio, which is quite frankly a nightmare. - // - // Also: If you find yourself fighting with RTL stuff you can disable it in the Android Studio - // editor by finding your `idea.properties` file and adding the line `editor.disable.rtl=true` - // My file was at: ~/.local/share/JetBrains/Toolbox/apps/android-studio-2/bin/idea.properties - private fun String.reverseWordOrder(): String { - return this.split(" ").reversed().joinToString(" ") - } - - // Unit test for durations in the Arabic language - @Test - fun timeSpanStrings_AR() { - // Expected single largest time unit outputs for Arabic. - // - // Note: As Arabic is a Right-to-Left language the number goes on the right! - // - // Also: This is not a PERFECT mapping to the correct time unit plural phrasings for Arabic - // because they have six separate time units based on 0, 1..2, 3..9, 11.12, 21..99, as well - // as round values of 10 in the range 10..90 (i.e., 10, 20, 30, ..., 80, 90). Our custom - // time unit phrases only handle singular & plural - so we're not going to be perfect, but - // we'll be good enough to get our point across, like if you said to me "See you in 3 day" - // I'd know that you means "See you in 3 dayS". - // Further reading: https://www.fluentarabic.net/numbers-in-arabic/ - val expectedOutputsSingleAR = listOf( - "1 ثانية".reverseWordOrder(), // 1 second - "2 ثانية".reverseWordOrder(), // 2 seconds - "1 دقيقة".reverseWordOrder(), // 1 minute - "2 دقائق".reverseWordOrder(), // 2 minutes - "1 ساعة".reverseWordOrder(), // 1 hour - "2 ساعات".reverseWordOrder(), // 2 hours - "1 يوم".reverseWordOrder(), // 1 day - "2 أيام".reverseWordOrder(), // 2 days - "1 يوم".reverseWordOrder(), // 1 day 7 hours as single unit (1 day) - "4 أيام".reverseWordOrder(), // 4 days 23 hours as single unit (4 days) - "1 أسبوع".reverseWordOrder(), // 1 week 2 days as single unit (1 week) - "2 أسابيع".reverseWordOrder() // 2 weeks 2 days as single unit (2 weeks) - ) - - // Arabic dual unit times (largest time unit is on the right!) - val expectedOutputsDualAR = listOf( - "0 دقائق 1 ثانية".reverseWordOrder(), // 0 minutes 1 second - "0 دقائق 2 ثانية".reverseWordOrder(), // 0 minutes 2 seconds - "1 دقيقة 0 ثانية".reverseWordOrder(), // 1 minute 0 seconds - "2 دقائق 0 ثانية".reverseWordOrder(), // 2 minutes 0 seconds - "1 ساعة 0 دقائق".reverseWordOrder(), // 1 hour 0 minutes - "2 ساعات 0 دقائق".reverseWordOrder(), // 2 hours 0 minutes - "1 يوم 0 ساعات".reverseWordOrder(), // 1 day 0 hours - "2 أيام 0 ساعات".reverseWordOrder(), // 2 days 0 hours - "1 يوم 7 ساعات".reverseWordOrder(), // 1 day 7 hours as single unit (1 day) - "4 أيام 23 ساعات".reverseWordOrder(), // 4 days 23 hours as single unit (4 days) - "1 أسبوع 2 أيام".reverseWordOrder(), // 1 week 2 days as single unit (1 week) - "2 أسابيع 2 أيام".reverseWordOrder() // 2 weeks 2 days as single unit (2 weeks) - ) - - Locale.setDefault(Locale.forLanguageTag("ar")) - if (printDebug) Log.w(TAG, "AR tests - current locale is: " + Locale.getDefault()) - - // Just changing the context language won't result in the app being in RTL mode so we'll - // force LocalisedTimeUtils to respond in RTL mode just for this instrumented test. - LocalisedTimeUtil.forceUseOfRtlForTests(true) - - performSingleTimeUnitStringComparison(expectedOutputsSingleAR) - performDualTimeUnitStringComparison(expectedOutputsDualAR) - } - - // Unit test for durations in the Japanese language - @Test - fun timeSpanStrings_JA() { - // Expected single largest time unit outputs for Japanese. - // Note: The plural for multiple days below is technically incorrect because we only get - // the added symbol (日間) for 5 days and above - but this will be correct more of the time - // than just using the '1..4 days' symbol (日) for day plurals. - val expectedOutputsSingle_JA = listOf( - "1 秒", // 1 second - "2 秒", // 2 seconds - "1 分", // 1 minute - "2 分", // 2 minutes - "1 時間", // 1 hour - "2 時間", // 2 hours - "1 日", // 1 day - "2 日間", // 2 days. - "1 日", // 1 day 7 hours as single unit is: 1 day - "4 日間", // 4 days 23 hours as single unit is: 4 days - "1 週間", // 1 week 2 days as single unit is: 1 week - "2 週間" // 2 weeks 2 days as single unit is: 2 weeks - ) - - // Expected dual largest time unit outputs for Japanese - val expectedOutputsDual_JA = listOf( - "0 分 1 秒", // 1 second - "0 分 2 秒", // 2 seconds - "1 分 0 秒", // 1 minute - "2 分 0 秒", // 2 minutes - "1 時間 0 分", // 1 hour - "2 時間 0 分", // 2 hours - "1 日 0 時間", // 1 day - "2 日間 0 時間", // 2 days - "1 日 7 時間", // 1 day 7 hours - "4 日間 23 時間", // 4 days 23 hours - "1 週間 2 日間", // 1 week 2 days - "2 週間 2 日間" // 2 weeks 2 days - ) - - Locale.setDefault(Locale.forLanguageTag("ja")) - if (printDebug) Log.w(TAG, "JA tests - current locale is: " + Locale.getDefault()) - - performSingleTimeUnitStringComparison(expectedOutputsSingle_JA) - performDualTimeUnitStringComparison(expectedOutputsDual_JA) - } - - // Unit test for durations in the Urdu language (RTL language) - @Test - fun timeSpanStrings_UR() { - // Expected single largest time unit outputs for Urdu - val expectedOutputsSingle_UR = listOf( - "1 سیکنڈ".reverseWordOrder(), // 1 second - "2 سیکنڈ".reverseWordOrder(), // 2 seconds - "1 منٹ".reverseWordOrder(), // 1 minute - "2 منٹ".reverseWordOrder(), // 2 minutes - "1 گھنٹہ".reverseWordOrder(), // 1 hour - "2 گھنٹے".reverseWordOrder(), // 2 hours - "1 دن".reverseWordOrder(), // 1 day - "2 دن".reverseWordOrder(), // 2 days. - "1 دن".reverseWordOrder(), // 1 day 7 hours as single unit is: 1 day - "4 دن".reverseWordOrder(), // 4 days 23 hours as single unit is: 4 days - "1 ہفتہ".reverseWordOrder(), // 1 week 2 days as single unit is: 1 week - "2 ہفتے".reverseWordOrder() // 2 weeks 2 days as single unit is: 2 weeks - ) - - // Expected dual largest time unit outputs for Urdu - val expectedOutputsDual_UR = listOf( - "0 منٹ 1 سیکنڈ".reverseWordOrder(), // 1 second -> 0 minutes 1 second - "0 منٹ 2 سیکنڈ".reverseWordOrder(), // 2 seconds -> 0 minutes 2 seconds - "1 منٹ 0 سیکنڈ".reverseWordOrder(), // 1 minute -> 1 minute 0 seconds - "2 منٹ 0 سیکنڈ".reverseWordOrder(), // 2 minutes -> 2 minutes 0 seconds - "1 گھنٹہ 0 منٹ".reverseWordOrder(), // 1 hour -> 1 hour 0 minutes - "2 گھنٹے 0 منٹ".reverseWordOrder(), // 2 hours -> 2 hours 0 minutes - "1 دن 0 گھنٹے".reverseWordOrder(), // 1 day -> 1 day 0 hours - "2 دن 0 گھنٹے".reverseWordOrder(), // 2 days -> 2 days 0 hours - "1 دن 7 گھنٹے".reverseWordOrder(), // 1 day 7 hours - "4 دن 23 گھنٹے".reverseWordOrder(), // 4 days 23 hours - "1 ہفتہ 2 دن".reverseWordOrder(), // 1 week 2 days - "2 ہفتے 2 دن".reverseWordOrder() // 2 weeks 2 days - ) - - Locale.setDefault(Locale.forLanguageTag("ur")) - if (printDebug) Log.w(TAG, "UR tests - current locale is: " + Locale.getDefault()) - - // Just changing the context language won't result in the app being in RTL mode so we'll - // force LocalisedTimeUtils to respond in RTL mode just for this instrumented test. - LocalisedTimeUtil.forceUseOfRtlForTests(true) - - performSingleTimeUnitStringComparison(expectedOutputsSingle_UR) - performDualTimeUnitStringComparison(expectedOutputsDual_UR) - } -} diff --git a/libsession/src/debug/res/values/values.xml b/libsession/src/debug/res/values/values.xml deleted file mode 100644 index 207edfc843..0000000000 --- a/libsession/src/debug/res/values/values.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - false - diff --git a/libsession/src/main/AndroidManifest.xml b/libsession/src/main/AndroidManifest.xml deleted file mode 100644 index 568741e54f..0000000000 --- a/libsession/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/libsession/src/main/res/values/arrays.xml b/libsession/src/main/res/values/arrays.xml deleted file mode 100644 index fd188b1eb1..0000000000 --- a/libsession/src/main/res/values/arrays.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - default - custom - - - - all - contact - none - - - - - - image - audio - - - - image - audio - video - documents - - - - - - - - #ffffff - #ff0000 - #ff00ff - #0000ff - #00ffff - #00ff00 - #ffff00 - #ff5500 - #000000 - - - diff --git a/libsession/src/main/res/values/attrs.xml b/libsession/src/main/res/values/attrs.xml deleted file mode 100644 index e4a1f90aa7..0000000000 --- a/libsession/src/main/res/values/attrs.xml +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/libsession/src/main/res/values/colors.xml b/libsession/src/main/res/values/colors.xml deleted file mode 100644 index 05ee740633..0000000000 --- a/libsession/src/main/res/values/colors.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - #353535 - #171717 - #36383C - #333132 - #1B1B1B - #141414 - #FFCE3A - - #ffffffff - #ff000000 - #ffababab - #ffbbbbbb - #ff808080 - #ff595959 - - #30000000 - #70000000 - #90000000 - - #30ffffff - #40ffffff - #aaffffff - #bbffffff - - #00FFFFFF - #00000000 - - @color/transparent_black_90 - - - - diff --git a/libsession/src/main/res/values/core_colors.xml b/libsession/src/main/res/values/core_colors.xml deleted file mode 100644 index e99bedcc97..0000000000 --- a/libsession/src/main/res/values/core_colors.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - #5bca5b - #f44336 - - #ffffff - #000000 - - #f8f9f9 - #eeefef - #d5d6d6 - #bbbdbe - #898a8c - #6b6d70 - #3d3e44 - #23252a - #17191d - #0f1012 - \ No newline at end of file diff --git a/libsession/src/main/res/values/crop_area_renderer.xml b/libsession/src/main/res/values/crop_area_renderer.xml deleted file mode 100644 index 953c9c04ca..0000000000 --- a/libsession/src/main/res/values/crop_area_renderer.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - 32dp - 2dp - - #ffffffff - #7f000000 - - \ No newline at end of file diff --git a/libsession/src/main/res/values/dimens.xml b/libsession/src/main/res/values/dimens.xml deleted file mode 100644 index 5428b89e65..0000000000 --- a/libsession/src/main/res/values/dimens.xml +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - - 12sp - 15sp - 17sp - 22sp - 26sp - - - 34dp - 38dp - 22dp - 4dp - 26dp - 36dp - 46dp - 76dp - - 14dp - 1dp - 36dp - 8dp - 10dp - 56dp - 8dp - 8dp - 8dp - 56dp - 8dp - 16dp - - - 8dp - 16dp - 24dp - 35dp - 64dp - 56dp - - - - 50dp - 220dp - 110dp - 170dp - 48dp - 16sp - 64dp - - 210dp - 105dp - 104dp - 140dp - 139dp - 69dp - 69dp - - 18dp - 4dp - 2dp - 1.5dp - 24dp - 24dp - 210dp - 150dp - - 175dp - 85dp - - 5dp - 4dp - - 120dp - - 40dp - - 10dp - - 13sp - - - - - - - - 14dp - - 24dp - 16dp - 56dp - - diff --git a/libsession/src/main/res/values/emoji.xml b/libsession/src/main/res/values/emoji.xml deleted file mode 100644 index 045e125f3d..0000000000 --- a/libsession/src/main/res/values/emoji.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/libsession/src/main/res/values/google-playstore-strings.xml b/libsession/src/main/res/values/google-playstore-strings.xml deleted file mode 100644 index 9054f40731..0000000000 --- a/libsession/src/main/res/values/google-playstore-strings.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - diff --git a/libsession/src/main/res/values/ic_launcher_background.xml b/libsession/src/main/res/values/ic_launcher_background.xml deleted file mode 100644 index 8e21cacbc4..0000000000 --- a/libsession/src/main/res/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #333132 - \ No newline at end of file diff --git a/libsession/src/main/res/values/ids.xml b/libsession/src/main/res/values/ids.xml deleted file mode 100644 index 045e125f3d..0000000000 --- a/libsession/src/main/res/values/ids.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/libsession/src/main/res/values/integers.xml b/libsession/src/main/res/values/integers.xml deleted file mode 100644 index 55344e5192..0000000000 --- a/libsession/src/main/res/values/integers.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/libsession/src/main/res/values/material_colors.xml b/libsession/src/main/res/values/material_colors.xml deleted file mode 100644 index 660f6c1718..0000000000 --- a/libsession/src/main/res/values/material_colors.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - #EF5350 - #F44336 - - #66BB6A - #4CAF50 - - #FFEB3B - - #78909C - - #E91E63 - - #26C6DA - #00BCD4 - - #AB47BC - - #42A5F5 - #2196F3 - - #FFA726 - - #F5F5F5 - #BDBDBD - #757575 - #424242 - #212121 - - #44BDBDBD - \ No newline at end of file diff --git a/libsession/src/main/res/values/text_styles.xml b/libsession/src/main/res/values/text_styles.xml deleted file mode 100644 index 54bd3c0f38..0000000000 --- a/libsession/src/main/res/values/text_styles.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/libsession/src/main/res/values/themes.xml b/libsession/src/main/res/values/themes.xml deleted file mode 100644 index 0d2c4cc409..0000000000 --- a/libsession/src/main/res/values/themes.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/libsession/src/main/res/values/values.xml b/libsession/src/main/res/values/values.xml deleted file mode 100644 index 5ac108e06b..0000000000 --- a/libsession/src/main/res/values/values.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - true - diff --git a/libsession/src/test/java/org/session/libsession/ExampleUnitTest.kt b/libsession/src/test/java/org/session/libsession/ExampleUnitTest.kt deleted file mode 100644 index bd68f901ef..0000000000 --- a/libsession/src/test/java/org/session/libsession/ExampleUnitTest.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.session.libsession - -import android.text.format.DateUtils -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - - /* - val s = getLocalizedTodayString() - println(s) - - val now = Calendar.getInstance() - val startOfDay = Calendar.getInstance().apply { - set(Calendar.HOUR_OF_DAY, 0) - set(Calendar.MINUTE, 0) - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - } - - val s2 = DateUtils.getRelativeTimeSpanString( - startOfDay.timeInMillis, - now.timeInMillis, - DateUtils.DAY_IN_MILLIS, - DateUtils.FORMAT_SHOW_DATE - ).toString(); - println(s2) - */ - } -} \ No newline at end of file diff --git a/libsession/src/test/java/org/session/libsession/utilities/BencoderTest.kt b/libsession/src/test/java/org/session/libsession/utilities/BencoderTest.kt deleted file mode 100644 index d96fa6658f..0000000000 --- a/libsession/src/test/java/org/session/libsession/utilities/BencoderTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -package org.session.libsession.utilities - -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Test -import org.session.libsession.utilities.bencode.Bencode -import org.session.libsession.utilities.bencode.BencodeDict -import org.session.libsession.utilities.bencode.BencodeInteger -import org.session.libsession.utilities.bencode.BencodeList -import org.session.libsession.utilities.bencode.bencode - -class BencoderTest { - - @Test - fun `it should decode a basic string`() { - val basicString = "5:howdy".toByteArray() - val bencoder = Bencode.Decoder(basicString) - val result = bencoder.decode() - assertEquals("howdy".bencode(), result) - } - - @Test - fun `it should decode a basic integer`() { - val basicInteger = "i3e".toByteArray() - val bencoder = Bencode.Decoder(basicInteger) - val result = bencoder.decode() - assertEquals(BencodeInteger(3), result) - } - - @Test - fun `it should decode a list of integers`() { - val basicIntList = "li1ei2ee".toByteArray() - val bencoder = Bencode.Decoder(basicIntList) - val result = bencoder.decode() - assertEquals( - BencodeList( - 1.bencode(), - 2.bencode() - ), - result - ) - } - - @Test - fun `it should decode a basic dict`() { - val basicDict = "d4:spaml1:a1:bee".toByteArray() - val bencoder = Bencode.Decoder(basicDict) - val result = bencoder.decode() - assertEquals( - BencodeDict( - "spam" to BencodeList( - "a".bencode(), - "b".bencode() - ) - ), - result - ) - } - - @Test - fun `it should encode a basic string`() { - val basicString = "5:howdy".toByteArray() - val element = "howdy".bencode() - assertArrayEquals(basicString, element.encode()) - } - - @Test - fun `it should encode a basic int`() { - val basicInt = "i3e".toByteArray() - val element = 3.bencode() - assertArrayEquals(basicInt, element.encode()) - } - - @Test - fun `it should encode a basic list`() { - val basicList = "li1ei2ee".toByteArray() - val element = BencodeList(1.bencode(),2.bencode()) - assertArrayEquals(basicList, element.encode()) - } - - @Test - fun `it should encode a basic dict`() { - val basicDict = "d4:spaml1:a1:bee".toByteArray() - val element = BencodeDict( - "spam" to BencodeList( - "a".bencode(), - "b".bencode() - ) - ) - assertArrayEquals(basicDict, element.encode()) - } - - @Test - fun `it should encode a more complex real world case`() { - val source = "d15:lastReadMessaged66:031122334455667788990011223344556677889900112233445566778899001122i1234568790e66:051122334455667788990011223344556677889900112233445566778899001122i1234568790ee5:seqNoi1ee".toByteArray() - val result = Bencode.Decoder(source).decode() - val expected = BencodeDict( - "lastReadMessage" to BencodeDict( - "051122334455667788990011223344556677889900112233445566778899001122" to 1234568790.bencode(), - "031122334455667788990011223344556677889900112233445566778899001122" to 1234568790.bencode() - ), - "seqNo" to BencodeInteger(1) - ) - assertEquals(expected, result) - } - -} \ No newline at end of file diff --git a/libsession/src/test/java/org/session/libsession/utilities/CommunityUrlParserTest.kt b/libsession/src/test/java/org/session/libsession/utilities/CommunityUrlParserTest.kt deleted file mode 100644 index f999eef850..0000000000 --- a/libsession/src/test/java/org/session/libsession/utilities/CommunityUrlParserTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -package org.session.libsession.utilities - -import org.junit.Assert.assertEquals -import org.junit.Test - -class CommunityUrlParserTest { - - @Test - fun parseUrlTest() { - val inputUrl = "https://sessionopengroup.co/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" - - val expectedHost = "https://sessionopengroup.co" - val expectedRoom = "main" - val expectedPublicKey = "658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" - - val result = OpenGroupUrlParser.parseUrl(inputUrl) - assertEquals(expectedHost, result.server) - assertEquals(expectedRoom, result.room) - assertEquals(expectedPublicKey, result.serverPublicKey) - } - - @Test - fun parseUrlNoHttpTest() { - val inputUrl = "sessionopengroup.co/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" - - val expectedHost = "http://sessionopengroup.co" - val expectedRoom = "main" - val expectedPublicKey = "658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" - - val result = OpenGroupUrlParser.parseUrl(inputUrl) - assertEquals(expectedHost, result.server) - assertEquals(expectedRoom, result.room) - assertEquals(expectedPublicKey, result.serverPublicKey) - } - - @Test - fun parseUrlWithIpTest() { - val inputUrl = "https://143.198.213.255:80/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" - - val expectedHost = "https://143.198.213.255:80" - val expectedRoom = "main" - val expectedPublicKey = "658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" - - val result = OpenGroupUrlParser.parseUrl(inputUrl) - assertEquals(expectedHost, result.server) - assertEquals(expectedRoom, result.room) - assertEquals(expectedPublicKey, result.serverPublicKey) - } - - @Test - fun parseUrlWithIpAndNoHttpTest() { - val inputUrl = "143.198.213.255/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" - - val expectedHost = "http://143.198.213.255" - val expectedRoom = "main" - val expectedPublicKey = "658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" - - val result = OpenGroupUrlParser.parseUrl(inputUrl) - assertEquals(expectedHost, result.server) - assertEquals(expectedRoom, result.room) - assertEquals(expectedPublicKey, result.serverPublicKey) - } - - @Test(expected = OpenGroupUrlParser.Error.MalformedURL::class) - fun parseUrlMalformedUrlTest() { - val inputUrl = "file:sessionopengroup.co/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" - OpenGroupUrlParser.parseUrl(inputUrl) - } - - @Test(expected = OpenGroupUrlParser.Error.NoRoomSpecified::class) - fun parseUrlNoRoomSpecifiedTest() { - val inputUrl = "https://sessionopengroup.comain?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" - OpenGroupUrlParser.parseUrl(inputUrl) - } - - @Test(expected = OpenGroupUrlParser.Error.NoPublicKey::class) - fun parseUrlNoPublicKeySpecifiedTest() { - val inputUrl = "https://sessionopengroup.co/main" - OpenGroupUrlParser.parseUrl(inputUrl) - } - - @Test(expected = OpenGroupUrlParser.Error.InvalidPublicKey::class) - fun parseUrlInvalidPublicKeyProviedTest() { - val inputUrl = "https://sessionopengroup.co/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adff" - OpenGroupUrlParser.parseUrl(inputUrl) - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 19e2d266c9..603ce10731 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,7 +13,8 @@ includeBuild("build-logic") // If libsession_util_project_path is set, include it as a build dependency val libSessionUtilProjectPath: String = System.getProperty("session.libsession_util.project.path", "") if (libSessionUtilProjectPath.isNotBlank()) { - includeBuild(libSessionUtilProjectPath) + include(":libsession-util-android") + project(":libsession-util-android").projectDir = file(libSessionUtilProjectPath).resolve("library") } include(":app")