mirror of
https://github.com/oxen-io/session-android.git
synced 2024-11-25 11:05:25 +00:00
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:
commit
677cd6a7cf
@ -38,6 +38,7 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/
|
||||
pull: 'always',
|
||||
environment: { ANDROID_HOME: '/usr/lib/android-sdk' },
|
||||
commands: [
|
||||
'apt-get update',
|
||||
'apt-get install -y ninja-build openjdk-17-jdk',
|
||||
'update-java-alternatives -s java-1.17.0-openjdk-amd64',
|
||||
'./gradlew testPlayDebugUnitTestCoverageReport'
|
||||
@ -79,6 +80,7 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/
|
||||
pull: 'always',
|
||||
environment: { SSH_KEY: { from_secret: 'SSH_KEY' }, ANDROID_HOME: '/usr/lib/android-sdk' },
|
||||
commands: [
|
||||
'apt-get update',
|
||||
'apt-get install -y ninja-build openjdk-17-jdk',
|
||||
'update-java-alternatives -s java-1.17.0-openjdk-amd64',
|
||||
'./gradlew assemblePlayDebug',
|
||||
|
@ -92,7 +92,6 @@ android {
|
||||
buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\""
|
||||
buildConfigField "int", "CONTENT_PROXY_PORT", "443"
|
||||
buildConfigField "String", "USER_AGENT", "\"OWA\""
|
||||
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
|
||||
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
|
||||
resourceConfigurations += []
|
||||
|
||||
|
@ -59,8 +59,6 @@ import org.session.libsession.utilities.TextSecurePreferences;
|
||||
import org.session.libsession.utilities.Toaster;
|
||||
import org.session.libsession.utilities.Util;
|
||||
import org.session.libsession.utilities.WindowDebouncer;
|
||||
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||
import org.session.libsession.utilities.dynamiclanguage.LocaleParser;
|
||||
import org.session.libsignal.utilities.HTTP;
|
||||
import org.session.libsignal.utilities.JsonUtil;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
@ -98,7 +96,6 @@ import org.thoughtcrime.securesms.sskenvironment.ReadReceiptManager;
|
||||
import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository;
|
||||
import org.thoughtcrime.securesms.util.Broadcaster;
|
||||
import org.thoughtcrime.securesms.util.VersionDataFetcher;
|
||||
import org.thoughtcrime.securesms.util.dynamiclanguage.LocaleParseHelper;
|
||||
import org.thoughtcrime.securesms.webrtc.CallMessageProcessor;
|
||||
import org.webrtc.PeerConnectionFactory;
|
||||
import org.webrtc.PeerConnectionFactory.InitializationOptions;
|
||||
@ -116,7 +113,6 @@ import javax.inject.Inject;
|
||||
|
||||
import dagger.hilt.EntryPoints;
|
||||
import dagger.hilt.android.HiltAndroidApp;
|
||||
import kotlin.Unit;
|
||||
import network.loki.messenger.BuildConfig;
|
||||
import network.loki.messenger.R;
|
||||
|
||||
@ -342,10 +338,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
super.onTerminate();
|
||||
}
|
||||
|
||||
public void initializeLocaleParser() {
|
||||
LocaleParser.Companion.configure(new LocaleParseHelper());
|
||||
}
|
||||
|
||||
public ExpiringMessageManager getExpiringMessageManager() {
|
||||
return expiringMessageManager;
|
||||
}
|
||||
@ -443,12 +435,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void attachBaseContext(Context base) {
|
||||
initializeLocaleParser();
|
||||
super.attachBaseContext(DynamicLanguageContextWrapper.updateContext(base, TextSecurePreferences.getLanguage(base)));
|
||||
}
|
||||
|
||||
private static class ProviderInitializationException extends RuntimeException { }
|
||||
private void setUpPollingIfNeeded() {
|
||||
String userPublicKey = textSecurePreferences.getLocalNumber();
|
||||
|
@ -17,8 +17,6 @@ import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import org.session.libsession.utilities.TextSecurePreferences;
|
||||
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageActivityHelper;
|
||||
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||
import org.thoughtcrime.securesms.conversation.v2.WindowUtil;
|
||||
import org.thoughtcrime.securesms.util.ActivityUtilitiesKt;
|
||||
import org.thoughtcrime.securesms.util.ThemeState;
|
||||
@ -97,7 +95,6 @@ public abstract class BaseActionBarActivity extends AppCompatActivity {
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
initializeScreenshotSecurity(true);
|
||||
DynamicLanguageActivityHelper.recreateIfNotInCorrectLanguage(this, TextSecurePreferences.getLanguage(this));
|
||||
String name = getResources().getString(R.string.app_name);
|
||||
Bitmap icon = BitmapFactory.decodeResource(getResources(), R.drawable.ic_launcher_foreground);
|
||||
int color = getResources().getColor(R.color.app_icon_background);
|
||||
@ -137,9 +134,4 @@ public abstract class BaseActionBarActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void attachBaseContext(Context newBase) {
|
||||
super.attachBaseContext(DynamicLanguageContextWrapper.updateContext(newBase, TextSecurePreferences.getLanguage(newBase)));
|
||||
}
|
||||
}
|
||||
|
@ -1,31 +1,20 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.app.ActivityManager;
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
|
||||
import org.session.libsession.utilities.TextSecurePreferences;
|
||||
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageActivityHelper;
|
||||
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
public abstract class BaseActivity extends FragmentActivity {
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
DynamicLanguageActivityHelper.recreateIfNotInCorrectLanguage(this, TextSecurePreferences.getLanguage(this));
|
||||
String name = getResources().getString(R.string.app_name);
|
||||
Bitmap icon = BitmapFactory.decodeResource(getResources(), R.drawable.ic_launcher_foreground);
|
||||
int color = getResources().getColor(R.color.app_icon_background);
|
||||
setTaskDescription(new ActivityManager.TaskDescription(name, icon, color));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void attachBaseContext(Context newBase) {
|
||||
super.attachBaseContext(DynamicLanguageContextWrapper.updateContext(newBase, TextSecurePreferences.getLanguage(newBase)));
|
||||
}
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ class DeleteMediaDialog {
|
||||
iconAttribute(R.attr.dialog_alert_icon)
|
||||
title(context.resources.getQuantityString(R.plurals.deleteMessage, recordCount, recordCount))
|
||||
text(context.resources.getString(R.string.deleteMessageDescriptionEveryone))
|
||||
button(R.string.delete) { doDelete.run() }
|
||||
dangerButton(R.string.delete) { doDelete.run() }
|
||||
cancelButton()
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
@ -11,20 +11,16 @@ import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.squareup.phrase.Phrase
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ViewConversationActionBarBinding
|
||||
import network.loki.messenger.databinding.ViewConversationSettingBinding
|
||||
import network.loki.messenger.libsession_util.util.ExpiryMode.AfterRead
|
||||
import org.session.libsession.LocalisedTimeUtil
|
||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||
import org.session.libsession.messaging.open_groups.OpenGroup
|
||||
import org.session.libsession.utilities.ExpirationUtil
|
||||
import org.session.libsession.utilities.StringSubstitutionConstants.DISAPPEARING_MESSAGES_TYPE_KEY
|
||||
import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY
|
||||
import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY
|
||||
import org.session.libsession.utilities.modifyLayoutParams
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase
|
||||
@ -130,7 +126,7 @@ class ConversationActionBarView @JvmOverloads constructor(
|
||||
settings += ConversationSetting(
|
||||
recipient.mutedUntil.takeUnless { it == Long.MAX_VALUE }
|
||||
?.let {
|
||||
context.getString(R.string.notificationsHeaderMute)
|
||||
context.getString(R.string.notificationsMuted)
|
||||
}
|
||||
?: context.getString(R.string.notificationsMuted),
|
||||
ConversationSettingType.NOTIFICATION,
|
||||
|
@ -66,21 +66,21 @@ internal fun StartConversationScreen(
|
||||
icon = R.drawable.ic_message,
|
||||
modifier = Modifier.contentDescription(R.string.AccessibilityId_messageNew),
|
||||
onClick = delegate::onNewMessageSelected)
|
||||
Divider(startIndent = LocalDimensions.current.dividerIndent)
|
||||
Divider(startIndent = LocalDimensions.current.minItemButtonHeight)
|
||||
ItemButton(
|
||||
textId = R.string.groupCreate,
|
||||
icon = R.drawable.ic_group,
|
||||
modifier = Modifier.contentDescription(R.string.AccessibilityId_groupCreate),
|
||||
onClick = delegate::onCreateGroupSelected
|
||||
)
|
||||
Divider(startIndent = LocalDimensions.current.dividerIndent)
|
||||
Divider(startIndent = LocalDimensions.current.minItemButtonHeight)
|
||||
ItemButton(
|
||||
textId = R.string.communityJoin,
|
||||
icon = R.drawable.ic_globe,
|
||||
modifier = Modifier.contentDescription(R.string.AccessibilityId_communityJoin),
|
||||
onClick = delegate::onJoinCommunitySelected
|
||||
)
|
||||
Divider(startIndent = LocalDimensions.current.dividerIndent)
|
||||
Divider(startIndent = LocalDimensions.current.minItemButtonHeight)
|
||||
ItemButton(
|
||||
textId = R.string.sessionInviteAFriend,
|
||||
icon = R.drawable.ic_invite_friend,
|
||||
|
@ -98,8 +98,7 @@ internal class NewMessageViewModel @Inject constructor(
|
||||
|
||||
private fun Exception.toMessage() = when (this) {
|
||||
is SnodeAPI.Error.Generic -> application.getString(R.string.onsErrorNotRecognized)
|
||||
is TimeoutException -> application.getString(R.string.onsErrorUnableToSearch)
|
||||
else -> application.getString(R.string.accountIdErrorInvalid)
|
||||
else -> application.getString(R.string.onsErrorUnableToSearch)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -187,6 +187,7 @@ import org.thoughtcrime.securesms.showSessionDialog
|
||||
import org.thoughtcrime.securesms.ui.OpenURLAlertDialog
|
||||
import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme
|
||||
import org.thoughtcrime.securesms.util.ActivityDispatcher
|
||||
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.NetworkUtils
|
||||
@ -725,7 +726,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
|
||||
// called from onCreate
|
||||
private fun setUpInputBar() {
|
||||
binding.inputBar.isGone = viewModel.hidesInputBar()
|
||||
binding.inputBar.delegate = this
|
||||
binding.inputBarRecordingView.delegate = this
|
||||
// GIF button
|
||||
@ -898,6 +898,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
if (!isFinishing) {
|
||||
finish()
|
||||
}
|
||||
|
||||
binding.inputBar.isGone = uiState.hideInputBar
|
||||
}
|
||||
}
|
||||
|
||||
@ -997,7 +999,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
|
||||
binding.declineMessageRequestButton.setOnClickListener {
|
||||
viewModel.declineMessageRequest()
|
||||
fun doDecline() {
|
||||
viewModel.declineMessageRequest()
|
||||
finish()
|
||||
}
|
||||
|
||||
showSessionDialog {
|
||||
title(R.string.delete)
|
||||
text(resources.getString(R.string.messageRequestsDelete))
|
||||
dangerButton(R.string.delete) { doDecline() }
|
||||
button(R.string.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
@ -1018,6 +1030,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
}
|
||||
|
||||
private fun acceptMessageRequest() {
|
||||
binding.messageRequestBar.isVisible = false
|
||||
viewModel.acceptMessageRequest()
|
||||
}
|
||||
|
||||
override fun inputBarEditTextContentChanged(newContent: CharSequence) {
|
||||
val inputBarText = binding.inputBar.text // TODO check if we should be referencing newContent here instead
|
||||
if (textSecurePreferences.isLinkPreviewsEnabled()) {
|
||||
@ -1826,10 +1843,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
attachmentManager.clear()
|
||||
// Reset attachments button if needed
|
||||
if (isShowingAttachmentOptions) { toggleAttachmentOptions() }
|
||||
// Put the message in the database
|
||||
message.id = mmsDb.insertMessageOutbox(outgoingTextMessage, viewModel.threadId, false, null, runThreadUpdate = true)
|
||||
// Send it
|
||||
MessageSender.send(message, recipient.address, attachments, quote, linkPreview)
|
||||
|
||||
// do the heavy work in the bg
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
// Put the message in the database
|
||||
message.id = mmsDb.insertMessageOutbox(
|
||||
outgoingTextMessage,
|
||||
viewModel.threadId,
|
||||
false,
|
||||
null,
|
||||
runThreadUpdate = true
|
||||
)
|
||||
// Send it
|
||||
MessageSender.send(message, recipient.address, attachments, quote, linkPreview)
|
||||
}
|
||||
|
||||
// Send a typing stopped message
|
||||
ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId)
|
||||
return Pair(recipient.address, sentTimestamp)
|
||||
@ -2135,7 +2163,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
showSessionDialog {
|
||||
title(resources.getQuantityString(R.plurals.deleteMessage, messages.count(), messages.count()))
|
||||
text(resources.getString(R.string.deleteMessageDescriptionEveryone))
|
||||
button(R.string.delete) { messages.forEach(viewModel::deleteForEveryone); endActionMode() }
|
||||
dangerButton(R.string.delete) { messages.forEach(viewModel::deleteForEveryone); endActionMode() }
|
||||
cancelButton { endActionMode() }
|
||||
}
|
||||
// Otherwise if this is a 1-on-1 conversation we may decided to delete just for ourselves or delete for everyone
|
||||
@ -2173,16 +2201,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
showSessionDialog {
|
||||
title(R.string.banUser)
|
||||
text(R.string.communityBanDescription)
|
||||
button(R.string.banUser) { viewModel.banUser(messages.first().individualRecipient); endActionMode() }
|
||||
dangerButton(R.string.theContinue) { viewModel.banUser(messages.first().individualRecipient); endActionMode() }
|
||||
cancelButton(::endActionMode)
|
||||
}
|
||||
}
|
||||
|
||||
override fun banAndDeleteAll(messages: Set<MessageRecord>) {
|
||||
showSessionDialog {
|
||||
title(R.string.banUser)
|
||||
title(R.string.banDeleteAll)
|
||||
text(R.string.communityBanDeleteDescription)
|
||||
button(R.string.banUser) { viewModel.banAndDeleteAll(messages.first()); endActionMode() }
|
||||
dangerButton(R.string.theContinue) { viewModel.banAndDeleteAll(messages.first()); endActionMode() }
|
||||
cancelButton(::endActionMode)
|
||||
}
|
||||
}
|
||||
@ -2501,7 +2529,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
// Note: The adapter itemCount is zero based - so calling this with the itemCount in
|
||||
// a non-zero based manner scrolls us to the bottom of the last message (including
|
||||
// to the bottom of long messages as required by Jira SES-789 / GitHub 1364).
|
||||
recyclerView.scrollToPosition(adapter.itemCount)
|
||||
recyclerView.smoothScrollToPosition(adapter.itemCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,7 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.util.SparseArray
|
||||
import android.util.SparseBooleanArray
|
||||
import android.view.MotionEvent
|
||||
@ -14,33 +11,22 @@ import androidx.core.util.getOrDefault
|
||||
import androidx.core.util.set
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.math.min
|
||||
import com.bumptech.glide.RequestManager
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
|
||||
import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView
|
||||
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
|
||||
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate
|
||||
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import com.bumptech.glide.RequestManager
|
||||
import com.squareup.phrase.Phrase
|
||||
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.MissingMicrophonePermissionDialog
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
|
||||
import org.thoughtcrime.securesms.showSessionDialog
|
||||
import org.thoughtcrime.securesms.ui.getSubbedCharSequence
|
||||
import org.thoughtcrime.securesms.ui.getSubbedString
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.math.min
|
||||
|
||||
class ConversationAdapter(
|
||||
context: Context,
|
||||
|
@ -11,8 +11,12 @@ import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import network.loki.messenger.R
|
||||
@ -34,6 +38,7 @@ import org.thoughtcrime.securesms.database.GroupDatabase
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.groups.OpenGroupManager
|
||||
import org.thoughtcrime.securesms.repository.ConversationRepository
|
||||
import java.util.UUID
|
||||
|
||||
@ -84,6 +89,8 @@ class ConversationViewModel(
|
||||
return repository.getInvitingAdmin(threadId)
|
||||
}
|
||||
|
||||
private var communityWriteAccessJob: Job? = null
|
||||
|
||||
private var _openGroup: RetrieveOnce<OpenGroup> = RetrieveOnce {
|
||||
storage.getOpenGroup(threadId)
|
||||
}
|
||||
@ -143,6 +150,27 @@ class ConversationViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// listen to community write access updates from this point
|
||||
communityWriteAccessJob?.cancel()
|
||||
communityWriteAccessJob = viewModelScope.launch {
|
||||
OpenGroupManager.getCommunitiesWriteAccessFlow()
|
||||
.map {
|
||||
if(openGroup?.groupId != null)
|
||||
it[openGroup?.groupId]
|
||||
else null
|
||||
}
|
||||
.filterNotNull()
|
||||
.collect{
|
||||
// update our community object
|
||||
_openGroup.updateTo(openGroup?.copy(canWrite = it))
|
||||
// when we get an update on the write access of a community
|
||||
// we need to update the input text accordingly
|
||||
_uiState.update { state ->
|
||||
state.copy(hideInputBar = shouldHideInputBar())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -396,7 +424,7 @@ class ConversationViewModel(
|
||||
* - We are dealing with a contact from a community (blinded recipient) that does not allow
|
||||
* requests form community members
|
||||
*/
|
||||
fun hidesInputBar(): Boolean = openGroup?.canWrite == false ||
|
||||
fun shouldHideInputBar(): Boolean = openGroup?.canWrite == false ||
|
||||
blindedRecipient?.blocksCommunityMessageRequests == true
|
||||
|
||||
fun legacyBannerRecipient(context: Context): Recipient? = recipient?.run {
|
||||
|
@ -18,7 +18,7 @@ class LinkPreviewDialog(private val onEnabled: () -> Unit) : DialogFragment() {
|
||||
title(R.string.linkPreviewsEnable)
|
||||
val txt = context.getSubbedCharSequence(R.string.linkPreviewsFirstDescription, APP_NAME_KEY to APP_NAME)
|
||||
text(txt)
|
||||
button(R.string.enable) { enable() }
|
||||
dangerButton(R.string.enable) { enable() }
|
||||
cancelButton { dismiss() }
|
||||
}
|
||||
|
||||
|
@ -205,13 +205,17 @@ class MentionViewModel(
|
||||
val sb = StringBuilder()
|
||||
var offset = 0
|
||||
for ((span, range) in spansWithRanges) {
|
||||
// Add content before the mention span
|
||||
sb.append(editable, offset, range.first)
|
||||
// Add content before the mention span. There's a possibility of overlapping spans so we need to
|
||||
// safe guard the start offset here to not go over our span's start.
|
||||
val thisMentionStart = range.first
|
||||
val lastMentionEnd = offset.coerceAtMost(thisMentionStart)
|
||||
sb.append(editable, lastMentionEnd, thisMentionStart)
|
||||
|
||||
// Replace the mention span with "@public key"
|
||||
sb.append('@').append(span.member.publicKey).append(' ')
|
||||
|
||||
offset = range.last + 1
|
||||
// Safe guard offset to not go over the end of the editable.
|
||||
offset = (range.last + 1).coerceAtMost(editable.length)
|
||||
}
|
||||
|
||||
// Add the remaining content
|
||||
|
@ -36,8 +36,6 @@ import org.session.libsignal.utilities.AccountId
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.session.libsignal.utilities.guava.Optional
|
||||
import org.session.libsignal.utilities.toHexString
|
||||
import org.thoughtcrime.securesms.MissingMicrophonePermissionDialog
|
||||
import org.thoughtcrime.securesms.media.MediaOverviewActivity
|
||||
import org.thoughtcrime.securesms.ShortcutLauncherActivity
|
||||
import org.thoughtcrime.securesms.calls.WebRtcCallActivity
|
||||
import org.thoughtcrime.securesms.contacts.SelectContactsActivity
|
||||
@ -49,11 +47,14 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.groups.EditGroupActivity
|
||||
import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity
|
||||
import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity.Companion.groupIDKey
|
||||
import org.thoughtcrime.securesms.media.MediaOverviewActivity
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
|
||||
import org.thoughtcrime.securesms.service.WebRtcCallService
|
||||
import org.thoughtcrime.securesms.showMuteDialog
|
||||
import org.thoughtcrime.securesms.showSessionDialog
|
||||
import org.thoughtcrime.securesms.ui.findActivity
|
||||
import org.thoughtcrime.securesms.ui.getSubbedString
|
||||
import org.thoughtcrime.securesms.util.BitmapUtil
|
||||
|
||||
object ConversationMenuHelper {
|
||||
@ -211,7 +212,15 @@ object ConversationMenuHelper {
|
||||
// or if the user has not granted audio/microphone permissions
|
||||
else if (!Permissions.hasAll(context, Manifest.permission.RECORD_AUDIO)) {
|
||||
Log.d("Loki", "Attempted to make a call without audio permissions")
|
||||
MissingMicrophonePermissionDialog.show(context)
|
||||
|
||||
Permissions.with(context.findActivity())
|
||||
.request(Manifest.permission.RECORD_AUDIO)
|
||||
.withPermanentDenialDialog(
|
||||
context.getSubbedString(R.string.permissionsMicrophoneAccessRequired,
|
||||
APP_NAME_KEY to context.getString(R.string.app_name))
|
||||
)
|
||||
.execute()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -19,11 +19,10 @@ import network.loki.messenger.libsession_util.util.ExpiryMode
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||
import org.session.libsession.messaging.utilities.UpdateMessageData
|
||||
import org.session.libsession.utilities.getColorFromAttr
|
||||
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
|
||||
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.getColorFromAttr
|
||||
import org.thoughtcrime.securesms.MissingMicrophonePermissionDialog
|
||||
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages
|
||||
import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
@ -31,6 +30,7 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
|
||||
import org.thoughtcrime.securesms.showSessionDialog
|
||||
import org.thoughtcrime.securesms.ui.findActivity
|
||||
import org.thoughtcrime.securesms.ui.getSubbedCharSequence
|
||||
import org.thoughtcrime.securesms.ui.getSubbedString
|
||||
import javax.inject.Inject
|
||||
@ -136,15 +136,6 @@ class ControlMessageView : LinearLayout {
|
||||
// handle click behaviour depending on criteria
|
||||
if (message.isMissedCall || message.isFirstMissedCall) {
|
||||
when {
|
||||
// if we're currently missing the audio/microphone permission,
|
||||
// show a dedicated permission dialog
|
||||
!Permissions.hasAll(context, Manifest.permission.RECORD_AUDIO) -> {
|
||||
showInfo()
|
||||
setOnClickListener {
|
||||
MissingMicrophonePermissionDialog.show(context)
|
||||
}
|
||||
}
|
||||
|
||||
// when the call toggle is disabled in the privacy screen,
|
||||
// show a dedicated privacy dialog
|
||||
!TextSecurePreferences.isCallNotificationsEnabled(context) -> {
|
||||
@ -171,6 +162,38 @@ class ControlMessageView : LinearLayout {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if we're currently missing the audio/microphone permission,
|
||||
// show a dedicated permission dialog
|
||||
!Permissions.hasAll(context, Manifest.permission.RECORD_AUDIO) -> {
|
||||
showInfo()
|
||||
setOnClickListener {
|
||||
context.showSessionDialog {
|
||||
val titleTxt = context.getSubbedString(
|
||||
R.string.callsMissedCallFrom,
|
||||
NAME_KEY to message.individualRecipient.name!!
|
||||
)
|
||||
title(titleTxt)
|
||||
|
||||
val bodyTxt = context.getSubbedCharSequence(
|
||||
R.string.callsMicrophonePermissionsRequired,
|
||||
NAME_KEY to message.individualRecipient.name!!
|
||||
)
|
||||
text(bodyTxt)
|
||||
|
||||
button(R.string.theContinue) {
|
||||
Permissions.with(context.findActivity())
|
||||
.request(Manifest.permission.RECORD_AUDIO)
|
||||
.withPermanentDenialDialog(
|
||||
context.getSubbedString(R.string.permissionsMicrophoneAccessRequired,
|
||||
APP_NAME_KEY to context.getString(R.string.app_name))
|
||||
)
|
||||
.execute()
|
||||
}
|
||||
cancelButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -16,7 +16,6 @@
|
||||
*/
|
||||
package org.thoughtcrime.securesms.conversation.v2.utilities;
|
||||
|
||||
import static com.google.android.gms.common.util.CollectionUtils.listOf;
|
||||
import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY;
|
||||
|
||||
import android.Manifest;
|
||||
@ -254,7 +253,7 @@ public class AttachmentManager {
|
||||
.request(Manifest.permission.READ_MEDIA_IMAGES)
|
||||
.request(Manifest.permission.READ_MEDIA_AUDIO)
|
||||
.withRationaleDialog(
|
||||
Phrase.from(c, R.string.permissionsStorageSend)
|
||||
Phrase.from(c, R.string.permissionsMusicAudio)
|
||||
.put(APP_NAME_KEY, c.getString(R.string.app_name)).format().toString()
|
||||
)
|
||||
.withPermanentDenialDialog(
|
||||
|
@ -9,6 +9,7 @@ import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.view.ViewOutlineProvider
|
||||
import android.view.ViewTreeObserver
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.view.isVisible
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
@ -27,7 +28,10 @@ import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri
|
||||
import com.bumptech.glide.RequestBuilder
|
||||
import com.bumptech.glide.RequestManager
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.mms.Slide
|
||||
import org.thoughtcrime.securesms.ui.afterMeasured
|
||||
import java.lang.Float.min
|
||||
|
||||
open class ThumbnailView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
@ -114,8 +118,23 @@ open class ThumbnailView @JvmOverloads constructor(
|
||||
isPreview: Boolean, naturalWidth: Int,
|
||||
naturalHeight: Int
|
||||
): ListenableFuture<Boolean> {
|
||||
binding.playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() &&
|
||||
(slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview))
|
||||
val showPlayOverlay = (slide.thumbnailUri != null && slide.hasPlayOverlay() &&
|
||||
(slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview))
|
||||
if(showPlayOverlay) {
|
||||
binding.playOverlay.isVisible = true
|
||||
// The views are poorly constructed at the moment and there is no good way to know
|
||||
// if this is used in the main conversation or in the tiny quote window of a reply...
|
||||
// But when the view is too small the 'play' icon does not scale,
|
||||
// so we can do it based on measured sizes here
|
||||
binding.playOverlay.afterMeasured {
|
||||
// max size if 60% of the width
|
||||
val ratio = min((binding.root.width * 0.6f) / binding.playOverlay.width, 1f)
|
||||
binding.playOverlay.scaleX = ratio
|
||||
binding.playOverlay.scaleY = ratio
|
||||
}
|
||||
} else {
|
||||
binding.playOverlay.isVisible = false
|
||||
}
|
||||
|
||||
if (equals(this.slide, slide)) {
|
||||
// don't re-load slide
|
||||
|
@ -1892,6 +1892,7 @@ open class Storage(
|
||||
configFactory.withMutableUserConfigs {
|
||||
it.contacts.upsertContact(recipient.address.serialize()) {
|
||||
this.approved = approved
|
||||
this.priority = PRIORITY_VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -104,24 +104,6 @@ public class ThreadRecord extends DisplayRecord {
|
||||
return name;
|
||||
}
|
||||
|
||||
private String getDisappearingMsgExpiryTypeString(Context context) {
|
||||
MessageRecord lm = this.lastMessage;
|
||||
if (lm == null) {
|
||||
Log.w("ThreadRecord", "Could not get last message to determine disappearing msg type.");
|
||||
return "Unknown";
|
||||
}
|
||||
long expireStarted = lm.getExpireStarted();
|
||||
|
||||
// Note: This works because expireStarted is 0 for messages which are 'Disappear after read'
|
||||
// while it's a touch higher than the sent timestamp for "Disappear after send". We could then
|
||||
// use `expireStarted == 0`, but that's not how it's done in UpdateMessageBuilder so to keep
|
||||
// things the same I'll assume there's a reason for this and follow suit.
|
||||
// Also: `this.lastMessage.getExpiresIn()` is available.
|
||||
if (expireStarted >= dateSent) {
|
||||
return context.getString(R.string.disappearingMessagesSent);
|
||||
}
|
||||
return context.getString(R.string.read);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CharSequence getDisplayBody(@NonNull Context context) {
|
||||
@ -151,8 +133,13 @@ public class ThreadRecord extends DisplayRecord {
|
||||
.put(NAME_KEY, getName())
|
||||
.format().toString();
|
||||
} else if (SmsDatabase.Types.isExpirationTimerUpdate(type)) {
|
||||
// Remove formatting on the message by calling .getString() on the SpannableString
|
||||
return lastMessage != null ? lastMessage.getDisplayBody(context).toString() : null;
|
||||
// Use the same message as we would for displaying on the conversation screen.
|
||||
// lastMessage shouldn't be null here, but we'll check just in case.
|
||||
if (lastMessage != null) {
|
||||
return lastMessage.getDisplayBody(context).toString();
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
} else if (MmsSmsColumns.Types.isMediaSavedExtraction(type)) {
|
||||
return Phrase.from(context, R.string.attachmentsMediaSaved)
|
||||
.put(NAME_KEY, getName())
|
||||
|
@ -4,6 +4,9 @@ import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.squareup.phrase.Phrase
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.concurrent.Executors
|
||||
import network.loki.messenger.R
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
@ -38,6 +41,9 @@ object OpenGroupManager {
|
||||
return true
|
||||
}
|
||||
|
||||
// flow holding information on write access for our current communities
|
||||
private val _communityWriteAccess: MutableStateFlow<Map<String, Boolean>> = MutableStateFlow(emptyMap())
|
||||
|
||||
fun startPolling() {
|
||||
if (isPolling) { return }
|
||||
isPolling = true
|
||||
@ -65,6 +71,8 @@ object OpenGroupManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun getCommunitiesWriteAccessFlow() = _communityWriteAccess.asStateFlow()
|
||||
|
||||
@WorkerThread
|
||||
fun add(server: String, room: String, publicKey: String, context: Context): Pair<Long,OpenGroupApi.RoomInfo?> {
|
||||
val openGroupID = "$server.$room"
|
||||
@ -164,9 +172,13 @@ object OpenGroupManager {
|
||||
|
||||
fun updateOpenGroup(openGroup: OpenGroup, context: Context) {
|
||||
val threadDB = DatabaseComponent.get(context).lokiThreadDatabase()
|
||||
val openGroupID = "${openGroup.server}.${openGroup.room}"
|
||||
val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context)
|
||||
val threadID = GroupManager.getOpenGroupThreadID(openGroup.groupId, context)
|
||||
threadDB.setOpenGroupChat(openGroup, threadID)
|
||||
|
||||
// update write access for this community
|
||||
val writeAccesses = _communityWriteAccess.value.toMutableMap()
|
||||
writeAccesses[openGroup.groupId] = openGroup.canWrite
|
||||
_communityWriteAccess.value = writeAccesses
|
||||
}
|
||||
|
||||
fun isUserModerator(context: Context, groupId: String, standardPublicKey: String, blindedPublicKey: String? = null): Boolean {
|
||||
|
@ -19,6 +19,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import network.loki.messenger.R
|
||||
import org.thoughtcrime.securesms.ui.SessionShieldIcon
|
||||
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
|
||||
import org.thoughtcrime.securesms.ui.components.SlimPrimaryOutlineButton
|
||||
import org.thoughtcrime.securesms.ui.contentDescription
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
||||
@ -60,8 +61,8 @@ internal fun SeedReminder(startRecoveryPasswordActivity: () -> Unit) {
|
||||
style = LocalType.current.small
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(LocalDimensions.current.xsSpacing))
|
||||
SlimPrimaryOutlineButton(
|
||||
Spacer(Modifier.width(LocalDimensions.current.smallSpacing))
|
||||
PrimaryOutlineButton(
|
||||
text = stringResource(R.string.theContinue),
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
|
@ -1,7 +1,10 @@
|
||||
package org.thoughtcrime.securesms.media
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import network.loki.messenger.R
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.RelativeDay
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.temporal.WeekFields
|
||||
import java.util.Locale
|
||||
@ -29,16 +32,15 @@ class FixedTimeBuckets(
|
||||
)
|
||||
|
||||
/**
|
||||
* Test the given time against the buckets and return the appropriate string resource the time
|
||||
* Test the given time against the buckets and return the appropriate string the time
|
||||
* bucket. If no bucket is appropriate, it will return null.
|
||||
*/
|
||||
@StringRes
|
||||
fun getBucketText(time: ZonedDateTime): Int? {
|
||||
fun getBucketText(context: Context, time: ZonedDateTime): String? {
|
||||
return when {
|
||||
time >= startOfToday -> R.string.BucketedThreadMedia_Today
|
||||
time >= startOfYesterday -> R.string.BucketedThreadMedia_Yesterday
|
||||
time >= startOfThisWeek -> R.string.attachmentsThisWeek
|
||||
time >= startOfThisMonth -> R.string.attachmentsThisMonth
|
||||
time >= startOfToday -> DateUtils.getLocalisedRelativeDayString(RelativeDay.TODAY)
|
||||
time >= startOfYesterday -> DateUtils.getLocalisedRelativeDayString(RelativeDay.YESTERDAY)
|
||||
time >= startOfThisWeek -> context.getString(R.string.attachmentsThisWeek)
|
||||
time >= startOfThisMonth -> context.getString(R.string.attachmentsThisMonth)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
@ -247,12 +247,7 @@ private fun DeleteConfirmationDialog(
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = context.resources.getQuantityString(
|
||||
R.plurals.ConversationFragment_delete_selected_messages, numSelected
|
||||
),
|
||||
text = context.resources.getQuantityString(
|
||||
R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages,
|
||||
numSelected,
|
||||
numSelected,
|
||||
R.plurals.deleteMessage, numSelected
|
||||
),
|
||||
buttons = listOf(
|
||||
DialogButtonModel(GetString(R.string.delete), color = LocalColors.current.danger, onClick = onAccepted),
|
||||
|
@ -130,7 +130,7 @@ class MediaOverviewViewModel(
|
||||
.groupBy { record ->
|
||||
val time =
|
||||
ZonedDateTime.ofInstant(Instant.ofEpochMilli(record.date), ZoneId.of("UTC"))
|
||||
timeBuckets.getBucketText(time)?.let(application::getString)
|
||||
timeBuckets.getBucketText(application, time)
|
||||
?: time.toLocalDate().withDayOfMonth(1)
|
||||
}
|
||||
.map { (bucket, records) ->
|
||||
@ -171,6 +171,11 @@ class MediaOverviewViewModel(
|
||||
|
||||
fun onItemClicked(item: MediaOverviewItem) {
|
||||
if (inSelectionMode.value) {
|
||||
if (item.slide.hasDocument()) {
|
||||
// We don't support selecting documents in selection mode
|
||||
return
|
||||
}
|
||||
|
||||
val newSet = mutableSelectedItemIDs.value.toMutableSet()
|
||||
if (item.id in newSet) {
|
||||
newSet.remove(item.id)
|
||||
@ -213,11 +218,6 @@ class MediaOverviewViewModel(
|
||||
}
|
||||
|
||||
fun onTabItemClicked(tab: MediaOverviewTab) {
|
||||
if (inSelectionMode.value) {
|
||||
// Not allowing to switch tabs while in selection mode
|
||||
return
|
||||
}
|
||||
|
||||
mutableSelectedTab.value = tab
|
||||
}
|
||||
|
||||
|
@ -110,11 +110,7 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat
|
||||
showSessionDialog {
|
||||
title(R.string.delete)
|
||||
text(resources.getString(R.string.messageRequestsDelete))
|
||||
if (thread.recipient.isClosedGroupV2Recipient) {
|
||||
dangerButton(R.string.delete, contentDescriptionRes = R.string.delete) { doDecline() }
|
||||
} else {
|
||||
dangerButton(R.string.decline, contentDescriptionRes = R.string.decline) { doDecline() }
|
||||
}
|
||||
dangerButton(R.string.delete) { doDecline() }
|
||||
button(R.string.cancel)
|
||||
}
|
||||
}
|
||||
@ -132,9 +128,10 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat
|
||||
}
|
||||
|
||||
showSessionDialog {
|
||||
title(resources.getString(R.string.clearAll))
|
||||
text(resources.getString(R.string.messageRequestsClearAllExplanation))
|
||||
button(R.string.yes) { doDeleteAllAndBlock() }
|
||||
button(R.string.no)
|
||||
dangerButton(R.string.clear) { doDeleteAllAndBlock() }
|
||||
button(R.string.cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -65,7 +65,7 @@ object RationaleDialog {
|
||||
text(message)
|
||||
}
|
||||
button(R.string.theContinue) { onPositive.run() }
|
||||
button(R.string.notNow) { onNegative.run() }
|
||||
button(R.string.cancel) { onNegative.run() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,11 +3,14 @@ package org.thoughtcrime.securesms.preferences
|
||||
import android.Manifest
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.preference.Preference
|
||||
import com.squareup.phrase.Phrase
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.TextSecurePreferences.Companion.setBooleanPreference
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.showSessionDialog
|
||||
import org.thoughtcrime.securesms.ui.getSubbedString
|
||||
|
||||
internal class CallToggleListener(
|
||||
private val context: Fragment,
|
||||
@ -39,6 +42,10 @@ internal class CallToggleListener(
|
||||
)
|
||||
setCallback(true)
|
||||
}
|
||||
.withPermanentDenialDialog(
|
||||
context.requireContext().getSubbedString(R.string.permissionsMicrophoneAccessRequired,
|
||||
APP_NAME_KEY to context.requireContext().getString(R.string.app_name)
|
||||
))
|
||||
.onAnyDenied { setCallback(false) }
|
||||
.execute()
|
||||
}
|
||||
|
@ -45,7 +45,6 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@ -58,11 +57,9 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.canhub.cropper.CropImageContract
|
||||
import com.squareup.phrase.Phrase
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
@ -80,7 +77,9 @@ import org.thoughtcrime.securesms.debugmenu.DebugActivity
|
||||
import org.thoughtcrime.securesms.home.PathActivity
|
||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.*
|
||||
import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.NoAvatar
|
||||
import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.TempAvatar
|
||||
import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.UserAvatar
|
||||
import org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity
|
||||
import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity
|
||||
import org.thoughtcrime.securesms.ui.AlertDialog
|
||||
@ -91,10 +90,10 @@ import org.thoughtcrime.securesms.ui.Divider
|
||||
import org.thoughtcrime.securesms.ui.GetString
|
||||
import org.thoughtcrime.securesms.ui.LargeItemButton
|
||||
import org.thoughtcrime.securesms.ui.LargeItemButtonWithDrawable
|
||||
import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator
|
||||
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
|
||||
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineCopyButton
|
||||
import org.thoughtcrime.securesms.ui.contentDescription
|
||||
import org.thoughtcrime.securesms.ui.qaTag
|
||||
import org.thoughtcrime.securesms.ui.setThemedContent
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
|
||||
@ -511,7 +510,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
) {
|
||||
startAvatarSelection()
|
||||
}
|
||||
.testTag(stringResource(R.string.AccessibilityId_avatarPicker))
|
||||
.qaTag(stringResource(R.string.AccessibilityId_avatarPicker))
|
||||
.background(
|
||||
shape = CircleShape,
|
||||
color = LocalColors.current.backgroundBubbleReceived,
|
||||
@ -574,6 +573,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
DialogButtonModel(
|
||||
text = GetString(R.string.remove),
|
||||
contentDescription = GetString(R.string.AccessibilityId_remove),
|
||||
color = LocalColors.current.danger,
|
||||
enabled = state is UserAvatar || // can remove is the user has an avatar set
|
||||
(state is TempAvatar && state.hasAvatar),
|
||||
onClick = removeAvatar
|
||||
|
@ -280,23 +280,19 @@ fun ItemButton(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
TextButton(
|
||||
modifier = modifier.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min)
|
||||
.heightIn(min = minHeight)
|
||||
.padding(horizontal = LocalDimensions.current.xsSpacing),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = colors,
|
||||
onClick = onClick,
|
||||
contentPadding = PaddingValues(),
|
||||
shape = RectangleShape,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxHeight()
|
||||
.aspectRatio(1f)
|
||||
.align(Alignment.CenterVertically)
|
||||
) {
|
||||
icon()
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(LocalDimensions.current.smallSpacing))
|
||||
modifier = Modifier
|
||||
.padding(horizontal = LocalDimensions.current.xxsSpacing)
|
||||
.size(minHeight)
|
||||
.align(Alignment.CenterVertically),
|
||||
content = icon
|
||||
)
|
||||
|
||||
Text(
|
||||
text,
|
||||
@ -320,6 +316,18 @@ fun PreviewItemButton() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewLargeItemButton() {
|
||||
PreviewTheme {
|
||||
LargeItemButton(
|
||||
textId = R.string.groupCreate,
|
||||
icon = R.drawable.ic_group,
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Cell(
|
||||
modifier: Modifier = Modifier,
|
||||
|
@ -3,8 +3,15 @@ package org.thoughtcrime.securesms.ui
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
@ -58,3 +65,26 @@ fun Context.findActivity(): Activity {
|
||||
}
|
||||
throw IllegalStateException("Permissions should be called in the context of an Activity")
|
||||
}
|
||||
|
||||
inline fun <T : View> T.afterMeasured(crossinline block: T.() -> Unit) {
|
||||
viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
|
||||
override fun onGlobalLayout() {
|
||||
if (measuredWidth > 0 && measuredHeight > 0) {
|
||||
viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
block()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* This is used to set the test tag that the QA team can use to retrieve an element in appium
|
||||
* In order to do so we need to set the testTagsAsResourceId to true, which ideally should be done only once
|
||||
* in the root composable, but our app is currently made up of multiple isolated composables
|
||||
* set up in the old activity/fragment view system
|
||||
* As such we need to repeat it for every component that wants to use testTag, until such
|
||||
* a time as we have one root composable
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun Modifier.qaTag(tag: String) = semantics { testTagsAsResourceId = true }.testTag(tag)
|
@ -49,6 +49,9 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.ChecksumException
|
||||
import com.google.zxing.FormatException
|
||||
@ -75,6 +78,7 @@ import java.util.concurrent.Executors
|
||||
|
||||
private const val TAG = "NewMessageFragment"
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun QRScannerScreen(
|
||||
errors: Flow<String>,
|
||||
@ -93,11 +97,11 @@ fun QRScannerScreen(
|
||||
|
||||
val context = LocalContext.current
|
||||
val permission = Manifest.permission.CAMERA
|
||||
val cameraPermissionState = rememberPermissionState(permission)
|
||||
|
||||
var showCameraPermissionDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (ContextCompat.checkSelfPermission(context, permission)
|
||||
== PackageManager.PERMISSION_GRANTED) {
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
ScanQrCode(errors, onScan)
|
||||
} else {
|
||||
Column(
|
||||
|
@ -122,7 +122,7 @@ fun SessionOutlinedTextField(
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.padding(vertical = 28.dp, horizontal = 21.dp)
|
||||
.padding(LocalDimensions.current.spacing)
|
||||
) {
|
||||
innerTextField()
|
||||
|
||||
|
@ -15,7 +15,6 @@ data class Dimensions(
|
||||
val mediumSpacing: Dp = 36.dp,
|
||||
val xlargeSpacing: Dp = 64.dp,
|
||||
|
||||
val dividerIndent: Dp = 60.dp,
|
||||
val appBarHeight: Dp = 64.dp,
|
||||
val minItemButtonHeight: Dp = 50.dp,
|
||||
val minLargeItemButtonHeight: Dp = 60.dp,
|
||||
|
@ -52,9 +52,6 @@ object DateUtils : android.text.format.DateUtils() {
|
||||
return isToday(`when` + TimeUnit.DAYS.toMillis(1))
|
||||
}
|
||||
|
||||
private fun convertDelta(millis: Long, to: TimeUnit): Int {
|
||||
return to.convert(System.currentTimeMillis() - millis, TimeUnit.MILLISECONDS).toInt()
|
||||
}
|
||||
|
||||
// Method to get the String for a relative day in a locale-aware fashion
|
||||
public fun getLocalisedRelativeDayString(relativeDay: RelativeDay): String {
|
||||
@ -76,10 +73,12 @@ object DateUtils : android.text.format.DateUtils() {
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
|
||||
return getRelativeTimeSpanString(comparisonTime.timeInMillis,
|
||||
val temp = getRelativeTimeSpanString(
|
||||
comparisonTime.timeInMillis,
|
||||
now.timeInMillis,
|
||||
DAY_IN_MILLIS,
|
||||
FORMAT_SHOW_DATE).toString()
|
||||
return temp
|
||||
}
|
||||
|
||||
fun getFormattedDateTime(time: Long, template: String, locale: Locale): String {
|
||||
@ -142,30 +141,4 @@ object DateUtils : android.text.format.DateUtils() {
|
||||
private fun getLocalizedPattern(template: String, locale: Locale): String {
|
||||
return DateFormat.getBestDateTimePattern(locale, template)
|
||||
}
|
||||
|
||||
/**
|
||||
* e.g. 2020-09-04T19:17:51Z
|
||||
* https://www.iso.org/iso-8601-date-and-time-format.html
|
||||
*
|
||||
* @return The timestamp if able to be parsed, otherwise -1.
|
||||
*/
|
||||
@SuppressLint("ObsoleteSdkInt")
|
||||
@JvmStatic
|
||||
public fun parseIso8601(date: String?): Long {
|
||||
|
||||
if (date.isNullOrEmpty()) { return -1 }
|
||||
|
||||
val format = if (Build.VERSION.SDK_INT >= 24) {
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX", Locale.getDefault())
|
||||
} else {
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault())
|
||||
}
|
||||
|
||||
try {
|
||||
return format.parse(date).time
|
||||
} catch (e: ParseException) {
|
||||
Log.w(TAG, "Failed to parse date.", e)
|
||||
return -1
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
||||
}
|
||||
}
|
9
app/src/main/res/drawable-ldrtl/ic_arrow_left.xml
Normal file
9
app/src/main/res/drawable-ldrtl/ic_arrow_left.xml
Normal 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>
|
9
app/src/main/res/drawable-ldrtl/ic_arrow_right.xml
Normal file
9
app/src/main/res/drawable-ldrtl/ic_arrow_right.xml
Normal 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>
|
5
app/src/main/res/drawable-ldrtl/search_bar_end.xml
Normal file
5
app/src/main/res/drawable-ldrtl/search_bar_end.xml
Normal 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>
|
5
app/src/main/res/drawable-ldrtl/search_bar_start.xml
Normal file
5
app/src/main/res/drawable-ldrtl/search_bar_start.xml
Normal 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>
|
@ -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>
|
@ -5,6 +5,4 @@
|
||||
|
||||
<solid android:color="?backgroundSecondary" />
|
||||
|
||||
<corners android:radius="?dialogCornerRadius" />
|
||||
|
||||
</shape>
|
@ -45,6 +45,7 @@
|
||||
android:layout_marginEnd="64dp"
|
||||
android:background="@null"
|
||||
android:gravity="center_vertical"
|
||||
android:maxLength="@integer/max_input_chars"
|
||||
android:hint="@string/message"
|
||||
android:textColorHint="?attr/input_bar_text_hint"
|
||||
android:textColor="?input_bar_text_user"
|
||||
|
@ -30,7 +30,7 @@
|
||||
<dimen name="text_view_corner_radius">8dp</dimen>
|
||||
<dimen name="fake_chat_view_bubble_corner_radius">10dp</dimen>
|
||||
<dimen name="setting_button_height">64dp</dimen>
|
||||
<dimen name="dialog_corner_radius">8dp</dimen>
|
||||
<dimen name="dialog_corner_radius">12dp</dimen>
|
||||
<dimen name="video_inset_radius">11dp</dimen>
|
||||
<dimen name="pn_option_corner_radius">8dp</dimen>
|
||||
<dimen name="path_status_view_size">8dp</dimen>
|
||||
|
@ -7,4 +7,5 @@
|
||||
|
||||
<integer name="max_user_nickname_length_chars">35</integer>
|
||||
<integer name="max_group_and_community_name_length_chars">35</integer>
|
||||
<integer name="max_input_chars">2000</integer>
|
||||
</resources>
|
@ -1,24 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!--
|
||||
All localisable strings are now in the 'libsession' module (which will be renamed to
|
||||
'common' in the near future), and all content description / AccessibilityID_ strings are in
|
||||
their own 'content-descriptions' module.
|
||||
-->
|
||||
|
||||
<!-- TODO: These need to be removed and the text generated by DateUtils.getLocalisedRelativeDayString - but
|
||||
it's going to be a nuisance because that returns a string not a resource ID so just leaving them for now -AL -->
|
||||
<string name="BucketedThreadMedia_Today">Today</string>
|
||||
<string name="BucketedThreadMedia_Yesterday">Yesterday</string>
|
||||
|
||||
<!-- TODO: We'll also need plurals for these strings -->
|
||||
<plurals name="ConversationFragment_delete_selected_messages">
|
||||
<item quantity="one">Delete selected message?</item>
|
||||
<item quantity="other">Delete selected messages?</item>
|
||||
</plurals>
|
||||
<plurals name="ConversationFragment_this_will_permanently_delete_all_n_selected_messages">
|
||||
<item quantity="one">This will permanently delete the selected message.</item>
|
||||
<item quantity="other">This will permanently delete all %1$d selected messages.</item>
|
||||
</plurals>
|
||||
|
||||
</resources>
|
@ -81,7 +81,7 @@
|
||||
</style>
|
||||
|
||||
<style name="ThemeOverlay.Session.AlertDialog" parent="ThemeOverlay.AppCompat.Dialog.Alert">
|
||||
<item name="android:windowBackground">@drawable/default_dialog_background</item>
|
||||
<item name="android:windowBackground">@drawable/default_dialog_background_inset</item>
|
||||
<item name="android:colorBackground">?backgroundSecondary</item>
|
||||
<item name="android:colorBackgroundFloating">?colorPrimary</item>
|
||||
<item name="backgroundTint">?colorPrimary</item>
|
||||
|
@ -7,9 +7,6 @@
|
||||
android:title="@string/helpReportABug"
|
||||
android:summary="@string/helpReportABugExportLogsDescription"
|
||||
android:widgetLayout="@layout/export_logs_widget" />
|
||||
|
||||
<!-- Note: Having this as `android:layout` rather than `android:layoutWidget` allows it to fit the screen width -->
|
||||
<Preference android:layout="@layout/preference_widget_progress" />
|
||||
</PreferenceCategory>
|
||||
|
||||
<PreferenceCategory>
|
||||
|
@ -195,7 +195,7 @@ class ConversationViewModelTest: BaseViewModelTest() {
|
||||
@Test
|
||||
fun `local recipient should have input and no blinded recipient`() {
|
||||
whenever(recipient.isLocalNumber).thenReturn(true)
|
||||
assertThat(viewModel.hidesInputBar(), equalTo(false))
|
||||
assertThat(viewModel.shouldHideInputBar(), equalTo(false))
|
||||
assertThat(viewModel.blindedRecipient, nullValue())
|
||||
}
|
||||
|
||||
@ -207,7 +207,7 @@ class ConversationViewModelTest: BaseViewModelTest() {
|
||||
}
|
||||
whenever(repository.maybeGetBlindedRecipient(recipient)).thenReturn(blinded)
|
||||
assertThat(viewModel.blindedRecipient, notNullValue())
|
||||
assertThat(viewModel.hidesInputBar(), equalTo(true))
|
||||
assertThat(viewModel.shouldHideInputBar(), equalTo(true))
|
||||
}
|
||||
|
||||
}
|
@ -179,6 +179,11 @@ class MentionViewModelTest {
|
||||
// Should have normalised message with selected candidate
|
||||
assertThat(mentionViewModel.normalizeMessageBody())
|
||||
.isEqualTo("Hi @pubkey1 ")
|
||||
|
||||
// Should have correct normalised message even with the last space deleted
|
||||
editable.delete(editable.length - 1, editable.length)
|
||||
assertThat(mentionViewModel.normalizeMessageBody())
|
||||
.isEqualTo("Hi @pubkey1 ")
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -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"}
|
@ -1 +0,0 @@
|
||||
{"LanguageName": "Japanese", "LanguageCode": "ja", "Week": "週間", "Weeks": "週間", "Day": "日", "Days": "日間", "Hour": "時間", "Hours": "時間", "Minute": "分", "Minutes": "分", "Second": "秒", "Seconds": "秒"}
|
@ -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"}
|
@ -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"}
|
@ -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
Loading…
Reference in New Issue
Block a user