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

View File

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

View File

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

View File

@ -17,8 +17,6 @@ import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import org.session.libsession.utilities.TextSecurePreferences; 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.conversation.v2.WindowUtil;
import org.thoughtcrime.securesms.util.ActivityUtilitiesKt; import org.thoughtcrime.securesms.util.ActivityUtilitiesKt;
import org.thoughtcrime.securesms.util.ThemeState; import org.thoughtcrime.securesms.util.ThemeState;
@ -97,7 +95,6 @@ public abstract class BaseActionBarActivity extends AppCompatActivity {
protected void onResume() { protected void onResume() {
super.onResume(); super.onResume();
initializeScreenshotSecurity(true); initializeScreenshotSecurity(true);
DynamicLanguageActivityHelper.recreateIfNotInCorrectLanguage(this, TextSecurePreferences.getLanguage(this));
String name = getResources().getString(R.string.app_name); String name = getResources().getString(R.string.app_name);
Bitmap icon = BitmapFactory.decodeResource(getResources(), R.drawable.ic_launcher_foreground); Bitmap icon = BitmapFactory.decodeResource(getResources(), R.drawable.ic_launcher_foreground);
int color = getResources().getColor(R.color.app_icon_background); 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; package org.thoughtcrime.securesms;
import android.app.ActivityManager; import android.app.ActivityManager;
import android.content.Context;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.graphics.BitmapFactory; import android.graphics.BitmapFactory;
import androidx.fragment.app.FragmentActivity; 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; import network.loki.messenger.R;
public abstract class BaseActivity extends FragmentActivity { public abstract class BaseActivity extends FragmentActivity {
@Override @Override
protected void onResume() { protected void onResume() {
super.onResume(); super.onResume();
DynamicLanguageActivityHelper.recreateIfNotInCorrectLanguage(this, TextSecurePreferences.getLanguage(this));
String name = getResources().getString(R.string.app_name); String name = getResources().getString(R.string.app_name);
Bitmap icon = BitmapFactory.decodeResource(getResources(), R.drawable.ic_launcher_foreground); Bitmap icon = BitmapFactory.decodeResource(getResources(), R.drawable.ic_launcher_foreground);
int color = getResources().getColor(R.color.app_icon_background); int color = getResources().getColor(R.color.app_icon_background);
setTaskDescription(new ActivityManager.TaskDescription(name, icon, color)); 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) iconAttribute(R.attr.dialog_alert_icon)
title(context.resources.getQuantityString(R.plurals.deleteMessage, recordCount, recordCount)) title(context.resources.getQuantityString(R.plurals.deleteMessage, recordCount, recordCount))
text(context.resources.getString(R.string.deleteMessageDescriptionEveryone)) text(context.resources.getString(R.string.deleteMessageDescriptionEveryone))
button(R.string.delete) { doDelete.run() } dangerButton(R.string.delete) { doDelete.run() }
cancelButton() 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.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewConversationActionBarBinding import network.loki.messenger.databinding.ViewConversationActionBarBinding
import network.loki.messenger.databinding.ViewConversationSettingBinding import network.loki.messenger.databinding.ViewConversationSettingBinding
import network.loki.messenger.libsession_util.util.ExpiryMode.AfterRead 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.messages.ExpirationConfiguration
import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.utilities.ExpirationUtil 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_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY
import org.session.libsession.utilities.modifyLayoutParams import org.session.libsession.utilities.modifyLayoutParams
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.GroupDatabase
@ -130,7 +126,7 @@ class ConversationActionBarView @JvmOverloads constructor(
settings += ConversationSetting( settings += ConversationSetting(
recipient.mutedUntil.takeUnless { it == Long.MAX_VALUE } recipient.mutedUntil.takeUnless { it == Long.MAX_VALUE }
?.let { ?.let {
context.getString(R.string.notificationsHeaderMute) context.getString(R.string.notificationsMuted)
} }
?: context.getString(R.string.notificationsMuted), ?: context.getString(R.string.notificationsMuted),
ConversationSettingType.NOTIFICATION, ConversationSettingType.NOTIFICATION,

View File

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

View File

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

View File

@ -187,6 +187,7 @@ import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.ui.OpenURLAlertDialog import org.thoughtcrime.securesms.ui.OpenURLAlertDialog
import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme
import org.thoughtcrime.securesms.util.ActivityDispatcher import org.thoughtcrime.securesms.util.ActivityDispatcher
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.NetworkUtils import org.thoughtcrime.securesms.util.NetworkUtils
@ -725,7 +726,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// called from onCreate // called from onCreate
private fun setUpInputBar() { private fun setUpInputBar() {
binding.inputBar.isGone = viewModel.hidesInputBar()
binding.inputBar.delegate = this binding.inputBar.delegate = this
binding.inputBarRecordingView.delegate = this binding.inputBarRecordingView.delegate = this
// GIF button // GIF button
@ -898,6 +898,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (!isFinishing) { if (!isFinishing) {
finish() finish()
} }
binding.inputBar.isGone = uiState.hideInputBar
} }
} }
@ -997,7 +999,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
binding.declineMessageRequestButton.setOnClickListener { 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 { lifecycleScope.launch {
@ -1018,6 +1030,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} }
private fun acceptMessageRequest() {
binding.messageRequestBar.isVisible = false
viewModel.acceptMessageRequest()
}
override fun inputBarEditTextContentChanged(newContent: CharSequence) { override fun inputBarEditTextContentChanged(newContent: CharSequence) {
val inputBarText = binding.inputBar.text // TODO check if we should be referencing newContent here instead val inputBarText = binding.inputBar.text // TODO check if we should be referencing newContent here instead
if (textSecurePreferences.isLinkPreviewsEnabled()) { if (textSecurePreferences.isLinkPreviewsEnabled()) {
@ -1826,10 +1843,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
attachmentManager.clear() attachmentManager.clear()
// Reset attachments button if needed // Reset attachments button if needed
if (isShowingAttachmentOptions) { toggleAttachmentOptions() } if (isShowingAttachmentOptions) { toggleAttachmentOptions() }
// Put the message in the database
message.id = mmsDb.insertMessageOutbox(outgoingTextMessage, viewModel.threadId, false, null, runThreadUpdate = true) // do the heavy work in the bg
// Send it lifecycleScope.launch(Dispatchers.IO) {
MessageSender.send(message, recipient.address, attachments, quote, linkPreview) // 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 // Send a typing stopped message
ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId)
return Pair(recipient.address, sentTimestamp) return Pair(recipient.address, sentTimestamp)
@ -2135,7 +2163,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
showSessionDialog { showSessionDialog {
title(resources.getQuantityString(R.plurals.deleteMessage, messages.count(), messages.count())) title(resources.getQuantityString(R.plurals.deleteMessage, messages.count(), messages.count()))
text(resources.getString(R.string.deleteMessageDescriptionEveryone)) 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() } cancelButton { endActionMode() }
} }
// Otherwise if this is a 1-on-1 conversation we may decided to delete just for ourselves or delete for everyone // 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 { showSessionDialog {
title(R.string.banUser) title(R.string.banUser)
text(R.string.communityBanDescription) 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) cancelButton(::endActionMode)
} }
} }
override fun banAndDeleteAll(messages: Set<MessageRecord>) { override fun banAndDeleteAll(messages: Set<MessageRecord>) {
showSessionDialog { showSessionDialog {
title(R.string.banUser) title(R.string.banDeleteAll)
text(R.string.communityBanDeleteDescription) text(R.string.communityBanDeleteDescription)
button(R.string.banUser) { viewModel.banAndDeleteAll(messages.first()); endActionMode() } dangerButton(R.string.theContinue) { viewModel.banAndDeleteAll(messages.first()); endActionMode() }
cancelButton(::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 // 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 // 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). // 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 package org.thoughtcrime.securesms.conversation.v2
import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent
import android.database.Cursor import android.database.Cursor
import android.net.Uri
import android.util.SparseArray import android.util.SparseArray
import android.util.SparseBooleanArray import android.util.SparseBooleanArray
import android.view.MotionEvent import android.view.MotionEvent
@ -14,33 +11,22 @@ import androidx.core.util.getOrDefault
import androidx.core.util.set import androidx.core.util.set
import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.LifecycleCoroutineScope
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import java.util.concurrent.atomic.AtomicLong import com.bumptech.glide.RequestManager
import kotlin.math.min
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment 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.ControlMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import com.bumptech.glide.RequestManager import java.util.concurrent.atomic.AtomicLong
import com.squareup.phrase.Phrase import kotlin.math.min
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
class ConversationAdapter( class ConversationAdapter(
context: Context, context: Context,

View File

@ -11,8 +11,12 @@ import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R 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.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ConversationRepository
import java.util.UUID import java.util.UUID
@ -84,6 +89,8 @@ class ConversationViewModel(
return repository.getInvitingAdmin(threadId) return repository.getInvitingAdmin(threadId)
} }
private var communityWriteAccessJob: Job? = null
private var _openGroup: RetrieveOnce<OpenGroup> = RetrieveOnce { private var _openGroup: RetrieveOnce<OpenGroup> = RetrieveOnce {
storage.getOpenGroup(threadId) 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 * - We are dealing with a contact from a community (blinded recipient) that does not allow
* requests form community members * requests form community members
*/ */
fun hidesInputBar(): Boolean = openGroup?.canWrite == false || fun shouldHideInputBar(): Boolean = openGroup?.canWrite == false ||
blindedRecipient?.blocksCommunityMessageRequests == true blindedRecipient?.blocksCommunityMessageRequests == true
fun legacyBannerRecipient(context: Context): Recipient? = recipient?.run { fun legacyBannerRecipient(context: Context): Recipient? = recipient?.run {

View File

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

View File

@ -205,13 +205,17 @@ class MentionViewModel(
val sb = StringBuilder() val sb = StringBuilder()
var offset = 0 var offset = 0
for ((span, range) in spansWithRanges) { for ((span, range) in spansWithRanges) {
// Add content before the mention span // Add content before the mention span. There's a possibility of overlapping spans so we need to
sb.append(editable, offset, range.first) // 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" // Replace the mention span with "@public key"
sb.append('@').append(span.member.publicKey).append(' ') 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 // 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.Log
import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.toHexString 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.ShortcutLauncherActivity
import org.thoughtcrime.securesms.calls.WebRtcCallActivity import org.thoughtcrime.securesms.calls.WebRtcCallActivity
import org.thoughtcrime.securesms.contacts.SelectContactsActivity 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.EditGroupActivity
import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity
import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity.Companion.groupIDKey import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity.Companion.groupIDKey
import org.thoughtcrime.securesms.media.MediaOverviewActivity
import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.service.WebRtcCallService import org.thoughtcrime.securesms.service.WebRtcCallService
import org.thoughtcrime.securesms.showMuteDialog import org.thoughtcrime.securesms.showMuteDialog
import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.ui.findActivity
import org.thoughtcrime.securesms.ui.getSubbedString
import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.BitmapUtil
object ConversationMenuHelper { object ConversationMenuHelper {
@ -211,7 +212,15 @@ object ConversationMenuHelper {
// or if the user has not granted audio/microphone permissions // or if the user has not granted audio/microphone permissions
else if (!Permissions.hasAll(context, Manifest.permission.RECORD_AUDIO)) { else if (!Permissions.hasAll(context, Manifest.permission.RECORD_AUDIO)) {
Log.d("Loki", "Attempted to make a call without audio permissions") 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 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.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.utilities.UpdateMessageData 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.StringSubstitutionConstants.NAME_KEY
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.getColorFromAttr
import org.thoughtcrime.securesms.MissingMicrophonePermissionDialog
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages
import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode
import org.thoughtcrime.securesms.database.model.MessageRecord 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.permissions.Permissions
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.ui.findActivity
import org.thoughtcrime.securesms.ui.getSubbedCharSequence import org.thoughtcrime.securesms.ui.getSubbedCharSequence
import org.thoughtcrime.securesms.ui.getSubbedString import org.thoughtcrime.securesms.ui.getSubbedString
import javax.inject.Inject import javax.inject.Inject
@ -136,15 +136,6 @@ class ControlMessageView : LinearLayout {
// handle click behaviour depending on criteria // handle click behaviour depending on criteria
if (message.isMissedCall || message.isFirstMissedCall) { if (message.isMissedCall || message.isFirstMissedCall) {
when { 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, // when the call toggle is disabled in the privacy screen,
// show a dedicated privacy dialog // show a dedicated privacy dialog
!TextSecurePreferences.isCallNotificationsEnabled(context) -> { !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; 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 static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY;
import android.Manifest; import android.Manifest;
@ -254,7 +253,7 @@ public class AttachmentManager {
.request(Manifest.permission.READ_MEDIA_IMAGES) .request(Manifest.permission.READ_MEDIA_IMAGES)
.request(Manifest.permission.READ_MEDIA_AUDIO) .request(Manifest.permission.READ_MEDIA_AUDIO)
.withRationaleDialog( .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() .put(APP_NAME_KEY, c.getString(R.string.app_name)).format().toString()
) )
.withPermanentDenialDialog( .withPermanentDenialDialog(

View File

@ -9,6 +9,7 @@ import android.util.AttributeSet
import android.util.TypedValue import android.util.TypedValue
import android.view.View import android.view.View
import android.view.ViewOutlineProvider import android.view.ViewOutlineProvider
import android.view.ViewTreeObserver
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.bumptech.glide.load.engine.DiskCacheStrategy 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 org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri
import com.bumptech.glide.RequestBuilder import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.RequestManager import com.bumptech.glide.RequestManager
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.ui.afterMeasured
import java.lang.Float.min
open class ThumbnailView @JvmOverloads constructor( open class ThumbnailView @JvmOverloads constructor(
context: Context, context: Context,
@ -114,8 +118,23 @@ open class ThumbnailView @JvmOverloads constructor(
isPreview: Boolean, naturalWidth: Int, isPreview: Boolean, naturalWidth: Int,
naturalHeight: Int naturalHeight: Int
): ListenableFuture<Boolean> { ): ListenableFuture<Boolean> {
binding.playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() && val showPlayOverlay = (slide.thumbnailUri != null && slide.hasPlayOverlay() &&
(slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview)) (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)) { if (equals(this.slide, slide)) {
// don't re-load slide // don't re-load slide

View File

@ -1892,6 +1892,7 @@ open class Storage(
configFactory.withMutableUserConfigs { configFactory.withMutableUserConfigs {
it.contacts.upsertContact(recipient.address.serialize()) { it.contacts.upsertContact(recipient.address.serialize()) {
this.approved = approved 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; 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 @Override
public CharSequence getDisplayBody(@NonNull Context context) { public CharSequence getDisplayBody(@NonNull Context context) {
@ -151,8 +133,13 @@ public class ThreadRecord extends DisplayRecord {
.put(NAME_KEY, getName()) .put(NAME_KEY, getName())
.format().toString(); .format().toString();
} else if (SmsDatabase.Types.isExpirationTimerUpdate(type)) { } else if (SmsDatabase.Types.isExpirationTimerUpdate(type)) {
// Remove formatting on the message by calling .getString() on the SpannableString // Use the same message as we would for displaying on the conversation screen.
return lastMessage != null ? lastMessage.getDisplayBody(context).toString() : null; // 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)) { } else if (MmsSmsColumns.Types.isMediaSavedExtraction(type)) {
return Phrase.from(context, R.string.attachmentsMediaSaved) return Phrase.from(context, R.string.attachmentsMediaSaved)
.put(NAME_KEY, getName()) .put(NAME_KEY, getName())

View File

@ -4,6 +4,9 @@ import android.content.Context
import android.widget.Toast import android.widget.Toast
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import com.squareup.phrase.Phrase 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 java.util.concurrent.Executors
import network.loki.messenger.R import network.loki.messenger.R
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@ -38,6 +41,9 @@ object OpenGroupManager {
return true return true
} }
// flow holding information on write access for our current communities
private val _communityWriteAccess: MutableStateFlow<Map<String, Boolean>> = MutableStateFlow(emptyMap())
fun startPolling() { fun startPolling() {
if (isPolling) { return } if (isPolling) { return }
isPolling = true isPolling = true
@ -65,6 +71,8 @@ object OpenGroupManager {
} }
} }
fun getCommunitiesWriteAccessFlow() = _communityWriteAccess.asStateFlow()
@WorkerThread @WorkerThread
fun add(server: String, room: String, publicKey: String, context: Context): Pair<Long,OpenGroupApi.RoomInfo?> { fun add(server: String, room: String, publicKey: String, context: Context): Pair<Long,OpenGroupApi.RoomInfo?> {
val openGroupID = "$server.$room" val openGroupID = "$server.$room"
@ -164,9 +172,13 @@ object OpenGroupManager {
fun updateOpenGroup(openGroup: OpenGroup, context: Context) { fun updateOpenGroup(openGroup: OpenGroup, context: Context) {
val threadDB = DatabaseComponent.get(context).lokiThreadDatabase() val threadDB = DatabaseComponent.get(context).lokiThreadDatabase()
val openGroupID = "${openGroup.server}.${openGroup.room}" val threadID = GroupManager.getOpenGroupThreadID(openGroup.groupId, context)
val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context)
threadDB.setOpenGroupChat(openGroup, threadID) 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 { 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 androidx.compose.ui.tooling.preview.PreviewParameter
import network.loki.messenger.R import network.loki.messenger.R
import org.thoughtcrime.securesms.ui.SessionShieldIcon 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.components.SlimPrimaryOutlineButton
import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalColors
@ -60,8 +61,8 @@ internal fun SeedReminder(startRecoveryPasswordActivity: () -> Unit) {
style = LocalType.current.small style = LocalType.current.small
) )
} }
Spacer(Modifier.width(LocalDimensions.current.xsSpacing)) Spacer(Modifier.width(LocalDimensions.current.smallSpacing))
SlimPrimaryOutlineButton( PrimaryOutlineButton(
text = stringResource(R.string.theContinue), text = stringResource(R.string.theContinue),
modifier = Modifier modifier = Modifier
.align(Alignment.CenterVertically) .align(Alignment.CenterVertically)

View File

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

View File

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

View File

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

View File

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

View File

@ -65,7 +65,7 @@ object RationaleDialog {
text(message) text(message)
} }
button(R.string.theContinue) { onPositive.run() } 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 android.Manifest
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.preference.Preference import androidx.preference.Preference
import com.squareup.phrase.Phrase
import network.loki.messenger.R 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
import org.session.libsession.utilities.TextSecurePreferences.Companion.setBooleanPreference import org.session.libsession.utilities.TextSecurePreferences.Companion.setBooleanPreference
import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.ui.getSubbedString
internal class CallToggleListener( internal class CallToggleListener(
private val context: Fragment, private val context: Fragment,
@ -39,6 +42,10 @@ internal class CallToggleListener(
) )
setCallback(true) setCallback(true)
} }
.withPermanentDenialDialog(
context.requireContext().getSubbedString(R.string.permissionsMicrophoneAccessRequired,
APP_NAME_KEY to context.requireContext().getString(R.string.app_name)
))
.onAnyDenied { setCallback(false) } .onAnyDenied { setCallback(false) }
.execute() .execute()
} }

View File

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

View File

@ -280,23 +280,19 @@ fun ItemButton(
onClick: () -> Unit onClick: () -> Unit
) { ) {
TextButton( TextButton(
modifier = modifier.fillMaxWidth() modifier = modifier.fillMaxWidth(),
.height(IntrinsicSize.Min)
.heightIn(min = minHeight)
.padding(horizontal = LocalDimensions.current.xsSpacing),
colors = colors, colors = colors,
onClick = onClick, onClick = onClick,
contentPadding = PaddingValues(),
shape = RectangleShape, shape = RectangleShape,
) { ) {
Box( Box(
modifier = Modifier.fillMaxHeight() modifier = Modifier
.aspectRatio(1f) .padding(horizontal = LocalDimensions.current.xxsSpacing)
.align(Alignment.CenterVertically) .size(minHeight)
) { .align(Alignment.CenterVertically),
icon() content = icon
} )
Spacer(modifier = Modifier.width(LocalDimensions.current.smallSpacing))
Text( Text(
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 @Composable
fun Cell( fun Cell(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,

View File

@ -3,8 +3,15 @@ package org.thoughtcrime.securesms.ui
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import android.view.View
import android.view.ViewTreeObserver
import androidx.compose.runtime.Composable 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.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 androidx.fragment.app.Fragment
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState 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") 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.compose.ui.viewinterop.AndroidView
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat 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.BinaryBitmap
import com.google.zxing.ChecksumException import com.google.zxing.ChecksumException
import com.google.zxing.FormatException import com.google.zxing.FormatException
@ -75,6 +78,7 @@ import java.util.concurrent.Executors
private const val TAG = "NewMessageFragment" private const val TAG = "NewMessageFragment"
@OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun QRScannerScreen( fun QRScannerScreen(
errors: Flow<String>, errors: Flow<String>,
@ -93,11 +97,11 @@ fun QRScannerScreen(
val context = LocalContext.current val context = LocalContext.current
val permission = Manifest.permission.CAMERA val permission = Manifest.permission.CAMERA
val cameraPermissionState = rememberPermissionState(permission)
var showCameraPermissionDialog by remember { mutableStateOf(false) } var showCameraPermissionDialog by remember { mutableStateOf(false) }
if (ContextCompat.checkSelfPermission(context, permission) if (cameraPermissionState.status.isGranted) {
== PackageManager.PERMISSION_GRANTED) {
ScanQrCode(errors, onScan) ScanQrCode(errors, onScan)
} else { } else {
Column( Column(

View File

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

View File

@ -15,7 +15,6 @@ data class Dimensions(
val mediumSpacing: Dp = 36.dp, val mediumSpacing: Dp = 36.dp,
val xlargeSpacing: Dp = 64.dp, val xlargeSpacing: Dp = 64.dp,
val dividerIndent: Dp = 60.dp,
val appBarHeight: Dp = 64.dp, val appBarHeight: Dp = 64.dp,
val minItemButtonHeight: Dp = 50.dp, val minItemButtonHeight: Dp = 50.dp,
val minLargeItemButtonHeight: Dp = 60.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)) 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 // Method to get the String for a relative day in a locale-aware fashion
public fun getLocalisedRelativeDayString(relativeDay: RelativeDay): String { public fun getLocalisedRelativeDayString(relativeDay: RelativeDay): String {
@ -76,10 +73,12 @@ object DateUtils : android.text.format.DateUtils() {
set(Calendar.MILLISECOND, 0) set(Calendar.MILLISECOND, 0)
} }
return getRelativeTimeSpanString(comparisonTime.timeInMillis, val temp = getRelativeTimeSpanString(
comparisonTime.timeInMillis,
now.timeInMillis, now.timeInMillis,
DAY_IN_MILLIS, DAY_IN_MILLIS,
FORMAT_SHOW_DATE).toString() FORMAT_SHOW_DATE).toString()
return temp
} }
fun getFormattedDateTime(time: Long, template: String, locale: Locale): String { 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 { private fun getLocalizedPattern(template: String, locale: Locale): String {
return DateFormat.getBestDateTimePattern(locale, template) 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" /> <solid android:color="?backgroundSecondary" />
<corners android:radius="?dialogCornerRadius" />
</shape> </shape>

View File

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

View File

@ -30,7 +30,7 @@
<dimen name="text_view_corner_radius">8dp</dimen> <dimen name="text_view_corner_radius">8dp</dimen>
<dimen name="fake_chat_view_bubble_corner_radius">10dp</dimen> <dimen name="fake_chat_view_bubble_corner_radius">10dp</dimen>
<dimen name="setting_button_height">64dp</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="video_inset_radius">11dp</dimen>
<dimen name="pn_option_corner_radius">8dp</dimen> <dimen name="pn_option_corner_radius">8dp</dimen>
<dimen name="path_status_view_size">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_user_nickname_length_chars">35</integer>
<integer name="max_group_and_community_name_length_chars">35</integer> <integer name="max_group_and_community_name_length_chars">35</integer>
<integer name="max_input_chars">2000</integer>
</resources> </resources>

View File

@ -1,24 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <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> </resources>

View File

@ -81,7 +81,7 @@
</style> </style>
<style name="ThemeOverlay.Session.AlertDialog" parent="ThemeOverlay.AppCompat.Dialog.Alert"> <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:colorBackground">?backgroundSecondary</item>
<item name="android:colorBackgroundFloating">?colorPrimary</item> <item name="android:colorBackgroundFloating">?colorPrimary</item>
<item name="backgroundTint">?colorPrimary</item> <item name="backgroundTint">?colorPrimary</item>

View File

@ -7,9 +7,6 @@
android:title="@string/helpReportABug" android:title="@string/helpReportABug"
android:summary="@string/helpReportABugExportLogsDescription" android:summary="@string/helpReportABugExportLogsDescription"
android:widgetLayout="@layout/export_logs_widget" /> 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>
<PreferenceCategory> <PreferenceCategory>

View File

@ -195,7 +195,7 @@ class ConversationViewModelTest: BaseViewModelTest() {
@Test @Test
fun `local recipient should have input and no blinded recipient`() { fun `local recipient should have input and no blinded recipient`() {
whenever(recipient.isLocalNumber).thenReturn(true) whenever(recipient.isLocalNumber).thenReturn(true)
assertThat(viewModel.hidesInputBar(), equalTo(false)) assertThat(viewModel.shouldHideInputBar(), equalTo(false))
assertThat(viewModel.blindedRecipient, nullValue()) assertThat(viewModel.blindedRecipient, nullValue())
} }
@ -207,7 +207,7 @@ class ConversationViewModelTest: BaseViewModelTest() {
} }
whenever(repository.maybeGetBlindedRecipient(recipient)).thenReturn(blinded) whenever(repository.maybeGetBlindedRecipient(recipient)).thenReturn(blinded)
assertThat(viewModel.blindedRecipient, notNullValue()) 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 // Should have normalised message with selected candidate
assertThat(mentionViewModel.normalizeMessageBody()) assertThat(mentionViewModel.normalizeMessageBody())
.isEqualTo("Hi @pubkey1 ") .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