Merge remote-tracking branch 'origin/dev' into closed_groups

# Conflicts:
#	app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java
#	app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt
#	app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt
#	app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt
#	app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt
#	app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt
#	app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt
#	app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java
#	app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt
#	app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt
This commit is contained in:
SessionHero01 2024-10-03 14:37:08 +10:00
commit 677cd6a7cf
No known key found for this signature in database
380 changed files with 65852 additions and 22630 deletions

View File

@ -38,6 +38,7 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/
pull: 'always',
environment: { ANDROID_HOME: '/usr/lib/android-sdk' },
commands: [
'apt-get update',
'apt-get install -y ninja-build openjdk-17-jdk',
'update-java-alternatives -s java-1.17.0-openjdk-amd64',
'./gradlew testPlayDebugUnitTestCoverageReport'
@ -79,6 +80,7 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/
pull: 'always',
environment: { SSH_KEY: { from_secret: 'SSH_KEY' }, ANDROID_HOME: '/usr/lib/android-sdk' },
commands: [
'apt-get update',
'apt-get install -y ninja-build openjdk-17-jdk',
'update-java-alternatives -s java-1.17.0-openjdk-amd64',
'./gradlew assemblePlayDebug',

View File

@ -92,7 +92,6 @@ android {
buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\""
buildConfigField "int", "CONTENT_PROXY_PORT", "443"
buildConfigField "String", "USER_AGENT", "\"OWA\""
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
resourceConfigurations += []

View File

@ -59,8 +59,6 @@ import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.Toaster;
import org.session.libsession.utilities.Util;
import org.session.libsession.utilities.WindowDebouncer;
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
import org.session.libsession.utilities.dynamiclanguage.LocaleParser;
import org.session.libsignal.utilities.HTTP;
import org.session.libsignal.utilities.JsonUtil;
import org.session.libsignal.utilities.Log;
@ -98,7 +96,6 @@ import org.thoughtcrime.securesms.sskenvironment.ReadReceiptManager;
import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository;
import org.thoughtcrime.securesms.util.Broadcaster;
import org.thoughtcrime.securesms.util.VersionDataFetcher;
import org.thoughtcrime.securesms.util.dynamiclanguage.LocaleParseHelper;
import org.thoughtcrime.securesms.webrtc.CallMessageProcessor;
import org.webrtc.PeerConnectionFactory;
import org.webrtc.PeerConnectionFactory.InitializationOptions;
@ -116,7 +113,6 @@ import javax.inject.Inject;
import dagger.hilt.EntryPoints;
import dagger.hilt.android.HiltAndroidApp;
import kotlin.Unit;
import network.loki.messenger.BuildConfig;
import network.loki.messenger.R;
@ -342,10 +338,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
super.onTerminate();
}
public void initializeLocaleParser() {
LocaleParser.Companion.configure(new LocaleParseHelper());
}
public ExpiringMessageManager getExpiringMessageManager() {
return expiringMessageManager;
}
@ -443,12 +435,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
});
}
@Override
protected void attachBaseContext(Context base) {
initializeLocaleParser();
super.attachBaseContext(DynamicLanguageContextWrapper.updateContext(base, TextSecurePreferences.getLanguage(base)));
}
private static class ProviderInitializationException extends RuntimeException { }
private void setUpPollingIfNeeded() {
String userPublicKey = textSecurePreferences.getLocalNumber();

View File

@ -17,8 +17,6 @@ import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageActivityHelper;
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
import org.thoughtcrime.securesms.conversation.v2.WindowUtil;
import org.thoughtcrime.securesms.util.ActivityUtilitiesKt;
import org.thoughtcrime.securesms.util.ThemeState;
@ -97,7 +95,6 @@ public abstract class BaseActionBarActivity extends AppCompatActivity {
protected void onResume() {
super.onResume();
initializeScreenshotSecurity(true);
DynamicLanguageActivityHelper.recreateIfNotInCorrectLanguage(this, TextSecurePreferences.getLanguage(this));
String name = getResources().getString(R.string.app_name);
Bitmap icon = BitmapFactory.decodeResource(getResources(), R.drawable.ic_launcher_foreground);
int color = getResources().getColor(R.color.app_icon_background);
@ -137,9 +134,4 @@ public abstract class BaseActionBarActivity extends AppCompatActivity {
}
}
}
@Override
protected void attachBaseContext(Context newBase) {
super.attachBaseContext(DynamicLanguageContextWrapper.updateContext(newBase, TextSecurePreferences.getLanguage(newBase)));
}
}

View File

@ -1,31 +1,20 @@
package org.thoughtcrime.securesms;
import android.app.ActivityManager;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import androidx.fragment.app.FragmentActivity;
import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageActivityHelper;
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
import network.loki.messenger.R;
public abstract class BaseActivity extends FragmentActivity {
@Override
protected void onResume() {
super.onResume();
DynamicLanguageActivityHelper.recreateIfNotInCorrectLanguage(this, TextSecurePreferences.getLanguage(this));
String name = getResources().getString(R.string.app_name);
Bitmap icon = BitmapFactory.decodeResource(getResources(), R.drawable.ic_launcher_foreground);
int color = getResources().getColor(R.color.app_icon_background);
setTaskDescription(new ActivityManager.TaskDescription(name, icon, color));
}
@Override
protected void attachBaseContext(Context newBase) {
super.attachBaseContext(DynamicLanguageContextWrapper.updateContext(newBase, TextSecurePreferences.getLanguage(newBase)));
}
}

View File

@ -10,7 +10,7 @@ class DeleteMediaDialog {
iconAttribute(R.attr.dialog_alert_icon)
title(context.resources.getQuantityString(R.plurals.deleteMessage, recordCount, recordCount))
text(context.resources.getString(R.string.deleteMessageDescriptionEveryone))
button(R.string.delete) { doDelete.run() }
dangerButton(R.string.delete) { doDelete.run() }
cancelButton()
}
}

View File

@ -1,21 +0,0 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.squareup.phrase.Phrase
import network.loki.messenger.R
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.thoughtcrime.securesms.permissions.SettingsDialog
class MissingMicrophonePermissionDialog {
companion object {
@JvmStatic
fun show(context: Context) = SettingsDialog.show(
context,
Phrase.from(context, R.string.permissionsMicrophoneAccessRequired)
.put(APP_NAME_KEY, context.getString(R.string.app_name))
.format().toString()
)
}
}

View File

@ -11,20 +11,16 @@ import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import com.google.android.material.tabs.TabLayoutMediator
import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint
import kotlin.time.Duration.Companion.milliseconds
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewConversationActionBarBinding
import network.loki.messenger.databinding.ViewConversationSettingBinding
import network.loki.messenger.libsession_util.util.ExpiryMode.AfterRead
import org.session.libsession.LocalisedTimeUtil
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.utilities.ExpirationUtil
import org.session.libsession.utilities.StringSubstitutionConstants.DISAPPEARING_MESSAGES_TYPE_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY
import org.session.libsession.utilities.modifyLayoutParams
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.database.GroupDatabase
@ -130,7 +126,7 @@ class ConversationActionBarView @JvmOverloads constructor(
settings += ConversationSetting(
recipient.mutedUntil.takeUnless { it == Long.MAX_VALUE }
?.let {
context.getString(R.string.notificationsHeaderMute)
context.getString(R.string.notificationsMuted)
}
?: context.getString(R.string.notificationsMuted),
ConversationSettingType.NOTIFICATION,

View File

@ -66,21 +66,21 @@ internal fun StartConversationScreen(
icon = R.drawable.ic_message,
modifier = Modifier.contentDescription(R.string.AccessibilityId_messageNew),
onClick = delegate::onNewMessageSelected)
Divider(startIndent = LocalDimensions.current.dividerIndent)
Divider(startIndent = LocalDimensions.current.minItemButtonHeight)
ItemButton(
textId = R.string.groupCreate,
icon = R.drawable.ic_group,
modifier = Modifier.contentDescription(R.string.AccessibilityId_groupCreate),
onClick = delegate::onCreateGroupSelected
)
Divider(startIndent = LocalDimensions.current.dividerIndent)
Divider(startIndent = LocalDimensions.current.minItemButtonHeight)
ItemButton(
textId = R.string.communityJoin,
icon = R.drawable.ic_globe,
modifier = Modifier.contentDescription(R.string.AccessibilityId_communityJoin),
onClick = delegate::onJoinCommunitySelected
)
Divider(startIndent = LocalDimensions.current.dividerIndent)
Divider(startIndent = LocalDimensions.current.minItemButtonHeight)
ItemButton(
textId = R.string.sessionInviteAFriend,
icon = R.drawable.ic_invite_friend,

View File

@ -98,8 +98,7 @@ internal class NewMessageViewModel @Inject constructor(
private fun Exception.toMessage() = when (this) {
is SnodeAPI.Error.Generic -> application.getString(R.string.onsErrorNotRecognized)
is TimeoutException -> application.getString(R.string.onsErrorUnableToSearch)
else -> application.getString(R.string.accountIdErrorInvalid)
else -> application.getString(R.string.onsErrorUnableToSearch)
}
}

View File

@ -187,6 +187,7 @@ import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.ui.OpenURLAlertDialog
import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme
import org.thoughtcrime.securesms.util.ActivityDispatcher
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.NetworkUtils
@ -725,7 +726,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// called from onCreate
private fun setUpInputBar() {
binding.inputBar.isGone = viewModel.hidesInputBar()
binding.inputBar.delegate = this
binding.inputBarRecordingView.delegate = this
// GIF button
@ -898,6 +898,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (!isFinishing) {
finish()
}
binding.inputBar.isGone = uiState.hideInputBar
}
}
@ -997,7 +999,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
binding.declineMessageRequestButton.setOnClickListener {
viewModel.declineMessageRequest()
fun doDecline() {
viewModel.declineMessageRequest()
finish()
}
showSessionDialog {
title(R.string.delete)
text(resources.getString(R.string.messageRequestsDelete))
dangerButton(R.string.delete) { doDecline() }
button(R.string.cancel)
}
}
lifecycleScope.launch {
@ -1018,6 +1030,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
}
private fun acceptMessageRequest() {
binding.messageRequestBar.isVisible = false
viewModel.acceptMessageRequest()
}
override fun inputBarEditTextContentChanged(newContent: CharSequence) {
val inputBarText = binding.inputBar.text // TODO check if we should be referencing newContent here instead
if (textSecurePreferences.isLinkPreviewsEnabled()) {
@ -1826,10 +1843,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
attachmentManager.clear()
// Reset attachments button if needed
if (isShowingAttachmentOptions) { toggleAttachmentOptions() }
// Put the message in the database
message.id = mmsDb.insertMessageOutbox(outgoingTextMessage, viewModel.threadId, false, null, runThreadUpdate = true)
// Send it
MessageSender.send(message, recipient.address, attachments, quote, linkPreview)
// do the heavy work in the bg
lifecycleScope.launch(Dispatchers.IO) {
// Put the message in the database
message.id = mmsDb.insertMessageOutbox(
outgoingTextMessage,
viewModel.threadId,
false,
null,
runThreadUpdate = true
)
// Send it
MessageSender.send(message, recipient.address, attachments, quote, linkPreview)
}
// Send a typing stopped message
ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId)
return Pair(recipient.address, sentTimestamp)
@ -2135,7 +2163,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
showSessionDialog {
title(resources.getQuantityString(R.plurals.deleteMessage, messages.count(), messages.count()))
text(resources.getString(R.string.deleteMessageDescriptionEveryone))
button(R.string.delete) { messages.forEach(viewModel::deleteForEveryone); endActionMode() }
dangerButton(R.string.delete) { messages.forEach(viewModel::deleteForEveryone); endActionMode() }
cancelButton { endActionMode() }
}
// Otherwise if this is a 1-on-1 conversation we may decided to delete just for ourselves or delete for everyone
@ -2173,16 +2201,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
showSessionDialog {
title(R.string.banUser)
text(R.string.communityBanDescription)
button(R.string.banUser) { viewModel.banUser(messages.first().individualRecipient); endActionMode() }
dangerButton(R.string.theContinue) { viewModel.banUser(messages.first().individualRecipient); endActionMode() }
cancelButton(::endActionMode)
}
}
override fun banAndDeleteAll(messages: Set<MessageRecord>) {
showSessionDialog {
title(R.string.banUser)
title(R.string.banDeleteAll)
text(R.string.communityBanDeleteDescription)
button(R.string.banUser) { viewModel.banAndDeleteAll(messages.first()); endActionMode() }
dangerButton(R.string.theContinue) { viewModel.banAndDeleteAll(messages.first()); endActionMode() }
cancelButton(::endActionMode)
}
}
@ -2501,7 +2529,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// Note: The adapter itemCount is zero based - so calling this with the itemCount in
// a non-zero based manner scrolls us to the bottom of the last message (including
// to the bottom of long messages as required by Jira SES-789 / GitHub 1364).
recyclerView.scrollToPosition(adapter.itemCount)
recyclerView.smoothScrollToPosition(adapter.itemCount)
}
}
}

View File

@ -1,10 +1,7 @@
package org.thoughtcrime.securesms.conversation.v2
import android.Manifest
import android.content.Context
import android.content.Intent
import android.database.Cursor
import android.net.Uri
import android.util.SparseArray
import android.util.SparseBooleanArray
import android.view.MotionEvent
@ -14,33 +11,22 @@ import androidx.core.util.getOrDefault
import androidx.core.util.set
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import java.util.concurrent.atomic.AtomicLong
import kotlin.math.min
import com.bumptech.glide.RequestManager
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import network.loki.messenger.R
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import com.bumptech.glide.RequestManager
import com.squareup.phrase.Phrase
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.MissingMicrophonePermissionDialog
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.ui.getSubbedCharSequence
import org.thoughtcrime.securesms.ui.getSubbedString
import java.util.concurrent.atomic.AtomicLong
import kotlin.math.min
class ConversationAdapter(
context: Context,

View File

@ -11,8 +11,12 @@ import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import network.loki.messenger.R
@ -34,6 +38,7 @@ import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.repository.ConversationRepository
import java.util.UUID
@ -84,6 +89,8 @@ class ConversationViewModel(
return repository.getInvitingAdmin(threadId)
}
private var communityWriteAccessJob: Job? = null
private var _openGroup: RetrieveOnce<OpenGroup> = RetrieveOnce {
storage.getOpenGroup(threadId)
}
@ -143,6 +150,27 @@ class ConversationViewModel(
}
}
}
// listen to community write access updates from this point
communityWriteAccessJob?.cancel()
communityWriteAccessJob = viewModelScope.launch {
OpenGroupManager.getCommunitiesWriteAccessFlow()
.map {
if(openGroup?.groupId != null)
it[openGroup?.groupId]
else null
}
.filterNotNull()
.collect{
// update our community object
_openGroup.updateTo(openGroup?.copy(canWrite = it))
// when we get an update on the write access of a community
// we need to update the input text accordingly
_uiState.update { state ->
state.copy(hideInputBar = shouldHideInputBar())
}
}
}
}
/**
@ -396,7 +424,7 @@ class ConversationViewModel(
* - We are dealing with a contact from a community (blinded recipient) that does not allow
* requests form community members
*/
fun hidesInputBar(): Boolean = openGroup?.canWrite == false ||
fun shouldHideInputBar(): Boolean = openGroup?.canWrite == false ||
blindedRecipient?.blocksCommunityMessageRequests == true
fun legacyBannerRecipient(context: Context): Recipient? = recipient?.run {

View File

@ -18,7 +18,7 @@ class LinkPreviewDialog(private val onEnabled: () -> Unit) : DialogFragment() {
title(R.string.linkPreviewsEnable)
val txt = context.getSubbedCharSequence(R.string.linkPreviewsFirstDescription, APP_NAME_KEY to APP_NAME)
text(txt)
button(R.string.enable) { enable() }
dangerButton(R.string.enable) { enable() }
cancelButton { dismiss() }
}

View File

@ -205,13 +205,17 @@ class MentionViewModel(
val sb = StringBuilder()
var offset = 0
for ((span, range) in spansWithRanges) {
// Add content before the mention span
sb.append(editable, offset, range.first)
// Add content before the mention span. There's a possibility of overlapping spans so we need to
// safe guard the start offset here to not go over our span's start.
val thisMentionStart = range.first
val lastMentionEnd = offset.coerceAtMost(thisMentionStart)
sb.append(editable, lastMentionEnd, thisMentionStart)
// Replace the mention span with "@public key"
sb.append('@').append(span.member.publicKey).append(' ')
offset = range.last + 1
// Safe guard offset to not go over the end of the editable.
offset = (range.last + 1).coerceAtMost(editable.length)
}
// Add the remaining content

View File

@ -36,8 +36,6 @@ import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.MissingMicrophonePermissionDialog
import org.thoughtcrime.securesms.media.MediaOverviewActivity
import org.thoughtcrime.securesms.ShortcutLauncherActivity
import org.thoughtcrime.securesms.calls.WebRtcCallActivity
import org.thoughtcrime.securesms.contacts.SelectContactsActivity
@ -49,11 +47,14 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.EditGroupActivity
import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity
import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity.Companion.groupIDKey
import org.thoughtcrime.securesms.media.MediaOverviewActivity
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.service.WebRtcCallService
import org.thoughtcrime.securesms.showMuteDialog
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.ui.findActivity
import org.thoughtcrime.securesms.ui.getSubbedString
import org.thoughtcrime.securesms.util.BitmapUtil
object ConversationMenuHelper {
@ -211,7 +212,15 @@ object ConversationMenuHelper {
// or if the user has not granted audio/microphone permissions
else if (!Permissions.hasAll(context, Manifest.permission.RECORD_AUDIO)) {
Log.d("Loki", "Attempted to make a call without audio permissions")
MissingMicrophonePermissionDialog.show(context)
Permissions.with(context.findActivity())
.request(Manifest.permission.RECORD_AUDIO)
.withPermanentDenialDialog(
context.getSubbedString(R.string.permissionsMicrophoneAccessRequired,
APP_NAME_KEY to context.getString(R.string.app_name))
)
.execute()
return
}

View File

@ -19,11 +19,10 @@ import network.loki.messenger.libsession_util.util.ExpiryMode
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.utilities.UpdateMessageData
import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.getColorFromAttr
import org.thoughtcrime.securesms.MissingMicrophonePermissionDialog
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages
import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode
import org.thoughtcrime.securesms.database.model.MessageRecord
@ -31,6 +30,7 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.ui.findActivity
import org.thoughtcrime.securesms.ui.getSubbedCharSequence
import org.thoughtcrime.securesms.ui.getSubbedString
import javax.inject.Inject
@ -136,15 +136,6 @@ class ControlMessageView : LinearLayout {
// handle click behaviour depending on criteria
if (message.isMissedCall || message.isFirstMissedCall) {
when {
// if we're currently missing the audio/microphone permission,
// show a dedicated permission dialog
!Permissions.hasAll(context, Manifest.permission.RECORD_AUDIO) -> {
showInfo()
setOnClickListener {
MissingMicrophonePermissionDialog.show(context)
}
}
// when the call toggle is disabled in the privacy screen,
// show a dedicated privacy dialog
!TextSecurePreferences.isCallNotificationsEnabled(context) -> {
@ -171,6 +162,38 @@ class ControlMessageView : LinearLayout {
}
}
}
// if we're currently missing the audio/microphone permission,
// show a dedicated permission dialog
!Permissions.hasAll(context, Manifest.permission.RECORD_AUDIO) -> {
showInfo()
setOnClickListener {
context.showSessionDialog {
val titleTxt = context.getSubbedString(
R.string.callsMissedCallFrom,
NAME_KEY to message.individualRecipient.name!!
)
title(titleTxt)
val bodyTxt = context.getSubbedCharSequence(
R.string.callsMicrophonePermissionsRequired,
NAME_KEY to message.individualRecipient.name!!
)
text(bodyTxt)
button(R.string.theContinue) {
Permissions.with(context.findActivity())
.request(Manifest.permission.RECORD_AUDIO)
.withPermanentDenialDialog(
context.getSubbedString(R.string.permissionsMicrophoneAccessRequired,
APP_NAME_KEY to context.getString(R.string.app_name))
)
.execute()
}
cancelButton()
}
}
}
}
}
}

View File

@ -16,7 +16,6 @@
*/
package org.thoughtcrime.securesms.conversation.v2.utilities;
import static com.google.android.gms.common.util.CollectionUtils.listOf;
import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY;
import android.Manifest;
@ -254,7 +253,7 @@ public class AttachmentManager {
.request(Manifest.permission.READ_MEDIA_IMAGES)
.request(Manifest.permission.READ_MEDIA_AUDIO)
.withRationaleDialog(
Phrase.from(c, R.string.permissionsStorageSend)
Phrase.from(c, R.string.permissionsMusicAudio)
.put(APP_NAME_KEY, c.getString(R.string.app_name)).format().toString()
)
.withPermanentDenialDialog(

View File

@ -9,6 +9,7 @@ import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
import android.view.ViewOutlineProvider
import android.view.ViewTreeObserver
import android.widget.FrameLayout
import androidx.core.view.isVisible
import com.bumptech.glide.load.engine.DiskCacheStrategy
@ -27,7 +28,10 @@ import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.RequestManager
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.ui.afterMeasured
import java.lang.Float.min
open class ThumbnailView @JvmOverloads constructor(
context: Context,
@ -114,8 +118,23 @@ open class ThumbnailView @JvmOverloads constructor(
isPreview: Boolean, naturalWidth: Int,
naturalHeight: Int
): ListenableFuture<Boolean> {
binding.playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() &&
(slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview))
val showPlayOverlay = (slide.thumbnailUri != null && slide.hasPlayOverlay() &&
(slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview))
if(showPlayOverlay) {
binding.playOverlay.isVisible = true
// The views are poorly constructed at the moment and there is no good way to know
// if this is used in the main conversation or in the tiny quote window of a reply...
// But when the view is too small the 'play' icon does not scale,
// so we can do it based on measured sizes here
binding.playOverlay.afterMeasured {
// max size if 60% of the width
val ratio = min((binding.root.width * 0.6f) / binding.playOverlay.width, 1f)
binding.playOverlay.scaleX = ratio
binding.playOverlay.scaleY = ratio
}
} else {
binding.playOverlay.isVisible = false
}
if (equals(this.slide, slide)) {
// don't re-load slide

View File

@ -1892,6 +1892,7 @@ open class Storage(
configFactory.withMutableUserConfigs {
it.contacts.upsertContact(recipient.address.serialize()) {
this.approved = approved
this.priority = PRIORITY_VISIBLE
}
}
}

View File

@ -1,236 +0,0 @@
package org.thoughtcrime.securesms.database.loaders;
import android.content.Context;
import android.database.ContentObserver;
import android.database.Cursor;
import androidx.annotation.NonNull;
import androidx.loader.content.AsyncTaskLoader;
import com.annimon.stream.Stream;
import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.recipients.Recipient;
import org.thoughtcrime.securesms.database.MediaDatabase;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.RelativeDay;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import network.loki.messenger.R;
public class BucketedThreadMediaLoader extends AsyncTaskLoader<BucketedThreadMediaLoader.BucketedThreadMedia> {
@SuppressWarnings("unused")
private static final String TAG = BucketedThreadMediaLoader.class.getSimpleName();
private final Address address;
private final ContentObserver observer;
public BucketedThreadMediaLoader(@NonNull Context context, @NonNull Address address) {
super(context);
this.address = address;
this.observer = new ForceLoadContentObserver();
onContentChanged();
}
@Override
protected void onStartLoading() {
if (takeContentChanged()) {
forceLoad();
}
}
@Override
protected void onStopLoading() {
cancelLoad();
}
@Override
protected void onAbandon() {
DatabaseComponent.get(getContext()).mediaDatabase().unsubscribeToMediaChanges(observer);
}
@Override
public BucketedThreadMedia loadInBackground() {
BucketedThreadMedia result = new BucketedThreadMedia(getContext());
long threadId = DatabaseComponent.get(getContext()).threadDatabase().getOrCreateThreadIdFor(Recipient.from(getContext(), address, true));
MediaDatabase mediaDatabase = DatabaseComponent.get(getContext()).mediaDatabase();
mediaDatabase.subscribeToMediaChanges(observer);
try (Cursor cursor = mediaDatabase.getGalleryMediaForThread(threadId)) {
while (cursor != null && cursor.moveToNext()) {
result.add(MediaDatabase.MediaRecord.from(getContext(), cursor));
}
}
return result;
}
public static class BucketedThreadMedia {
private final TimeBucket TODAY;
private final TimeBucket YESTERDAY;
private final TimeBucket THIS_WEEK;
private final TimeBucket THIS_MONTH;
private final MonthBuckets OLDER;
private final TimeBucket[] TIME_SECTIONS;
public BucketedThreadMedia(@NonNull Context context) {
String localisedTodayString = DateUtils.INSTANCE.getLocalisedRelativeDayString(RelativeDay.TODAY);
String localisedYesterdayString = DateUtils.INSTANCE.getLocalisedRelativeDayString(RelativeDay.YESTERDAY);
this.TODAY = new TimeBucket(localisedTodayString, TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -1), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, 1000));
this.YESTERDAY = new TimeBucket(localisedYesterdayString, TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -2), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -1));
this.THIS_WEEK = new TimeBucket(context.getString(R.string.attachmentsThisWeek), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -7), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -2));
this.THIS_MONTH = new TimeBucket(context.getString(R.string.attachmentsThisMonth), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -30), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -7));
this.TIME_SECTIONS = new TimeBucket[] { TODAY, YESTERDAY, THIS_WEEK, THIS_MONTH };
this.OLDER = new MonthBuckets();
}
public void add(MediaDatabase.MediaRecord mediaRecord) {
for (TimeBucket timeSection : TIME_SECTIONS) {
if (timeSection.inRange(mediaRecord.getDate())) {
timeSection.add(mediaRecord);
return;
}
}
OLDER.add(mediaRecord);
}
public int getSectionCount() {
return (int)Stream.of(TIME_SECTIONS)
.filter(timeBucket -> !timeBucket.isEmpty())
.count() +
OLDER.getSectionCount();
}
public int getSectionItemCount(int section) {
List<TimeBucket> activeTimeBuckets = Stream.of(TIME_SECTIONS).filter(timeBucket -> !timeBucket.isEmpty()).toList();
if (section < activeTimeBuckets.size()) return activeTimeBuckets.get(section).getItemCount();
else return OLDER.getSectionItemCount(section - activeTimeBuckets.size());
}
public MediaDatabase.MediaRecord get(int section, int item) {
List<TimeBucket> activeTimeBuckets = Stream.of(TIME_SECTIONS).filter(timeBucket -> !timeBucket.isEmpty()).toList();
if (section < activeTimeBuckets.size()) return activeTimeBuckets.get(section).getItem(item);
else return OLDER.getItem(section - activeTimeBuckets.size(), item);
}
public String getName(int section, Locale locale) {
List<TimeBucket> activeTimeBuckets = Stream.of(TIME_SECTIONS).filter(timeBucket -> !timeBucket.isEmpty()).toList();
if (section < activeTimeBuckets.size()) return activeTimeBuckets.get(section).getName();
else return OLDER.getName(section - activeTimeBuckets.size(), locale);
}
private static class TimeBucket {
private final List<MediaDatabase.MediaRecord> records = new LinkedList<>();
private final long startTime;
private final long endtime;
private final String name;
TimeBucket(String name, long startTime, long endtime) {
this.name = name;
this.startTime = startTime;
this.endtime = endtime;
}
void add(MediaDatabase.MediaRecord record) {
this.records.add(record);
}
boolean inRange(long timestamp) {
return timestamp > startTime && timestamp <= endtime;
}
boolean isEmpty() {
return records.isEmpty();
}
int getItemCount() {
return records.size();
}
MediaDatabase.MediaRecord getItem(int position) {
return records.get(position);
}
String getName() {
return name;
}
static long addToCalendar(int field, int amount) {
Calendar calendar = Calendar.getInstance();
calendar.add(field, amount);
return calendar.getTimeInMillis();
}
}
private static class MonthBuckets {
private final Map<Date, List<MediaDatabase.MediaRecord>> months = new HashMap<>();
void add(MediaDatabase.MediaRecord record) {
Calendar calendar = Calendar.getInstance();
calendar.setTimeInMillis(record.getDate());
int year = calendar.get(Calendar.YEAR) - 1900;
int month = calendar.get(Calendar.MONTH);
Date date = new Date(year, month, 1);
if (months.containsKey(date)) {
months.get(date).add(record);
} else {
List<MediaDatabase.MediaRecord> list = new LinkedList<>();
list.add(record);
months.put(date, list);
}
}
int getSectionCount() {
return months.size();
}
int getSectionItemCount(int section) {
return months.get(getSection(section)).size();
}
MediaDatabase.MediaRecord getItem(int section, int position) {
return months.get(getSection(section)).get(position);
}
Date getSection(int section) {
ArrayList<Date> keys = new ArrayList<>(months.keySet());
Collections.sort(keys, Collections.reverseOrder());
return keys.get(section);
}
String getName(int section, Locale locale) {
Date sectionDate = getSection(section);
return new SimpleDateFormat("MMMM, yyyy", locale).format(sectionDate);
}
}
}
}

View File

@ -104,24 +104,6 @@ public class ThreadRecord extends DisplayRecord {
return name;
}
private String getDisappearingMsgExpiryTypeString(Context context) {
MessageRecord lm = this.lastMessage;
if (lm == null) {
Log.w("ThreadRecord", "Could not get last message to determine disappearing msg type.");
return "Unknown";
}
long expireStarted = lm.getExpireStarted();
// Note: This works because expireStarted is 0 for messages which are 'Disappear after read'
// while it's a touch higher than the sent timestamp for "Disappear after send". We could then
// use `expireStarted == 0`, but that's not how it's done in UpdateMessageBuilder so to keep
// things the same I'll assume there's a reason for this and follow suit.
// Also: `this.lastMessage.getExpiresIn()` is available.
if (expireStarted >= dateSent) {
return context.getString(R.string.disappearingMessagesSent);
}
return context.getString(R.string.read);
}
@Override
public CharSequence getDisplayBody(@NonNull Context context) {
@ -151,8 +133,13 @@ public class ThreadRecord extends DisplayRecord {
.put(NAME_KEY, getName())
.format().toString();
} else if (SmsDatabase.Types.isExpirationTimerUpdate(type)) {
// Remove formatting on the message by calling .getString() on the SpannableString
return lastMessage != null ? lastMessage.getDisplayBody(context).toString() : null;
// 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())

View File

@ -4,6 +4,9 @@ import android.content.Context
import android.widget.Toast
import androidx.annotation.WorkerThread
import com.squareup.phrase.Phrase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.Executors
import network.loki.messenger.R
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@ -38,6 +41,9 @@ object OpenGroupManager {
return true
}
// flow holding information on write access for our current communities
private val _communityWriteAccess: MutableStateFlow<Map<String, Boolean>> = MutableStateFlow(emptyMap())
fun startPolling() {
if (isPolling) { return }
isPolling = true
@ -65,6 +71,8 @@ object OpenGroupManager {
}
}
fun getCommunitiesWriteAccessFlow() = _communityWriteAccess.asStateFlow()
@WorkerThread
fun add(server: String, room: String, publicKey: String, context: Context): Pair<Long,OpenGroupApi.RoomInfo?> {
val openGroupID = "$server.$room"
@ -164,9 +172,13 @@ object OpenGroupManager {
fun updateOpenGroup(openGroup: OpenGroup, context: Context) {
val threadDB = DatabaseComponent.get(context).lokiThreadDatabase()
val openGroupID = "${openGroup.server}.${openGroup.room}"
val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context)
val threadID = GroupManager.getOpenGroupThreadID(openGroup.groupId, context)
threadDB.setOpenGroupChat(openGroup, threadID)
// update write access for this community
val writeAccesses = _communityWriteAccess.value.toMutableMap()
writeAccesses[openGroup.groupId] = openGroup.canWrite
_communityWriteAccess.value = writeAccesses
}
fun isUserModerator(context: Context, groupId: String, standardPublicKey: String, blindedPublicKey: String? = null): Boolean {

View File

@ -19,6 +19,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import network.loki.messenger.R
import org.thoughtcrime.securesms.ui.SessionShieldIcon
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
import org.thoughtcrime.securesms.ui.components.SlimPrimaryOutlineButton
import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.theme.LocalColors
@ -60,8 +61,8 @@ internal fun SeedReminder(startRecoveryPasswordActivity: () -> Unit) {
style = LocalType.current.small
)
}
Spacer(Modifier.width(LocalDimensions.current.xsSpacing))
SlimPrimaryOutlineButton(
Spacer(Modifier.width(LocalDimensions.current.smallSpacing))
PrimaryOutlineButton(
text = stringResource(R.string.theContinue),
modifier = Modifier
.align(Alignment.CenterVertically)

View File

@ -1,7 +1,10 @@
package org.thoughtcrime.securesms.media
import android.content.Context
import androidx.annotation.StringRes
import network.loki.messenger.R
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.RelativeDay
import java.time.ZonedDateTime
import java.time.temporal.WeekFields
import java.util.Locale
@ -29,16 +32,15 @@ class FixedTimeBuckets(
)
/**
* Test the given time against the buckets and return the appropriate string resource the time
* Test the given time against the buckets and return the appropriate string the time
* bucket. If no bucket is appropriate, it will return null.
*/
@StringRes
fun getBucketText(time: ZonedDateTime): Int? {
fun getBucketText(context: Context, time: ZonedDateTime): String? {
return when {
time >= startOfToday -> R.string.BucketedThreadMedia_Today
time >= startOfYesterday -> R.string.BucketedThreadMedia_Yesterday
time >= startOfThisWeek -> R.string.attachmentsThisWeek
time >= startOfThisMonth -> R.string.attachmentsThisMonth
time >= startOfToday -> DateUtils.getLocalisedRelativeDayString(RelativeDay.TODAY)
time >= startOfYesterday -> DateUtils.getLocalisedRelativeDayString(RelativeDay.YESTERDAY)
time >= startOfThisWeek -> context.getString(R.string.attachmentsThisWeek)
time >= startOfThisMonth -> context.getString(R.string.attachmentsThisMonth)
else -> null
}
}

View File

@ -247,12 +247,7 @@ private fun DeleteConfirmationDialog(
AlertDialog(
onDismissRequest = onDismissRequest,
title = context.resources.getQuantityString(
R.plurals.ConversationFragment_delete_selected_messages, numSelected
),
text = context.resources.getQuantityString(
R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages,
numSelected,
numSelected,
R.plurals.deleteMessage, numSelected
),
buttons = listOf(
DialogButtonModel(GetString(R.string.delete), color = LocalColors.current.danger, onClick = onAccepted),

View File

@ -130,7 +130,7 @@ class MediaOverviewViewModel(
.groupBy { record ->
val time =
ZonedDateTime.ofInstant(Instant.ofEpochMilli(record.date), ZoneId.of("UTC"))
timeBuckets.getBucketText(time)?.let(application::getString)
timeBuckets.getBucketText(application, time)
?: time.toLocalDate().withDayOfMonth(1)
}
.map { (bucket, records) ->
@ -171,6 +171,11 @@ class MediaOverviewViewModel(
fun onItemClicked(item: MediaOverviewItem) {
if (inSelectionMode.value) {
if (item.slide.hasDocument()) {
// We don't support selecting documents in selection mode
return
}
val newSet = mutableSelectedItemIDs.value.toMutableSet()
if (item.id in newSet) {
newSet.remove(item.id)
@ -213,11 +218,6 @@ class MediaOverviewViewModel(
}
fun onTabItemClicked(tab: MediaOverviewTab) {
if (inSelectionMode.value) {
// Not allowing to switch tabs while in selection mode
return
}
mutableSelectedTab.value = tab
}

View File

@ -110,11 +110,7 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat
showSessionDialog {
title(R.string.delete)
text(resources.getString(R.string.messageRequestsDelete))
if (thread.recipient.isClosedGroupV2Recipient) {
dangerButton(R.string.delete, contentDescriptionRes = R.string.delete) { doDecline() }
} else {
dangerButton(R.string.decline, contentDescriptionRes = R.string.decline) { doDecline() }
}
dangerButton(R.string.delete) { doDecline() }
button(R.string.cancel)
}
}
@ -132,9 +128,10 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat
}
showSessionDialog {
title(resources.getString(R.string.clearAll))
text(resources.getString(R.string.messageRequestsClearAllExplanation))
button(R.string.yes) { doDeleteAllAndBlock() }
button(R.string.no)
dangerButton(R.string.clear) { doDeleteAllAndBlock() }
button(R.string.cancel)
}
}
}

View File

@ -65,7 +65,7 @@ object RationaleDialog {
text(message)
}
button(R.string.theContinue) { onPositive.run() }
button(R.string.notNow) { onNegative.run() }
button(R.string.cancel) { onNegative.run() }
}
}
}

View File

@ -3,11 +3,14 @@ package org.thoughtcrime.securesms.preferences
import android.Manifest
import androidx.fragment.app.Fragment
import androidx.preference.Preference
import com.squareup.phrase.Phrase
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.setBooleanPreference
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.ui.getSubbedString
internal class CallToggleListener(
private val context: Fragment,
@ -39,6 +42,10 @@ internal class CallToggleListener(
)
setCallback(true)
}
.withPermanentDenialDialog(
context.requireContext().getSubbedString(R.string.permissionsMicrophoneAccessRequired,
APP_NAME_KEY to context.requireContext().getString(R.string.app_name)
))
.onAnyDenied { setCallback(false) }
.execute()
}

View File

@ -45,7 +45,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -58,11 +57,9 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.canhub.cropper.CropImageContract
import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
@ -80,7 +77,9 @@ import org.thoughtcrime.securesms.debugmenu.DebugActivity
import org.thoughtcrime.securesms.home.PathActivity
import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.*
import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.NoAvatar
import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.TempAvatar
import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.UserAvatar
import org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity
import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity
import org.thoughtcrime.securesms.ui.AlertDialog
@ -91,10 +90,10 @@ import org.thoughtcrime.securesms.ui.Divider
import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.LargeItemButton
import org.thoughtcrime.securesms.ui.LargeItemButtonWithDrawable
import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineCopyButton
import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.qaTag
import org.thoughtcrime.securesms.ui.setThemedContent
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
@ -511,7 +510,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
) {
startAvatarSelection()
}
.testTag(stringResource(R.string.AccessibilityId_avatarPicker))
.qaTag(stringResource(R.string.AccessibilityId_avatarPicker))
.background(
shape = CircleShape,
color = LocalColors.current.backgroundBubbleReceived,
@ -574,6 +573,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
DialogButtonModel(
text = GetString(R.string.remove),
contentDescription = GetString(R.string.AccessibilityId_remove),
color = LocalColors.current.danger,
enabled = state is UserAvatar || // can remove is the user has an avatar set
(state is TempAvatar && state.hasAvatar),
onClick = removeAvatar

View File

@ -280,23 +280,19 @@ fun ItemButton(
onClick: () -> Unit
) {
TextButton(
modifier = modifier.fillMaxWidth()
.height(IntrinsicSize.Min)
.heightIn(min = minHeight)
.padding(horizontal = LocalDimensions.current.xsSpacing),
modifier = modifier.fillMaxWidth(),
colors = colors,
onClick = onClick,
contentPadding = PaddingValues(),
shape = RectangleShape,
) {
Box(
modifier = Modifier.fillMaxHeight()
.aspectRatio(1f)
.align(Alignment.CenterVertically)
) {
icon()
}
Spacer(modifier = Modifier.width(LocalDimensions.current.smallSpacing))
modifier = Modifier
.padding(horizontal = LocalDimensions.current.xxsSpacing)
.size(minHeight)
.align(Alignment.CenterVertically),
content = icon
)
Text(
text,
@ -320,6 +316,18 @@ fun PreviewItemButton() {
}
}
@Preview
@Composable
fun PreviewLargeItemButton() {
PreviewTheme {
LargeItemButton(
textId = R.string.groupCreate,
icon = R.drawable.ic_group,
onClick = {}
)
}
}
@Composable
fun Cell(
modifier: Modifier = Modifier,

View File

@ -3,8 +3,15 @@ package org.thoughtcrime.securesms.ui
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.view.View
import android.view.ViewTreeObserver
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.fragment.app.Fragment
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
@ -58,3 +65,26 @@ fun Context.findActivity(): Activity {
}
throw IllegalStateException("Permissions should be called in the context of an Activity")
}
inline fun <T : View> T.afterMeasured(crossinline block: T.() -> Unit) {
viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
if (measuredWidth > 0 && measuredHeight > 0) {
viewTreeObserver.removeOnGlobalLayoutListener(this)
block()
}
}
})
}
/**
* This is used to set the test tag that the QA team can use to retrieve an element in appium
* In order to do so we need to set the testTagsAsResourceId to true, which ideally should be done only once
* in the root composable, but our app is currently made up of multiple isolated composables
* set up in the old activity/fragment view system
* As such we need to repeat it for every component that wants to use testTag, until such
* a time as we have one root composable
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun Modifier.qaTag(tag: String) = semantics { testTagsAsResourceId = true }.testTag(tag)

View File

@ -49,6 +49,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.zxing.BinaryBitmap
import com.google.zxing.ChecksumException
import com.google.zxing.FormatException
@ -75,6 +78,7 @@ import java.util.concurrent.Executors
private const val TAG = "NewMessageFragment"
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun QRScannerScreen(
errors: Flow<String>,
@ -93,11 +97,11 @@ fun QRScannerScreen(
val context = LocalContext.current
val permission = Manifest.permission.CAMERA
val cameraPermissionState = rememberPermissionState(permission)
var showCameraPermissionDialog by remember { mutableStateOf(false) }
if (ContextCompat.checkSelfPermission(context, permission)
== PackageManager.PERMISSION_GRANTED) {
if (cameraPermissionState.status.isGranted) {
ScanQrCode(errors, onScan)
} else {
Column(

View File

@ -122,7 +122,7 @@ fun SessionOutlinedTextField(
)
.fillMaxWidth()
.wrapContentHeight()
.padding(vertical = 28.dp, horizontal = 21.dp)
.padding(LocalDimensions.current.spacing)
) {
innerTextField()

View File

@ -15,7 +15,6 @@ data class Dimensions(
val mediumSpacing: Dp = 36.dp,
val xlargeSpacing: Dp = 64.dp,
val dividerIndent: Dp = 60.dp,
val appBarHeight: Dp = 64.dp,
val minItemButtonHeight: Dp = 50.dp,
val minLargeItemButtonHeight: Dp = 60.dp,

View File

@ -52,9 +52,6 @@ object DateUtils : android.text.format.DateUtils() {
return isToday(`when` + TimeUnit.DAYS.toMillis(1))
}
private fun convertDelta(millis: Long, to: TimeUnit): Int {
return to.convert(System.currentTimeMillis() - millis, TimeUnit.MILLISECONDS).toInt()
}
// Method to get the String for a relative day in a locale-aware fashion
public fun getLocalisedRelativeDayString(relativeDay: RelativeDay): String {
@ -76,10 +73,12 @@ object DateUtils : android.text.format.DateUtils() {
set(Calendar.MILLISECOND, 0)
}
return getRelativeTimeSpanString(comparisonTime.timeInMillis,
val temp = getRelativeTimeSpanString(
comparisonTime.timeInMillis,
now.timeInMillis,
DAY_IN_MILLIS,
FORMAT_SHOW_DATE).toString()
return temp
}
fun getFormattedDateTime(time: Long, template: String, locale: Locale): String {
@ -142,30 +141,4 @@ object DateUtils : android.text.format.DateUtils() {
private fun getLocalizedPattern(template: String, locale: Locale): String {
return DateFormat.getBestDateTimePattern(locale, template)
}
/**
* e.g. 2020-09-04T19:17:51Z
* https://www.iso.org/iso-8601-date-and-time-format.html
*
* @return The timestamp if able to be parsed, otherwise -1.
*/
@SuppressLint("ObsoleteSdkInt")
@JvmStatic
public fun parseIso8601(date: String?): Long {
if (date.isNullOrEmpty()) { return -1 }
val format = if (Build.VERSION.SDK_INT >= 24) {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX", Locale.getDefault())
} else {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault())
}
try {
return format.parse(date).time
} catch (e: ParseException) {
Log.w(TAG, "Failed to parse date.", e)
return -1
}
}
}

View File

@ -1,26 +0,0 @@
package org.thoughtcrime.securesms.util.dynamiclanguage
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat
import network.loki.messenger.BuildConfig
import org.session.libsession.utilities.dynamiclanguage.LocaleParserHelperProtocol
import java.util.*
class LocaleParseHelper: LocaleParserHelperProtocol {
override fun appSupportsTheExactLocale(locale: Locale?): Boolean {
return if (locale == null) {
false
} else Arrays.asList(*BuildConfig.LANGUAGES).contains(locale.toString())
}
override fun findBestSystemLocale(): Locale {
val config = Resources.getSystem().configuration
val firstMatch = ConfigurationCompat.getLocales(config)
.getFirstMatch(BuildConfig.LANGUAGES)
return firstMatch ?: Locale.ENGLISH
}
}

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="50"
android:viewportHeight="50">
<path
android:pathData="M15.575,11.473L30.841,23.853L15.247,36.509C15.053,36.658 14.889,36.848 14.764,37.067C14.639,37.286 14.557,37.529 14.521,37.783C14.484,38.037 14.496,38.296 14.554,38.545C14.613,38.794 14.717,39.028 14.86,39.233C15.003,39.438 15.184,39.61 15.39,39.739C15.596,39.868 15.824,39.951 16.061,39.984C16.298,40.017 16.538,39.999 16.768,39.93C16.998,39.861 17.212,39.744 17.4,39.584L34.455,25.746C34.637,25.594 34.792,25.408 34.91,25.196C34.993,25.112 35.07,25.022 35.14,24.926C35.426,24.518 35.549,24.005 35.482,23.499C35.416,22.994 35.166,22.537 34.787,22.23L17.732,8.391C17.545,8.238 17.331,8.127 17.104,8.063C16.877,7.999 16.64,7.983 16.407,8.018C16.174,8.053 15.95,8.137 15.748,8.265C15.545,8.393 15.368,8.563 15.227,8.766C15.084,8.968 14.98,9.198 14.921,9.444C14.861,9.689 14.847,9.945 14.879,10.197C14.911,10.448 14.99,10.69 15.109,10.909C15.228,11.128 15.386,11.32 15.575,11.473Z"
android:fillColor="?android:textColorPrimary"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="50"
android:viewportHeight="50">
<path
android:pathData="M34.425,36.527L19.159,24.147L34.753,11.491C34.947,11.342 35.111,11.152 35.236,10.933C35.361,10.714 35.443,10.471 35.479,10.217C35.515,9.963 35.504,9.704 35.446,9.455C35.387,9.206 35.284,8.972 35.14,8.767C34.996,8.562 34.816,8.39 34.61,8.261C34.404,8.132 34.176,8.049 33.939,8.016C33.702,7.983 33.462,8.001 33.232,8.07C33.002,8.139 32.787,8.256 32.6,8.416L15.545,22.254C15.363,22.406 15.208,22.592 15.09,22.804C15.007,22.888 14.93,22.978 14.86,23.074C14.574,23.482 14.451,23.995 14.517,24.501C14.584,25.006 14.834,25.463 15.212,25.77L32.268,39.609C32.455,39.762 32.668,39.873 32.896,39.937C33.123,40.001 33.36,40.016 33.593,39.982C33.826,39.947 34.05,39.863 34.252,39.735C34.455,39.607 34.632,39.437 34.773,39.235C34.916,39.032 35.02,38.802 35.079,38.556C35.139,38.311 35.153,38.055 35.121,37.803C35.089,37.552 35.011,37.31 34.891,37.091C34.772,36.872 34.614,36.68 34.425,36.527Z"
android:fillColor="#000000"/>
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?searchBackgroundColor" />
<corners android:topLeftRadius="100dp" android:bottomLeftRadius="100dp" />
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?searchBackgroundColor" />
<corners android:topRightRadius="100dp" android:bottomRightRadius="100dp" />
</shape>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/default_dialog_background"
android:insetRight="24dp"
android:insetLeft="24dp"> <!-- Using Material's inset size -->
</inset>

View File

@ -5,6 +5,4 @@
<solid android:color="?backgroundSecondary" />
<corners android:radius="?dialogCornerRadius" />
</shape>

View File

@ -45,6 +45,7 @@
android:layout_marginEnd="64dp"
android:background="@null"
android:gravity="center_vertical"
android:maxLength="@integer/max_input_chars"
android:hint="@string/message"
android:textColorHint="?attr/input_bar_text_hint"
android:textColor="?input_bar_text_user"

View File

@ -30,7 +30,7 @@
<dimen name="text_view_corner_radius">8dp</dimen>
<dimen name="fake_chat_view_bubble_corner_radius">10dp</dimen>
<dimen name="setting_button_height">64dp</dimen>
<dimen name="dialog_corner_radius">8dp</dimen>
<dimen name="dialog_corner_radius">12dp</dimen>
<dimen name="video_inset_radius">11dp</dimen>
<dimen name="pn_option_corner_radius">8dp</dimen>
<dimen name="path_status_view_size">8dp</dimen>

View File

@ -7,4 +7,5 @@
<integer name="max_user_nickname_length_chars">35</integer>
<integer name="max_group_and_community_name_length_chars">35</integer>
<integer name="max_input_chars">2000</integer>
</resources>

View File

@ -1,24 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
All localisable strings are now in the 'libsession' module (which will be renamed to
'common' in the near future), and all content description / AccessibilityID_ strings are in
their own 'content-descriptions' module.
-->
<!-- TODO: These need to be removed and the text generated by DateUtils.getLocalisedRelativeDayString - but
it's going to be a nuisance because that returns a string not a resource ID so just leaving them for now -AL -->
<string name="BucketedThreadMedia_Today">Today</string>
<string name="BucketedThreadMedia_Yesterday">Yesterday</string>
<!-- TODO: We'll also need plurals for these strings -->
<plurals name="ConversationFragment_delete_selected_messages">
<item quantity="one">Delete selected message?</item>
<item quantity="other">Delete selected messages?</item>
</plurals>
<plurals name="ConversationFragment_this_will_permanently_delete_all_n_selected_messages">
<item quantity="one">This will permanently delete the selected message.</item>
<item quantity="other">This will permanently delete all %1$d selected messages.</item>
</plurals>
</resources>

View File

@ -81,7 +81,7 @@
</style>
<style name="ThemeOverlay.Session.AlertDialog" parent="ThemeOverlay.AppCompat.Dialog.Alert">
<item name="android:windowBackground">@drawable/default_dialog_background</item>
<item name="android:windowBackground">@drawable/default_dialog_background_inset</item>
<item name="android:colorBackground">?backgroundSecondary</item>
<item name="android:colorBackgroundFloating">?colorPrimary</item>
<item name="backgroundTint">?colorPrimary</item>

View File

@ -7,9 +7,6 @@
android:title="@string/helpReportABug"
android:summary="@string/helpReportABugExportLogsDescription"
android:widgetLayout="@layout/export_logs_widget" />
<!-- Note: Having this as `android:layout` rather than `android:layoutWidget` allows it to fit the screen width -->
<Preference android:layout="@layout/preference_widget_progress" />
</PreferenceCategory>
<PreferenceCategory>

View File

@ -195,7 +195,7 @@ class ConversationViewModelTest: BaseViewModelTest() {
@Test
fun `local recipient should have input and no blinded recipient`() {
whenever(recipient.isLocalNumber).thenReturn(true)
assertThat(viewModel.hidesInputBar(), equalTo(false))
assertThat(viewModel.shouldHideInputBar(), equalTo(false))
assertThat(viewModel.blindedRecipient, nullValue())
}
@ -207,7 +207,7 @@ class ConversationViewModelTest: BaseViewModelTest() {
}
whenever(repository.maybeGetBlindedRecipient(recipient)).thenReturn(blinded)
assertThat(viewModel.blindedRecipient, notNullValue())
assertThat(viewModel.hidesInputBar(), equalTo(true))
assertThat(viewModel.shouldHideInputBar(), equalTo(true))
}
}

View File

@ -179,6 +179,11 @@ class MentionViewModelTest {
// Should have normalised message with selected candidate
assertThat(mentionViewModel.normalizeMessageBody())
.isEqualTo("Hi @pubkey1 ")
// Should have correct normalised message even with the last space deleted
editable.delete(editable.length - 1, editable.length)
assertThat(mentionViewModel.normalizeMessageBody())
.isEqualTo("Hi @pubkey1 ")
}
}
}

View File

@ -1,87 +0,0 @@
package org.thoughtcrime.securesms.l10n;
import android.app.Application;
import android.content.res.Resources;
import org.junit.Ignore;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import network.loki.messenger.BuildConfig;
import network.loki.messenger.R;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import androidx.test.core.app.ApplicationProvider;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
//FIXME AC: This test group is outdated.
@Ignore("This test group uses outdated instrumentation and needs a migration to modern tools.")
@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE, application = Application.class)
public final class LanguageResourcesTest {
@Test
public void language_entries_match_language_values_in_length() {
Resources resources = ApplicationProvider.getApplicationContext().getResources();
String[] values = resources.getStringArray(R.array.language_values);
String[] entries = resources.getStringArray(R.array.language_entries);
assertEquals(values.length, entries.length);
}
@Test
public void language_options_matches_available_resources() {
Set<String> languageEntries = languageEntries();
Set<String> foundResources = buildConfigResources();
if (!languageEntries.equals(foundResources)) {
assertSubset(foundResources, languageEntries, "Missing language_entries for resources");
assertSubset(languageEntries, foundResources, "Missing resources for language_entries");
fail("Unexpected");
}
}
private static Set<String> languageEntries() {
Resources resources = ApplicationProvider.getApplicationContext().getResources();
String[] values = resources.getStringArray(R.array.language_values);
List<String> tail = Arrays.asList(values).subList(1, values.length);
Set<String> set = new HashSet<>(tail);
assertEquals("First is not the default", "zz", values[0]);
assertEquals("List contains duplicates", tail.size(), set.size());
return set;
}
private static Set<String> buildConfigResources() {
Set<String> set = new HashSet<>();
Collections.addAll(set, BuildConfig.LANGUAGES);
assertEquals("List contains duplicates", BuildConfig.LANGUAGES.length, set.size());
return set;
}
/**
* Fails if "a" is not a subset of "b", lists the additional values found in "a"
*/
private static void assertSubset(Set<String> a, Set<String> b, String message) {
Set<String> delta = subtract(a, b);
if (!delta.isEmpty()) {
fail(message + ": " + String.join(", ", delta));
}
}
/**
* Set a - Set b
*/
private static Set<String> subtract(Set<String> a, Set<String> b) {
Set<String> set = new HashSet<>(a);
set.removeAll(b);
return set;
}
}

View File

@ -1,56 +0,0 @@
package org.thoughtcrime.securesms.util.dynamiclanguage;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.session.libsession.utilities.dynamiclanguage.LanguageString;
import java.util.Arrays;
import java.util.Collection;
import java.util.Locale;
import static org.junit.Assert.assertEquals;
@RunWith(Parameterized.class)
public final class LanguageStringTest {
private final Locale expected;
private final String input;
@Parameterized.Parameters
public static Collection<Object[]> data() {
return Arrays.asList(new Object[][]{
/* Language */
{ new Locale("en"), "en" },
{ new Locale("de"), "de" },
{ new Locale("fr"), "FR" },
/* Language and region */
{ new Locale("en", "US"), "en_US" },
{ new Locale("es", "US"), "es_US" },
{ new Locale("es", "MX"), "es_MX" },
{ new Locale("es", "MX"), "es_mx" },
{ new Locale("de", "DE"), "de_DE" },
/* Not parsable input */
{ null, null },
{ null, "" },
{ null, "zz" },
{ null, "zz_ZZ" },
{ null, "fr_ZZ" },
{ null, "zz_FR" },
});
}
public LanguageStringTest(Locale expected, String input) {
this.expected = expected;
this.input = input;
}
@Test
public void parse() {
assertEquals(expected, LanguageString.parseLocale(input));
}
}

View File

@ -1,58 +0,0 @@
package org.thoughtcrime.securesms.util.dynamiclanguage;
import android.app.Application;
import org.junit.Ignore;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.session.libsession.utilities.dynamiclanguage.LocaleParser;
import network.loki.messenger.BuildConfig;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
//FIXME AC: This test group is outdated.
@Ignore("This test group uses outdated instrumentation and needs a migration to modern tools.")
@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE, application = Application.class)
public final class LocaleParserTest {
@Test
public void findBestMatchingLocaleForLanguage_all_build_config_languages_can_be_resolved() {
for (String lang : buildConfigLanguages()) {
Locale locale = LocaleParser.findBestMatchingLocaleForLanguage(lang);
assertEquals(lang, locale.toString());
}
}
@Test
@Config(qualifiers = "fr")
public void findBestMatchingLocaleForLanguage_a_non_build_config_language_defaults_to_device_value_which_is_supported_directly() {
String unsupportedLanguage = getUnsupportedLanguage();
assertEquals(Locale.FRENCH, LocaleParser.findBestMatchingLocaleForLanguage(unsupportedLanguage));
}
@Test
@Config(qualifiers = "en-rCA")
public void findBestMatchingLocaleForLanguage_a_non_build_config_language_defaults_to_device_value_which_is_not_supported_directly() {
String unsupportedLanguage = getUnsupportedLanguage();
assertEquals(Locale.CANADA, LocaleParser.findBestMatchingLocaleForLanguage(unsupportedLanguage));
}
private static String getUnsupportedLanguage() {
String unsupportedLanguage = "af";
assertFalse("Language should be an unsupported one", buildConfigLanguages().contains(unsupportedLanguage));
return unsupportedLanguage;
}
private static List<String> buildConfigLanguages() {
return Arrays.asList(BuildConfig.LANGUAGES);
}
}

View File

@ -1 +0,0 @@
{"LanguageName": "Afrikaans", "LanguageCode": "af", "Week": "week", "Weeks": "weke", "Day": "dag", "Days": "dae", "Hour": "uur", "Hours": "ure", "Minute": "minuut", "Minutes": "minute", "Second": "tweede", "Seconds": "sekondes"}

View File

@ -1 +0,0 @@
{"LanguageName": "Amharic", "LanguageCode": "am", "Week": "\u1233\u121d\u1295\u1275", "Weeks": "\u1233\u121d\u1295\u1273\u1275", "Day": "\u1240\u1295", "Days": "\u1240\u1293\u1275", "Hour": "\u1230\u12a0\u1275", "Hours": "\u1230\u12d3\u1273\u1275", "Minute": "\u12f0\u1242\u1243", "Minutes": "\u12f0\u1242\u1243\u12ce\u127d", "Second": "\u1201\u1208\u1270\u129b", "Seconds": "\u1230\u12a8\u1295\u12f6\u127d"}

View File

@ -1 +0,0 @@
{"LanguageName": "Arabic", "LanguageCode": "ar", "Week": "\u0623\u0633\u0628\u0648\u0639", "Weeks": "\u0623\u0633\u0627\u0628\u064a\u0639", "Day": "\u064a\u0648\u0645", "Days": "\u0623\u064a\u0627\u0645", "Hour": "\u0633\u0627\u0639\u0629", "Hours": "\u0633\u0627\u0639\u0627\u062a", "Minute": "دقيقة", "Minutes": "دقائق", "Second": "\u062b\u0627\u0646\u064a\u0629", "Seconds": "\u062b\u0627\u0646\u064a\u0629"}

View File

@ -1 +0,0 @@
{"LanguageName": "Azerbaijani", "LanguageCode": "az", "Week": "h\u0259ft\u0259", "Weeks": "h\u0259ft\u0259l\u0259r", "Day": "g\u00fcn", "Days": "g\u00fcnl\u0259r", "Hour": "saat", "Hours": "saat", "Minute": "d\u0259qiq\u0259", "Minutes": "d\u0259qiq\u0259", "Second": "ikinci", "Seconds": "saniy\u0259"}

View File

@ -1 +0,0 @@
{"LanguageName": "Belarusian", "LanguageCode": "be", "Week": "\u0442\u044b\u0434\u0437\u0435\u043d\u044c", "Weeks": "\u0442\u044b\u0434\u043d\u044f\u045e", "Day": "\u0434\u0437\u0435\u043d\u044c", "Days": "\u0434\u0437\u0451\u043d", "Hour": "\u0433\u0430\u0434\u0437\u0456\u043d\u0443", "Hours": "\u0433\u0430\u0434\u0437\u0456\u043d\u044b", "Minute": "\u0445\u0432\u0456\u043b\u0456\u043d\u0430", "Minutes": "\u0445\u0432\u0456\u043b\u0456\u043d", "Second": "\u0434\u0440\u0443\u0433\u0456", "Seconds": "\u0441\u0435\u043a\u0443\u043d\u0434"}

View File

@ -1 +0,0 @@
{"LanguageName": "Bulgarian", "LanguageCode": "bg", "Week": "\u0441\u0435\u0434\u043c\u0438\u0446\u0430", "Weeks": "\u0441\u0435\u0434\u043c\u0438\u0446\u0438", "Day": "\u0434\u0435\u043d", "Days": "\u0434\u043d\u0438", "Hour": "\u0447\u0430\u0441", "Hours": "\u0447\u0430\u0441\u0430", "Minute": "\u043c\u0438\u043d\u0443\u0442\u0430", "Minutes": "\u043c\u0438\u043d\u0443\u0442\u0438", "Second": "\u0432\u0442\u043e\u0440\u043e", "Seconds": "\u0441\u0435\u043a\u0443\u043d\u0434\u0438"}

View File

@ -1 +0,0 @@
{"LanguageName": "Bengali", "LanguageCode": "bn", "Week": "\u09b8\u09aa\u09cd\u09a4\u09be\u09b9", "Weeks": "\u09b8\u09aa\u09cd\u09a4\u09be\u09b9", "Day": "\u09a6\u09bf\u09a8", "Days": "\u09a6\u09bf\u09a8", "Hour": "\u0998\u09a8\u09cd\u099f\u09be", "Hours": "\u0998\u09a8\u09cd\u099f\u09be\u09b0", "Minute": "\u09ae\u09bf\u09a8\u09bf\u099f", "Minutes": "\u09ae\u09bf\u09a8\u09bf\u099f", "Second": "\u09a6\u09cd\u09ac\u09bf\u09a4\u09c0\u09af\u09bc", "Seconds": "\u09b8\u09c7\u0995\u09c7\u09a8\u09cd\u09a1"}

View File

@ -1 +0,0 @@
{"LanguageName": "Bosnian", "LanguageCode": "bs", "Week": "sedmica", "Weeks": "weken", "Day": "dag", "Days": "dagen", "Hour": "uur", "Hours": "uur", "Minute": "minuut", "Minutes": "minuten", "Second": "seconde", "Seconds": "seconden"}

View File

@ -1 +0,0 @@
{"LanguageName": "Catalan", "LanguageCode": "ca", "Week": "setmana", "Weeks": "setmanes", "Day": "dia", "Days": "dies", "Hour": "hores", "Hours": "hores", "Minute": "minut", "Minutes": "minuts", "Second": "segon", "Seconds": "segons"}

View File

@ -1 +0,0 @@
{"LanguageName": "Corsican", "LanguageCode": "co", "Week": "settimana", "Weeks": "settimane", "Day": "ghjornu", "Days": "ghjorni", "Hour": "ora", "Hours": "ore", "Minute": "minutu", "Minutes": "minuti", "Second": "sicondu", "Seconds": "seconde"}

View File

@ -1 +0,0 @@
{"LanguageName": "Czech", "LanguageCode": "cs", "Week": "t\u00fdden", "Weeks": "t\u00fddn\u016f", "Day": "den", "Days": "dn\u00ed", "Hour": "hodina", "Hours": "hodin", "Minute": "minuta", "Minutes": "minut", "Second": "druh\u00fd", "Seconds": "sekundy"}

View File

@ -1 +0,0 @@
{"LanguageName": "Welsh", "LanguageCode": "cy", "Week": "wythnos", "Weeks": "wythnosau", "Day": "Dydd", "Days": "dyddiau", "Hour": "awr", "Hours": "oriau", "Minute": "munud", "Minutes": "munudau", "Second": "ail", "Seconds": "eiliadau"}

View File

@ -1 +0,0 @@
{"LanguageName": "Danish", "LanguageCode": "da", "Week": "uge", "Weeks": "uger", "Day": "dag", "Days": "dage", "Hour": "time", "Hours": "timer", "Minute": "minut", "Minutes": "minutter", "Second": "anden", "Seconds": "sekunder"}

View File

@ -1 +0,0 @@
{"LanguageName": "German", "LanguageCode": "de", "Week": "Woche", "Weeks": "Wochen", "Day": "Tag", "Days": "Tage", "Hour": "Stunde", "Hours": "Std.", "Minute": "Minute", "Minutes": "Protokoll", "Second": "zweite", "Seconds": "Sekunden"}

View File

@ -1 +0,0 @@
{"LanguageName": "Greek", "LanguageCode": "el", "Week": "\u03b5\u03b2\u03b4\u03bf\u03bc\u03ac\u03b4\u03b1", "Weeks": "\u03b5\u03b2\u03b4\u03bf\u03bc\u03ac\u03b4\u03b5\u03c2", "Day": "\u03b7\u03bc\u03ad\u03c1\u03b1", "Days": "\u03b7\u03bc\u03ad\u03c1\u03b5\u03c2", "Hour": "\u03ce\u03c1\u03b1", "Hours": "\u03ce\u03c1\u03b5\u03c2", "Minute": "\u03bb\u03b5\u03c0\u03c4\u03cc", "Minutes": "\u03bb\u03b5\u03c0\u03c4\u03ac", "Second": "\u03b4\u03b5\u03cd\u03c4\u03b5\u03c1\u03bf\u03c2", "Seconds": "\u03b4\u03b5\u03c5\u03c4\u03b5\u03c1\u03cc\u03bb\u03b5\u03c0\u03c4\u03b1"}

View File

@ -1 +0,0 @@
{"LanguageName": "English", "LanguageCode": "en", "Week": "week", "Weeks": "weeks", "Day": "day", "Days": "days", "Hour": "hour", "Hours": "hours", "Minute": "minute", "Minutes": "minutes", "Second": "second", "Seconds": "seconds"}

View File

@ -1 +0,0 @@
{"LanguageName": "Esperanto", "LanguageCode": "eo", "Week": "semajno", "Weeks": "semajnoj", "Day": "tago", "Days": "tagoj", "Hour": "horo", "Hours": "horoj", "Minute": "minuto", "Minutes": "minutoj", "Second": "dua", "Seconds": "sekundoj"}

View File

@ -1 +0,0 @@
{"LanguageName": "Spanish", "LanguageCode": "es", "Week": "semana", "Weeks": "semanas", "Day": "d\u00eda", "Days": "d\u00edas", "Hour": "hora", "Hours": "horas", "Minute": "minuto", "Minutes": "minutos", "Second": "segundo", "Seconds": "segundos"}

View File

@ -1 +0,0 @@
{"LanguageName": "Estonian", "LanguageCode": "et", "Week": "n\u00e4dal", "Weeks": "n\u00e4dalaid", "Day": "p\u00e4eval", "Days": "p\u00e4evadel", "Hour": "tund", "Hours": "tundi", "Minute": "minut", "Minutes": "minutit", "Second": "teiseks", "Seconds": "sekundit"}

View File

@ -1 +0,0 @@
{"LanguageName": "Basque", "LanguageCode": "eu", "Week": "astea", "Weeks": "asteak", "Day": "eguna", "Days": "egunak", "Hour": "ordua", "Hours": "orduak", "Minute": "minutua", "Minutes": "minutu", "Second": "bigarrena", "Seconds": "segundoak"}

View File

@ -1 +0,0 @@
{"LanguageName": "Persian", "LanguageCode": "fa", "Week": "\u0647\u0641\u062a\u0647", "Weeks": "\u0647\u0641\u062a\u0647 \u0647\u0627", "Day": "\u0631\u0648\u0632", "Days": "\u0631\u0648\u0632\u0647\u0627", "Hour": "\u0633\u0627\u0639\u062a", "Hours": "\u0633\u0627\u0639\u062a \u0647\u0627", "Minute": "\u062f\u0642\u06cc\u0642\u0647", "Minutes": "\u062f\u0642\u0627\u06cc\u0642", "Second": "\u062f\u0648\u0645\u06cc\u0646", "Seconds": "\u062b\u0627\u0646\u06cc\u0647"}

View File

@ -1 +0,0 @@
{"LanguageName": "Finnish", "LanguageCode": "fi", "Week": "viikko", "Weeks": "viikkoa", "Day": "p\u00e4iv\u00e4", "Days": "p\u00e4iv\u00e4\u00e4", "Hour": "tunnin", "Hours": "tuntia", "Minute": "minuutti", "Minutes": "p\u00f6yt\u00e4kirja", "Second": "toinen", "Seconds": "sekuntia"}

View File

@ -1 +0,0 @@
{"LanguageName": "Filipino", "LanguageCode": "fil", "Week": "linggo", "Weeks": "linggo", "Day": "araw", "Days": "araw", "Hour": "oras", "Hours": "oras", "Minute": "minuto", "Minutes": "minuto", "Second": "pangalawa", "Seconds": "segundo"}

View File

@ -1 +0,0 @@
{"LanguageName": "French", "LanguageCode": "fr", "Week": "semaine", "Weeks": "semaines", "Day": "jour", "Days": "jours", "Hour": "heure", "Hours": "heures", "Minute": "minute", "Minutes": "minutes", "Second": "seconde", "Seconds": "secondes"}

View File

@ -1 +0,0 @@
{"LanguageName": "Frisian", "LanguageCode": "fy", "Week": "wike", "Weeks": "wiken", "Day": "dei", "Days": "dagen", "Hour": "oere", "Hours": "oeren", "Minute": "min\u00fat", "Minutes": "minuten", "Second": "twadde", "Seconds": "sekonden"}

View File

@ -1 +0,0 @@
{"LanguageName": "Scots Gaelic", "LanguageCode": "gd", "Week": "seachdain", "Weeks": "seachdainean", "Day": "latha", "Days": "l\u00e0ithean", "Hour": "uair", "Hours": "uairean", "Minute": "mionaid", "Minutes": "mionaidean", "Second": "an d\u00e0rna", "Seconds": "diog"}

View File

@ -1 +0,0 @@
{"LanguageName": "Galician", "LanguageCode": "gl", "Week": "semana", "Weeks": "semanas", "Day": "d\u00eda", "Days": "d\u00edas", "Hour": "hora", "Hours": "horas", "Minute": "minuto", "Minutes": "minutos", "Second": "segundo", "Seconds": "segundos"}

View File

@ -1 +0,0 @@
{"LanguageName": "Gujarati", "LanguageCode": "gu", "Week": "\u0ab8\u0aaa\u0acd\u0aa4\u0abe\u0ab9", "Weeks": "\u0a85\u0aa0\u0ab5\u0abe\u0aa1\u0abf\u0aaf\u0abe", "Day": "\u0aa6\u0abf\u0ab5\u0ab8", "Days": "\u0aa6\u0abf\u0ab5\u0ab8", "Hour": "\u0a95\u0ab2\u0abe\u0a95", "Hours": "\u0a95\u0ab2\u0abe\u0a95", "Minute": "\u0aae\u0abf\u0aa8\u0abf\u0a9f", "Minutes": "\u0aae\u0abf\u0aa8\u0abf\u0a9f", "Second": "\u0aac\u0ac0\u0a9c\u0ac1\u0a82", "Seconds": "\u0ab8\u0ac7\u0a95\u0aa8\u0acd\u0aa1"}

View File

@ -1 +0,0 @@
{"LanguageName": "Hausa", "LanguageCode": "ha", "Week": "mako", "Weeks": "makonni", "Day": "rana", "Days": "kwanaki", "Hour": "awa", "Hours": "hours", "Minute": "minti", "Minutes": "mintuna", "Second": "na biyu", "Seconds": "seconds"}

View File

@ -1 +0,0 @@
{"LanguageName": "Hawaiian", "LanguageCode": "haw", "Week": "pule", "Weeks": "pule pule", "Day": "l\u0101", "Days": "l\u0101", "Hour": "hola", "Hours": "hola", "Minute": "minuke", "Minutes": "minuke", "Second": "ka lua", "Seconds": "kekona"}

View File

@ -1 +0,0 @@
{"LanguageName": "Hindi", "LanguageCode": "hi", "Week": "\u0938\u092a\u094d\u0924\u093e\u0939", "Weeks": "\u0939\u092b\u094d\u0924\u094b\u0902", "Day": "\u0926\u093f\u0928", "Days": "\u0926\u093f\u0928", "Hour": "\u0918\u0902\u091f\u093e", "Hours": "\u0918\u0902\u091f\u0947", "Minute": "\u092e\u093f\u0928\u091f", "Minutes": "\u092e\u093f\u0928\u091f", "Second": "\u0926\u0942\u0938\u0930\u093e", "Seconds": "\u0938\u0947\u0915\u0902\u0921"}

View File

@ -1 +0,0 @@
{"LanguageName": "Hmong", "LanguageCode": "hmn", "Week": "lub lim tiam", "Weeks": "lub lis piam", "Day": "hnub", "Days": "hnub", "Hour": "teev", "Hours": "teev", "Minute": "feeb", "Minutes": "feeb", "Second": "thib ob", "Seconds": "vib nas this"}

View File

@ -1 +0,0 @@
{"LanguageName": "Croatian", "LanguageCode": "hr", "Week": "tjedan", "Weeks": "tjedni", "Day": "dan", "Days": "dana", "Hour": "sat", "Hours": "sati", "Minute": "minuta", "Minutes": "minuta", "Second": "drugi", "Seconds": "sekundi"}

View File

@ -1 +0,0 @@
{"LanguageName": "Haitian Creole", "LanguageCode": "ht", "Week": "sem\u00e8n", "Weeks": "sem\u00e8n", "Day": "jou", "Days": "jou", "Hour": "\u00e8dtan", "Hours": "\u00e8dtan", "Minute": "minit", "Minutes": "minit", "Second": "dezy\u00e8m", "Seconds": "segonn"}

View File

@ -1 +0,0 @@
{"LanguageName": "Hungarian", "LanguageCode": "hu", "Week": "h\u00e9t", "Weeks": "h\u00e9tig", "Day": "nap", "Days": "napok", "Hour": "\u00f3ra", "Hours": "\u00f3r\u00e1k", "Minute": "perc", "Minutes": "percek", "Second": "m\u00e1sodik", "Seconds": "m\u00e1sodpercig"}

View File

@ -1 +0,0 @@
{"LanguageName": "Aermenian", "LanguageCode": "hy", "Week": "\u0576\u0565\u0564\u0561\u056c", "Weeks": "n\u00e4dalaid", "Day": "p\u00e4eval", "Days": "p\u00e4evadel", "Hour": "\u057f\u0578\u0582\u0576", "Hours": "\u057f\u0578\u0582\u0576\u0564\u056b", "Minute": "\u0580\u0578\u057a\u0565", "Minutes": "\u0580\u0578\u057a\u0565", "Second": "teiseks", "Seconds": "\u057d\u0565\u056f\u0578\u0582\u0576\u0564\u056b\u057f"}

View File

@ -1 +0,0 @@
{"LanguageName": "Indonesian", "LanguageCode": "id", "Week": "pekan", "Weeks": "minggu", "Day": "hari", "Days": "hari", "Hour": "jam", "Hours": "jam", "Minute": "menit", "Minutes": "menit", "Second": "Kedua", "Seconds": "detik"}

View File

@ -1 +0,0 @@
{"LanguageName": "Igbo", "LanguageCode": "ig", "Week": "izu", "Weeks": "izu", "Day": "\u1ee5b\u1ecdch\u1ecb", "Days": "\u1ee5b\u1ecdch\u1ecb", "Hour": "awa", "Hours": "awa", "Minute": "nkeji", "Minutes": "nkeji", "Second": "nke ab\u1ee5\u1ecd", "Seconds": "sek\u1ecdnd"}

View File

@ -1 +0,0 @@
{"LanguageName": "Icelandic", "LanguageCode": "is", "Week": "vika", "Weeks": "vikur", "Day": "dagur", "Days": "daga", "Hour": "klukkustund", "Hours": "klukkustundir", "Minute": "m\u00edn\u00fatu", "Minutes": "m\u00edn\u00fatur", "Second": "anna\u00f0", "Seconds": "sek\u00fandur"}

View File

@ -1 +0,0 @@
{"LanguageName": "Italian", "LanguageCode": "it", "Week": "settimana", "Weeks": "settimane", "Day": "giorno", "Days": "giorni", "Hour": "ora", "Hours": "ore", "Minute": "minuto", "Minutes": "minuti", "Second": "secondo", "Seconds": "secondi"}

View File

@ -1 +0,0 @@
{"LanguageName": "Hebrew", "LanguageCode": "iw", "Week": "\u05e9\u05c1\u05b8\u05d1\u05d5\u05bc\u05e2\u05b7", "Weeks": "\u05e9\u05d1\u05d5\u05e2\u05d5\u05ea", "Day": "\u05d9\u05b0\u05d5\u05b9\u05dd", "Days": "\u05d9\u05de\u05d9\u05dd", "Hour": "\u05e9\u05c1\u05b8\u05e2\u05b8\u05d4", "Hours": "\u05e9\u05e2\u05d4 (\u05d5\u05ea", "Minute": "\u05d3\u05b7\u05e7\u05b8\u05d4", "Minutes": "\u05d3\u05e7\u05d5\u05ea", "Second": "\u05e9\u05c1\u05b0\u05e0\u05b4\u05d9\u05b8\u05d4", "Seconds": "\u05e9\u05e0\u05d9\u05d5\u05ea"}

View File

@ -1 +0,0 @@
{"LanguageName": "Japanese", "LanguageCode": "ja", "Week": "週間", "Weeks": "週間", "Day": "日", "Days": "日間", "Hour": "時間", "Hours": "時間", "Minute": "分", "Minutes": "分", "Second": "秒", "Seconds": "秒"}

View File

@ -1 +0,0 @@
{"LanguageName": "Javanese", "LanguageCode": "jv", "Week": "minggu", "Weeks": "minggu", "Day": "dina", "Days": "dina", "Hour": "jam", "Hours": "jam", "Minute": "menit", "Minutes": "menit", "Second": "kapindho", "Seconds": "detik"}

View File

@ -1 +0,0 @@
{"LanguageName": "Georgian", "LanguageCode": "ka", "Week": "\u10d9\u10d5\u10d8\u10e0\u10d0", "Weeks": "\u10d9\u10d5\u10d8\u10e0\u10d4\u10d1\u10d8", "Day": "\u10d3\u10e6\u10d4\u10e1", "Days": "\u10d3\u10e6\u10d4\u10d4\u10d1\u10d8", "Hour": "\u10e1\u10d0\u10d0\u10d7\u10d8", "Hours": "\u10e1\u10d0\u10d0\u10d7\u10d4\u10d1\u10d8", "Minute": "\u10ec\u10e3\u10d7\u10d8", "Minutes": "\u10ec\u10e3\u10d7\u10d4\u10d1\u10d8", "Second": "\u10db\u10d4\u10dd\u10e0\u10d4", "Seconds": "\u10ec\u10d0\u10db\u10d8"}

View File

@ -1 +0,0 @@
{"LanguageName": "Kazakh", "LanguageCode": "kk", "Week": "\u0430\u043f\u0442\u0430", "Weeks": "\u0430\u043f\u0442\u0430", "Day": "\u043a\u04af\u043d\u0456", "Days": "\u043a\u04af\u043d\u0434\u0435\u0440", "Hour": "\u0441\u0430\u0493\u0430\u0442", "Hours": "\u0441\u0430\u0493\u0430\u0442", "Minute": "\u043c\u0438\u043d\u0443\u0442", "Minutes": "\u043c\u0438\u043d\u0443\u0442", "Second": "\u0435\u043a\u0456\u043d\u0448\u0456", "Seconds": "\u0441\u0435\u043a\u0443\u043d\u0434"}

Some files were not shown because too many files have changed in this diff Show More