Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package org.session.libsession.messaging.groups

import android.content.Context
import com.squareup.phrase.Phrase
import org.session.libsession.utilities.Phrase
import network.loki.messenger.R
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY
Expand Down
118 changes: 118 additions & 0 deletions app/src/main/java/org/session/libsession/utilities/Phrase.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package org.session.libsession.utilities

import android.content.Context
import android.content.res.Resources
import android.view.View
import android.widget.TextView
import com.squareup.phrase.Phrase as SquarePhrase

/**
* Drop-in replacement for [com.squareup.phrase.Phrase] which never throws when a substitution
* doesn't line up with the pattern.
*
* Square's implementation throws [IllegalArgumentException] both when [put] is given a key the
* pattern doesn't contain ("Invalid key") and when [format] is reached with a key the pattern does
* contain but nothing was bound to ("Missing keys"). Either is a crash in front of the user for
* what is only ever a text problem, and both became far more likely once the Crowdin pipeline
* started substituting the non-translatable constants at generation time — every `{app_name}` the
* client still tried to fill would take the first path.
*
* This instead matches iOS's `LocalizationHelper`: an unknown key is ignored, and a key the
* pattern contains but nothing filled is left in the output verbatim (`{app_name}`), so the failure
* is visible in the UI rather than fatal.
*
* Formatting itself is still delegated to Square's implementation so that spans carried by the
* resource (the `<b>`/`<font>` markup the generated `strings.xml` contains) survive substitution.
*/
class Phrase private constructor(private val pattern: CharSequence) {
private val delegate: SquarePhrase? = try {
SquarePhrase.from(pattern)
} catch (e: IllegalArgumentException) {
// The pattern isn't valid Phrase syntax (keys must be lower case a-z/`_`). Square would
// have thrown before we could substitute anything, so fall back to plain replacement.
null
}
private val boundKeys: MutableSet<String> = mutableSetOf()
private val fallbackValues: MutableMap<String, CharSequence> = mutableMapOf()

fun put(key: String, value: CharSequence?): Phrase {
// A null value can't be substituted, so leave the token in place rather than guessing
if (value == null) return this

boundKeys.add(key)

if (delegate == null) {
fallbackValues[key] = value
} else {
// putOptional (rather than put) so a key the pattern doesn't contain is ignored
delegate.putOptional(key, value)
}

return this
}

fun put(key: String, value: Int): Phrase = put(key, value.toString())

fun putOptional(key: String, value: CharSequence?): Phrase = put(key, value)

fun putOptional(key: String, value: Int): Phrase = put(key, value.toString())

fun format(): CharSequence {
val delegate = this.delegate ?: return formatWithoutDelegate()

// Bind anything the pattern still expects to its own token so that `format` can't throw
unboundKeys().forEach { delegate.putOptional(it, "{$it}") }

return delegate.format()
}

fun into(target: TextView?) {
target?.text = format()
}

override fun toString(): String = format().toString()

private fun unboundKeys(): List<String> =
KEY_PATTERN.findAll(pattern)
.map { it.groupValues[1] }
.distinct()
.filterNot { boundKeys.contains(it) }
.toList()

private fun formatWithoutDelegate(): CharSequence =
fallbackValues.entries.fold(pattern.toString()) { result, (key, value) ->
result.replace("{$key}", value.toString())
}

companion object {
// Matches Square's key grammar: lower case a-z plus `_`, and `{{` escapes a literal brace.
// Note: the closing brace has to be escaped. Android matches with ICU, which rejects a bare
// `}` as a dangling quantifier — unlike the JVM's java.util.regex, so an unescaped one
// compiles fine under Robolectric and then throws PatternSyntaxException on a device.
private val KEY_PATTERN = Regex("""(?<!\{)\{([a-z_][a-z_0-9]*)\}""")

@JvmStatic
fun from(pattern: CharSequence): Phrase = Phrase(pattern)

@JvmStatic
fun from(context: Context, resId: Int): Phrase = Phrase(context.getText(resId))

@JvmStatic
fun from(resources: Resources, resId: Int): Phrase = Phrase(resources.getText(resId))

@JvmStatic
fun from(view: View, resId: Int): Phrase = Phrase(view.resources.getText(resId))

@JvmStatic
fun fromPlural(context: Context, resId: Int, quantity: Int): Phrase =
fromPlural(context.resources, resId, quantity)

@JvmStatic
fun fromPlural(resources: Resources, resId: Int, quantity: Int): Phrase =
Phrase(resources.getQuantityText(resId, quantity))

@JvmStatic
fun fromPlural(view: View, resId: Int, quantity: Int): Phrase =
fromPlural(view.resources, resId, quantity)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ package org.session.libsession.utilities
typealias StringSubKey = String

// String substitution keys for use with the Phrase library.
// Note: The substitution will be to {app_name} etc. in the strings - but do NOT include the curly braces in these keys!
// Note: The substitution will be to {name} etc. in the strings - but do NOT include the curly braces in these keys!
// Note: Non-translatable constants ({app_name}, {pro}, ...) are NOT listed here — they are substituted
// when the strings are generated from Crowdin, so they never reach the runtime as tokens.
object StringSubstitutionConstants {
const val ACCOUNT_ID_KEY: StringSubKey = "account_id"
const val APP_NAME_KEY: StringSubKey = "app_name"
const val AUTHOR_KEY: StringSubKey = "author"
const val COMMUNITY_NAME_KEY: StringSubKey = "community_name"
const val CONVERSATION_COUNT_KEY: StringSubKey = "conversation_count"
Expand All @@ -16,7 +17,6 @@ object StringSubstitutionConstants {
const val DATE_KEY: StringSubKey = "date"
const val DATE_TIME_KEY: StringSubKey = "date_time"
const val DISAPPEARING_MESSAGES_TYPE_KEY: StringSubKey = "disappearing_messages_type"
const val DOWNLOAD_URL_KEY: StringSubKey = "session_download_url" // Used to invite people to download Session
const val EMOJI_KEY: StringSubKey = "emoji"
const val ETHEREUM_KEY: StringSubKey = "ethereum"
const val FILE_TYPE_KEY: StringSubKey = "file_type"
Expand All @@ -26,30 +26,23 @@ object StringSubstitutionConstants {
const val MESSAGE_COUNT_KEY: StringSubKey = "message_count"
const val MESSAGE_SNIPPET_KEY: StringSubKey = "message_snippet"
const val NAME_KEY: StringSubKey = "name"
const val NETWORK_NAME_KEY: StringSubKey = "network_name"
const val OTHER_NAME_KEY: StringSubKey = "other_name"
const val PRICE_DATA_POWERED_BY_KEY: StringSubKey = "price_data_powered_by"
const val QUERY_KEY: StringSubKey = "query"
const val RELATIVE_TIME_KEY: StringSubKey = "relative_time"
const val SECONDS_KEY: StringSubKey = "seconds"
const val SESSION_DOWNLOAD_URL_KEY: StringSubKey = "session_download_url"
const val STAKING_REWARD_POOL_KEY: StringSubKey = "staking_reward_pool"
const val TIME_KEY: StringSubKey = "time"
const val TIME_LARGE_KEY: StringSubKey = "time_large"
const val TIME_SMALL_KEY: StringSubKey = "time_small"
const val TOKEN_BONUS_TITLE_KEY: StringSubKey = "token_bonus_title"
const val TOKEN_NAME_LONG_KEY: StringSubKey = "token_name_long"
const val TOKEN_NAME_LONG_PLURAL_KEY: StringSubKey = "token_name_long_plural"
const val TOKEN_NAME_SHORT_KEY: StringSubKey = "token_name_short"
const val TOTAL_COUNT_KEY: StringSubKey = "total_count"
const val URL_KEY: StringSubKey = "url"
const val VALUE_KEY: StringSubKey = "value"
const val VERSION_KEY: StringSubKey = "version"
const val LIMIT_KEY: StringSubKey = "limit"
const val STORE_VARIANT_KEY: StringSubKey = "storevariant"
const val BUILD_VARIANT_KEY: StringSubKey = "build_variant"
const val APP_PRO_KEY: StringSubKey = "app_pro"
const val PRO_KEY: StringSubKey = "pro"
const val PLAN_LENGTH_KEY: StringSubKey = "plan_length"
const val CURRENT_PLAN_LENGTH_KEY: StringSubKey = "current_plan_length"
const val SELECTED_PLAN_LENGTH_KEY: StringSubKey = "selected_plan_length"
Expand All @@ -62,10 +55,7 @@ object StringSubstitutionConstants {
const val PRICE_KEY: StringSubKey = "price"
const val PERCENT_KEY: StringSubKey = "percent"
const val DEVICE_TYPE_KEY: StringSubKey = "device_type"
const val SESSION_FOUNDATION_KEY: StringSubKey = "session_foundation"
const val ACTION_TYPE_KEY: StringSubKey = "action_type"
const val ACTIVATION_TYPE_KEY: StringSubKey = "activation_type"
const val ENTITY_KEY: StringSubKey = "entity"
const val DONATE_APPEAL_KEY: StringSubKey = "donate_appeal_name"
const val ENTITY_STF_SHORT_KEY: StringSubKey = "entity_stf_short"
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package org.thoughtcrime.securesms

import android.content.Context
import androidx.lifecycle.ViewModel
import com.squareup.phrase.Phrase
import org.session.libsession.utilities.Phrase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager
import com.squareup.phrase.Phrase
import org.session.libsession.utilities.Phrase
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
Expand All @@ -75,7 +75,6 @@ import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
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
import org.session.libsession.utilities.isGroupOrCommunity
import org.session.libsession.utilities.isLegacyGroup
Expand Down Expand Up @@ -557,7 +556,6 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(),
applicationContext,
R.string.permissionsStorageDeniedLegacy
)
.put(APP_NAME_KEY, getString(R.string.app_name))
.format().toString()

private fun sendMediaSavedNotificationIfNeeded() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,9 @@ import android.widget.Toast
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricManager
import androidx.core.content.ContextCompat
import com.squareup.phrase.Phrase
import org.session.libsession.utilities.Phrase
import java.lang.Exception
import network.loki.messenger.R
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.TextSecurePreferences.Companion.isScreenLockEnabled
import org.session.libsession.utilities.TextSecurePreferences.Companion.setScreenLockEnabled
Expand Down Expand Up @@ -303,7 +302,6 @@ class ScreenLockActivity : BaseActionBarActivity() {
private fun initializeResources() {
val statusTitle = findViewById<TextView>(R.id.app_lock_status_title)
statusTitle?.text = Phrase.from(applicationContext, R.string.lockAppLocked)
.put(APP_NAME_KEY, getString(R.string.app_name))
.format().toString()

fingerprintPrompt = findViewById(R.id.fingerprint_auth_container)
Expand Down
4 changes: 1 addition & 3 deletions app/src/main/java/org/thoughtcrime/securesms/ShareScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.squareup.phrase.Phrase
import org.session.libsession.utilities.Phrase
import network.loki.messenger.R
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.thoughtcrime.securesms.groups.compose.MemberItem
import org.thoughtcrime.securesms.ui.SearchBar
import org.thoughtcrime.securesms.ui.components.BackAppBar
Expand Down Expand Up @@ -78,7 +77,6 @@ fun ShareList(
topBar = {
BackAppBar(
title = Phrase.from(LocalContext.current, R.string.shareToSession)
.put(APP_NAME_KEY, stringResource(R.string.app_name))
.format().toString(),
onBack = onBack,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.squareup.phrase.Phrase
import org.session.libsession.utilities.Phrase
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.withCreationCallback
import kotlinx.coroutines.CancellationException
Expand Down Expand Up @@ -115,7 +115,6 @@ 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
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.CONVERSATION_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
Expand Down Expand Up @@ -210,7 +209,6 @@ import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository
import org.thoughtcrime.securesms.ui.LatchedAnimatedVisibility
import org.thoughtcrime.securesms.ui.components.AudioMiniPlayer
import org.thoughtcrime.securesms.ui.components.ConversationAppBar
import org.thoughtcrime.securesms.ui.getSubbedString
import org.thoughtcrime.securesms.ui.setThemedContent
import org.thoughtcrime.securesms.util.ActivityDispatcher
import org.thoughtcrime.securesms.util.DateUtils
Expand Down Expand Up @@ -1733,8 +1731,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate,
Permissions.with(this)
.request(Manifest.permission.RECORD_AUDIO)
.withPermanentDenialDialog(
getSubbedString(R.string.permissionsMicrophoneAccessRequired,
APP_NAME_KEY to getString(R.string.app_name))
getString(R.string.permissionsMicrophoneAccessRequired)
)
.execute()

Expand Down Expand Up @@ -2578,7 +2575,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate,
if (!hasSeenGIFMetaDataWarning) {
showSessionDialog {
title(R.string.giphyWarning)
text(Phrase.from(context, R.string.giphyWarningDescription).put(APP_NAME_KEY, getString(R.string.app_name)).format())
text(Phrase.from(context, R.string.giphyWarningDescription).format())
button(R.string.theContinue) {
textSecurePreferences.setHasSeenGIFMetaDataWarning()
selectGif()
Expand Down Expand Up @@ -2738,7 +2735,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate,
Permissions.with(this)
.request(Manifest.permission.RECORD_AUDIO)
.withPermanentDenialDialog(Phrase.from(applicationContext, R.string.permissionsMicrophoneAccessRequired)
.put(APP_NAME_KEY, getString(R.string.app_name))
.format().toString())
.execute()
}
Expand Down Expand Up @@ -3041,7 +3037,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate,
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.maxSdkVersion(Build.VERSION_CODES.P) // P is 28
.withPermanentDenialDialog(Phrase.from(applicationContext, R.string.permissionsStorageDeniedLegacy)
.put(APP_NAME_KEY, getString(R.string.app_name))
.format().toString())
.onAnyDenied {
endActionMode()
Expand All @@ -3051,7 +3046,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate,
title(R.string.permissionsRequired)

val txt = Phrase.from(applicationContext, R.string.permissionsStorageDeniedLegacy)
.put(APP_NAME_KEY, getString(R.string.app_name))
.format().toString()
text(txt)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.doOnLayout
import androidx.core.view.isVisible
import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat
import com.squareup.phrase.Phrase
import org.session.libsession.utilities.Phrase
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import com.squareup.phrase.Phrase
import org.session.libsession.utilities.Phrase
import network.loki.messenger.R
import org.session.libsession.utilities.StringSubstitutionConstants.CONVERSATION_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import androidx.lifecycle.viewModelScope
import coil3.imageLoader
import coil3.request.CachePolicy
import coil3.request.ImageRequest
import com.squareup.phrase.Phrase
import org.session.libsession.utilities.Phrase
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
Expand Down Expand Up @@ -69,7 +69,6 @@ import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.CommunityUrlParser
import org.session.libsession.utilities.ExpirationUtil
import org.session.libsession.utilities.NonTranslatableStringConstants.APP_NAME
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY
import org.session.libsession.utilities.UserConfigType
Expand Down Expand Up @@ -1444,7 +1443,6 @@ class ConversationViewModel @AssistedInject constructor(
showSimpleDialog = SimpleDialogData(
title = application.getString(R.string.linkPreviewsEnable),
message = Phrase.from(application, R.string.linkPreviewsFirstDescription)
.put(APP_NAME_KEY, APP_NAME)
.format(),
positiveStyleDanger = true,
positiveText = application.getString(R.string.enable),
Expand Down
Loading
Loading