diff --git a/.drone.jsonnet b/.drone.jsonnet index dc81115ce9..b459742392 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -38,7 +38,9 @@ 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 install -y ninja-build', + 'apt-get update', + 'apt-get install -y ninja-build openjdk-17-jdk', + 'update-java-alternatives -s java-1.17.0-openjdk-amd64', './gradlew testPlayDebugUnitTestCoverageReport' ], } @@ -78,7 +80,9 @@ 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 install -y ninja-build', + 'apt-get update', + 'apt-get install -y ninja-build openjdk-17-jdk', + 'update-java-alternatives -s java-1.17.0-openjdk-amd64', './gradlew assemblePlayDebug', './scripts/drone-static-upload.sh' ], diff --git a/app/build.gradle b/app/build.gradle index df003aa855..88609da7af 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,8 +13,8 @@ configurations.forEach { it.exclude module: "commons-logging" } -def canonicalVersionCode = 380 -def canonicalVersionName = "1.19.2" +def canonicalVersionCode = 382 +def canonicalVersionName = "1.20.0" def postFixSize = 10 def abiPostFix = ['armeabi-v7a' : 1, @@ -88,7 +88,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 += [] @@ -221,11 +220,13 @@ android { } dependencies { + implementation project(':content-descriptions') - implementation("com.google.dagger:hilt-android:$daggerHiltVersion") - ksp("com.google.dagger:hilt-compiler:$daggerHiltVersion") ksp("androidx.hilt:hilt-compiler:$jetpackHiltVersion") + ksp("com.google.dagger:hilt-compiler:$daggerHiltVersion") + ksp("com.github.bumptech.glide:ksp:$glideVersion") + implementation("com.google.dagger:hilt-android:$daggerHiltVersion") implementation "androidx.appcompat:appcompat:$appcompatVersion" implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation "com.google.android.material:material:$materialVersion" @@ -241,6 +242,7 @@ dependencies { implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion" + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" implementation "androidx.paging:paging-runtime-ktx:$pagingVersion" implementation 'androidx.activity:activity-ktx:1.5.1' @@ -248,12 +250,15 @@ dependencies { implementation 'androidx.fragment:fragment-ktx:1.5.3' implementation "androidx.core:core-ktx:$coreVersion" implementation "androidx.work:work-runtime-ktx:2.7.1" + playImplementation ("com.google.firebase:firebase-messaging:24.0.0") { exclude group: 'com.google.firebase', module: 'firebase-core' exclude group: 'com.google.firebase', module: 'firebase-analytics' exclude group: 'com.google.firebase', module: 'firebase-measurement-connector' } + if (project.hasProperty('huawei')) huaweiImplementation 'com.huawei.hms:push:6.7.0.300' + implementation 'androidx.media3:media3-exoplayer:1.4.0' implementation 'androidx.media3:media3-ui:1.4.0' implementation 'org.conscrypt:conscrypt-android:2.5.2' @@ -267,7 +272,6 @@ dependencies { implementation 'com.github.chrisbanes:PhotoView:2.1.3' implementation "com.github.bumptech.glide:glide:$glideVersion" implementation "com.github.bumptech.glide:compose:1.0.0-beta01" - ksp "com.github.bumptech.glide:ksp:$glideVersion" implementation 'com.makeramen:roundedimageview:2.1.0' implementation 'com.pnikosis:materialish-progress:1.5' implementation 'org.greenrobot:eventbus:3.0.0' @@ -305,7 +309,6 @@ dependencies { implementation "nl.komponents.kovenant:kovenant:$kovenantVersion" implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion" implementation "com.jakewharton.rxbinding3:rxbinding:3.1.0" - implementation "com.github.tbruyelle:rxpermissions:0.10.2" implementation "com.github.ybq:Android-SpinKit:1.4.0" implementation "com.opencsv:opencsv:4.6" testImplementation "junit:junit:$junitVersion" @@ -361,7 +364,7 @@ dependencies { debugImplementation "androidx.compose.ui:ui-test-manifest:$composeVersion" implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha" - implementation "com.google.accompanist:accompanist-permissions:0.33.1-alpha" + implementation "com.google.accompanist:accompanist-permissions:0.36.0" implementation "com.google.accompanist:accompanist-drawablepainter:0.33.1-alpha" implementation "androidx.camera:camera-camera2:1.3.2" diff --git a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt index 2f3ba9fb10..43b347ba42 100644 --- a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt @@ -2,8 +2,6 @@ package network.loki.messenger import android.Manifest import android.app.Instrumentation -import android.content.ClipboardManager -import android.content.Context import android.view.View import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.pressBack @@ -16,8 +14,6 @@ import androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isRoot import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withSubstring -import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest @@ -25,6 +21,7 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import com.adevinta.android.barista.interaction.PermissionGranter +import com.bumptech.glide.Glide import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf @@ -36,11 +33,9 @@ import org.junit.Test import org.junit.runner.RunWith import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.utilities.guava.Optional import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBar import org.thoughtcrime.securesms.home.HomeActivity -import com.bumptech.glide.Glide /** * Currently not used as part of our CI/Deployment processes !!!! @@ -62,7 +57,6 @@ class HomeActivityTests { @Before fun setUp() { InstrumentationRegistry.getInstrumentation().addMonitor(activityMonitor) - } @After @@ -96,10 +90,10 @@ class HomeActivityTests { device.pressKeyCode(67) // Continue with display name - objectFromDesc(R.string.continue_2).click() + objectFromDesc(R.string.theContinue).click() // Continue with default push notification setting - objectFromDesc(R.string.continue_2).click() + objectFromDesc(R.string.theContinue).click() // PN select if (hasViewedSeed) { @@ -110,7 +104,6 @@ class HomeActivityTests { PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS) } - /* private fun goToMyChat() { onView(withId(R.id.newConversationButton)).perform(ViewActions.click()) onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click()) @@ -131,7 +124,7 @@ class HomeActivityTests { @Test fun testLaunches_dismiss_seedView() { setupLoggedInState() - objectFromDesc(R.string.continue_2).click() + objectFromDesc(R.string.theContinue).click() objectFromDesc(R.string.copy).click() pressBack() onView(withId(R.id.seedReminderView)).check(matches(not(isDisplayed()))) @@ -182,6 +175,7 @@ class HomeActivityTests { onView(withText(dialogPromptText)).check(matches(isDisplayed())) }*/ + /** * Perform action of waiting for a specific time. */ @@ -198,5 +192,4 @@ class HomeActivityTests { } } } - } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 98f9aa4b5f..eac4ef3300 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -37,7 +37,7 @@ - + @@ -79,7 +79,7 @@ android:networkSecurityConfig="@xml/network_security_configuration" android:supportsRtl="true" android:theme="@style/Theme.Session.DayNight" - tools:replace="android:allowBackup"> + tools:replace="android:allowBackup,android:label" > @@ -130,12 +130,16 @@ + android:label="@string/sessionSettings" /> + @@ -147,11 +151,11 @@ android:name="org.thoughtcrime.securesms.preferences.BlockedContactsActivity" android:screenOrientation="portrait" android:theme="@style/Theme.Session.DayNight.FlatActionBar" - android:label="@string/blocked_contacts_title" + android:label="@string/conversationsBlockedContacts" /> @@ -264,18 +268,10 @@ - { - // Don't generate a new profile key here; we do that when the user changes their profile picture - Log.d("Loki-Avatar", "Uploading Avatar Started"); - String encodedProfileKey = TextSecurePreferences.getProfileKey(ApplicationContext.this); - try { - // Read the file into a byte array - InputStream inputStream = AvatarHelper.getInputStreamFor(ApplicationContext.this, Address.fromSerialized(userPublicKey)); - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - int count; - byte[] buffer = new byte[1024]; - while ((count = inputStream.read(buffer, 0, buffer.length)) != -1) { - baos.write(buffer, 0, count); - } - baos.flush(); - byte[] profilePicture = baos.toByteArray(); - // Re-upload it - ProfilePictureUtilities.INSTANCE.upload(profilePicture, encodedProfileKey, ApplicationContext.this).success(unit -> { - // Update the last profile picture upload date - TextSecurePreferences.setLastProfilePictureUpload(ApplicationContext.this, new Date().getTime()); - Log.d("Loki-Avatar", "Uploading Avatar Finished"); - return Unit.INSTANCE; - }); - } catch (Exception e) { - Log.e("Loki-Avatar", "Uploading avatar failed."); - } - }); + ProfilePictureUtilities.INSTANCE.resubmitProfilePictureIfNeeded(this); } private void loadEmojiSearchIndexIfNeeded() { @@ -486,7 +459,7 @@ private void loadEmojiSearchIndexIfNeeded() { // Method to clear the local data - returns true on success otherwise false /** - * Clear all local profile data and message history then restart the app after a brief delay. + * Clear all local profile data and message history. * @return true on success, false otherwise. */ @SuppressLint("ApplySharedPref") @@ -498,6 +471,16 @@ public boolean clearAllData() { return false; } configFactory.keyPairChanged(); + return true; + } + + /** + * Clear all local profile data and message history then restart the app after a brief delay. + * @return true on success, false otherwise. + */ + @SuppressLint("ApplySharedPref") + public boolean clearAllDataAndRestart() { + clearAllData(); Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200)); return true; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java index a99fe83430..c3321504ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java @@ -17,8 +17,6 @@ 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 @@ protected void onCreate(Bundle savedInstanceState) { 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 @@ private void initializeScreenshotSecurity(boolean isResume) { } } } - - @Override - protected void attachBaseContext(Context newBase) { - super.attachBaseContext(DynamicLanguageContextWrapper.updateContext(newBase, TextSecurePreferences.getLanguage(newBase))); - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/BaseActivity.java b/app/src/main/java/org/thoughtcrime/securesms/BaseActivity.java index d44978b05b..8722c0e092 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BaseActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BaseActivity.java @@ -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))); - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaDialog.kt index af38c31ff3..3d38857b50 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaDialog.kt @@ -8,20 +8,9 @@ class DeleteMediaDialog { @JvmStatic fun show(context: Context, recordCount: Int, doDelete: Runnable) = context.showSessionDialog { iconAttribute(R.attr.dialog_alert_icon) - title( - context.resources.getQuantityString( - R.plurals.MediaOverviewActivity_Media_delete_confirm_title, - recordCount, - recordCount - ) - ) - text( - context.resources.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message, - recordCount, - recordCount - ) - ) - button(R.string.delete) { doDelete.run() } + title(context.resources.getQuantityString(R.plurals.deleteMessage, recordCount, recordCount)) + text(context.resources.getString(R.string.deleteMessageDescriptionEveryone)) + dangerButton(R.string.delete) { doDelete.run() } cancelButton() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaPreviewDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaPreviewDialog.kt index 0390a3007d..b8aad6c22a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaPreviewDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaPreviewDialog.kt @@ -9,9 +9,9 @@ class DeleteMediaPreviewDialog { fun show(context: Context, doDelete: Runnable) { context.showSessionDialog { iconAttribute(R.attr.dialog_alert_icon) - title(R.string.MediaPreviewActivity_media_delete_confirmation_title) - text(R.string.MediaPreviewActivity_media_delete_confirmation_message) - button(R.string.delete) { doDelete.run() } + title(context.resources.getQuantityString(R.plurals.deleteMessage, 1, 1)) + text(R.string.deleteMessageDescriptionEveryone) + dangerButton(R.string.delete) { doDelete.run() } cancelButton() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index c71f5d041c..f761cdd4e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -16,6 +16,8 @@ */ package org.thoughtcrime.securesms; +import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; + import android.annotation.SuppressLint; import android.content.Context; import android.content.Intent; @@ -23,8 +25,8 @@ import android.database.CursorIndexOutOfBoundsException; import android.net.Uri; import android.os.AsyncTask; -import android.os.Build; import android.os.Build.VERSION; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; @@ -42,7 +44,6 @@ import android.widget.FrameLayout; import android.widget.TextView; import android.widget.Toast; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; @@ -54,10 +55,14 @@ import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.PagerAdapter; import androidx.viewpager.widget.ViewPager; - import com.bumptech.glide.Glide; import com.bumptech.glide.RequestManager; - +import com.squareup.phrase.Phrase; +import java.io.IOException; +import java.util.Locale; +import java.util.WeakHashMap; +import kotlin.Unit; +import network.loki.messenger.R; import org.session.libsession.messaging.messages.control.DataExtractionNotification; import org.session.libsession.messaging.sending_receiving.MessageSender; import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment; @@ -78,15 +83,8 @@ import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.util.AttachmentUtil; import org.thoughtcrime.securesms.util.DateUtils; -import org.thoughtcrime.securesms.util.SaveAttachmentTask; import org.thoughtcrime.securesms.util.SaveAttachmentTask.Attachment; - -import java.io.IOException; -import java.util.Locale; -import java.util.WeakHashMap; - -import kotlin.Unit; -import network.loki.messenger.R; +import org.thoughtcrime.securesms.util.SaveAttachmentTask; /** * Activity for displaying media attachments in-app @@ -242,12 +240,12 @@ private void updateActionBar() { CharSequence relativeTimeSpan; if (mediaItem.date > 0) { - relativeTimeSpan = DateUtils.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), mediaItem.date); + relativeTimeSpan = DateUtils.INSTANCE.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), mediaItem.date); } else { - relativeTimeSpan = getString(R.string.MediaPreviewActivity_draft); + relativeTimeSpan = getString(R.string.draft); } - if (mediaItem.outgoing) getSupportActionBar().setTitle(getString(R.string.MediaPreviewActivity_you)); + if (mediaItem.outgoing) getSupportActionBar().setTitle(getString(R.string.you)); else if (mediaItem.recipient != null) getSupportActionBar().setTitle(mediaItem.recipient.toShortString()); else getSupportActionBar().setTitle(""); @@ -258,7 +256,6 @@ private void updateActionBar() { @Override public void onResume() { super.onResume(); - initializeMedia(); } @@ -291,7 +288,7 @@ private void initializeViews() { captionContainer = findViewById(R.id.media_preview_caption_container); playbackControlsContainer = findViewById(R.id.media_preview_playback_controls_container); - setSupportActionBar(findViewById(R.id.toolbar)); + setSupportActionBar(findViewById(R.id.search_toolbar)); ActionBar actionBar = getSupportActionBar(); actionBar.setDisplayHomeAsUpEnabled(true); actionBar.setHomeButtonEnabled(true); @@ -361,7 +358,7 @@ public boolean onSingleTapUp(MotionEvent e) { private void initializeMedia() { if (!isContentTypeSupported(initialMediaType)) { Log.w(TAG, "Unsupported media type sent to MediaPreviewActivity, finishing."); - Toast.makeText(getApplicationContext(), R.string.MediaPreviewActivity_unssuported_media_type, Toast.LENGTH_LONG).show(); + Toast.makeText(getApplicationContext(), R.string.attachmentsErrorNotSupported, Toast.LENGTH_LONG).show(); finish(); } @@ -411,12 +408,14 @@ private void saveToDisk() { MediaItem mediaItem = getCurrentMediaItem(); if (mediaItem == null) return; - SaveAttachmentTask.showWarningDialog(this, 1, () -> { + SaveAttachmentTask.showOneTimeWarningDialogOrSave(this, 1, () -> { Permissions.with(this) .request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) .maxSdkVersion(Build.VERSION_CODES.P) - .withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied)) - .onAnyDenied(() -> Toast.makeText(this, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show()) + .withPermanentDenialDialog(getPermanentlyDeniedStorageText()) + .onAnyDenied(() -> { + Toast.makeText(this, getPermanentlyDeniedStorageText(), Toast.LENGTH_LONG).show(); + }) .onAllGranted(() -> { SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this); long saveDate = (mediaItem.date > 0) ? mediaItem.date : SnodeAPI.getNowWithOffset(); @@ -432,6 +431,12 @@ private void saveToDisk() { }); } + private String getPermanentlyDeniedStorageText(){ + return Phrase.from(getApplicationContext(), R.string.permissionsStorageDeniedLegacy) + .put(APP_NAME_KEY, getString(R.string.app_name)) + .format().toString(); + } + private void sendMediaSavedNotificationIfNeeded() { if (conversationRecipient.isGroupRecipient()) return; DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset())); @@ -482,6 +487,7 @@ public boolean onOptionsItemSelected(MenuItem item) { super.onOptionsItemSelected(item); switch (item.getItemId()) { + // TODO / WARNING: R.id values are NON-CONSTANT in Gradle 8.0+ - what would be the best way to address this?! -AL 2024/08/26 case R.id.media_preview__overview: showOverview(); return true; case R.id.media_preview__forward: forward(); return true; case R.id.save: saveToDisk(); return true; @@ -532,15 +538,11 @@ public void onLoadFinished(@NonNull Loader> loader, @Nulla throw new RuntimeException("restartItem = " + restartItem + ", data.second = " + data.second + " leftIsRecent = " + leftIsRecent, e); } - if (item == 0) { - viewPagerListener.onPageSelected(0); - } + if (item == 0) { viewPagerListener.onPageSelected(0); } } @Override - public void onLoaderReset(@NonNull Loader> loader) { - - } + public void onLoaderReset(@NonNull Loader> loader) { /* Do nothing */ } private class ViewPagerListener implements ViewPager.OnPageChangeListener { @@ -575,13 +577,11 @@ public void onPageUnselected(int position) { @Override public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { - + /* Do nothing */ } @Override - public void onPageScrollStateChanged(int state) { - - } + public void onPageScrollStateChanged(int state) { /* Do nothing */ } } private static class SingleItemPagerAdapter extends MediaItemAdapter { @@ -646,9 +646,7 @@ public MediaItem getMediaItemFor(int position) { } @Override - public void pause(int position) { - - } + public void pause(int position) { /* Do nothing */ } @Override public @Nullable View getPlaybackControls(int position) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt index 071da43311..d5e551d02a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt @@ -4,24 +4,49 @@ import android.content.Context import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import network.loki.messenger.R +import org.session.libsession.LocalisedTimeUtil +import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY +import org.thoughtcrime.securesms.ui.getSubbedString import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds fun showMuteDialog( context: Context, onMuteDuration: (Long) -> Unit ): AlertDialog = context.showSessionDialog { - title(R.string.MuteDialog_mute_notifications) - items(Option.values().map { it.stringRes }.map(context::getString).toTypedArray()) { - onMuteDuration(Option.values()[it].getTime()) - } -} + title(R.string.notificationsMute) -private enum class Option(@StringRes val stringRes: Int, val getTime: () -> Long) { - ONE_HOUR(R.string.arrays__mute_for_one_hour, duration = TimeUnit.HOURS.toMillis(1)), - TWO_HOURS(R.string.arrays__mute_for_two_hours, duration = TimeUnit.HOURS.toMillis(2)), - ONE_DAY(R.string.arrays__mute_for_one_day, duration = TimeUnit.DAYS.toMillis(1)), - SEVEN_DAYS(R.string.arrays__mute_for_seven_days, duration = TimeUnit.DAYS.toMillis(7)), - FOREVER(R.string.arrays__mute_forever, getTime = { Long.MAX_VALUE }); + items(Option.entries.mapIndexed { index, entry -> - constructor(@StringRes stringRes: Int, duration: Long): this(stringRes, { System.currentTimeMillis() + duration }) + if (entry.stringRes == R.string.notificationsMute) { + context.getString(R.string.notificationsMute) + } else { + val largeTimeUnitString = LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit( + context, + Option.entries[index].duration.milliseconds + ) + context.getSubbedString(entry.stringRes, TIME_LARGE_KEY to largeTimeUnitString) + } + }.toTypedArray()) { + // Note: We add the current timestamp to the mute duration to get the un-mute timestamp + // that gets stored in the database via ConversationMenuHelper.mute(). + // Also: This is a kludge, but we ADD one second to the mute duration because otherwise by + // the time the view for how long the conversation is muted for gets set then it's actually + // less than the entire duration - so 1 hour becomes 59 minutes, 1 day becomes 23 hours etc. + // As we really want to see the actual set time (1 hour / 1 day etc.) then we'll bump it by + // 1 second which is neither here nor there in the grand scheme of things. + val muteTime = Option.entries[it].duration + val muteTimeFromNow = if (muteTime == Long.MAX_VALUE) muteTime + else muteTime + System.currentTimeMillis() + 1.seconds.inWholeMilliseconds + onMuteDuration(muteTimeFromNow) + } } + +private enum class Option(@StringRes val stringRes: Int, val duration: Long) { + ONE_HOUR(R.string.notificationsMuteFor, duration = TimeUnit.HOURS.toMillis(1)), + TWO_HOURS(R.string.notificationsMuteFor, duration = TimeUnit.HOURS.toMillis(2)), + ONE_DAY(R.string.notificationsMuteFor, duration = TimeUnit.DAYS.toMillis(1)), + SEVEN_DAYS(R.string.notificationsMuteFor, duration = TimeUnit.DAYS.toMillis(7)), + FOREVER(R.string.notificationsMute, duration = Long.MAX_VALUE ); +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java index afc993df8a..16b5856766 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java @@ -16,6 +16,8 @@ */ package org.thoughtcrime.securesms; +import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; + import android.animation.Animator; import android.app.KeyguardManager; import android.content.ComponentName; @@ -25,20 +27,18 @@ import android.graphics.PorterDuff; import android.os.Bundle; import android.os.IBinder; -import android.text.SpannableString; -import android.text.Spanned; -import android.text.style.RelativeSizeSpan; -import android.text.style.TypefaceSpan; import android.view.View; import android.view.animation.Animation; import android.view.animation.BounceInterpolator; import android.view.animation.TranslateAnimation; import android.widget.Button; import android.widget.ImageView; - +import android.widget.TextView; import androidx.core.hardware.fingerprint.FingerprintManagerCompat; import androidx.core.os.CancellationSignal; - +import com.squareup.phrase.Phrase; +import java.security.Signature; +import network.loki.messenger.R; import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.components.AnimatingToggle; @@ -46,11 +46,6 @@ import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.util.AnimationCompleteListener; -import java.security.InvalidKeyException; -import java.security.Signature; - -import network.loki.messenger.R; - //TODO Rename to ScreenLockActivity and refactor to Kotlin. public class PassphrasePromptActivity extends BaseActionBarActivity { @@ -158,6 +153,16 @@ private void handleAuthenticated() { } private void initializeResources() { + + TextView statusTitle = findViewById(R.id.app_lock_status_title); + if (statusTitle != null) { + Context c = getApplicationContext(); + String lockedTxt = Phrase.from(c, R.string.lockAppLocked) + .put(APP_NAME_KEY, c.getString(R.string.app_name)) + .format().toString(); + statusTitle.setText(lockedTxt); + } + visibilityToggle = findViewById(R.id.button_toggle); fingerprintPrompt = findViewById(R.id.fingerprint_auth_container); lockScreenButton = findViewById(R.id.lock_screen_auth_container); @@ -165,10 +170,6 @@ private void initializeResources() { fingerprintCancellationSignal = new CancellationSignal(); fingerprintListener = new FingerprintListener(); - SpannableString hint = new SpannableString(" " + getString(R.string.PassphrasePromptActivity_enter_passphrase)); - hint.setSpan(new RelativeSizeSpan(0.9f), 0, hint.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); - hint.setSpan(new TypefaceSpan("sans-serif"), 0, hint.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); - fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp); fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_primary), PorterDuff.Mode.SRC_IN); diff --git a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt index 71e04230f2..69d58411f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.net.Uri @@ -7,12 +9,14 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.ArrayAdapter import android.widget.Button import android.widget.LinearLayout import android.widget.LinearLayout.VERTICAL import android.widget.Space import android.widget.TextView import androidx.annotation.AttrRes +import androidx.annotation.ColorRes import androidx.annotation.LayoutRes import androidx.annotation.StringRes import androidx.annotation.StyleRes @@ -30,6 +34,7 @@ annotation class DialogDsl @DialogDsl class SessionDialogBuilder(val context: Context) { + private val dp8 = toPx(8, context.resources) private val dp20 = toPx(20, context.resources) private val dp40 = toPx(40, context.resources) private val dp60 = toPx(60, context.resources) @@ -37,13 +42,15 @@ class SessionDialogBuilder(val context: Context) { private val dialogBuilder: AlertDialog.Builder = AlertDialog.Builder(context) private var dialog: AlertDialog? = null - private fun dismiss() = dialog?.dismiss() + fun dismiss() = dialog?.dismiss() private val topView = LinearLayout(context) .apply { setPadding(0, dp20, 0, 0) } .apply { orientation = VERTICAL } .also(dialogBuilder::setCustomTitle) + private val contentView = LinearLayout(context).apply { orientation = VERTICAL } + private val buttonLayout = LinearLayout(context) private val root = LinearLayout(context).apply { orientation = VERTICAL } @@ -53,24 +60,29 @@ class SessionDialogBuilder(val context: Context) { addView(buttonLayout) } + // Main title entry point + fun title(text: String?) { + text(text, R.style.TextAppearance_Session_Dialog_Title) { setPadding(dp20, 0, dp20, 0) } + } + + // Convenience assessor for title that takes a string resource fun title(@StringRes id: Int) = title(context.getString(id)) + // Convenience accessor for title that takes a CharSequence fun title(text: CharSequence?) = title(text?.toString()) - fun title(text: String?) { - text(text, R.style.TextAppearance_AppCompat_Title) { setPadding(dp20, 0, dp20, 0) } - } - fun text(@StringRes id: Int, style: Int = 0) = text(context.getString(id), style) - fun text(text: CharSequence?, @StyleRes style: Int = 0) { + fun text(@StringRes id: Int, style: Int? = null) = text(context.getString(id), style) + + fun text(text: CharSequence?, @StyleRes style: Int? = null) { text(text, style) { layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) .apply { updateMargins(dp40, 0, dp40, 0) } } } - private fun text(text: CharSequence?, @StyleRes style: Int, modify: TextView.() -> Unit) { + private fun text(text: CharSequence?, @StyleRes style: Int? = null, modify: TextView.() -> Unit) { text ?: return - TextView(context, null, 0, style) + TextView(context, null, 0, style ?: R.style.TextAppearance_Session_Dialog_Message) .apply { setText(text) textAlignment = View.TEXT_ALIGNMENT_CENTER @@ -78,7 +90,7 @@ class SessionDialogBuilder(val context: Context) { }.let(topView::addView) Space(context).apply { - layoutParams = LinearLayout.LayoutParams(0, dp20) + layoutParams = LinearLayout.LayoutParams(0, dp8) }.let(topView::addView) } @@ -95,17 +107,31 @@ class SessionDialogBuilder(val context: Context) { fun singleChoiceItems( options: Collection, currentSelected: Int = 0, + dismissOnRadioSelect: Boolean = true, onSelect: (Int) -> Unit - ) = singleChoiceItems(options.toTypedArray(), currentSelected, onSelect) + ) = singleChoiceItems( + options.toTypedArray(), + currentSelected, + dismissOnRadioSelect, + onSelect + ) fun singleChoiceItems( options: Array, currentSelected: Int = 0, + dismissOnRadioSelect: Boolean = true, onSelect: (Int) -> Unit - ): AlertDialog.Builder = dialogBuilder.setSingleChoiceItems( - options, - currentSelected - ) { dialog, it -> onSelect(it); dialog.dismiss() } + ): AlertDialog.Builder{ + val adapter = ArrayAdapter(context, R.layout.view_dialog_single_choice_item, options) + + return dialogBuilder.setSingleChoiceItems( + adapter, + currentSelected + ) { dialog, it -> + onSelect(it) + if(dismissOnRadioSelect) dialog.dismiss() + } + } fun items( options: Array, @@ -125,16 +151,21 @@ class SessionDialogBuilder(val context: Context) { ) { listener() } fun okButton(listener: (() -> Unit) = {}) = button(android.R.string.ok) { listener() } - fun cancelButton(listener: (() -> Unit) = {}) = button(android.R.string.cancel, R.string.AccessibilityId_cancel_button) { listener() } + + fun cancelButton(listener: (() -> Unit) = {}) = button(android.R.string.cancel, R.string.AccessibilityId_cancel) { listener() } fun button( @StringRes text: Int, @StringRes contentDescriptionRes: Int = text, @StyleRes style: Int = R.style.Widget_Session_Button_Dialog_UnimportantText, + @ColorRes textColor: Int? = null, dismiss: Boolean = true, listener: (() -> Unit) = {} ) = Button(context, null, 0, style).apply { setText(text) + textColor?.let{ + setTextColor(it) + } contentDescription = resources.getString(contentDescriptionRes) layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, dp60, 1f) setOnClickListener { @@ -149,22 +180,18 @@ class SessionDialogBuilder(val context: Context) { fun Context.showSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog = SessionDialogBuilder(this).apply { build() }.show() -fun Context.showOpenUrlDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog = - SessionDialogBuilder(this).apply { - title(R.string.urlOpen) - text(R.string.urlOpenBrowser) - build() - }.show() - -fun Context.showOpenUrlDialog(url: String): AlertDialog = - showOpenUrlDialog { - okButton { openUrl(url) } - cancelButton() - } +public fun Context.copyURLToClipboard(url: String) { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(url, url) + clipboard.setPrimaryClip(clip) +} + +// Method to actually open a given URL via an Intent that will use the default browser fun Context.openUrl(url: String) = Intent(Intent.ACTION_VIEW, Uri.parse(url)).let(::startActivity) fun Fragment.showSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog = SessionDialogBuilder(requireContext()).apply { build() }.show() + fun Fragment.createSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog = SessionDialogBuilder(requireContext()).apply { build() }.create() diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.java index f03840c1ab..3b939bf647 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.java @@ -17,6 +17,8 @@ package org.thoughtcrime.securesms; +import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; + import android.annotation.SuppressLint; import android.content.Context; import android.content.Intent; @@ -29,12 +31,21 @@ import android.view.MenuItem; import android.view.View; import android.widget.ImageView; +import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.widget.Toolbar; +import com.squareup.phrase.Phrase; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; + +import network.loki.messenger.R; + import org.session.libsession.utilities.Address; import org.session.libsession.utilities.DistributionTypes; import org.session.libsession.utilities.ViewUtil; @@ -49,274 +60,267 @@ import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.util.MediaUtil; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; - -import network.loki.messenger.R; - /** * An activity to quickly share content with contacts * * @author Jake McGinty */ public class ShareActivity extends PassphraseRequiredActionBarActivity - implements ContactSelectionListFragment.OnContactSelectedListener -{ - private static final String TAG = ShareActivity.class.getSimpleName(); - - public static final String EXTRA_THREAD_ID = "thread_id"; - public static final String EXTRA_ADDRESS_MARSHALLED = "address_marshalled"; - public static final String EXTRA_DISTRIBUTION_TYPE = "distribution_type"; - - - private ContactSelectionListFragment contactsFragment; - private SearchToolbar searchToolbar; - private ImageView searchAction; - private View progressWheel; - private Uri resolvedExtra; - private CharSequence resolvedPlaintext; - private String mimeType; - private boolean isPassingAlongMedia; - - @Override - protected void onCreate(Bundle icicle, boolean ready) { - if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) { - getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, DisplayMode.FLAG_ALL); - } + implements ContactSelectionListFragment.OnContactSelectedListener { + private static final String TAG = ShareActivity.class.getSimpleName(); + + public static final String EXTRA_THREAD_ID = "thread_id"; + public static final String EXTRA_ADDRESS_MARSHALLED = "address_marshalled"; + public static final String EXTRA_DISTRIBUTION_TYPE = "distribution_type"; + + private ContactSelectionListFragment contactsFragment; + private SearchToolbar searchToolbar; + private ImageView searchAction; + private View progressWheel; + private Uri resolvedExtra; + private CharSequence resolvedPlaintext; + private String mimeType; + private boolean isPassingAlongMedia; - getIntent().putExtra(ContactSelectionListFragment.REFRESHABLE, false); - - setContentView(R.layout.share_activity); - - initializeToolbar(); - initializeResources(); - initializeSearch(); - initializeMedia(); - } - - @Override - protected void onNewIntent(Intent intent) { - Log.i(TAG, "onNewIntent()"); - super.onNewIntent(intent); - setIntent(intent); - initializeMedia(); - } - - @Override - public void onPause() { - super.onPause(); - if (!isPassingAlongMedia && resolvedExtra != null) { - BlobProvider.getInstance().delete(this, resolvedExtra); - - if (!isFinishing()) { - finish(); - } - } - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - onBackPressed(); - return true; - } - return super.onOptionsItemSelected(item); - } - - @Override - public void onBackPressed() { - if (searchToolbar.isVisible()) searchToolbar.collapse(); - else super.onBackPressed(); - } - - private void initializeToolbar() { - Toolbar toolbar = findViewById(R.id.toolbar); - setSupportActionBar(toolbar); - ActionBar actionBar = getSupportActionBar(); - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setHomeButtonEnabled(true); - } - - private void initializeResources() { - progressWheel = findViewById(R.id.progress_wheel); - searchToolbar = findViewById(R.id.search_toolbar); - searchAction = findViewById(R.id.search_action); - contactsFragment = (ContactSelectionListFragment) getSupportFragmentManager().findFragmentById(R.id.contact_selection_list_fragment); - contactsFragment.setOnContactSelectedListener(this); - } - - private void initializeSearch() { - searchAction.setOnClickListener(v -> searchToolbar.display(searchAction.getX() + (searchAction.getWidth() / 2), - searchAction.getY() + (searchAction.getHeight() / 2))); - - searchToolbar.setListener(new SearchToolbar.SearchListener() { - @Override - public void onSearchTextChange(String text) { - if (contactsFragment != null) { - contactsFragment.setQueryFilter(text); + @Override + protected void onCreate(Bundle icicle, boolean ready) { + if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) { + getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, DisplayMode.FLAG_ALL); } - } - @Override - public void onSearchClosed() { - if (contactsFragment != null) { - contactsFragment.resetQueryFilter(); - } - } - }); - } - - private void initializeMedia() { - final Context context = this; - isPassingAlongMedia = false; - - Uri streamExtra = getIntent().getParcelableExtra(Intent.EXTRA_STREAM); - CharSequence charSequenceExtra = getIntent().getCharSequenceExtra(Intent.EXTRA_TEXT); - mimeType = getMimeType(streamExtra); - - if (streamExtra != null && PartAuthority.isLocalUri(streamExtra)) { - isPassingAlongMedia = true; - resolvedExtra = streamExtra; - handleResolvedMedia(getIntent(), false); - } else if (charSequenceExtra != null && mimeType != null && mimeType.startsWith("text/")) { - resolvedPlaintext = charSequenceExtra; - handleResolvedMedia(getIntent(), false); - } else { - contactsFragment.getView().setVisibility(View.GONE); - progressWheel.setVisibility(View.VISIBLE); - new ResolveMediaTask(context).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, streamExtra); + getIntent().putExtra(ContactSelectionListFragment.REFRESHABLE, false); + + setContentView(R.layout.share_activity); + + initializeToolbar(); + initializeResources(); + initializeSearch(); + initializeMedia(); } - } - - private void handleResolvedMedia(Intent intent, boolean animate) { - long threadId = intent.getLongExtra(EXTRA_THREAD_ID, -1); - int distributionType = intent.getIntExtra(EXTRA_DISTRIBUTION_TYPE, -1); - Address address = null; - - if (intent.hasExtra(EXTRA_ADDRESS_MARSHALLED)) { - Parcel parcel = Parcel.obtain(); - byte[] marshalled = intent.getByteArrayExtra(EXTRA_ADDRESS_MARSHALLED); - parcel.unmarshall(marshalled, 0, marshalled.length); - parcel.setDataPosition(0); - address = parcel.readParcelable(getClassLoader()); - parcel.recycle(); + + @Override + protected void onNewIntent(Intent intent) { + Log.i(TAG, "onNewIntent()"); + super.onNewIntent(intent); + setIntent(intent); + initializeMedia(); } - boolean hasResolvedDestination = threadId != -1 && address != null && distributionType != -1; + @Override + public void onPause() { + super.onPause(); + if (!isPassingAlongMedia && resolvedExtra != null) { + BlobProvider.getInstance().delete(this, resolvedExtra); - if (!hasResolvedDestination && animate) { - ViewUtil.fadeIn(contactsFragment.getView(), 300); - ViewUtil.fadeOut(progressWheel, 300); - } else if (!hasResolvedDestination) { - contactsFragment.getView().setVisibility(View.VISIBLE); - progressWheel.setVisibility(View.GONE); - } else { - createConversation(threadId, address, distributionType); + if (!isFinishing()) { + finish(); + } + } } - } - private void createConversation(long threadId, Address address, int distributionType) { - final Intent intent = getBaseShareIntent(ConversationActivityV2.class); - intent.putExtra(ConversationActivityV2.ADDRESS, address); - intent.putExtra(ConversationActivityV2.THREAD_ID, threadId); + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + onBackPressed(); + return true; + } + return super.onOptionsItemSelected(item); + } - isPassingAlongMedia = true; - startActivity(intent); - } + @Override + public void onBackPressed() { + if (searchToolbar.isVisible()) searchToolbar.collapse(); + else super.onBackPressed(); + } - private Intent getBaseShareIntent(final @NonNull Class target) { - final Intent intent = new Intent(this, target); + private void initializeToolbar() { + TextView tootlbarTitle = findViewById(R.id.title); + tootlbarTitle.setText( + Phrase.from(getApplicationContext(), R.string.shareToSession) + .put(APP_NAME_KEY, getString(R.string.app_name)) + .format().toString() + ); + } - if (resolvedExtra != null) { - intent.setDataAndType(resolvedExtra, mimeType); - } else if (resolvedPlaintext != null) { - intent.putExtra(Intent.EXTRA_TEXT, resolvedPlaintext); - intent.setType("text/plain"); + private void initializeResources() { + progressWheel = findViewById(R.id.progress_wheel); + searchToolbar = findViewById(R.id.search_toolbar); + searchAction = findViewById(R.id.search_action); + contactsFragment = (ContactSelectionListFragment) getSupportFragmentManager().findFragmentById(R.id.contact_selection_list_fragment); + contactsFragment.setOnContactSelectedListener(this); } - return intent; - } + private void initializeSearch() { + searchAction.setOnClickListener(v -> searchToolbar.display(searchAction.getX() + (searchAction.getWidth() / 2), + searchAction.getY() + (searchAction.getHeight() / 2))); + + searchToolbar.setListener(new SearchToolbar.SearchListener() { + @Override + public void onSearchTextChange(String text) { + if (contactsFragment != null) { + contactsFragment.setQueryFilter(text); + } + } - private String getMimeType(@Nullable Uri uri) { - if (uri != null) { - final String mimeType = MediaUtil.getMimeType(getApplicationContext(), uri); - if (mimeType != null) return mimeType; + @Override + public void onSearchClosed() { + if (contactsFragment != null) { + contactsFragment.resetQueryFilter(); + } + } + }); } - return MediaUtil.getCorrectedMimeType(getIntent().getType()); - } - - @Override - public void onContactSelected(String number) { - Recipient recipient = Recipient.from(this, Address.fromExternal(this, number), true); - long existingThread = DatabaseComponent.get(this).threadDatabase().getThreadIdIfExistsFor(recipient); - createConversation(existingThread, recipient.getAddress(), DistributionTypes.DEFAULT); - } - - @Override - public void onContactDeselected(String number) { - } - - @SuppressLint("StaticFieldLeak") - private class ResolveMediaTask extends AsyncTask { - private final Context context; - - ResolveMediaTask(Context context) { - this.context = context; + + private void initializeMedia() { + final Context context = this; + isPassingAlongMedia = false; + + Uri streamExtra = getIntent().getParcelableExtra(Intent.EXTRA_STREAM); + CharSequence charSequenceExtra = getIntent().getCharSequenceExtra(Intent.EXTRA_TEXT); + mimeType = getMimeType(streamExtra); + + if (streamExtra != null && PartAuthority.isLocalUri(streamExtra)) { + isPassingAlongMedia = true; + resolvedExtra = streamExtra; + handleResolvedMedia(getIntent(), false); + } else if (charSequenceExtra != null && mimeType != null && mimeType.startsWith("text/")) { + resolvedPlaintext = charSequenceExtra; + handleResolvedMedia(getIntent(), false); + } else { + contactsFragment.getView().setVisibility(View.GONE); + progressWheel.setVisibility(View.VISIBLE); + new ResolveMediaTask(context).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, streamExtra); + } } - @Override - protected Uri doInBackground(Uri... uris) { - try { - if (uris.length != 1 || uris[0] == null) { - return null; + private void handleResolvedMedia(Intent intent, boolean animate) { + long threadId = intent.getLongExtra(EXTRA_THREAD_ID, -1); + int distributionType = intent.getIntExtra(EXTRA_DISTRIBUTION_TYPE, -1); + Address address = null; + + if (intent.hasExtra(EXTRA_ADDRESS_MARSHALLED)) { + Parcel parcel = Parcel.obtain(); + byte[] marshalled = intent.getByteArrayExtra(EXTRA_ADDRESS_MARSHALLED); + parcel.unmarshall(marshalled, 0, marshalled.length); + parcel.setDataPosition(0); + address = parcel.readParcelable(getClassLoader()); + parcel.recycle(); } - InputStream inputStream; + boolean hasResolvedDestination = threadId != -1 && address != null && distributionType != -1; - if ("file".equals(uris[0].getScheme())) { - inputStream = new FileInputStream(uris[0].getPath()); + if (!hasResolvedDestination && animate) { + ViewUtil.fadeIn(contactsFragment.getView(), 300); + ViewUtil.fadeOut(progressWheel, 300); + } else if (!hasResolvedDestination) { + contactsFragment.getView().setVisibility(View.VISIBLE); + progressWheel.setVisibility(View.GONE); } else { - inputStream = context.getContentResolver().openInputStream(uris[0]); + createConversation(threadId, address, distributionType); } + } - if (inputStream == null) { - return null; + private void createConversation(long threadId, Address address, int distributionType) { + final Intent intent = getBaseShareIntent(ConversationActivityV2.class); + intent.putExtra(ConversationActivityV2.ADDRESS, address); + intent.putExtra(ConversationActivityV2.THREAD_ID, threadId); + + isPassingAlongMedia = true; + startActivity(intent); + } + + private Intent getBaseShareIntent(final @NonNull Class target) { + final Intent intent = new Intent(this, target); + + if (resolvedExtra != null) { + intent.setDataAndType(resolvedExtra, mimeType); + } else if (resolvedPlaintext != null) { + intent.putExtra(Intent.EXTRA_TEXT, resolvedPlaintext); + intent.setType("text/plain"); } - Cursor cursor = getContentResolver().query(uris[0], new String[] {OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}, null, null, null); - String fileName = null; - Long fileSize = null; + return intent; + } - try { - if (cursor != null && cursor.moveToFirst()) { - try { - fileName = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)); - fileSize = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); - } catch (IllegalArgumentException e) { - Log.w(TAG, e); - } - } - } finally { - if (cursor != null) cursor.close(); + private String getMimeType(@Nullable Uri uri) { + if (uri != null) { + final String mimeType = MediaUtil.getMimeType(getApplicationContext(), uri); + if (mimeType != null) return mimeType; } + return MediaUtil.getCorrectedMimeType(getIntent().getType()); + } - return BlobProvider.getInstance() - .forData(inputStream, fileSize == null ? 0 : fileSize) - .withMimeType(mimeType) - .withFileName(fileName) - .createForMultipleSessionsOnDisk(context, e -> Log.w(TAG, "Failed to write to disk.", e)); - } catch (IOException ioe) { - Log.w(TAG, ioe); - return null; - } + @Override + public void onContactSelected(String number) { + Recipient recipient = Recipient.from(this, Address.fromExternal(this, number), true); + long existingThread = DatabaseComponent.get(this).threadDatabase().getThreadIdIfExistsFor(recipient); + createConversation(existingThread, recipient.getAddress(), DistributionTypes.DEFAULT); } @Override - protected void onPostExecute(Uri uri) { - resolvedExtra = uri; - handleResolvedMedia(getIntent(), true); + public void onContactDeselected(String number) { + } + + @SuppressLint("StaticFieldLeak") + private class ResolveMediaTask extends AsyncTask { + private final Context context; + + ResolveMediaTask(Context context) { + this.context = context; + } + + @Override + protected Uri doInBackground(Uri... uris) { + try { + if (uris.length != 1 || uris[0] == null) { + return null; + } + + InputStream inputStream; + + if ("file".equals(uris[0].getScheme())) { + inputStream = new FileInputStream(uris[0].getPath()); + } else { + inputStream = context.getContentResolver().openInputStream(uris[0]); + } + + if (inputStream == null) { + return null; + } + + Cursor cursor = getContentResolver().query(uris[0], new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}, null, null, null); + String fileName = null; + Long fileSize = null; + + try { + if (cursor != null && cursor.moveToFirst()) { + try { + fileName = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)); + fileSize = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); + } catch (IllegalArgumentException e) { + Log.w(TAG, e); + } + } + } finally { + if (cursor != null) cursor.close(); + } + + return BlobProvider.getInstance() + .forData(inputStream, fileSize == null ? 0 : fileSize) + .withMimeType(mimeType) + .withFileName(fileName) + .createForMultipleSessionsOnDisk(context, e -> Log.w(TAG, "Failed to write to disk.", e)); + } catch (IOException ioe) { + Log.w(TAG, ioe); + return null; + } + } + + @Override + protected void onPostExecute(Uri uri) { + resolvedExtra = uri; + handleResolvedMedia(getIntent(), true); + } } - } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShortcutLauncherActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ShortcutLauncherActivity.java index 37fdf2367d..2090e64925 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ShortcutLauncherActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ShortcutLauncherActivity.java @@ -37,7 +37,7 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { String serializedAddress = getIntent().getStringExtra(KEY_SERIALIZED_ADDRESS); if (serializedAddress == null) { - Toast.makeText(this, R.string.ShortcutLauncherActivity_invalid_shortcut, Toast.LENGTH_SHORT).show(); + Toast.makeText(this, R.string.invalidShortcut, Toast.LENGTH_SHORT).show(); startActivity(new Intent(this, HomeActivity.class)); finish(); return; diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java index fc88656469..ac70a6024a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java @@ -8,10 +8,9 @@ import android.media.AudioManager; import android.os.Handler; import android.os.Message; -import android.os.PowerManager; import android.os.PowerManager.WakeLock; +import android.os.PowerManager; import android.util.Pair; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.OptIn; @@ -23,7 +22,8 @@ import androidx.media3.common.Player; import androidx.media3.common.util.UnstableApi; import androidx.media3.exoplayer.ExoPlayer; - +import java.io.IOException; +import java.lang.ref.WeakReference; import org.jetbrains.annotations.NotNull; import org.session.libsession.utilities.ServiceUtil; import org.session.libsession.utilities.Util; @@ -32,9 +32,6 @@ import org.thoughtcrime.securesms.attachments.AttachmentServer; import org.thoughtcrime.securesms.mms.AudioSlide; -import java.io.IOException; -import java.lang.ref.WeakReference; - public class AudioSlidePlayer implements SensorEventListener { private static final String TAG = AudioSlidePlayer.class.getSimpleName(); @@ -170,7 +167,6 @@ public void onPlaybackStateChanged(int playbackState) { } } - @Override public void onPlayerError(PlaybackException error) { Log.w(TAG, "MediaPlayer Error: " + error); @@ -209,9 +205,7 @@ public synchronized void stop() { this.mediaPlayer.release(); } - if (this.audioAttachmentServer != null) { - this.audioAttachmentServer.stop(); - } + if (this.audioAttachmentServer != null) { this.audioAttachmentServer.stop(); } sensorManager.unregisterListener(AudioSlidePlayer.this); @@ -220,9 +214,7 @@ public synchronized void stop() { } public synchronized static void stopAll() { - if (playing.isPresent()) { - playing.get().stop(); - } + if (playing.isPresent()) { playing.get().stop(); } } public synchronized boolean isReady() { @@ -364,9 +356,8 @@ public void onSensorChanged(SensorEvent event) { } @Override - public void onAccuracyChanged(Sensor sensor, int accuracy) { + public void onAccuracyChanged(Sensor sensor, int accuracy) { /* Do nothing */ } - } public interface Listener { void onPlayerStart(@NonNull AudioSlidePlayer player); diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt index ddf7ac5c77..bf19c3cc34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt @@ -32,7 +32,7 @@ class AvatarSelection( private val bgColor by lazy { activity.getColorFromAttr(android.R.attr.colorPrimary) } private val txtColor by lazy { activity.getColorFromAttr(android.R.attr.textColorPrimary) } private val imageScrim by lazy { ContextCompat.getColor(activity, R.color.avatar_background) } - private val activityTitle by lazy { activity.getString(R.string.CropImageActivity_profile_avatar) } + private val activityTitle by lazy { activity.getString(R.string.image) } /** * Returns result on [.REQUEST_CODE_CROP_IMAGE] @@ -74,8 +74,9 @@ class AvatarSelection( */ fun startAvatarSelection( includeClear: Boolean, - attemptToIncludeCamera: Boolean - ): File? { + attemptToIncludeCamera: Boolean, + createTempFile: ()->File? + ) { var captureFile: File? = null val hasCameraPermission = ContextCompat .checkSelfPermission( @@ -83,18 +84,11 @@ class AvatarSelection( Manifest.permission.CAMERA ) == PackageManager.PERMISSION_GRANTED if (attemptToIncludeCamera && hasCameraPermission) { - try { - captureFile = File.createTempFile("avatar-capture", ".jpg", getImageDir(activity)) - } catch (e: IOException) { - Log.e("Cannot reserve a temporary avatar capture file.", e) - } catch (e: NoExternalStorageException) { - Log.e("Cannot reserve a temporary avatar capture file.", e) - } + captureFile = createTempFile() } val chooserIntent = createAvatarSelectionIntent(activity, captureFile, includeClear) onPickImage.launch(chooserIntent) - return captureFile } private fun createAvatarSelectionIntent( @@ -120,7 +114,7 @@ class AvatarSelection( val chooserIntent = Intent.createChooser( galleryIntent, - context.getString(R.string.CreateProfileActivity_profile_photo) + context.getString(R.string.image) ) if (!extraIntents.isEmpty()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt index 2bded3cccb..9fdc6b1063 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt @@ -13,11 +13,13 @@ import android.view.MenuItem import android.view.View import android.view.ViewOutlineProvider import android.view.WindowManager +import android.widget.TextView import androidx.activity.viewModels import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -27,6 +29,7 @@ import network.loki.messenger.R import network.loki.messenger.databinding.ActivityWebrtcBinding import org.apache.commons.lang3.time.DurationFormatUtils import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.truncateIdForDisplay import org.session.libsignal.utilities.Log @@ -86,9 +89,9 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { return super.onOptionsItemSelected(item) } - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - if (intent?.action == ACTION_ANSWER) { + if (intent.action == ACTION_ANSWER) { val answerIntent = WebRtcCallService.acceptCallIntent(this) answerIntent.flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT ContextCompat.startForegroundService(this, answerIntent) @@ -202,6 +205,10 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { update() } } + + // Substitute "Session" into the "{app_name} Call" text + val sessionCallTV = findViewById(R.id.sessionCallText) + sessionCallTV?.text = Phrase.from(this, R.string.callsSessionCall).put(APP_NAME_KEY, getString(R.string.app_name)).format() } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AttachmentTypeSelector.java b/app/src/main/java/org/thoughtcrime/securesms/components/AttachmentTypeSelector.java deleted file mode 100644 index 1c6a4097f5..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AttachmentTypeSelector.java +++ /dev/null @@ -1,299 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.Manifest; -import android.animation.Animator; -import android.app.Activity; -import android.content.Context; -import android.graphics.drawable.BitmapDrawable; -import android.net.Uri; -import android.util.Pair; -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewAnimationUtils; -import android.view.ViewTreeObserver; -import android.view.animation.Animation; -import android.view.animation.AnimationSet; -import android.view.animation.OvershootInterpolator; -import android.view.animation.ScaleAnimation; -import android.view.animation.TranslateAnimation; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.PopupWindow; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.loader.app.LoaderManager; - -import org.session.libsession.utilities.ViewUtil; -import org.thoughtcrime.securesms.permissions.Permissions; - -import network.loki.messenger.R; - -public class AttachmentTypeSelector extends PopupWindow { - - public static final int ADD_GALLERY = 1; - public static final int ADD_DOCUMENT = 2; - public static final int ADD_SOUND = 3; - public static final int ADD_CONTACT_INFO = 4; - public static final int TAKE_PHOTO = 5; - public static final int ADD_LOCATION = 6; - public static final int ADD_GIF = 7; - - private static final int ANIMATION_DURATION = 300; - - @SuppressWarnings("unused") - private static final String TAG = AttachmentTypeSelector.class.getSimpleName(); - - private final @NonNull Context context; - public int keyboardHeight; - private final @NonNull LoaderManager loaderManager; - private final @NonNull RecentPhotoViewRail recentRail; - private final @NonNull ImageView imageButton; - private final @NonNull ImageView audioButton; - private final @NonNull ImageView documentButton; - private final @NonNull ImageView contactButton; - private final @NonNull ImageView cameraButton; - private final @NonNull ImageView locationButton; - private final @NonNull ImageView gifButton; - private final @NonNull ImageView closeButton; - - private @Nullable View currentAnchor; - private @Nullable AttachmentClickedListener listener; - - public AttachmentTypeSelector(@NonNull Context context, @NonNull LoaderManager loaderManager, @Nullable AttachmentClickedListener listener, int keyboardHeight) { - super(context); - - this.context = context; - this.keyboardHeight = keyboardHeight; - - LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); - LinearLayout layout = (LinearLayout) inflater.inflate(R.layout.attachment_type_selector, null, true); - - this.listener = listener; - this.loaderManager = loaderManager; - this.recentRail = ViewUtil.findById(layout, R.id.recent_photos); - this.imageButton = ViewUtil.findById(layout, R.id.gallery_button); - this.audioButton = ViewUtil.findById(layout, R.id.audio_button); - this.documentButton = ViewUtil.findById(layout, R.id.document_button); - this.contactButton = ViewUtil.findById(layout, R.id.contact_button); - this.cameraButton = ViewUtil.findById(layout, R.id.camera_button); - this.locationButton = ViewUtil.findById(layout, R.id.location_button); - this.gifButton = ViewUtil.findById(layout, R.id.giphy_button); - this.closeButton = ViewUtil.findById(layout, R.id.close_button); - - this.imageButton.setOnClickListener(new PropagatingClickListener(ADD_GALLERY)); - this.audioButton.setOnClickListener(new PropagatingClickListener(ADD_SOUND)); - this.documentButton.setOnClickListener(new PropagatingClickListener(ADD_DOCUMENT)); - this.contactButton.setOnClickListener(new PropagatingClickListener(ADD_CONTACT_INFO)); - this.cameraButton.setOnClickListener(new PropagatingClickListener(TAKE_PHOTO)); - this.locationButton.setOnClickListener(new PropagatingClickListener(ADD_LOCATION)); - this.gifButton.setOnClickListener(new PropagatingClickListener(ADD_GIF)); - this.closeButton.setOnClickListener(new CloseClickListener()); - this.recentRail.setListener(new RecentPhotoSelectedListener()); - - setContentView(layout); - setWidth(LinearLayout.LayoutParams.MATCH_PARENT); - setHeight(LinearLayout.LayoutParams.WRAP_CONTENT); - setBackgroundDrawable(new BitmapDrawable()); - setAnimationStyle(0); - setInputMethodMode(PopupWindow.INPUT_METHOD_NOT_NEEDED); - setFocusable(true); - setTouchable(true); - - updateHeight(); - - loaderManager.initLoader(1, null, recentRail); - } - - public void show(@NonNull Activity activity, final @NonNull View anchor) { - updateHeight(); - - if (Permissions.hasAll(activity, Manifest.permission.READ_EXTERNAL_STORAGE)) { - recentRail.setVisibility(View.VISIBLE); - loaderManager.restartLoader(1, null, recentRail); - } else { - recentRail.setVisibility(View.GONE); - } - - this.currentAnchor = anchor; - - showAtLocation(anchor, Gravity.BOTTOM, 0, 0); - - getContentView().getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - getContentView().getViewTreeObserver().removeGlobalOnLayoutListener(this); - - animateWindowInCircular(anchor, getContentView()); - } - }); - - animateButtonIn(imageButton, ANIMATION_DURATION / 2); - animateButtonIn(cameraButton, ANIMATION_DURATION / 2); - - animateButtonIn(audioButton, ANIMATION_DURATION / 3); - animateButtonIn(locationButton, ANIMATION_DURATION / 3); - animateButtonIn(documentButton, ANIMATION_DURATION / 4); - animateButtonIn(gifButton, ANIMATION_DURATION / 4); - animateButtonIn(contactButton, 0); - animateButtonIn(closeButton, 0); - } - - private void updateHeight() { - int thresholdInDP = 120; - float scale = context.getResources().getDisplayMetrics().density; - int thresholdInPX = (int)(thresholdInDP * scale); - View contentView = ViewUtil.findById(getContentView(), R.id.contentView); - LinearLayout.LayoutParams contentViewLayoutParams = (LinearLayout.LayoutParams)contentView.getLayoutParams(); - contentViewLayoutParams.height = keyboardHeight > thresholdInPX ? keyboardHeight : LinearLayout.LayoutParams.WRAP_CONTENT; - contentView.setLayoutParams(contentViewLayoutParams); - } - - @Override - public void dismiss() { - animateWindowOutCircular(currentAnchor, getContentView()); - } - - public void setListener(@Nullable AttachmentClickedListener listener) { - this.listener = listener; - } - - private void animateButtonIn(View button, int delay) { - AnimationSet animation = new AnimationSet(true); - Animation scale = new ScaleAnimation(0.0f, 1.0f, 0.0f, 1.0f, - Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.0f); - - animation.addAnimation(scale); - animation.setInterpolator(new OvershootInterpolator(1)); - animation.setDuration(ANIMATION_DURATION); - animation.setStartOffset(delay); - button.startAnimation(animation); - } - - private void animateWindowInCircular(@Nullable View anchor, @NonNull View contentView) { - Pair coordinates = getClickOrigin(anchor, contentView); - Animator animator = ViewAnimationUtils.createCircularReveal(contentView, - coordinates.first, - coordinates.second, - 0, - Math.max(contentView.getWidth(), contentView.getHeight())); - animator.setDuration(ANIMATION_DURATION); - animator.start(); - } - - private void animateWindowInTranslate(@NonNull View contentView) { - Animation animation = new TranslateAnimation(0, 0, contentView.getHeight(), 0); - animation.setDuration(ANIMATION_DURATION); - - getContentView().startAnimation(animation); - } - - private void animateWindowOutCircular(@Nullable View anchor, @NonNull View contentView) { - Pair coordinates = getClickOrigin(anchor, contentView); - Animator animator = ViewAnimationUtils.createCircularReveal(getContentView(), - coordinates.first, - coordinates.second, - Math.max(getContentView().getWidth(), getContentView().getHeight()), - 0); - - animator.setDuration(ANIMATION_DURATION); - animator.addListener(new Animator.AnimatorListener() { - @Override - public void onAnimationStart(Animator animation) { - } - - @Override - public void onAnimationEnd(Animator animation) { - AttachmentTypeSelector.super.dismiss(); - } - - @Override - public void onAnimationCancel(Animator animation) { - } - - @Override - public void onAnimationRepeat(Animator animation) { - } - }); - - animator.start(); - } - - private void animateWindowOutTranslate(@NonNull View contentView) { - Animation animation = new TranslateAnimation(0, 0, 0, contentView.getTop() + contentView.getHeight()); - animation.setDuration(ANIMATION_DURATION); - animation.setAnimationListener(new Animation.AnimationListener() { - @Override - public void onAnimationStart(Animation animation) { - } - - @Override - public void onAnimationEnd(Animation animation) { - AttachmentTypeSelector.super.dismiss(); - } - - @Override - public void onAnimationRepeat(Animation animation) { - } - }); - - getContentView().startAnimation(animation); - } - - private Pair getClickOrigin(@Nullable View anchor, @NonNull View contentView) { - if (anchor == null) return new Pair<>(0, 0); - - final int[] anchorCoordinates = new int[2]; - anchor.getLocationOnScreen(anchorCoordinates); - anchorCoordinates[0] += anchor.getWidth() / 2; - anchorCoordinates[1] += anchor.getHeight() / 2; - - final int[] contentCoordinates = new int[2]; - contentView.getLocationOnScreen(contentCoordinates); - - int x = anchorCoordinates[0] - contentCoordinates[0]; - int y = anchorCoordinates[1] - contentCoordinates[1]; - - return new Pair<>(x, y); - } - - private class RecentPhotoSelectedListener implements RecentPhotoViewRail.OnItemClickedListener { - @Override - public void onItemClicked(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height, long size) { - animateWindowOutTranslate(getContentView()); - - if (listener != null) listener.onQuickAttachment(uri, mimeType, bucketId, dateTaken, width, height, size); - } - } - - private class PropagatingClickListener implements View.OnClickListener { - - private final int type; - - private PropagatingClickListener(int type) { - this.type = type; - } - - @Override - public void onClick(View v) { - animateWindowOutTranslate(getContentView()); - - if (listener != null) listener.onClick(type); - } - - } - - private class CloseClickListener implements View.OnClickListener { - @Override - public void onClick(View v) { - dismiss(); - } - } - - public interface AttachmentClickedListener { - void onClick(int type); - void onQuickAttachment(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height, long size); - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/CustomDefaultPreference.java b/app/src/main/java/org/thoughtcrime/securesms/components/CustomDefaultPreference.java deleted file mode 100644 index 178803a9f0..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/CustomDefaultPreference.java +++ /dev/null @@ -1,259 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.app.Dialog; -import android.content.Context; -import android.content.res.TypedArray; -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.preference.DialogPreference; -import androidx.preference.PreferenceDialogFragmentCompat; -import android.text.Editable; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.util.AttributeSet; -import org.session.libsignal.utilities.Log; -import android.view.View; -import android.widget.AdapterView; -import android.widget.Button; -import android.widget.EditText; -import android.widget.Spinner; -import android.widget.TextView; - -import network.loki.messenger.R; -import org.thoughtcrime.securesms.components.CustomDefaultPreference.CustomDefaultPreferenceDialogFragmentCompat.CustomPreferenceValidator; -import org.session.libsession.utilities.TextSecurePreferences; - -import java.net.URI; -import java.net.URISyntaxException; - - -public class CustomDefaultPreference extends DialogPreference { - - private static final String TAG = CustomDefaultPreference.class.getSimpleName(); - - private final int inputType; - private final String customPreference; - private final String customToggle; - - private CustomPreferenceValidator validator; - private String defaultValue; - - public CustomDefaultPreference(Context context, AttributeSet attrs) { - super(context, attrs); - - int[] attributeNames = new int[]{android.R.attr.inputType, R.attr.custom_pref_toggle}; - TypedArray attributes = context.obtainStyledAttributes(attrs, attributeNames); - - this.inputType = attributes.getInt(0, 0); - this.customPreference = getKey(); - this.customToggle = attributes.getString(1); - this.validator = new CustomDefaultPreferenceDialogFragmentCompat.NullValidator(); - - attributes.recycle(); - - setPersistent(false); - setDialogLayoutResource(R.layout.custom_default_preference_dialog); - } - - public CustomDefaultPreference setValidator(CustomPreferenceValidator validator) { - this.validator = validator; - return this; - } - - public CustomDefaultPreference setDefaultValue(String defaultValue) { - this.defaultValue = defaultValue; - this.setSummary(getSummary()); - return this; - } - - @Override - public String getSummary() { - if (isCustom()) { - return getContext().getString(R.string.CustomDefaultPreference_using_custom, - getPrettyPrintValue(getCustomValue())); - } else { - return getContext().getString(R.string.CustomDefaultPreference_using_default, - getPrettyPrintValue(getDefaultValue())); - } - } - - private String getPrettyPrintValue(String value) { - if (TextUtils.isEmpty(value)) return getContext().getString(R.string.CustomDefaultPreference_none); - else return value; - } - - private boolean isCustom() { - return TextSecurePreferences.getBooleanPreference(getContext(), customToggle, false); - } - - private void setCustom(boolean custom) { - TextSecurePreferences.setBooleanPreference(getContext(), customToggle, custom); - } - - private String getCustomValue() { - return TextSecurePreferences.getStringPreference(getContext(), customPreference, ""); - } - - private void setCustomValue(String value) { - TextSecurePreferences.setStringPreference(getContext(), customPreference, value); - } - - private String getDefaultValue() { - return defaultValue; - } - - - public static class CustomDefaultPreferenceDialogFragmentCompat extends PreferenceDialogFragmentCompat { - - private static final String INPUT_TYPE = "input_type"; - - private Spinner spinner; - private EditText customText; - private TextView defaultLabel; - - public static CustomDefaultPreferenceDialogFragmentCompat newInstance(String key) { - CustomDefaultPreferenceDialogFragmentCompat fragment = new CustomDefaultPreferenceDialogFragmentCompat(); - Bundle b = new Bundle(1); - b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key); - fragment.setArguments(b); - return fragment; - } - - @Override - protected void onBindDialogView(@NonNull View view) { - Log.i(TAG, "onBindDialogView"); - super.onBindDialogView(view); - - CustomDefaultPreference preference = (CustomDefaultPreference)getPreference(); - - this.spinner = (Spinner) view.findViewById(R.id.default_or_custom); - this.defaultLabel = (TextView) view.findViewById(R.id.default_label); - this.customText = (EditText) view.findViewById(R.id.custom_edit); - - this.customText.setInputType(preference.inputType); - this.customText.addTextChangedListener(new TextValidator()); - this.customText.setText(preference.getCustomValue()); - this.spinner.setOnItemSelectedListener(new SelectionLister()); - this.defaultLabel.setText(preference.getPrettyPrintValue(preference.defaultValue)); - } - - - @Override - public @NonNull Dialog onCreateDialog(Bundle instanceState) { - Dialog dialog = super.onCreateDialog(instanceState); - - CustomDefaultPreference preference = (CustomDefaultPreference)getPreference(); - - if (preference.isCustom()) spinner.setSelection(1, true); - else spinner.setSelection(0, true); - - return dialog; - } - - @Override - public void onDialogClosed(boolean positiveResult) { - CustomDefaultPreference preference = (CustomDefaultPreference)getPreference(); - - if (positiveResult) { - if (spinner != null) preference.setCustom(spinner.getSelectedItemPosition() == 1); - if (customText != null) preference.setCustomValue(customText.getText().toString()); - - preference.setSummary(preference.getSummary()); - } - } - - interface CustomPreferenceValidator { - public boolean isValid(String value); - } - - private static class NullValidator implements CustomPreferenceValidator { - @Override - public boolean isValid(String value) { - return true; - } - } - - private class TextValidator implements TextWatcher { - - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) {} - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) {} - - @Override - public void afterTextChanged(Editable s) { - CustomDefaultPreference preference = (CustomDefaultPreference)getPreference(); - - if (spinner.getSelectedItemPosition() == 1) { - Button positiveButton = ((AlertDialog)getDialog()).getButton(AlertDialog.BUTTON_POSITIVE); - positiveButton.setEnabled(preference.validator.isValid(s.toString())); - } - } - } - - public static class UriValidator implements CustomPreferenceValidator { - @Override - public boolean isValid(String value) { - if (TextUtils.isEmpty(value)) return true; - - try { - new URI(value); - return true; - } catch (URISyntaxException mue) { - return false; - } - } - } - - public static class HostnameValidator implements CustomPreferenceValidator { - @Override - public boolean isValid(String value) { - if (TextUtils.isEmpty(value)) return true; - - try { - URI uri = new URI(null, value, null, null); - return true; - } catch (URISyntaxException mue) { - return false; - } - } - } - - public static class PortValidator implements CustomPreferenceValidator { - @Override - public boolean isValid(String value) { - try { - Integer.parseInt(value); - return true; - } catch (NumberFormatException e) { - return false; - } - } - } - - private class SelectionLister implements AdapterView.OnItemSelectedListener { - - @Override - public void onItemSelected(AdapterView parent, View view, int position, long id) { - CustomDefaultPreference preference = (CustomDefaultPreference)getPreference(); - Button positiveButton = ((AlertDialog)getDialog()).getButton(AlertDialog.BUTTON_POSITIVE); - - defaultLabel.setVisibility(position == 0 ? View.VISIBLE : View.GONE); - customText.setVisibility(position == 0 ? View.GONE : View.VISIBLE); - positiveButton.setEnabled(position == 0 || preference.validator.isValid(customText.getText().toString())); - } - - @Override - public void onNothingSelected(AdapterView parent) { - defaultLabel.setVisibility(View.VISIBLE); - customText.setVisibility(View.GONE); - } - } - - } - - - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java b/app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java index d1d70fdac0..f51b4a7d93 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java @@ -105,7 +105,7 @@ public void setDocument(final @NonNull DocumentSlide documentSlide, this.documentSlide = documentSlide; - this.fileName.setText(documentSlide.getFileName().or(getContext().getString(R.string.DocumentView_unknown_file))); + this.fileName.setText(documentSlide.getFileName().or(getContext().getString(R.string.attachmentsErrorNotSupported))); this.fileSize.setText(Util.getPrettyFileSize(documentSlide.getFileSize())); this.document.setText(getFileType(documentSlide.getFileName())); this.setOnClickListener(new OpenClickedListener(documentSlide)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java index d98c56ede5..ae9f5e6e70 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java @@ -54,7 +54,7 @@ public void setText(Recipient recipient, boolean read) { if (recipient.isLocalNumber()) { - builder.append(getContext().getString(R.string.note_to_self)); + builder.append(getContext().getString(R.string.noteToSelf)); } else if (recipient.getName() == null && !TextUtils.isEmpty(recipient.getProfileName())) { SpannableString profileName = new SpannableString(" (~" + recipient.getProfileName() + ") "); profileName.setSpan(new CenterAlignedRelativeSizeSpan(0.75f), 0, profileName.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java b/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java index 07d0883bd2..30e609a047 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java @@ -2,13 +2,11 @@ import android.animation.Animator; import android.content.Context; -import android.os.Build; import android.util.AttributeSet; import android.view.MenuItem; import android.view.View; import android.view.ViewAnimationUtils; import android.widget.EditText; -import android.widget.LinearLayout; import androidx.annotation.MainThread; import androidx.annotation.Nullable; @@ -19,7 +17,7 @@ import network.loki.messenger.R; -public class SearchToolbar extends LinearLayout { +public class SearchToolbar extends Toolbar { private float x, y; private MenuItem searchItem; @@ -41,23 +39,17 @@ public SearchToolbar(Context context, @Nullable AttributeSet attrs, int defStyle } private void initialize() { - inflate(getContext(), R.layout.search_toolbar, this); - setOrientation(VERTICAL); + setNavigationIcon(getContext().getResources().getDrawable(R.drawable.ic_baseline_clear_24)); + inflateMenu(R.menu.conversation_list_search); - Toolbar toolbar = findViewById(R.id.toolbar); - - toolbar.setNavigationIcon( - getContext().getResources().getDrawable(R.drawable.ic_baseline_clear_24)); - toolbar.inflateMenu(R.menu.conversation_list_search); - - this.searchItem = toolbar.getMenu().findItem(R.id.action_filter_search); + this.searchItem = getMenu().findItem(R.id.action_filter_search); SearchView searchView = (SearchView) searchItem.getActionView(); EditText searchText = searchView.findViewById(androidx.appcompat.R.id.search_src_text); searchView.setSubmitButtonEnabled(false); - if (searchText != null) searchText.setHint(R.string.SearchToolbar_search); - else searchView.setQueryHint(getResources().getString(R.string.SearchToolbar_search)); + if (searchText != null) searchText.setHint(R.string.search); + else searchView.setQueryHint(getResources().getString(R.string.search)); searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { @Override @@ -83,7 +75,7 @@ public boolean onMenuItemActionCollapse(MenuItem item) { } }); - toolbar.setNavigationOnClickListener(v -> hide()); + setNavigationOnClickListener(v -> hide()); } @MainThread diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.java b/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.java deleted file mode 100644 index 6e7993a575..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.util.AttributeSet; - -import androidx.preference.CheckBoxPreference; -import androidx.preference.Preference; - -import network.loki.messenger.R; - -public class SwitchPreferenceCompat extends CheckBoxPreference { - - private Preference.OnPreferenceClickListener listener; - - public SwitchPreferenceCompat(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - setLayoutRes(); - } - - public SwitchPreferenceCompat(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - setLayoutRes(); - } - - public SwitchPreferenceCompat(Context context, AttributeSet attrs) { - super(context, attrs); - setLayoutRes(); - } - - public SwitchPreferenceCompat(Context context) { - super(context); - setLayoutRes(); - } - - private void setLayoutRes() { - setWidgetLayoutResource(R.layout.switch_compat_preference); - } - - @Override - public void setOnPreferenceClickListener(Preference.OnPreferenceClickListener listener) { - this.listener = listener; - } - - @Override - protected void onClick() { - if (listener == null || !listener.onPreferenceClick(this)) { - super.onClick(); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.kt b/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.kt new file mode 100644 index 0000000000..9161dd828d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.kt @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.components + +import android.content.Context +import android.util.AttributeSet +import androidx.preference.CheckBoxPreference +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.thoughtcrime.securesms.ui.getSubbedCharSequence +import org.thoughtcrime.securesms.ui.getSubbedString + +class SwitchPreferenceCompat : CheckBoxPreference { + private var listener: OnPreferenceClickListener? = null + + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context!!, attrs, defStyleAttr) { + setLayoutRes() + } + + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context!!, attrs, defStyleAttr, defStyleRes) { + setLayoutRes() + } + + constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs) { + setLayoutRes() + } + + constructor(context: Context?) : super(context!!) { + setLayoutRes() + } + + private fun setLayoutRes() { + widgetLayoutResource = R.layout.switch_compat_preference + + if (this.hasKey()) { + val key = this.key + + // Substitute app name into lockscreen preference summary + if (key.equals(LOCK_SCREEN_KEY, ignoreCase = true)) { + val c = context + val substitutedSummaryCS = c.getSubbedCharSequence(R.string.lockAppDescription, APP_NAME_KEY to c.getString(R.string.app_name)) + this.summary = substitutedSummaryCS + } + } + } + + override fun setOnPreferenceClickListener(listener: OnPreferenceClickListener?) { + this.listener = listener + } + + override fun onClick() { + if (listener == null || !listener!!.onPreferenceClick(this)) { + super.onClick() + } + } + + companion object { + private const val LOCK_SCREEN_KEY = "pref_android_screen_lock" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TransferControlView.java b/app/src/main/java/org/thoughtcrime/securesms/components/TransferControlView.java deleted file mode 100644 index 36a607c819..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/TransferControlView.java +++ /dev/null @@ -1,227 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.animation.LayoutTransition; -import android.content.Context; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import android.util.AttributeSet; -import android.view.View; -import android.widget.FrameLayout; -import android.widget.TextView; - -import com.annimon.stream.Stream; -import com.pnikosis.materialishprogress.ProgressWheel; - -import org.greenrobot.eventbus.EventBus; -import org.greenrobot.eventbus.Subscribe; -import org.greenrobot.eventbus.ThreadMode; -import org.session.libsession.messaging.sending_receiving.attachments.Attachment; -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress; -import org.thoughtcrime.securesms.database.AttachmentDatabase; -import org.thoughtcrime.securesms.events.PartProgressEvent; -import org.thoughtcrime.securesms.mms.Slide; - -import org.session.libsession.utilities.ViewUtil; - -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import network.loki.messenger.R; - -public class TransferControlView extends FrameLayout { - - @Nullable private List slides; - @Nullable private View current; - - private final ProgressWheel progressWheel; - private final View downloadDetails; - private final TextView downloadDetailsText; - - private final Map downloadProgress; - - public TransferControlView(Context context) { - this(context, null); - } - - public TransferControlView(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public TransferControlView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - inflate(context, R.layout.transfer_controls_view, this); - - setLongClickable(false); - ViewUtil.setBackground(this, ContextCompat.getDrawable(context, R.drawable.transfer_controls_background)); - setVisibility(GONE); - setLayoutTransition(new LayoutTransition()); - - this.downloadProgress = new HashMap<>(); - this.progressWheel = ViewUtil.findById(this, R.id.progress_wheel); - this.downloadDetails = ViewUtil.findById(this, R.id.download_details); - this.downloadDetailsText = ViewUtil.findById(this, R.id.download_details_text); - } - - @Override - public void setFocusable(boolean focusable) { - super.setFocusable(focusable); - downloadDetails.setFocusable(focusable); - } - - @Override - public void setClickable(boolean clickable) { - super.setClickable(clickable); - downloadDetails.setClickable(clickable); - } - - @Override - protected void onAttachedToWindow() { - super.onAttachedToWindow(); - if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this); - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - EventBus.getDefault().unregister(this); - } - - public void setSlide(final @NonNull Slide slides) { - setSlides(Collections.singletonList(slides)); - } - - public void setSlides(final @NonNull List slides) { - if (slides.isEmpty()) { - throw new IllegalArgumentException("Must provide at least one slide."); - } - - this.slides = slides; - - if (!isUpdateToExistingSet(slides)) { - downloadProgress.clear(); - Stream.of(slides).forEach(s -> downloadProgress.put(s.asAttachment(), 0f)); - } - - for (Slide slide : slides) { - if (slide.asAttachment().getTransferState() == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE) { - downloadProgress.put(slide.asAttachment(), 1f); - } - } - - switch (getTransferState(slides)) { - case AttachmentTransferProgress.TRANSFER_PROGRESS_STARTED: - showProgressSpinner(calculateProgress(downloadProgress)); - break; - case AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING: - case AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED: - downloadDetailsText.setText(getDownloadText(this.slides)); - display(downloadDetails); - break; - default: - display(null); - break; - } - } - - public void showProgressSpinner() { - showProgressSpinner(calculateProgress(downloadProgress)); - } - - public void showProgressSpinner(float progress) { - if (progress == 0) { - progressWheel.spin(); - } else { - progressWheel.setInstantProgress(progress); - } - - display(progressWheel); - } - - public void setDownloadClickListener(final @Nullable OnClickListener listener) { - downloadDetails.setOnClickListener(listener); - } - - public void clear() { - clearAnimation(); - setVisibility(GONE); - if (current != null) { - current.clearAnimation(); - current.setVisibility(GONE); - } - current = null; - slides = null; - } - - public void setShowDownloadText(boolean showDownloadText) { - downloadDetailsText.setVisibility(showDownloadText ? VISIBLE : GONE); - forceLayout(); - } - - private boolean isUpdateToExistingSet(@NonNull List slides) { - if (slides.size() != downloadProgress.size()) { - return false; - } - - for (Slide slide : slides) { - if (!downloadProgress.containsKey(slide.asAttachment())) { - return false; - } - } - - return true; - } - - private int getTransferState(@NonNull List slides) { - int transferState = AttachmentTransferProgress.TRANSFER_PROGRESS_DONE; - for (Slide slide : slides) { - if (slide.getTransferState() == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING && transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE) { - transferState = slide.getTransferState(); - } else { - transferState = Math.max(transferState, slide.getTransferState()); - } - } - return transferState; - } - - private String getDownloadText(@NonNull List slides) { - if (slides.size() == 1) { - return slides.get(0).getContentDescription(); - } else { - int downloadCount = Stream.of(slides).reduce(0, (count, slide) -> slide.getTransferState() != AttachmentTransferProgress.TRANSFER_PROGRESS_DONE ? count + 1 : count); - return getContext().getResources().getQuantityString(R.plurals.TransferControlView_n_items, downloadCount, downloadCount); - } - } - - private void display(@Nullable final View view) { - if (current != null) { - current.setVisibility(GONE); - } - - if (view != null) { - view.setVisibility(VISIBLE); - } else { - setVisibility(GONE); - } - - current = view; - } - - private float calculateProgress(@NonNull Map downloadProgress) { - float totalProgress = 0; - for (float progress : downloadProgress.values()) { - totalProgress += progress / downloadProgress.size(); - } - return totalProgress; - } - - @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) - public void onEventAsync(final PartProgressEvent event) { - if (downloadProgress.containsKey(event.attachment)) { - downloadProgress.put(event.attachment, ((float) event.progress) / event.total); - progressWheel.setInstantProgress(calculateProgress(downloadProgress)); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TransferControlView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/TransferControlView.kt new file mode 100644 index 0000000000..03604079a5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/TransferControlView.kt @@ -0,0 +1,182 @@ +package org.thoughtcrime.securesms.components + +import android.animation.LayoutTransition +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.FrameLayout +import android.widget.TextView +import androidx.core.content.ContextCompat +import com.annimon.stream.Stream +import com.pnikosis.materialishprogress.ProgressWheel +import kotlin.math.max +import network.loki.messenger.R +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.session.libsession.messaging.sending_receiving.attachments.Attachment +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress +import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY +import org.session.libsession.utilities.ViewUtil +import org.thoughtcrime.securesms.events.PartProgressEvent +import org.thoughtcrime.securesms.mms.Slide +import org.thoughtcrime.securesms.ui.getSubbedString + +class TransferControlView @JvmOverloads constructor(context: Context?, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : FrameLayout(context!!, attrs, defStyleAttr) { + private var slides: List? = null + private var current: View? = null + + private val progressWheel: ProgressWheel + private val downloadDetails: View + private val downloadDetailsText: TextView + private val downloadProgress: MutableMap + + init { + inflate(context, R.layout.transfer_controls_view, this) + + isLongClickable = false + ViewUtil.setBackground(this, ContextCompat.getDrawable(context!!, R.drawable.transfer_controls_background)) + visibility = GONE + layoutTransition = LayoutTransition() + + this.downloadProgress = HashMap() + this.progressWheel = ViewUtil.findById(this, R.id.progress_wheel) + this.downloadDetails = ViewUtil.findById(this, R.id.download_details) + this.downloadDetailsText = ViewUtil.findById(this, R.id.download_details_text) + } + + override fun setFocusable(focusable: Boolean) { + super.setFocusable(focusable) + downloadDetails.isFocusable = focusable + } + + override fun setClickable(clickable: Boolean) { + super.setClickable(clickable) + downloadDetails.isClickable = clickable + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + EventBus.getDefault().unregister(this) + } + + private fun setSlides(slides: List) { + require(slides.isNotEmpty()) { "Must provide at least one slide." } + + this.slides = slides + + if (!isUpdateToExistingSet(slides)) { + downloadProgress.clear() + Stream.of(slides).forEach { s: Slide -> downloadProgress[s.asAttachment()] = 0f } + } + + for (slide in slides) { + if (slide.asAttachment().transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE) { + downloadProgress[slide.asAttachment()] = 1f + } + } + + when (getTransferState(slides)) { + AttachmentTransferProgress.TRANSFER_PROGRESS_STARTED -> showProgressSpinner(calculateProgress(downloadProgress)) + AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING, AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED -> { + downloadDetailsText.text = getDownloadText(this.slides!!) + display(downloadDetails) + } + + else -> display(null) + } + } + + @JvmOverloads + fun showProgressSpinner(progress: Float = calculateProgress(downloadProgress)) { + if (progress == 0f) { + progressWheel.spin() + } else { + progressWheel.setInstantProgress(progress) + } + display(progressWheel) + } + + fun clear() { + clearAnimation() + visibility = GONE + if (current != null) { + current!!.clearAnimation() + current!!.visibility = GONE + } + current = null + slides = null + } + + private fun isUpdateToExistingSet(slides: List): Boolean { + if (slides.size != downloadProgress.size) { + return false + } + + for (slide in slides) { + if (!downloadProgress.containsKey(slide.asAttachment())) { + return false + } + } + + return true + } + + private fun getTransferState(slides: List): Int { + var transferState = AttachmentTransferProgress.TRANSFER_PROGRESS_DONE + for (slide in slides) { + transferState = if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING && transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE) { + slide.transferState + } else { + max(transferState.toDouble(), slide.transferState.toDouble()).toInt() + } + } + return transferState + } + + private fun getDownloadText(slides: List): String { + if (slides.size == 1) { + return slides[0].contentDescription + } else { + val downloadCount = Stream.of(slides).reduce(0) { count: Int, slide: Slide -> + if (slide.transferState != AttachmentTransferProgress.TRANSFER_PROGRESS_DONE) count + 1 else count + } + return context.getSubbedString(R.string.andMore, COUNT_KEY to downloadCount.toString()) + } + } + + private fun display(view: View?) { + if (current != null) { + current!!.visibility = GONE + } + + if (view != null) { + view.visibility = VISIBLE + } else { + visibility = GONE + } + + current = view + } + + private fun calculateProgress(downloadProgress: Map): Float { + var totalProgress = 0f + for (progress in downloadProgress.values) { + totalProgress += progress / downloadProgress.size + } + return totalProgress + } + + @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) + fun onEventAsync(event: PartProgressEvent) { + if (downloadProgress.containsKey(event.attachment)) { + downloadProgress[event.attachment] = event.progress.toFloat() / event.total + progressWheel.setInstantProgress(calculateProgress(downloadProgress)) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPages.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPages.java index 78d085fb71..dddcb56a8e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPages.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPages.java @@ -1,13 +1,9 @@ package org.thoughtcrime.securesms.components.emoji; import android.net.Uri; - import network.loki.messenger.R; -import org.session.libsignal.utilities.Pair; import org.thoughtcrime.securesms.emoji.EmojiCategory; - import java.util.Arrays; -import java.util.LinkedList; import java.util.List; class EmojiPages { @@ -58,24 +54,6 @@ class EmojiPages { new Emoji("\ud83c\udfc1"), new Emoji("\ud83d\udea9"), new Emoji("\ud83c\udf8c"), new Emoji("\ud83c\udff4"), new Emoji("\ud83c\udff3\ufe0f"), new Emoji("\ud83c\udff3\ufe0f\u200d\ud83c\udf08"), new Emoji("\ud83c\udde6\ud83c\udde8"), new Emoji("\ud83c\udde6\ud83c\udde9"), new Emoji("\ud83c\udde6\ud83c\uddea"), new Emoji("\ud83c\udde6\ud83c\uddeb"), new Emoji("\ud83c\udde6\ud83c\uddec"), new Emoji("\ud83c\udde6\ud83c\uddee"), new Emoji("\ud83c\udde6\ud83c\uddf1"), new Emoji("\ud83c\udde6\ud83c\uddf2"), new Emoji("\ud83c\udde6\ud83c\uddf4"), new Emoji("\ud83c\udde6\ud83c\uddf6"), new Emoji("\ud83c\udde6\ud83c\uddf7"), new Emoji("\ud83c\udde6\ud83c\uddf8"), new Emoji("\ud83c\udde6\ud83c\uddf9"), new Emoji("\ud83c\udde6\ud83c\uddfa"), new Emoji("\ud83c\udde6\ud83c\uddfc"), new Emoji("\ud83c\udde6\ud83c\uddfd"), new Emoji("\ud83c\udde6\ud83c\uddff"), new Emoji("\ud83c\udde7\ud83c\udde6"), new Emoji("\ud83c\udde7\ud83c\udde7"), new Emoji("\ud83c\udde7\ud83c\udde9"), new Emoji("\ud83c\udde7\ud83c\uddea"), new Emoji("\ud83c\udde7\ud83c\uddeb"), new Emoji("\ud83c\udde7\ud83c\uddec"), new Emoji("\ud83c\udde7\ud83c\udded"), new Emoji("\ud83c\udde7\ud83c\uddee"), new Emoji("\ud83c\udde7\ud83c\uddef"), new Emoji("\ud83c\udde7\ud83c\uddf1"), new Emoji("\ud83c\udde7\ud83c\uddf2"), new Emoji("\ud83c\udde7\ud83c\uddf3"), new Emoji("\ud83c\udde7\ud83c\uddf4"), new Emoji("\ud83c\udde7\ud83c\uddf6"), new Emoji("\ud83c\udde7\ud83c\uddf7"), new Emoji("\ud83c\udde7\ud83c\uddf8"), new Emoji("\ud83c\udde7\ud83c\uddf9"), new Emoji("\ud83c\udde7\ud83c\uddfb"), new Emoji("\ud83c\udde7\ud83c\uddfc"), new Emoji("\ud83c\udde7\ud83c\uddfe"), new Emoji("\ud83c\udde7\ud83c\uddff"), new Emoji("\ud83c\udde8\ud83c\udde6"), new Emoji("\ud83c\udde8\ud83c\udde8"), new Emoji("\ud83c\udde8\ud83c\udde9"), new Emoji("\ud83c\udde8\ud83c\uddeb"), new Emoji("\ud83c\udde8\ud83c\uddec"), new Emoji("\ud83c\udde8\ud83c\udded"), new Emoji("\ud83c\udde8\ud83c\uddee"), new Emoji("\ud83c\udde8\ud83c\uddf0"), new Emoji("\ud83c\udde8\ud83c\uddf1"), new Emoji("\ud83c\udde8\ud83c\uddf2"), new Emoji("\ud83c\udde8\ud83c\uddf3"), new Emoji("\ud83c\udde8\ud83c\uddf4"), new Emoji("\ud83c\udde8\ud83c\uddf5"), new Emoji("\ud83c\udde8\ud83c\uddf7"), new Emoji("\ud83c\udde8\ud83c\uddfa"), new Emoji("\ud83c\udde8\ud83c\uddfb"), new Emoji("\ud83c\udde8\ud83c\uddfc"), new Emoji("\ud83c\udde8\ud83c\uddfd"), new Emoji("\ud83c\udde8\ud83c\uddfe"), new Emoji("\ud83c\udde8\ud83c\uddff"), new Emoji("\ud83c\udde9\ud83c\uddea"), new Emoji("\ud83c\udde9\ud83c\uddec"), new Emoji("\ud83c\udde9\ud83c\uddef"), new Emoji("\ud83c\udde9\ud83c\uddf0"), new Emoji("\ud83c\udde9\ud83c\uddf2"), new Emoji("\ud83c\udde9\ud83c\uddf4"), new Emoji("\ud83c\udde9\ud83c\uddff"), new Emoji("\ud83c\uddea\ud83c\udde6"), new Emoji("\ud83c\uddea\ud83c\udde8"), new Emoji("\ud83c\uddea\ud83c\uddea"), new Emoji("\ud83c\uddea\ud83c\uddec"), new Emoji("\ud83c\uddea\ud83c\udded"), new Emoji("\ud83c\uddea\ud83c\uddf7"), new Emoji("\ud83c\uddea\ud83c\uddf8"), new Emoji("\ud83c\uddea\ud83c\uddf9"), new Emoji("\ud83c\uddea\ud83c\uddfa"), new Emoji("\ud83c\uddeb\ud83c\uddee"), new Emoji("\ud83c\uddeb\ud83c\uddef"), new Emoji("\ud83c\uddeb\ud83c\uddf0"), new Emoji("\ud83c\uddeb\ud83c\uddf2"), new Emoji("\ud83c\uddeb\ud83c\uddf4"), new Emoji("\ud83c\uddeb\ud83c\uddf7"), new Emoji("\ud83c\uddec\ud83c\udde6"), new Emoji("\ud83c\uddec\ud83c\udde7"), new Emoji("\ud83c\uddec\ud83c\udde9"), new Emoji("\ud83c\uddec\ud83c\uddea"), new Emoji("\ud83c\uddec\ud83c\uddeb"), new Emoji("\ud83c\uddec\ud83c\uddec"), new Emoji("\ud83c\uddec\ud83c\udded"), new Emoji("\ud83c\uddec\ud83c\uddee"), new Emoji("\ud83c\uddec\ud83c\uddf1"), new Emoji("\ud83c\uddec\ud83c\uddf2"), new Emoji("\ud83c\uddec\ud83c\uddf3"), new Emoji("\ud83c\uddec\ud83c\uddf5"), new Emoji("\ud83c\uddec\ud83c\uddf6"), new Emoji("\ud83c\uddec\ud83c\uddf7"), new Emoji("\ud83c\uddec\ud83c\uddf8"), new Emoji("\ud83c\uddec\ud83c\uddf9"), new Emoji("\ud83c\uddec\ud83c\uddfa"), new Emoji("\ud83c\uddec\ud83c\uddfc"), new Emoji("\ud83c\uddec\ud83c\uddfe"), new Emoji("\ud83c\udded\ud83c\uddf0"), new Emoji("\ud83c\udded\ud83c\uddf2"), new Emoji("\ud83c\udded\ud83c\uddf3"), new Emoji("\ud83c\udded\ud83c\uddf7"), new Emoji("\ud83c\udded\ud83c\uddf9"), new Emoji("\ud83c\udded\ud83c\uddfa"), new Emoji("\ud83c\uddee\ud83c\udde8"), new Emoji("\ud83c\uddee\ud83c\udde9"), new Emoji("\ud83c\uddee\ud83c\uddea"), new Emoji("\ud83c\uddee\ud83c\uddf1"), new Emoji("\ud83c\uddee\ud83c\uddf2"), new Emoji("\ud83c\uddee\ud83c\uddf3"), new Emoji("\ud83c\uddee\ud83c\uddf4"), new Emoji("\ud83c\uddee\ud83c\uddf6"), new Emoji("\ud83c\uddee\ud83c\uddf7"), new Emoji("\ud83c\uddee\ud83c\uddf8"), new Emoji("\ud83c\uddee\ud83c\uddf9"), new Emoji("\ud83c\uddef\ud83c\uddea"), new Emoji("\ud83c\uddef\ud83c\uddf2"), new Emoji("\ud83c\uddef\ud83c\uddf4"), new Emoji("\ud83c\uddef\ud83c\uddf5"), new Emoji("\ud83c\uddf0\ud83c\uddea"), new Emoji("\ud83c\uddf0\ud83c\uddec"), new Emoji("\ud83c\uddf0\ud83c\udded"), new Emoji("\ud83c\uddf0\ud83c\uddee"), new Emoji("\ud83c\uddf0\ud83c\uddf2"), new Emoji("\ud83c\uddf0\ud83c\uddf3"), new Emoji("\ud83c\uddf0\ud83c\uddf5"), new Emoji("\ud83c\uddf0\ud83c\uddf7"), new Emoji("\ud83c\uddf0\ud83c\uddfc"), new Emoji("\ud83c\uddf0\ud83c\uddfe"), new Emoji("\ud83c\uddf0\ud83c\uddff"), new Emoji("\ud83c\uddf1\ud83c\udde6"), new Emoji("\ud83c\uddf1\ud83c\udde7"), new Emoji("\ud83c\uddf1\ud83c\udde8"), new Emoji("\ud83c\uddf1\ud83c\uddee"), new Emoji("\ud83c\uddf1\ud83c\uddf0"), new Emoji("\ud83c\uddf1\ud83c\uddf7"), new Emoji("\ud83c\uddf1\ud83c\uddf8"), new Emoji("\ud83c\uddf1\ud83c\uddf9"), new Emoji("\ud83c\uddf1\ud83c\uddfa"), new Emoji("\ud83c\uddf1\ud83c\uddfb"), new Emoji("\ud83c\uddf1\ud83c\uddfe"), new Emoji("\ud83c\uddf2\ud83c\udde6"), new Emoji("\ud83c\uddf2\ud83c\udde8"), new Emoji("\ud83c\uddf2\ud83c\udde9"), new Emoji("\ud83c\uddf2\ud83c\uddea"), new Emoji("\ud83c\uddf2\ud83c\uddeb"), new Emoji("\ud83c\uddf2\ud83c\uddec"), new Emoji("\ud83c\uddf2\ud83c\udded"), new Emoji("\ud83c\uddf2\ud83c\uddf0"), new Emoji("\ud83c\uddf2\ud83c\uddf1"), new Emoji("\ud83c\uddf2\ud83c\uddf2"), new Emoji("\ud83c\uddf2\ud83c\uddf3"), new Emoji("\ud83c\uddf2\ud83c\uddf4"), new Emoji("\ud83c\uddf2\ud83c\uddf5"), new Emoji("\ud83c\uddf2\ud83c\uddf6"), new Emoji("\ud83c\uddf2\ud83c\uddf7"), new Emoji("\ud83c\uddf2\ud83c\uddf8"), new Emoji("\ud83c\uddf2\ud83c\uddf9"), new Emoji("\ud83c\uddf2\ud83c\uddfa"), new Emoji("\ud83c\uddf2\ud83c\uddfb"), new Emoji("\ud83c\uddf2\ud83c\uddfc"), new Emoji("\ud83c\uddf2\ud83c\uddfd"), new Emoji("\ud83c\uddf2\ud83c\uddfe"), new Emoji("\ud83c\uddf2\ud83c\uddff"), new Emoji("\ud83c\uddf3\ud83c\udde6"), new Emoji("\ud83c\uddf3\ud83c\udde8"), new Emoji("\ud83c\uddf3\ud83c\uddea"), new Emoji("\ud83c\uddf3\ud83c\uddeb"), new Emoji("\ud83c\uddf3\ud83c\uddec"), new Emoji("\ud83c\uddf3\ud83c\uddee"), new Emoji("\ud83c\uddf3\ud83c\uddf1"), new Emoji("\ud83c\uddf3\ud83c\uddf4"), new Emoji("\ud83c\uddf3\ud83c\uddf5"), new Emoji("\ud83c\uddf3\ud83c\uddf7"), new Emoji("\ud83c\uddf3\ud83c\uddfa"), new Emoji("\ud83c\uddf3\ud83c\uddff"), new Emoji("\ud83c\uddf4\ud83c\uddf2"), new Emoji("\ud83c\uddf5\ud83c\udde6"), new Emoji("\ud83c\uddf5\ud83c\uddea"), new Emoji("\ud83c\uddf5\ud83c\uddeb"), new Emoji("\ud83c\uddf5\ud83c\uddec"), new Emoji("\ud83c\uddf5\ud83c\udded"), new Emoji("\ud83c\uddf5\ud83c\uddf0"), new Emoji("\ud83c\uddf5\ud83c\uddf1"), new Emoji("\ud83c\uddf5\ud83c\uddf2"), new Emoji("\ud83c\uddf5\ud83c\uddf3"), new Emoji("\ud83c\uddf5\ud83c\uddf7"), new Emoji("\ud83c\uddf5\ud83c\uddf8"), new Emoji("\ud83c\uddf5\ud83c\uddf9"), new Emoji("\ud83c\uddf5\ud83c\uddfc"), new Emoji("\ud83c\uddf5\ud83c\uddfe"), new Emoji("\ud83c\uddf6\ud83c\udde6"), new Emoji("\ud83c\uddf7\ud83c\uddea"), new Emoji("\ud83c\uddf7\ud83c\uddf4"), new Emoji("\ud83c\uddf7\ud83c\uddf8"), new Emoji("\ud83c\uddf7\ud83c\uddfa"), new Emoji("\ud83c\uddf7\ud83c\uddfc"), new Emoji("\ud83c\uddf8\ud83c\udde6"), new Emoji("\ud83c\uddf8\ud83c\udde7"), new Emoji("\ud83c\uddf8\ud83c\udde8"), new Emoji("\ud83c\uddf8\ud83c\udde9"), new Emoji("\ud83c\uddf8\ud83c\uddea"), new Emoji("\ud83c\uddf8\ud83c\uddec"), new Emoji("\ud83c\uddf8\ud83c\udded"), new Emoji("\ud83c\uddf8\ud83c\uddee"), new Emoji("\ud83c\uddf8\ud83c\uddef"), new Emoji("\ud83c\uddf8\ud83c\uddf0"), new Emoji("\ud83c\uddf8\ud83c\uddf1"), new Emoji("\ud83c\uddf8\ud83c\uddf2"), new Emoji("\ud83c\uddf8\ud83c\uddf3"), new Emoji("\ud83c\uddf8\ud83c\uddf4"), new Emoji("\ud83c\uddf8\ud83c\uddf7"), new Emoji("\ud83c\uddf8\ud83c\uddf8"), new Emoji("\ud83c\uddf8\ud83c\uddf9"), new Emoji("\ud83c\uddf8\ud83c\uddfb"), new Emoji("\ud83c\uddf8\ud83c\uddfd"), new Emoji("\ud83c\uddf8\ud83c\uddfe"), new Emoji("\ud83c\uddf8\ud83c\uddff"), new Emoji("\ud83c\uddf9\ud83c\udde6"), new Emoji("\ud83c\uddf9\ud83c\udde8"), new Emoji("\ud83c\uddf9\ud83c\udde9"), new Emoji("\ud83c\uddf9\ud83c\uddeb"), new Emoji("\ud83c\uddf9\ud83c\uddec"), new Emoji("\ud83c\uddf9\ud83c\udded"), new Emoji("\ud83c\uddf9\ud83c\uddef"), new Emoji("\ud83c\uddf9\ud83c\uddf0"), new Emoji("\ud83c\uddf9\ud83c\uddf1"), new Emoji("\ud83c\uddf9\ud83c\uddf2"), new Emoji("\ud83c\uddf9\ud83c\uddf3"), new Emoji("\ud83c\uddf9\ud83c\uddf4"), new Emoji("\ud83c\uddf9\ud83c\uddf7"), new Emoji("\ud83c\uddf9\ud83c\uddf9"), new Emoji("\ud83c\uddf9\ud83c\uddfb"), new Emoji("\ud83c\uddf9\ud83c\uddfc"), new Emoji("\ud83c\uddf9\ud83c\uddff"), new Emoji("\ud83c\uddfa\ud83c\udde6"), new Emoji("\ud83c\uddfa\ud83c\uddec"), new Emoji("\ud83c\uddfa\ud83c\uddf2"), new Emoji("\ud83c\uddfa\ud83c\uddf8"), new Emoji("\ud83c\uddfa\ud83c\uddfe"), new Emoji("\ud83c\uddfa\ud83c\uddff"), new Emoji("\ud83c\uddfb\ud83c\udde6"), new Emoji("\ud83c\uddfb\ud83c\udde8"), new Emoji("\ud83c\uddfb\ud83c\uddea"), new Emoji("\ud83c\uddfb\ud83c\uddec"), new Emoji("\ud83c\uddfb\ud83c\uddee"), new Emoji("\ud83c\uddfb\ud83c\uddf3"), new Emoji("\ud83c\uddfb\ud83c\uddfa"), new Emoji("\ud83c\uddfc\ud83c\uddeb"), new Emoji("\ud83c\uddfc\ud83c\uddf8"), new Emoji("\ud83c\uddfd\ud83c\uddf0"), new Emoji("\ud83c\uddfe\ud83c\uddea"), new Emoji("\ud83c\uddfe\ud83c\uddf9"), new Emoji("\ud83c\uddff\ud83c\udde6"), new Emoji("\ud83c\uddff\ud83c\uddf2"), new Emoji("\ud83c\uddff\ud83c\uddfc"), new Emoji("\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f"), new Emoji("\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f"), new Emoji("\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f") ), Uri.parse("emoji/Flags.png")); - private static final EmojiPageModel PAGE_EMOTICONS = new StaticEmojiPageModel(EmojiCategory.EMOTICONS, new String[] { - ":-)", ";-)", "(-:", ":->", ":-D", "\\o/", - ":-P", "B-)", ":-$", ":-*", "O:-)", "=-O", - "O_O", "O_o", "o_O", ":O", ":-!", ":-x", - ":-|", ":-\\", ":-(", ":'(", ":-[", ">:-(", - "^.^", "^_^", "\\(\u02c6\u02da\u02c6)/", - "\u30fd(\u00b0\u25c7\u00b0 )\u30ce", "\u00af\\(\u00b0_o)/\u00af", - "\u00af\\_(\u30c4)_/\u00af", "(\u00ac_\u00ac)", - "(>_<)", "(\u2565\ufe4f\u2565)", "(\u261e\uff9f\u30ee\uff9f)\u261e", - "\u261c(\uff9f\u30ee\uff9f\u261c)", "\u261c(\u2312\u25bd\u2312)\u261e", - "(\u256f\u00b0\u25a1\u00b0)\u256f\ufe35", "\u253b\u2501\u253b", - "\u252c\u2500\u252c", "\u30ce(\u00b0\u2013\u00b0\u30ce)", - "(^._.^)\uff89", "\u0e05^\u2022\ufecc\u2022^\u0e05", - "\u0295\u2022\u1d25\u2022\u0294", "(\u2022_\u2022)", - " \u25a0-\u25a0\u00ac <(\u2022_\u2022) ", "(\u25a0_\u25a0\u00ac)", - "\u01aa(\u0693\u05f2)\u200e\u01aa\u200b\u200b" - }, null); - static final List DISPLAY_PAGES = Arrays.asList(PAGE_PEOPLE, PAGE_NATURE, PAGE_FOODS, @@ -83,226 +61,7 @@ class EmojiPages { PAGE_PLACES, PAGE_OBJECTS, PAGE_SYMBOLS, - PAGE_FLAGS, - PAGE_EMOTICONS); + PAGE_FLAGS); - static final List DATA_PAGES = Arrays.asList(PAGE_PEOPLE_0, - PAGE_PEOPLE_1, - PAGE_PEOPLE_2, - PAGE_PEOPLE_3, - PAGE_NATURE, - PAGE_FOODS, - PAGE_ACTIVITY, - PAGE_PLACES, - PAGE_OBJECTS, - PAGE_SYMBOLS, - PAGE_FLAGS, - PAGE_EMOTICONS); - static final List> OBSOLETE = new LinkedList>() {{ - add(new Pair<>("\ud83d\udc6e", "\ud83d\udc6e\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc6e\ud83c\udffb", "\ud83d\udc6e\ud83c\udffb\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc6e\ud83c\udffc", "\ud83d\udc6e\ud83c\udffc\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc6e\ud83c\udffd", "\ud83d\udc6e\ud83c\udffd\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc6e\ud83c\udffe", "\ud83d\udc6e\ud83c\udffe\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc6e\ud83c\udfff", "\ud83d\udc6e\ud83c\udfff\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udd75\ufe0f", "\ud83d\udd75\ufe0f\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udd75\ud83c\udffb", "\ud83d\udd75\ud83c\udffb\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udd75\ud83c\udffc", "\ud83d\udd75\ud83c\udffc\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udd75\ud83c\udffd", "\ud83d\udd75\ud83c\udffd\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udd75\ud83c\udffe", "\ud83d\udd75\ud83c\udffe\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udd75\ud83c\udfff", "\ud83d\udd75\ud83c\udfff\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc82", "\ud83d\udc82\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc82\ud83c\udffb", "\ud83d\udc82\ud83c\udffb\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc82\ud83c\udffc", "\ud83d\udc82\ud83c\udffc\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc82\ud83c\udffd", "\ud83d\udc82\ud83c\udffd\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc82\ud83c\udffe", "\ud83d\udc82\ud83c\udffe\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc82\ud83c\udfff", "\ud83d\udc82\ud83c\udfff\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc77", "\ud83d\udc77\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc77\ud83c\udffb", "\ud83d\udc77\ud83c\udffb\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc77\ud83c\udffc", "\ud83d\udc77\ud83c\udffc\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc77\ud83c\udffd", "\ud83d\udc77\ud83c\udffd\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc77\ud83c\udffe", "\ud83d\udc77\ud83c\udffe\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc77\ud83c\udfff", "\ud83d\udc77\ud83c\udfff\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc73", "\ud83d\udc73\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc73\ud83c\udffb", "\ud83d\udc73\ud83c\udffb\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc73\ud83c\udffc", "\ud83d\udc73\ud83c\udffc\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc73\ud83c\udffd", "\ud83d\udc73\ud83c\udffd\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc73\ud83c\udffe", "\ud83d\udc73\ud83c\udffe\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc73\ud83c\udfff", "\ud83d\udc73\ud83c\udfff\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc71", "\ud83d\udc71\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc71\ud83c\udffb", "\ud83d\udc71\ud83c\udffb\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc71\ud83c\udffc", "\ud83d\udc71\ud83c\udffc\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc71\ud83c\udffd", "\ud83d\udc71\ud83c\udffd\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc71\ud83c\udffe", "\ud83d\udc71\ud83c\udffe\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc71\ud83c\udfff", "\ud83d\udc71\ud83c\udfff\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83e\uddd9", "\ud83e\uddd9\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddd9\ud83c\udffb", "\ud83e\uddd9\ud83c\udffb\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddd9\ud83c\udffc", "\ud83e\uddd9\ud83c\udffc\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddd9\ud83c\udffd", "\ud83e\uddd9\ud83c\udffd\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddd9\ud83c\udffe", "\ud83e\uddd9\ud83c\udffe\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddd9\ud83c\udfff", "\ud83e\uddd9\ud83c\udfff\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddda", "\ud83e\uddda\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddda\ud83c\udffb", "\ud83e\uddda\ud83c\udffb\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddda\ud83c\udffc", "\ud83e\uddda\ud83c\udffc\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddda\ud83c\udffd", "\ud83e\uddda\ud83c\udffd\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddda\ud83c\udffe", "\ud83e\uddda\ud83c\udffe\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddda\ud83c\udfff", "\ud83e\uddda\ud83c\udfff\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\udddb", "\ud83e\udddb\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\udddb\ud83c\udffb", "\ud83e\udddb\ud83c\udffb\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\udddb\ud83c\udffc", "\ud83e\udddb\ud83c\udffc\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\udddb\ud83c\udffd", "\ud83e\udddb\ud83c\udffd\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\udddb\ud83c\udffe", "\ud83e\udddb\ud83c\udffe\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\udddb\ud83c\udfff", "\ud83e\udddb\ud83c\udfff\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\udddc", "\ud83e\udddc\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83e\udddc\ud83c\udffb", "\ud83e\udddc\ud83c\udffb\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83e\udddc\ud83c\udffc", "\ud83e\udddc\ud83c\udffc\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83e\udddc\ud83c\udffd", "\ud83e\udddc\ud83c\udffd\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83e\udddc\ud83c\udffe", "\ud83e\udddc\ud83c\udffe\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83e\udddc\ud83c\udfff", "\ud83e\udddc\ud83c\udfff\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83e\udddd", "\ud83e\udddd\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83e\udddd\ud83c\udffb", "\ud83e\udddd\ud83c\udffb\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83e\udddd\ud83c\udffc", "\ud83e\udddd\ud83c\udffc\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83e\udddd\ud83c\udffd", "\ud83e\udddd\ud83c\udffd\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83e\udddd\ud83c\udffe", "\ud83e\udddd\ud83c\udffe\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83e\udddd\ud83c\udfff", "\ud83e\udddd\ud83c\udfff\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83e\uddde", "\ud83e\uddde\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83e\udddf", "\ud83e\udddf\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\ude4d", "\ud83d\ude4d\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude4d\ud83c\udffb", "\ud83d\ude4d\ud83c\udffb\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude4d\ud83c\udffc", "\ud83d\ude4d\ud83c\udffc\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude4d\ud83c\udffd", "\ud83d\ude4d\ud83c\udffd\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude4d\ud83c\udffe", "\ud83d\ude4d\ud83c\udffe\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude4d\ud83c\udfff", "\ud83d\ude4d\ud83c\udfff\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude4e", "\ud83d\ude4e\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude4e\ud83c\udffb", "\ud83d\ude4e\ud83c\udffb\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude4e\ud83c\udffc", "\ud83d\ude4e\ud83c\udffc\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude4e\ud83c\udffd", "\ud83d\ude4e\ud83c\udffd\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude4e\ud83c\udffe", "\ud83d\ude4e\ud83c\udffe\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude4e\ud83c\udfff", "\ud83d\ude4e\ud83c\udfff\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude45", "\ud83d\ude45\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude45\ud83c\udffb", "\ud83d\ude45\ud83c\udffb\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude45\ud83c\udffc", "\ud83d\ude45\ud83c\udffc\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude45\ud83c\udffd", "\ud83d\ude45\ud83c\udffd\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude45\ud83c\udffe", "\ud83d\ude45\ud83c\udffe\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude45\ud83c\udfff", "\ud83d\ude45\ud83c\udfff\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude46", "\ud83d\ude46\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude46\ud83c\udffb", "\ud83d\ude46\ud83c\udffb\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude46\ud83c\udffc", "\ud83d\ude46\ud83c\udffc\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude46\ud83c\udffd", "\ud83d\ude46\ud83c\udffd\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude46\ud83c\udffe", "\ud83d\ude46\ud83c\udffe\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude46\ud83c\udfff", "\ud83d\ude46\ud83c\udfff\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\udc81", "\ud83d\udc81\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\udc81\ud83c\udffb", "\ud83d\udc81\ud83c\udffb\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\udc81\ud83c\udffc", "\ud83d\udc81\ud83c\udffc\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\udc81\ud83c\udffd", "\ud83d\udc81\ud83c\udffd\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\udc81\ud83c\udffe", "\ud83d\udc81\ud83c\udffe\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\udc81\ud83c\udfff", "\ud83d\udc81\ud83c\udfff\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude4b", "\ud83d\ude4b\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude4b\ud83c\udffb", "\ud83d\ude4b\ud83c\udffb\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude4b\ud83c\udffc", "\ud83d\ude4b\ud83c\udffc\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude4b\ud83c\udffd", "\ud83d\ude4b\ud83c\udffd\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude4b\ud83c\udffe", "\ud83d\ude4b\ud83c\udffe\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude4b\ud83c\udfff", "\ud83d\ude4b\ud83c\udfff\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\ude47", "\ud83d\ude47\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\ude47\ud83c\udffb", "\ud83d\ude47\ud83c\udffb\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\ude47\ud83c\udffc", "\ud83d\ude47\ud83c\udffc\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\ude47\ud83c\udffd", "\ud83d\ude47\ud83c\udffd\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\ude47\ud83c\udffe", "\ud83d\ude47\ud83c\udffe\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\ude47\ud83c\udfff", "\ud83d\ude47\ud83c\udfff\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc86", "\ud83d\udc86\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\udc86\ud83c\udffb", "\ud83d\udc86\ud83c\udffb\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\udc86\ud83c\udffc", "\ud83d\udc86\ud83c\udffc\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\udc86\ud83c\udffd", "\ud83d\udc86\ud83c\udffd\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\udc86\ud83c\udffe", "\ud83d\udc86\ud83c\udffe\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\udc86\ud83c\udfff", "\ud83d\udc86\ud83c\udfff\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\udc87", "\ud83d\udc87\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\udc87\ud83c\udffb", "\ud83d\udc87\ud83c\udffb\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\udc87\ud83c\udffc", "\ud83d\udc87\ud83c\udffc\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\udc87\ud83c\udffd", "\ud83d\udc87\ud83c\udffd\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\udc87\ud83c\udffe", "\ud83d\udc87\ud83c\udffe\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\udc87\ud83c\udfff", "\ud83d\udc87\ud83c\udfff\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83d\udeb6", "\ud83d\udeb6\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udeb6\ud83c\udffb", "\ud83d\udeb6\ud83c\udffb\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udeb6\ud83c\udffc", "\ud83d\udeb6\ud83c\udffc\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udeb6\ud83c\udffd", "\ud83d\udeb6\ud83c\udffd\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udeb6\ud83c\udffe", "\ud83d\udeb6\ud83c\udffe\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udeb6\ud83c\udfff", "\ud83d\udeb6\ud83c\udfff\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfc3", "\ud83c\udfc3\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfc3\ud83c\udffb", "\ud83c\udfc3\ud83c\udffb\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfc3\ud83c\udffc", "\ud83c\udfc3\ud83c\udffc\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfc3\ud83c\udffd", "\ud83c\udfc3\ud83c\udffd\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfc3\ud83c\udffe", "\ud83c\udfc3\ud83c\udffe\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfc3\ud83c\udfff", "\ud83c\udfc3\ud83c\udfff\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc6f", "\ud83d\udc6f\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddd6", "\ud83e\uddd6\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83e\uddd6\ud83c\udffb", "\ud83e\uddd6\ud83c\udffb\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83e\uddd6\ud83c\udffc", "\ud83e\uddd6\ud83c\udffc\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83e\uddd6\ud83c\udffd", "\ud83e\uddd6\ud83c\udffd\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83e\uddd6\ud83c\udffe", "\ud83e\uddd6\ud83c\udffe\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83e\uddd6\ud83c\udfff", "\ud83e\uddd6\ud83c\udfff\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83e\uddd7", "\ud83e\uddd7\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddd7\ud83c\udffb", "\ud83e\uddd7\ud83c\udffb\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddd7\ud83c\udffc", "\ud83e\uddd7\ud83c\udffc\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddd7\ud83c\udffd", "\ud83e\uddd7\ud83c\udffd\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddd7\ud83c\udffe", "\ud83e\uddd7\ud83c\udffe\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddd7\ud83c\udfff", "\ud83e\uddd7\ud83c\udfff\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddd8", "\ud83e\uddd8\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddd8\ud83c\udffb", "\ud83e\uddd8\ud83c\udffb\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddd8\ud83c\udffc", "\ud83e\uddd8\ud83c\udffc\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddd8\ud83c\udffd", "\ud83e\uddd8\ud83c\udffd\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddd8\ud83c\udffe", "\ud83e\uddd8\ud83c\udffe\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83e\uddd8\ud83c\udfff", "\ud83e\uddd8\ud83c\udfff\u200d\u2640\ufe0f")); - add(new Pair<>("\ud83c\udfcc\ufe0f", "\ud83c\udfcc\ufe0f\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfcc\ud83c\udffb", "\ud83c\udfcc\ud83c\udffb\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfcc\ud83c\udffc", "\ud83c\udfcc\ud83c\udffc\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfcc\ud83c\udffd", "\ud83c\udfcc\ud83c\udffd\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfcc\ud83c\udffe", "\ud83c\udfcc\ud83c\udffe\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfcc\ud83c\udfff", "\ud83c\udfcc\ud83c\udfff\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfc4", "\ud83c\udfc4\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfc4\ud83c\udffb", "\ud83c\udfc4\ud83c\udffb\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfc4\ud83c\udffc", "\ud83c\udfc4\ud83c\udffc\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfc4\ud83c\udffd", "\ud83c\udfc4\ud83c\udffd\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfc4\ud83c\udffe", "\ud83c\udfc4\ud83c\udffe\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfc4\ud83c\udfff", "\ud83c\udfc4\ud83c\udfff\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udea3", "\ud83d\udea3\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udea3\ud83c\udffb", "\ud83d\udea3\ud83c\udffb\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udea3\ud83c\udffc", "\ud83d\udea3\ud83c\udffc\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udea3\ud83c\udffd", "\ud83d\udea3\ud83c\udffd\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udea3\ud83c\udffe", "\ud83d\udea3\ud83c\udffe\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udea3\ud83c\udfff", "\ud83d\udea3\ud83c\udfff\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfca", "\ud83c\udfca\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfca\ud83c\udffb", "\ud83c\udfca\ud83c\udffb\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfca\ud83c\udffc", "\ud83c\udfca\ud83c\udffc\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfca\ud83c\udffd", "\ud83c\udfca\ud83c\udffd\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfca\ud83c\udffe", "\ud83c\udfca\ud83c\udffe\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfca\ud83c\udfff", "\ud83c\udfca\ud83c\udfff\u200d\u2642\ufe0f")); - add(new Pair<>("\u26f9\ufe0f", "\u26f9\ufe0f\u200d\u2642\ufe0f")); - add(new Pair<>("\u26f9\ud83c\udffb", "\u26f9\ud83c\udffb\u200d\u2642\ufe0f")); - add(new Pair<>("\u26f9\ud83c\udffc", "\u26f9\ud83c\udffc\u200d\u2642\ufe0f")); - add(new Pair<>("\u26f9\ud83c\udffd", "\u26f9\ud83c\udffd\u200d\u2642\ufe0f")); - add(new Pair<>("\u26f9\ud83c\udffe", "\u26f9\ud83c\udffe\u200d\u2642\ufe0f")); - add(new Pair<>("\u26f9\ud83c\udfff", "\u26f9\ud83c\udfff\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfcb\ufe0f", "\ud83c\udfcb\ufe0f\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfcb\ud83c\udffb", "\ud83c\udfcb\ud83c\udffb\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfcb\ud83c\udffc", "\ud83c\udfcb\ud83c\udffc\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfcb\ud83c\udffd", "\ud83c\udfcb\ud83c\udffd\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfcb\ud83c\udffe", "\ud83c\udfcb\ud83c\udffe\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83c\udfcb\ud83c\udfff", "\ud83c\udfcb\ud83c\udfff\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udeb4", "\ud83d\udeb4\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udeb4\ud83c\udffb", "\ud83d\udeb4\ud83c\udffb\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udeb4\ud83c\udffc", "\ud83d\udeb4\ud83c\udffc\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udeb4\ud83c\udffd", "\ud83d\udeb4\ud83c\udffd\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udeb4\ud83c\udffe", "\ud83d\udeb4\ud83c\udffe\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udeb4\ud83c\udfff", "\ud83d\udeb4\ud83c\udfff\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udeb5", "\ud83d\udeb5\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udeb5\ud83c\udffb", "\ud83d\udeb5\ud83c\udffb\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udeb5\ud83c\udffc", "\ud83d\udeb5\ud83c\udffc\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udeb5\ud83c\udffd", "\ud83d\udeb5\ud83c\udffd\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udeb5\ud83c\udffe", "\ud83d\udeb5\ud83c\udffe\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udeb5\ud83c\udfff", "\ud83d\udeb5\ud83c\udfff\u200d\u2642\ufe0f")); - add(new Pair<>("\ud83d\udc8f", "\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68")); - add(new Pair<>("\ud83d\udc91", "\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc68")); - add(new Pair<>("\ud83d\udc6a", "\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66")); - }}; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java index d560247fb9..01317bc9b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java @@ -73,7 +73,7 @@ public List getNumbersForThreadSearchFilter(Context context, String cons } } -// if (context.getString(R.string.note_to_self).toLowerCase().contains(constraint.toLowerCase()) && +// if (context.getString(R.string.noteToSelf).toLowerCase().contains(constraint.toLowerCase()) && // !numberList.contains(TextSecurePreferences.getLocalNumber(context))) // { // numberList.add(TextSecurePreferences.getLocalNumber(context)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt index 90e0ce50f2..0db2ec8962 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt @@ -36,7 +36,7 @@ class ContactSelectionListLoader(context: Context, val mode: Int, val filter: St list.addAll(getClosedGroups(contacts)) } if (isFlagSet(DisplayMode.FLAG_OPEN_GROUPS)) { - list.addAll(getOpenGroups(contacts)) + list.addAll(getCommunities(contacts)) } if (isFlagSet(DisplayMode.FLAG_CONTACTS)) { list.addAll(getContacts(contacts)) @@ -45,19 +45,19 @@ class ContactSelectionListLoader(context: Context, val mode: Int, val filter: St } private fun getContacts(contacts: List): List { - return getItems(contacts, context.getString(R.string.fragment_contact_selection_contacts_title)) { + return getItems(contacts, context.getString(R.string.contactContacts)) { !it.isGroupRecipient } } private fun getClosedGroups(contacts: List): List { - return getItems(contacts, context.getString(R.string.fragment_contact_selection_closed_groups_title)) { + return getItems(contacts, context.getString(R.string.conversationsGroups)) { it.address.isClosedGroup } } - private fun getOpenGroups(contacts: List): List { - return getItems(contacts, context.getString(R.string.fragment_contact_selection_open_groups_title)) { + private fun getCommunities(contacts: List): List { + return getItems(contacts, context.getString(R.string.conversationsCommunities)) { it.address.isCommunity } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactUtil.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactUtil.java index 5284fb0015..046c20002d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactUtil.java @@ -18,10 +18,10 @@ public final class ContactUtil { String contactName = ContactUtil.getDisplayName(contact); if (!TextUtils.isEmpty(contactName)) { - return context.getString(R.string.MessageNotifier_contact_message, EmojiStrings.BUST_IN_SILHOUETTE, contactName); + return EmojiStrings.BUST_IN_SILHOUETTE + " " + contactName; } - return SpanUtil.italic(context.getString(R.string.MessageNotifier_unknown_contact_message)); + return SpanUtil.italic(context.getString(R.string.unknown)); } private static @NonNull String getDisplayName(@Nullable Contact contact) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java index e1ea0c5e2e..83084d2673 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java @@ -159,7 +159,7 @@ private Cursor getContactsHeaderCursor() { private Cursor getGroupsHeaderCursor() { MatrixCursor groupHeader = new MatrixCursor(CONTACT_PROJECTION, 1); - groupHeader.addRow(new Object[]{ getContext().getString(R.string.ContactsCursorLoader_groups), + groupHeader.addRow(new Object[]{ getContext().getString(R.string.conversationsGroups), "", ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, "", @@ -221,16 +221,6 @@ private Cursor getGroupsCursor() { return groupContacts; } - private Cursor getNewNumberCursor() { - MatrixCursor newNumberCursor = new MatrixCursor(CONTACT_PROJECTION, 1); - newNumberCursor.addRow(new Object[] { getContext().getString(R.string.contact_selection_list__unknown_contact), - filter, - ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM, - "\u21e2", - NEW_TYPE }); - return newNumberCursor; - } - private static boolean isCursorListEmpty(List list) { int sum = 0; for (Cursor cursor : list) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsActivity.kt index 1160ed92ab..a3fd6ac1dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsActivity.kt @@ -35,7 +35,7 @@ class SelectContactsActivity : PassphraseRequiredActionBarActivity(), LoaderMana super.onCreate(savedInstanceState, isReady) binding = ActivitySelectContactsBinding.inflate(layoutInflater) setContentView(binding.root) - supportActionBar!!.title = resources.getString(R.string.activity_select_contacts_title) + supportActionBar!!.title = resources.getString(R.string.membersInvite) usersToExclude = intent.getStringArrayExtra(usersToExcludeKey)?.toSet() ?: setOf() val emptyStateText = intent.getStringExtra(emptyStateTextKey) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt index e0ca2a4242..77f579e14d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt @@ -14,7 +14,6 @@ import com.bumptech.glide.RequestManager class UserView : LinearLayout { private lateinit var binding: ViewUserBinding - var openGroupThreadID: Long = -1 // FIXME: This is a bit ugly enum class ActionIndicator { None, @@ -47,11 +46,13 @@ class UserView : LinearLayout { // region Updating fun bind(user: Recipient, glide: RequestManager, actionIndicator: ActionIndicator, isSelected: Boolean = false) { val isLocalUser = user.isLocalNumber + fun getUserDisplayName(publicKey: String): String { - if (isLocalUser) return context.getString(R.string.MessageRecord_you) + if (isLocalUser) return context.getString(R.string.you) val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey) return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey } + val address = user.address.serialize() binding.profilePictureView.update(user) binding.actionIndicatorImageView.setImageResource(R.drawable.ic_baseline_edit_24) @@ -84,8 +85,6 @@ class UserView : LinearLayout { } } - fun unbind() { - binding.profilePictureView.recycle() - } + fun unbind() { binding.profilePictureView.recycle() } // endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt index 8f2da7a733..2a88aac463 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt @@ -15,16 +15,16 @@ import dagger.hilt.android.AndroidEntryPoint 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 +import network.loki.messenger.libsession_util.util.ExpiryMode.AfterRead 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.TIME_KEY import org.session.libsession.utilities.modifyLayoutParams import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.LokiAPIDatabase -import org.thoughtcrime.securesms.util.DateUtils -import java.util.Locale +import org.thoughtcrime.securesms.ui.getSubbedString import javax.inject.Inject @AndroidEntryPoint @@ -82,7 +82,7 @@ class ConversationActionBarView @JvmOverloads constructor( fun update(recipient: Recipient, openGroup: OpenGroup? = null, config: ExpirationConfiguration? = null) { binding.profilePictureView.update(recipient) - binding.conversationTitleView.text = recipient.takeUnless { it.isLocalNumber }?.toShortString() ?: context.getString(R.string.note_to_self) + binding.conversationTitleView.text = recipient.takeUnless { it.isLocalNumber }?.toShortString() ?: context.getString(R.string.noteToSelf) updateSubtitle(recipient, openGroup, config) binding.conversationTitleContainer.modifyLayoutParams { @@ -92,37 +92,53 @@ class ConversationActionBarView @JvmOverloads constructor( fun updateSubtitle(recipient: Recipient, openGroup: OpenGroup? = null, config: ExpirationConfiguration? = null) { val settings = mutableListOf() + + // Specify the disappearing messages subtitle if we should if (config?.isEnabled == true) { - val prefix = when (config.expiryMode) { - is ExpiryMode.AfterRead -> R.string.expiration_type_disappear_after_read - else -> R.string.expiration_type_disappear_after_send - }.let(context::getString) + // Get the type of disappearing message and the abbreviated duration.. + val dmTypeString = when (config.expiryMode) { + is AfterRead -> R.string.disappearingMessagesDisappearAfterReadState + else -> R.string.disappearingMessagesDisappearAfterSendState + } + val durationAbbreviated = ExpirationUtil.getExpirationAbbreviatedDisplayValue(config.expiryMode.expirySeconds) + + // ..then substitute into the string.. + val subtitleTxt = context.getSubbedString(dmTypeString, + TIME_KEY to durationAbbreviated + ) + + // .. and apply to the subtitle. settings += ConversationSetting( - "$prefix - ${ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, config.expiryMode.expirySeconds)}", + subtitleTxt, ConversationSettingType.EXPIRATION, R.drawable.ic_timer, - resources.getString(R.string.AccessibilityId_disappearing_messages_type_and_time) + resources.getString(R.string.AccessibilityId_disappearingMessagesDisappear) ) } + if (recipient.isMuted) { settings += ConversationSetting( recipient.mutedUntil.takeUnless { it == Long.MAX_VALUE } - ?.let { context.getString(R.string.ConversationActivity_muted_until_date, DateUtils.getFormattedDateTime(it, "EEE, MMM d, yyyy HH:mm", Locale.getDefault())) } - ?: context.getString(R.string.ConversationActivity_muted_forever), + ?.let { + context.getString(R.string.notificationsMuted) + } + ?: context.getString(R.string.notificationsMuted), ConversationSettingType.NOTIFICATION, R.drawable.ic_outline_notifications_off_24 ) } + if (recipient.isGroupRecipient) { val title = if (recipient.isCommunityRecipient) { val userCount = openGroup?.let { lokiApiDb.getUserCount(it.room, it.server) } ?: 0 - context.getString(R.string.ConversationActivity_active_member_count, userCount) + resources.getQuantityString(R.plurals.membersActive, userCount, userCount) } else { val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size - context.getString(R.string.ConversationActivity_member_count, userCount) + resources.getQuantityString(R.plurals.members, userCount, userCount) } settings += ConversationSetting(title, ConversationSettingType.MEMBER_COUNT) } + settingsAdapter.submitList(settings) binding.settingsTabLayout.isVisible = settings.size > 1 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt index 38da11ae24..e086c95924 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt @@ -12,10 +12,14 @@ import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.ExpirationUtil import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol +import org.session.libsession.utilities.StringSubstitutionConstants.DISAPPEARING_MESSAGES_TYPE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.getExpirationTypeDisplayValue import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.showSessionDialog +import org.thoughtcrime.securesms.ui.getSubbedCharSequence +import org.thoughtcrime.securesms.ui.getSubbedString import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds @@ -43,22 +47,18 @@ class DisappearingMessages @Inject constructor( } fun showFollowSettingDialog(context: Context, message: MessageRecord) = context.showSessionDialog { - title(R.string.dialog_disappearing_messages_follow_setting_title) + title(R.string.disappearingMessagesFollowSetting) text(if (message.expiresIn == 0L) { - context.getString(R.string.dialog_disappearing_messages_follow_setting_off_body) + context.getText(R.string.disappearingMessagesFollowSettingOff) } else { - context.getString( - R.string.dialog_disappearing_messages_follow_setting_on_body, - ExpirationUtil.getExpirationDisplayValue( - context, - message.expiresIn.milliseconds - ), - context.getExpirationTypeDisplayValue(message.isNotDisappearAfterRead) - ) + context.getSubbedCharSequence(R.string.disappearingMessagesFollowSettingOn, + TIME_KEY to ExpirationUtil.getExpirationDisplayValue(context, message.expiresIn.milliseconds), + DISAPPEARING_MESSAGES_TYPE_KEY to context.getExpirationTypeDisplayValue(message.isNotDisappearAfterRead)) }) + dangerButton( - text = if (message.expiresIn == 0L) R.string.dialog_disappearing_messages_follow_setting_confirm else R.string.dialog_disappearing_messages_follow_setting_set, - contentDescription = if (message.expiresIn == 0L) R.string.AccessibilityId_confirm else R.string.AccessibilityId_set_button + text = if (message.expiresIn == 0L) R.string.confirm else R.string.set, + contentDescription = if (message.expiresIn == 0L) R.string.AccessibilityId_confirm else R.string.AccessibilityId_setButton ) { set(message.threadId, message.recipient.address, message.expiryMode, message.recipient.isClosedGroupRecipient) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt index f66512d79f..2716a3d883 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt @@ -52,7 +52,7 @@ class DisappearingMessagesActivity: PassphraseRequiredActionBarActivity() { viewModel.event.collect { when (it) { Event.SUCCESS -> finish() - Event.FAIL -> showToast(getString(R.string.DisappearingMessagesActivity_settings_not_updated)) + Event.FAIL -> showToast(getString(R.string.communityErrorDescription)) } } } @@ -72,9 +72,9 @@ class DisappearingMessagesActivity: PassphraseRequiredActionBarActivity() { } private fun setUpToolbar() { - setSupportActionBar(binding.toolbar) + setSupportActionBar(binding.searchToolbar) supportActionBar?.apply { - title = getString(R.string.activity_disappearing_messages_title) + title = getString(R.string.disappearingMessages) setDisplayHomeAsUpEnabled(true) setHomeButtonEnabled(true) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt index 32e20b73d9..915ff66971 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt @@ -58,7 +58,7 @@ class DisappearingMessagesViewModel( init { viewModelScope.launch { - val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode?.maybeConvertToLegacy(isNewConfigEnabled) ?: ExpiryMode.NONE + val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode ?: ExpiryMode.NONE val recipient = threadDb.getRecipientForThreadId(threadId) val groupRecord = recipient?.takeIf { it.isClosedGroupRecipient } ?.run { groupDb.getGroup(address.toGroupString()).orNull() } @@ -80,7 +80,7 @@ class DisappearingMessagesViewModel( override fun onSetClick() = viewModelScope.launch { val state = _state.value - val mode = state.expiryMode?.coerceLegacyToAfterSend() + val mode = state.expiryMode val address = state.address if (address == null || mode == null) { _event.send(Event.FAIL) @@ -92,8 +92,6 @@ class DisappearingMessagesViewModel( _event.send(Event.SUCCESS) } - private fun ExpiryMode.coerceLegacyToAfterSend() = takeUnless { it is ExpiryMode.Legacy } ?: ExpiryMode.AfterSend(expirySeconds) - @dagger.assisted.AssistedFactory interface AssistedFactory { fun create(threadId: Long): Factory @@ -125,5 +123,3 @@ class DisappearingMessagesViewModel( ) as T } } - -private fun ExpiryMode.maybeConvertToLegacy(isNewConfigEnabled: Boolean): ExpiryMode = takeIf { isNewConfigEnabled } ?: ExpiryMode.Legacy(expirySeconds) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/State.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/State.kt index ced4cc0035..eb4114ab54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/State.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/State.kt @@ -24,22 +24,21 @@ data class State( val showDebugOptions: Boolean = false ) { val subtitle get() = when { - isGroup || isNoteToSelf -> GetString(R.string.activity_disappearing_messages_subtitle_sent) - else -> GetString(R.string.activity_disappearing_messages_subtitle) + isGroup || isNoteToSelf -> GetString(R.string.disappearingMessagesDisappearAfterSendDescription) + else -> GetString(R.string.disappearingMessagesDescription1) } val typeOptionsHidden get() = isNoteToSelf || (isGroup && isNewConfigEnabled) val nextType get() = when { expiryType == ExpiryType.AFTER_READ -> ExpiryType.AFTER_READ - isNewConfigEnabled -> ExpiryType.AFTER_SEND - else -> ExpiryType.LEGACY + else -> ExpiryType.AFTER_SEND } val duration get() = expiryMode?.duration val expiryType get() = expiryMode?.type - val isTimeOptionsEnabled = isNoteToSelf || isSelfAdmin && (isNewConfigEnabled || expiryType == ExpiryType.LEGACY) + val isTimeOptionsEnabled = isNoteToSelf || isSelfAdmin && isNewConfigEnabled } @@ -51,25 +50,20 @@ enum class ExpiryType( ) { NONE( { ExpiryMode.NONE }, - R.string.expiration_off, - contentDescription = R.string.AccessibilityId_disable_disappearing_messages, - ), - LEGACY( - ExpiryMode::Legacy, - R.string.expiration_type_disappear_legacy, - contentDescription = R.string.expiration_type_disappear_legacy_description + R.string.off, + contentDescription = R.string.AccessibilityId_disappearingMessagesOff, ), AFTER_READ( ExpiryMode::AfterRead, - R.string.expiration_type_disappear_after_read, - R.string.expiration_type_disappear_after_read_description, - R.string.AccessibilityId_disappear_after_read_option + R.string.disappearingMessagesDisappearAfterRead, + R.string.disappearingMessagesDisappearAfterReadDescription, + R.string.AccessibilityId_disappearingMessagesDisappearAfterRead ), AFTER_SEND( ExpiryMode::AfterSend, - R.string.expiration_type_disappear_after_send, - R.string.expiration_type_disappear_after_send_description, - R.string.AccessibilityId_disappear_after_send_option + R.string.disappearingMessagesDisappearAfterSend, + R.string.disappearingMessagesDisappearAfterSendDescription, + R.string.AccessibilityId_disappearingMessagesDisappearAfterSent ); fun mode(seconds: Long) = if (seconds != 0L) createMode(seconds) else ExpiryMode.NONE @@ -83,7 +77,6 @@ enum class ExpiryType( } val ExpiryMode.type: ExpiryType get() = when(this) { - is ExpiryMode.Legacy -> ExpiryType.LEGACY is ExpiryMode.AfterSend -> ExpiryType.AFTER_SEND is ExpiryMode.AfterRead -> ExpiryType.AFTER_READ else -> ExpiryType.NONE diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt index d78d33a2f9..d4b3b0602a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt @@ -13,8 +13,8 @@ import kotlin.time.Duration.Companion.seconds fun State.toUiState() = UiState( cards = listOfNotNull( - typeOptions()?.let { ExpiryOptionsCardData(GetString(R.string.activity_disappearing_messages_delete_type), it) }, - timeOptions()?.let { ExpiryOptionsCardData(GetString(R.string.activity_disappearing_messages_timer), it) } + typeOptions()?.let { ExpiryOptionsCardData(GetString(R.string.disappearingMessagesDeleteType), it) }, + timeOptions()?.let { ExpiryOptionsCardData(GetString(R.string.disappearingMessagesTimer), it) } ), showGroupFooter = isGroup && isNewConfigEnabled, showSetButton = isSelfAdmin @@ -23,7 +23,6 @@ fun State.toUiState() = UiState( private fun State.typeOptions(): List? = if (typeOptionsHidden) null else { buildList { add(offTypeOption()) - if (!isNewConfigEnabled) add(legacyTypeOption()) if (!isGroup) add(afterReadTypeOption()) add(afterSendTypeOption()) } @@ -48,7 +47,6 @@ private fun State.timeOptions(): List? { } private fun State.offTypeOption() = typeOption(ExpiryType.NONE) -private fun State.legacyTypeOption() = typeOption(ExpiryType.LEGACY) private fun State.afterReadTypeOption() = newTypeOption(ExpiryType.AFTER_READ) private fun State.afterSendTypeOption() = newTypeOption(ExpiryType.AFTER_SEND) private fun State.newTypeOption(type: ExpiryType) = typeOption(type, isNewConfigEnabled && isSelfAdmin) @@ -66,11 +64,14 @@ private fun State.typeOption( ) private fun debugTimes(isDebug: Boolean) = if (isDebug) listOf(10.seconds, 30.seconds, 1.minutes) else emptyList() + private fun debugModes(isDebug: Boolean, type: ExpiryType) = debugTimes(isDebug).map { type.mode(it.inWholeSeconds) } + private fun State.debugOptions(): List = debugModes(showDebugOptions, nextType).map { timeOption(it, subtitle = GetString("for testing purposes")) } +// Standard list of available disappearing message times private val afterSendTimes = listOf(12.hours, 1.days, 7.days, 14.days) private val afterReadTimes = buildList { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt index ae17e6a09b..b066a96cc7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.conversation.disappearingmessages.ui -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -16,21 +15,21 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp import network.loki.messenger.R import network.loki.messenger.libsession_util.util.ExpiryMode import org.thoughtcrime.securesms.ui.Callbacks -import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.NoOpCallbacks import org.thoughtcrime.securesms.ui.OptionsCard import org.thoughtcrime.securesms.ui.RadioOption -import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton import org.thoughtcrime.securesms.ui.components.SlimOutlineButton import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.fadingEdges +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType -typealias ExpiryCallbacks = Callbacks +typealias ExpiryCallbacks = Callbacks typealias ExpiryRadioOption = RadioOption @Composable @@ -59,7 +58,9 @@ fun DisappearingMessages( } if (state.showGroupFooter) Text( - text = stringResource(R.string.activity_disappearing_messages_group_footer), + text = stringResource(R.string.disappearingMessagesDescription) + + "\n" + + stringResource(R.string.disappearingMessagesOnlyAdmins), style = LocalType.current.extraSmall, fontWeight = FontWeight(400), color = LocalColors.current.textSecondary, @@ -71,13 +72,15 @@ fun DisappearingMessages( } } - if (state.showSetButton) SlimOutlineButton( - stringResource(R.string.disappearing_messages_set_button_title), - modifier = Modifier - .contentDescription(R.string.AccessibilityId_set_button) - .align(Alignment.CenterHorizontally) - .padding(bottom = LocalDimensions.current.spacing), - onClick = callbacks::onSetClick - ) + if (state.showSetButton){ + PrimaryOutlineButton( + stringResource(R.string.set), + modifier = Modifier + .contentDescription(R.string.AccessibilityId_setButton) + .align(Alignment.CenterHorizontally) + .padding(bottom = LocalDimensions.current.spacing), + onClick = callbacks::onSetClick + ) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt index d043cc314f..48d6539d8a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt @@ -27,21 +27,18 @@ fun PreviewStates( } class StatePreviewParameterProvider : PreviewParameterProvider { - override val values = newConfigValues.filter { it.expiryType != ExpiryType.LEGACY } + newConfigValues.map { it.copy(isNewConfigEnabled = false) } + override val values = newConfigValues + newConfigValues.map { it.copy(isNewConfigEnabled = false) } private val newConfigValues get() = sequenceOf( // new 1-1 State(expiryMode = ExpiryMode.NONE), - State(expiryMode = ExpiryMode.Legacy(43200)), State(expiryMode = ExpiryMode.AfterRead(300)), State(expiryMode = ExpiryMode.AfterSend(43200)), // new group non-admin State(isGroup = true, isSelfAdmin = false), - State(isGroup = true, isSelfAdmin = false, expiryMode = ExpiryMode.Legacy(43200)), State(isGroup = true, isSelfAdmin = false, expiryMode = ExpiryMode.AfterSend(43200)), // new group admin State(isGroup = true), - State(isGroup = true, expiryMode = ExpiryMode.Legacy(43200)), State(isGroup = true, expiryMode = ExpiryMode.AfterSend(43200)), // new note-to-self State(isNoteToSelf = true), diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt index 15ca216777..f00fbf44a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -41,12 +42,14 @@ internal fun StartConversationScreen( accountId: String, delegate: StartConversationDelegate ) { + val context = LocalContext.current + Column(modifier = Modifier.background( LocalColors.current.backgroundSecondary, shape = MaterialTheme.shapes.small )) { BasicAppBar( - title = stringResource(R.string.dialog_start_conversation_title), + title = stringResource(R.string.conversationsStart), backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container actions = { AppBarCloseIcon(onClose = delegate::onDialogClosePressed) } ) @@ -57,30 +60,31 @@ internal fun StartConversationScreen( Column( modifier = Modifier.verticalScroll(rememberScrollState()) ) { + val newMessageTitleTxt:String = context.resources.getQuantityString(R.plurals.messageNew, 1, 1) ItemButton( - textId = R.string.messageNew, + text = newMessageTitleTxt, icon = R.drawable.ic_message, - modifier = Modifier.contentDescription(R.string.AccessibilityId_new_direct_message), + modifier = Modifier.contentDescription(R.string.AccessibilityId_messageNew), onClick = delegate::onNewMessageSelected) Divider(startIndent = LocalDimensions.current.dividerIndent) ItemButton( - textId = R.string.activity_create_group_title, + textId = R.string.groupCreate, icon = R.drawable.ic_group, - modifier = Modifier.contentDescription(R.string.AccessibilityId_create_group), + modifier = Modifier.contentDescription(R.string.AccessibilityId_groupCreate), onClick = delegate::onCreateGroupSelected ) Divider(startIndent = LocalDimensions.current.dividerIndent) ItemButton( - textId = R.string.dialog_join_community_title, + textId = R.string.communityJoin, icon = R.drawable.ic_globe, - modifier = Modifier.contentDescription(R.string.AccessibilityId_join_community), + modifier = Modifier.contentDescription(R.string.AccessibilityId_communityJoin), onClick = delegate::onJoinCommunitySelected ) Divider(startIndent = LocalDimensions.current.dividerIndent) ItemButton( - textId = R.string.activity_settings_invite_button_title, + textId = R.string.sessionInviteAFriend, icon = R.drawable.ic_invite_friend, - Modifier.contentDescription(R.string.AccessibilityId_invite_friend_button), + Modifier.contentDescription(R.string.AccessibilityId_sessionInviteAFriendButton), onClick = delegate::onInviteFriend ) Column( @@ -99,7 +103,7 @@ internal fun StartConversationScreen( Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) QrImage( string = accountId, - Modifier.contentDescription(R.string.AccessibilityId_qr_code), + Modifier.contentDescription(R.string.AccessibilityId_qrCode), icon = R.drawable.session ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt index 3453fb5722..bc298c5bd3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt @@ -14,10 +14,13 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import com.squareup.phrase.Phrase import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.SlimOutlineButton @@ -43,7 +46,7 @@ internal fun InviteFriend( shape = MaterialTheme.shapes.small )) { BackAppBar( - title = stringResource(R.string.invite_a_friend), + title = stringResource(R.string.sessionInviteAFriend), backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container onBack = onBack, actions = { AppBarCloseIcon(onClose = onClose) } @@ -55,7 +58,7 @@ internal fun InviteFriend( Text( accountId, modifier = Modifier - .contentDescription(R.string.AccessibilityId_account_id) + .contentDescription(R.string.AccessibilityId_shareAccountId) .fillMaxWidth() .border() .padding(LocalDimensions.current.spacing), @@ -66,7 +69,10 @@ internal fun InviteFriend( Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) Text( - stringResource(R.string.invite_your_friend_to_chat_with_you_on_session_by_sharing_your_account_id_with_them), + stringResource(R.string.shareAccountIdDescription).let { txt -> + val c = LocalContext.current + Phrase.from(txt).put(APP_NAME_KEY, c.getString(R.string.app_name)).format().toString() + }, textAlign = TextAlign.Center, style = LocalType.current.small, color = LocalColors.current.textSecondary, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt index df54f9cae8..0a40c6ee39 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.rememberNestedScrollInteropConnection @@ -61,7 +62,7 @@ import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors import kotlin.math.max -private val TITLES = listOf(R.string.enter_account_id, R.string.qrScan) +private val TITLES = listOf(R.string.accountIdEnter, R.string.qrScan) @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable @@ -79,8 +80,12 @@ internal fun NewMessage( LocalColors.current.backgroundSecondary, shape = MaterialTheme.shapes.small )) { + // `messageNew` is now a plurals string so get the singular version + val context = LocalContext.current + val newMessageTitleTxt:String = context.resources.getQuantityString(R.plurals.messageNew, 1, 1) + BackAppBar( - title = stringResource(R.string.messageNew), + title = newMessageTitleTxt, backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container onBack = onBack, actions = { AppBarCloseIcon(onClose = onClose) } @@ -88,7 +93,7 @@ internal fun NewMessage( SessionTabRow(pagerState, TITLES) HorizontalPager(pagerState) { when (TITLES[it]) { - R.string.enter_account_id -> EnterAccountId(state, callbacks, onHelp) + R.string.accountIdEnter -> EnterAccountId(state, callbacks, onHelp) R.string.qrScan -> QRScannerScreen(qrErrors, onScan = callbacks::onScanQrCode) } } @@ -116,7 +121,7 @@ private fun EnterAccountId( .verticalScroll(rememberScrollState()) // There is a known issue with the ime padding on android versions below 30 - /// So on these older versions we need to resort to some manual padding based on the visible height + // So on these older versions we need to resort to some manual padding based on the visible height // when the keyboard is up if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { val keyboardHeight by keyboardHeight() @@ -149,9 +154,9 @@ private fun EnterAccountId( Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) BorderlessButtonWithIcon( - text = stringResource(R.string.messageNewDescription), + text = stringResource(R.string.messageNewDescriptionMobile), modifier = Modifier - .contentDescription(R.string.AccessibilityId_help_desk_link) + .contentDescription(R.string.AccessibilityId_messageNewDescriptionMobile) .padding(horizontal = LocalDimensions.current.mediumSpacing) .fillMaxWidth(), style = LocalType.current.small, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt index 6ed8a08233..be7630b536 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt @@ -4,6 +4,8 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.concurrent.TimeoutException +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow @@ -19,8 +21,6 @@ import org.session.libsession.snode.SnodeAPI import org.session.libsignal.utilities.PublicKeyValidation import org.session.libsignal.utilities.timeout import org.thoughtcrime.securesms.ui.GetString -import java.util.concurrent.TimeoutException -import javax.inject.Inject @HiltViewModel internal class NewMessageViewModel @Inject constructor( @@ -41,7 +41,6 @@ internal class NewMessageViewModel @Inject constructor( override fun onChange(value: String) { loadOnsJob?.cancel() loadOnsJob = null - _state.update { it.copy(newMessageIdOrOns = value, isTextErrorColor = false, loading = false) } } @@ -59,7 +58,7 @@ internal class NewMessageViewModel @Inject constructor( if (PublicKeyValidation.isValid(value, isPrefixRequired = false) && PublicKeyValidation.hasValidPrefix(value)) { onPublicKey(value) } else { - _qrErrors.tryEmit(application.getString(R.string.this_qr_code_does_not_contain_an_account_id)) + _qrErrors.tryEmit(application.getString(R.string.qrNotAccountId)) } } @@ -98,8 +97,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.fragment_enter_public_key_error_message) + else -> application.getString(R.string.onsErrorUnableToSearch) } } @@ -112,4 +110,4 @@ internal data class State( val isNextButtonEnabled: Boolean get() = newMessageIdOrOns.isNotBlank() } -internal data class Success(val publicKey: String) +internal data class Success(val publicKey: String) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index d3192abc05..a1eca65eb4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -7,6 +7,7 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.content.res.Resources import android.database.Cursor import android.graphics.Rect @@ -18,10 +19,7 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.provider.MediaStore -import android.text.SpannableStringBuilder -import android.text.SpannedString import android.text.TextUtils -import android.text.style.StyleSpan import android.util.Pair import android.util.TypedValue import android.view.ActionMode @@ -35,8 +33,12 @@ import android.widget.Toast import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels -import androidx.core.text.set -import androidx.core.text.toSpannable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.content.ContextCompat +import androidx.core.view.drawToBitmap import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment @@ -51,6 +53,8 @@ import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.annimon.stream.Stream +import com.bumptech.glide.Glide +import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow @@ -64,7 +68,6 @@ import network.loki.messenger.databinding.ActivityConversationV2Binding import network.loki.messenger.libsession_util.util.ExpiryMode import nl.komponents.kovenant.ui.successUi import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.control.DataExtractionNotification @@ -84,6 +87,10 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.MediaTypes +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.CONVERSATION_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.Stub import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.concurrent.SimpleTask @@ -109,6 +116,8 @@ import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companio import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_DELETE import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_REPLY import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_RESEND +import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_COPY +import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_SAVE import org.thoughtcrime.securesms.conversation.v2.dialogs.BlockedDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton @@ -155,7 +164,6 @@ import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.MediaSendActivity import org.thoughtcrime.securesms.mms.AudioSlide import org.thoughtcrime.securesms.mms.GifSlide -import com.bumptech.glide.Glide import org.thoughtcrime.securesms.mms.ImageSlide import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.mms.Slide @@ -165,19 +173,21 @@ import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment 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 import org.thoughtcrime.securesms.util.SaveAttachmentTask -import org.thoughtcrime.securesms.util.drawToBitmap import org.thoughtcrime.securesms.util.isScrolledToBottom import org.thoughtcrime.securesms.util.isScrolledToWithin30dpOfBottom import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.show import org.thoughtcrime.securesms.util.toPx import java.lang.ref.WeakReference +import java.util.LinkedList import java.util.Locale import java.util.concurrent.ExecutionException import java.util.concurrent.atomic.AtomicBoolean @@ -188,6 +198,8 @@ import kotlin.math.abs import kotlin.math.min import kotlin.math.roundToInt import kotlin.math.sqrt +import kotlin.time.Duration.Companion.minutes + private const val TAG = "ConversationActivityV2" @@ -231,6 +243,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe .get(LinkPreviewViewModel::class.java) } + private var openLinkDialogUrl: String? by mutableStateOf(null) + private val threadId: Long by lazy { var threadId = intent.getLongExtra(THREAD_ID, -1L) if (threadId == -1L) { @@ -279,8 +293,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe var searchViewItem: MenuItem? = null private val bufferedLastSeenChannel = Channel(capacity = 512, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private var emojiPickerVisible = false + // Queue of timestamps used to rate-limit emoji reactions + private val emojiRateLimiterQueue = LinkedList() + + // Constants used to enforce the given maximum emoji reactions allowed per minute (emoji reactions + // that occur above this limit will result in a "Slow down" toast rather than adding the reaction). + private val EMOJI_REACTIONS_ALLOWED_PER_MINUTE = 20 + private val ONE_MINUTE_IN_MILLISECONDS = 1.minutes.inWholeMilliseconds + private val isScrolledToBottom: Boolean get() = binding.conversationRecyclerView.isScrolledToBottom @@ -385,12 +408,33 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } // endregion + fun showOpenUrlDialog(url: String){ + openLinkDialogUrl = url + } + // region Lifecycle override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) binding = ActivityConversationV2Binding.inflate(layoutInflater) setContentView(binding.root) + // set the compose dialog content + binding.dialogOpenUrl.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + SessionMaterialTheme { + if(!openLinkDialogUrl.isNullOrEmpty()){ + OpenURLAlertDialog( + url = openLinkDialogUrl!!, + onDismissRequest = { + openLinkDialogUrl = null + } + ) + } + } + } + } + // messageIdToScroll messageToScrollTimestamp.set(intent.getLongExtra(SCROLL_MESSAGE_ID, -1)) messageToScrollAuthor.set(intent.getParcelableExtra(SCROLL_MESSAGE_AUTHOR)) @@ -666,7 +710,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 @@ -704,7 +747,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun onFailure(e: ExecutionException?) { - Toast.makeText(this@ConversationActivityV2, R.string.activity_conversation_attachment_prep_failed, Toast.LENGTH_LONG).show() + Toast.makeText(this@ConversationActivityV2, R.string.attachmentsErrorLoad, Toast.LENGTH_LONG).show() } }) return @@ -755,9 +798,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // called from onCreate private fun setUpBlockedBanner() { val recipient = viewModel.recipient?.takeUnless { it.isGroupRecipient } ?: return - val accountID = recipient.address.toString() - val name = sessionContactDb.getContactWithAccountID(accountID)?.displayName(Contact.ContactContext.REGULAR) ?: accountID - binding.blockedBannerTextView.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name) + binding.blockedBannerTextView.text = applicationContext.getString(R.string.blockBlockedDescription) binding.blockedBanner.isVisible = recipient.isBlocked binding.blockedBanner.setOnClickListener { viewModel.unblock() } } @@ -770,8 +811,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe binding.outdatedBanner.isVisible = shouldShowLegacy if (shouldShowLegacy) { - binding.outdatedBannerTextView.text = - resources.getString(R.string.activity_conversation_outdated_client_banner_text, legacyRecipient!!.name) + + val txt = Phrase.from(applicationContext, R.string.disappearingMessagesLegacy) + .put(NAME_KEY, legacyRecipient!!.name) + .format() + binding?.outdatedBannerTextView?.text = txt } } @@ -809,6 +853,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // Conversation should be deleted now, just go back finish() } + + binding.inputBar.isGone = uiState.hideInputBar } } } @@ -903,11 +949,20 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe block(deleteThread = true) } binding.declineMessageRequestButton.setOnClickListener { - viewModel.declineMessageRequest() - lifecycleScope.launch(Dispatchers.IO) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2) + fun doDecline() { + viewModel.declineMessageRequest() + lifecycleScope.launch(Dispatchers.IO) { + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2) + } + finish() + } + + showSessionDialog { + title(R.string.delete) + text(resources.getString(R.string.messageRequestsDelete)) + dangerButton(R.string.delete) { doDecline() } + button(R.string.cancel) } - finish() } } @@ -1056,34 +1111,48 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe updateUnreadCountIndicator() } + // Update placeholder / control messages in a conversation private fun updatePlaceholder() { val recipient = viewModel.recipient ?: return Log.w("Loki", "recipient was null in placeholder update") val blindedRecipient = viewModel.blindedRecipient val openGroup = viewModel.openGroup - val (textResource, insertParam) = when { - recipient.isLocalNumber -> R.string.activity_conversation_empty_state_note_to_self to null - openGroup != null && !openGroup.canWrite -> R.string.activity_conversation_empty_state_read_only to recipient.toShortString() - blindedRecipient?.blocksCommunityMessageRequests == true -> R.string.activity_conversation_empty_state_blocks_community_requests to recipient.toShortString() - else -> R.string.activity_conversation_empty_state_default to recipient.toShortString() + // Get the correct placeholder text for this type of empty conversation + val isNoteToSelf = recipient.isLocalNumber + val txtCS: CharSequence = when { + recipient.isLocalNumber -> getString(R.string.noteToSelfEmpty) + + // If this is a community which we cannot write to + openGroup != null && !openGroup.canWrite -> { + Phrase.from(applicationContext, R.string.conversationsEmpty) + .put(CONVERSATION_NAME_KEY, openGroup.name) + .format() + } + + // If we're trying to message someone who has blocked community message requests + blindedRecipient?.blocksCommunityMessageRequests == true -> { + Phrase.from(applicationContext, R.string.messageRequestsTurnedOff) + .put(NAME_KEY, recipient.toShortString()) + .format() + } + + recipient.isGroupRecipient -> { + // If this is a group or community that we CAN send messages to + Phrase.from(applicationContext, R.string.groupNoMessages) + .put(GROUP_NAME_KEY, recipient.toShortString()) + .format() + } + + else -> { + Log.w(TAG, "Something else happened in updatePlaceholder - we're not sure what.") + "" + } } + val showPlaceholder = adapter.itemCount == 0 binding.placeholderText.isVisible = showPlaceholder if (showPlaceholder) { - if (insertParam != null) { - val span = getText(textResource) as SpannedString - val annotations = span.getSpans(0, span.length, StyleSpan::class.java) - val boldSpan = annotations.first() - val spannedParam = insertParam.toSpannable() - spannedParam[0 until spannedParam.length] = StyleSpan(boldSpan.style) - val originalStart = span.getSpanStart(boldSpan) - val originalEnd = span.getSpanEnd(boldSpan) - val newString = SpannableStringBuilder(span) - .replace(originalStart, originalEnd, spannedParam) - binding.placeholderText.text = newString - } else { - binding.placeholderText.setText(textResource) - } + binding.placeholderText.text = txtCS } } @@ -1117,11 +1186,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun block(deleteThread: Boolean) { + val recipient = viewModel.recipient ?: return Log.w("Loki", "Recipient was null for block action") showSessionDialog { - title(R.string.RecipientPreferenceActivity_block_this_contact_question) - text(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact) - dangerButton(R.string.RecipientPreferenceActivity_block, R.string.AccessibilityId_block_confirm) { + title(R.string.block) + text( + Phrase.from(context, R.string.blockDescription) + .put(NAME_KEY, recipient.name) + .format() + ) + dangerButton(R.string.block, R.string.AccessibilityId_blockConfirm) { viewModel.block() + + // Block confirmation toast added as per SS-64 + val txt = Phrase.from(context, R.string.blockBlockedUser).put(NAME_KEY, recipient.name).format().toString() + Toast.makeText(context, txt, Toast.LENGTH_LONG).show() + if (deleteThread) { viewModel.deleteThread() finish() @@ -1135,7 +1214,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val clip = ClipData.newPlainText("Account ID", accountId) val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager manager.setPrimaryClip(clip) - Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() + Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show() } override fun copyOpenGroupUrl(thread: Recipient) { @@ -1147,7 +1226,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val clip = ClipData.newPlainText("Community URL", openGroup.joinURL) val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager manager.setPrimaryClip(clip) - Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() + Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show() } override fun showDisappearingMessages(thread: Recipient) { @@ -1160,13 +1239,20 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun unblock() { + val recipient = viewModel.recipient ?: return Log.w("Loki", "Recipient was null for unblock action") + + if (!recipient.isContactRecipient) { + return Log.w("Loki", "Cannot unblock a user who is not a contact recipient - aborting unblock attempt.") + } + showSessionDialog { - title(R.string.ConversationActivity_unblock_this_contact_question) - text(R.string.ConversationActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact) - dangerButton( - R.string.ConversationActivity_unblock, - R.string.AccessibilityId_block_confirm - ) { viewModel.unblock() } + title(R.string.blockUnblock) + text( + Phrase.from(context, R.string.blockUnblockName) + .put(NAME_KEY, recipient.name) + .format() + ) + dangerButton(R.string.blockUnblock, R.string.AccessibilityId_unblockConfirm) { viewModel.unblock() } cancelButton() } } @@ -1177,10 +1263,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (actionMode != null) { onDeselect(message, position, actionMode) } else { - // NOTE: - // We have to use onContentClick (rather than a click listener directly on + // NOTE: We have to use onContentClick (rather than a click listener directly on // the view) so as to not interfere with all the other gestures. Do not add - // onClickListeners directly to message content views. + // onClickListeners directly to message content views! view.onContentClick(event) } } @@ -1279,7 +1364,45 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } + // Method to add an emoji to a queue and remove it a short while later - this is used as a + // rate-limiting mechanism and is called from the `sendEmojiReaction` method, below. + + fun canPerformEmojiReaction(timestamp: Long): Boolean { + // If the emoji reaction queue is full.. + if (emojiRateLimiterQueue.size >= EMOJI_REACTIONS_ALLOWED_PER_MINUTE) { + // ..grab the timestamp of the oldest emoji reaction. + val headTimestamp = emojiRateLimiterQueue.peekFirst() + if (headTimestamp == null) { + Log.w(TAG, "Could not get emoji react head timestamp - should never happen, but we'll allow the emoji reaction.") + return true + } + + // With the queue full, if the earliest emoji reaction occurred less than 1 minute ago + // then we reject it.. + if (System.currentTimeMillis() - headTimestamp <= ONE_MINUTE_IN_MILLISECONDS) { + return false + } else { + // ..otherwise if the earliest emoji reaction was more than a minute ago we'll + // remove that early reaction to move the timestamp at index 1 into index 0, add + // our new timestamp and return true to accept the emoji reaction. + emojiRateLimiterQueue.removeFirst() + emojiRateLimiterQueue.addLast(timestamp) + return true + } + } else { + // If the queue isn't already full then we add the new timestamp to the back of the queue and allow the emoji reaction + emojiRateLimiterQueue.addLast(timestamp) + return true + } + } + private fun sendEmojiReaction(emoji: String, originalMessage: MessageRecord) { + // Only allow the emoji reaction if we aren't currently rate limited + if (!canPerformEmojiReaction(System.currentTimeMillis())) { + Toast.makeText(this, getString(R.string.emojiReactsCoolDown), Toast.LENGTH_SHORT).show() + return + } + // Create the message val recipient = viewModel.recipient ?: return Log.w(TAG, "Could not locate recipient when sending emoji reaction") val reactionMessage = VisibleMessage() @@ -1325,6 +1448,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } + // Method to remove a emoji reaction from a message. + // Note: We do not count emoji removal towards the emojiRateLimiterQueue. private fun sendEmojiRemoval(emoji: String, originalMessage: MessageRecord) { val recipient = viewModel.recipient ?: return val message = VisibleMessage() @@ -1529,13 +1654,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - override fun onReactionLongClicked(messageId: MessageId) { + override fun onReactionLongClicked(messageId: MessageId, emoji: String?) { if (viewModel.recipient?.isGroupRecipient == true) { val isUserModerator = viewModel.openGroup?.let { openGroup -> val userPublicKey = textSecurePreferences.getLocalNumber() ?: return@let false OpenGroupManager.isUserModerator(this, openGroup.id, userPublicKey, viewModel.blindedPublicKey) } ?: false - val fragment = ReactionsDialogFragment.create(messageId, isUserModerator) + val fragment = ReactionsDialogFragment.create(messageId, isUserModerator, emoji) fragment.show(supportFragmentManager, null) } } @@ -1591,9 +1716,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey) if (seed in text && !isNoteToSelf && !hasPermissionToSendSeed) { showSessionDialog { - title(R.string.dialog_send_seed_title) - text(R.string.dialog_send_seed_explanation) - button(R.string.dialog_send_seed_send_button_title) { sendTextOnlyMessage(true) } + title(R.string.warning) + text(R.string.recoveryPasswordWarningSendDescription) + button(R.string.send) { sendTextOnlyMessage(true) } cancelButton() } @@ -1660,10 +1785,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) @@ -1673,9 +1809,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val hasSeenGIFMetaDataWarning: Boolean = textSecurePreferences.hasSeenGIFMetaDataWarning() if (!hasSeenGIFMetaDataWarning) { showSessionDialog { - title(R.string.giphy_permission_title) - text(R.string.giphy_permission_message) - button(R.string.continue_2) { + title(R.string.giphyWarning) + text(Phrase.from(context, R.string.giphyWarningDescription).put(APP_NAME_KEY, getString(R.string.app_name)).format()) + button(R.string.theContinue) { textSecurePreferences.setHasSeenGIFMetaDataWarning() selectGif() } @@ -1718,11 +1854,27 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val mediaPreppedListener = object : ListenableFuture.Listener { override fun onSuccess(result: Boolean?) { + if (result == null) { + Log.w(TAG, "Media prepper returned a null result - bailing.") + return + } + + // If the attachment was too large or MediaConstraints.isSatisfied failed for some + // other reason then we reset the attachment manager & shown buttons then bail.. + if (!result) { + attachmentManager.clear() + if (isShowingAttachmentOptions) { toggleAttachmentOptions() } + return + } + + // ..otherwise we can attempt to send the attachment(s). + // Note: The only multi-attachment message type is when sending images - all others + // attempt send the attachment immediately upon file selection. sendAttachments(attachmentManager.buildSlideDeck().asAttachments(), null) } override fun onFailure(e: ExecutionException?) { - Toast.makeText(this@ConversationActivityV2, R.string.activity_conversation_attachment_prep_failed, Toast.LENGTH_LONG).show() + Toast.makeText(this@ConversationActivityV2, R.string.attachmentsErrorLoad, Toast.LENGTH_LONG).show() } } when (requestCode) { @@ -1805,8 +1957,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } else { Permissions.with(this) .request(Manifest.permission.RECORD_AUDIO) - .withRationaleDialog(getString(R.string.ConversationActivity_to_send_audio_messages_allow_signal_access_to_your_microphone), R.drawable.ic_baseline_mic_48) - .withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_requires_the_microphone_permission_in_order_to_send_audio_messages)) + .withPermanentDenialDialog(Phrase.from(applicationContext, R.string.permissionsMicrophoneAccessRequired) + .put(APP_NAME_KEY, getString(R.string.app_name)) + .format().toString()) .execute() } } @@ -1876,7 +2029,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun onFailure(e: ExecutionException) { - Toast.makeText(this@ConversationActivityV2, R.string.ConversationActivity_unable_to_record_audio, Toast.LENGTH_LONG).show() + Toast.makeText(this@ConversationActivityV2, R.string.audioUnableToRecord, Toast.LENGTH_LONG).show() } }) } @@ -1927,10 +2080,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun showDeleteLocallyUI(messages: Set) { - val messageCount = 1 showSessionDialog { - title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) - text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) + title(resources.getQuantityString(R.plurals.deleteMessage, messages.count(), messages.count())) + text(resources.getString(R.string.deleteMessagesDescriptionDevice)) button(R.string.delete) { messages.forEach(viewModel::deleteLocally); endActionMode() } cancelButton(::endActionMode) } @@ -1950,13 +2102,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // If the recipient is a community OR a Note-to-Self then we delete the message for everyone if (recipient.isCommunityRecipient || recipient.isLocalNumber) { - val messageCount = 1 // Only used for plurals string showSessionDialog { - title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) - text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) - button(R.string.delete) { - messages.forEach(viewModel::deleteForEveryone); endActionMode() - } + title(resources.getQuantityString(R.plurals.deleteMessage, messages.count(), messages.count())) + text(resources.getString(R.string.deleteMessageDescriptionEveryone)) + 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 @@ -1981,13 +2130,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } else // Finally, if this is a closed group and you are deleting someone else's message(s) then we can only delete locally. { - val messageCount = 1 showSessionDialog { - title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) - text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) - button(R.string.delete) { - messages.forEach(viewModel::deleteLocally); endActionMode() - } + title(resources.getQuantityString(R.plurals.deleteMessage, messages.count(), messages.count())) + text(resources.getString(R.string.deleteMessageDescriptionDevice)) + dangerButton(R.string.delete) { messages.forEach(viewModel::deleteLocally); endActionMode() } cancelButton(::endActionMode) } } @@ -1995,18 +2141,18 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun banUser(messages: Set) { showSessionDialog { - title(R.string.ConversationFragment_ban_selected_user) - text("This will ban the selected user from this room. It won't ban them from other rooms.") - button(R.string.ban) { viewModel.banUser(messages.first().individualRecipient); endActionMode() } + title(R.string.banUser) + text(R.string.communityBanDescription) + dangerButton(R.string.theContinue) { viewModel.banUser(messages.first().individualRecipient); endActionMode() } cancelButton(::endActionMode) } } override fun banAndDeleteAll(messages: Set) { showSessionDialog { - title(R.string.ConversationFragment_ban_selected_user) - text("This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.") - button(R.string.ban) { viewModel.banAndDeleteAll(messages.first()); endActionMode() } + title(R.string.banDeleteAll) + text(R.string.communityBanDeleteDescription) + dangerButton(R.string.theContinue) { viewModel.banAndDeleteAll(messages.first()); endActionMode() } cancelButton(::endActionMode) } } @@ -2042,7 +2188,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (TextUtils.isEmpty(result)) { return } val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager manager.setPrimaryClip(ClipData.newPlainText("Message Content", result)) - Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() + Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show() endActionMode() } @@ -2051,7 +2197,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val clip = ClipData.newPlainText("Account ID", accountID) val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager manager.setPrimaryClip(clip) - Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() + Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show() endActionMode() } @@ -2079,52 +2225,113 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe ON_REPLY -> reply(set) ON_RESEND -> resendMessage(set) ON_DELETE -> deleteMessages(set) + ON_COPY -> copyMessages(set) + ON_SAVE -> { + if(message is MmsMessageRecord) saveAttachmentsIfPossible(setOf(message)) + } } } override fun showMessageDetail(messages: Set) { Intent(this, MessageDetailActivity::class.java) .apply { putExtra(MESSAGE_TIMESTAMP, messages.first().timestamp) } - .let { handleMessageDetail.launch(it) } + .let { + handleMessageDetail.launch(it) + overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) + } endActionMode() } - override fun saveAttachment(messages: Set) { + private fun saveAttachments(message: MmsMessageRecord) { + val attachments: List = Stream.of(message.slideDeck.slides) + .filter { s: Slide -> s.uri != null && (s.hasImage() || s.hasVideo() || s.hasAudio() || s.hasDocument()) } + .map { s: Slide -> SaveAttachmentTask.Attachment(s.uri!!, s.contentType, message.dateReceived, s.fileName.orNull()) } + .toList() + if (attachments.isNotEmpty()) { + val saveTask = SaveAttachmentTask(this) + saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, *attachments.toTypedArray()) + if (!message.isOutgoing) { sendMediaSavedNotification() } + return + } + // Implied else that there were no attachment(s) + Toast.makeText(this, resources.getString(R.string.attachmentsSaveError), Toast.LENGTH_LONG).show() + } + + private fun hasPermission(permission: String): Boolean { + val result = ContextCompat.checkSelfPermission(this, permission) + return result == PackageManager.PERMISSION_GRANTED + } + + override fun saveAttachmentsIfPossible(messages: Set) { val message = messages.first() as MmsMessageRecord - // Do not allow the user to download a file attachment before it has finished downloading + // Note: The save option is only added to the menu in ConversationReactionOverlay.getMenuActionItems + // if the attachment has finished downloading, so we don't really have to check for message.isMediaPending + // here - but we'll do it anyway and bail should that be the case as a defensive programming strategy. if (message.isMediaPending) { - Toast.makeText(this, resources.getString(R.string.conversation_activity__wait_until_attachment_has_finished_downloading), Toast.LENGTH_LONG).show() + Log.w(TAG, "Somehow we were asked to download an attachment before it had finished downloading - aborting download.") return } - SaveAttachmentTask.showWarningDialog(this) { + // Before saving an attachment, regardless of Android API version or permissions, we always want to ensure + // that we've warned the user just _once_ that any attachments they save can be accessed by other apps. + val haveWarned = TextSecurePreferences.getHaveWarnedUserAboutSavingAttachments(this) + if (haveWarned) { + // On Android versions below 29 we require the WRITE_EXTERNAL_STORAGE permission to save attachments. + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + // Save the attachment(s) then bail if we already have permission to do so + if (hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + saveAttachments(message) + return + } else { + /* If we don't have the permission then do nothing - which means we continue on to the SaveAttachmentTask part below where we ask for permissions */ + } + } else { + // On more modern versions of Android on API 30+ WRITE_EXTERNAL_STORAGE is no longer used and we can just + // save files to the public directories like "Downloads", "Pictures" etc. + saveAttachments(message) + return + } + } + + // ..otherwise we must ask for it first (only on Android APIs up to 28). + SaveAttachmentTask.showOneTimeWarningDialogOrSave(this) { Permissions.with(this) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) - .maxSdkVersion(Build.VERSION_CODES.P) - .withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied)) + .maxSdkVersion(Build.VERSION_CODES.P) // P is 28 + .withPermanentDenialDialog(Phrase.from(applicationContext, R.string.permissionsStorageDeniedLegacy) + .put(APP_NAME_KEY, getString(R.string.app_name)) + .format().toString()) .onAnyDenied { endActionMode() - Toast.makeText(this@ConversationActivityV2, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show() + + // If permissions were denied inform the user that we can't proceed without them and offer to take the user to Settings + showSessionDialog { + title(R.string.permissionsRequired) + + val txt = Phrase.from(applicationContext, R.string.permissionsStorageDeniedLegacy) + .put(APP_NAME_KEY, getString(R.string.app_name)) + .format().toString() + text(txt) + + // Take the user directly to the settings app for Session to grant the permission if they + // initially denied it but then have a change of heart when they realise they can't + // proceed without it. + dangerButton(R.string.theContinue) { + val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val uri = Uri.fromParts("package", packageName, null) + intent.setData(uri) + startActivity(intent) + } + + button(R.string.cancel) + } } .onAllGranted { endActionMode() - val attachments: List = Stream.of(message.slideDeck.slides) - .filter { s: Slide -> s.uri != null && (s.hasImage() || s.hasVideo() || s.hasAudio() || s.hasDocument()) } - .map { s: Slide -> SaveAttachmentTask.Attachment(s.uri!!, s.contentType, message.dateReceived, s.fileName.orNull()) } - .toList() - if (attachments.isNotEmpty()) { - val saveTask = SaveAttachmentTask(this) - saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, *attachments.toTypedArray()) - if (!message.isOutgoing) { - sendMediaSavedNotification() - } - return@onAllGranted - } - Toast.makeText(this, - resources.getQuantityString(R.plurals.ConversationFragment_error_while_saving_attachments_to_sd_card, 1), - Toast.LENGTH_LONG).show() + saveAttachments(message) } .execute() } @@ -2179,6 +2386,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe searchViewModel.onMissingResult() } } } + binding.searchBottomBar.setData(result.position, result.getResults().size) }) } @@ -2188,6 +2396,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe binding.searchBottomBar.visibility = View.VISIBLE binding.searchBottomBar.setData(0, 0) binding.inputBar.visibility = View.INVISIBLE + } fun onSearchClosed() { @@ -2241,7 +2450,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe ConversationReactionOverlay.Action.REPLY -> reply(selectedItems) ConversationReactionOverlay.Action.RESYNC -> resyncMessage(selectedItems) ConversationReactionOverlay.Action.RESEND -> resendMessage(selectedItems) - ConversationReactionOverlay.Action.DOWNLOAD -> saveAttachment(selectedItems) + ConversationReactionOverlay.Action.DOWNLOAD -> saveAttachmentsIfPossible(selectedItems) ConversationReactionOverlay.Action.COPY_MESSAGE -> copyMessages(selectedItems) ConversationReactionOverlay.Action.VIEW_INFO -> showMessageDetail(selectedItems) ConversationReactionOverlay.Action.SELECT -> selectMessages(selectedItems) @@ -2262,7 +2471,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) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt index 1c57dc8d5f..880dacb070 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.conversation.v2 import android.content.Context -import android.content.Intent import android.database.Cursor import android.util.SparseArray import android.util.SparseBooleanArray @@ -12,12 +11,12 @@ import androidx.core.util.getOrDefault import androidx.core.util.set import androidx.lifecycle.LifecycleCoroutineScope import androidx.recyclerview.widget.RecyclerView.ViewHolder +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.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView @@ -26,9 +25,6 @@ import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDel 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 org.thoughtcrime.securesms.preferences.PrivacySettingsActivity -import org.thoughtcrime.securesms.showSessionDialog import java.util.concurrent.atomic.AtomicLong import kotlin.math.min @@ -118,7 +114,11 @@ class ConversationAdapter( val senderId = message.individualRecipient.address.serialize() val senderIdHash = senderId.hashCode() updateQueue.trySend(senderId) - if (contactCache[senderIdHash] == null && !contactLoadedCache.getOrDefault(senderIdHash, false)) { + if (contactCache[senderIdHash] == null && !contactLoadedCache.getOrDefault( + senderIdHash, + false + ) + ) { getSenderInfo(senderId)?.let { contact -> contactCache[senderIdHash] = contact } @@ -126,46 +126,41 @@ class ConversationAdapter( val contact = contactCache[senderIdHash] visibleMessageView.bind( - message, - messageBefore, - getMessageAfter(position, cursor), - glide, - searchQuery, - contact, - senderId, - lastSeen.get(), - visibleMessageViewDelegate, - onAttachmentNeedsDownload, - lastSentMessageId + message, + messageBefore, + getMessageAfter(position, cursor), + glide, + searchQuery, + contact, + senderId, + lastSeen.get(), + visibleMessageViewDelegate, + onAttachmentNeedsDownload, + lastSentMessageId ) if (!message.isDeleted) { - visibleMessageView.onPress = { event -> onItemPress(message, viewHolder.adapterPosition, visibleMessageView, event) } - visibleMessageView.onSwipeToReply = { onItemSwipeToReply(message, viewHolder.adapterPosition) } - visibleMessageView.onLongPress = { onItemLongPress(message, viewHolder.adapterPosition, visibleMessageView) } + visibleMessageView.onPress = { event -> + onItemPress( + message, + viewHolder.adapterPosition, + visibleMessageView, + event + ) + } + visibleMessageView.onSwipeToReply = + { onItemSwipeToReply(message, viewHolder.adapterPosition) } + visibleMessageView.onLongPress = + { onItemLongPress(message, viewHolder.adapterPosition, visibleMessageView) } } else { visibleMessageView.onPress = null visibleMessageView.onSwipeToReply = null visibleMessageView.onLongPress = null } } + is ControlMessageViewHolder -> { viewHolder.view.bind(message, messageBefore) - if (message.isCallLog && message.isFirstMissedCall) { - viewHolder.view.setOnClickListener { - context.showSessionDialog { - title(R.string.CallNotificationBuilder_first_call_title) - text(R.string.CallNotificationBuilder_first_call_message) - button(R.string.activity_settings_title) { - Intent(context, PrivacySettingsActivity::class.java) - .let(context::startActivity) - } - cancelButton() - } - } - } else { - viewHolder.view.setOnClickListener(null) - } } } } @@ -190,7 +185,7 @@ class ConversationAdapter( private fun getMessageBefore(position: Int, cursor: Cursor): MessageRecord? { // The message that's visually before the current one is actually after the current // one for the cursor because the layout is reversed - if (isReversed && !cursor.moveToPosition(position + 1)) { return null } + if (isReversed && !cursor.moveToPosition(position + 1)) { return null } if (!isReversed && !cursor.moveToPosition(position - 1)) { return null } return messageDB.readerFor(cursor).current diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index 9f2046334b..d445d002cb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -22,6 +22,7 @@ import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import androidx.core.view.doOnLayout import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat +import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -30,7 +31,9 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R +import org.session.libsession.LocalisedTimeUtil.toShortTwoPartString import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber import org.session.libsession.utilities.ThemeUtil import org.thoughtcrime.securesms.components.emoji.EmojiImageView @@ -48,12 +51,7 @@ import org.thoughtcrime.securesms.util.AnimationCompleteListener import org.thoughtcrime.securesms.util.DateUtils import java.util.Locale import javax.inject.Inject -import kotlin.time.Duration -import kotlin.time.Duration.Companion.days -import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds @AndroidEntryPoint class ConversationReactionOverlay : FrameLayout { @@ -215,7 +213,7 @@ class ConversationReactionOverlay : FrameLayout { endY = backgroundView.height + menuPadding + reactionBarTopPadding } } else { - endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.height + endY = overlayHeight - contextMenu.getMaxHeight() - 2*menuPadding - conversationItemSnapshot.height reactionBarBackgroundY = endY - reactionBarHeight - menuPadding } endApparentTop = endY @@ -529,46 +527,54 @@ class ConversationReactionOverlay : FrameLayout { ?: return emptyList() val userPublicKey = getLocalNumber(context)!! // Select message - items += ActionItem(R.attr.menu_select_icon, R.string.conversation_context__menu_select, { handleActionItemClicked(Action.SELECT) }, R.string.AccessibilityId_select) + items += ActionItem(R.attr.menu_select_icon, R.string.select, { handleActionItemClicked(Action.SELECT) }, R.string.AccessibilityId_select) // Reply val canWrite = openGroup == null || openGroup.canWrite if (canWrite && !message.isPending && !message.isFailed && !message.isOpenGroupInvitation) { - items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_reply, { handleActionItemClicked(Action.REPLY) }, R.string.AccessibilityId_reply_message) + items += ActionItem(R.attr.menu_reply_icon, R.string.reply, { handleActionItemClicked(Action.REPLY) }, R.string.AccessibilityId_reply) } // Copy message text if (!containsControlMessage && hasText) { items += ActionItem(R.attr.menu_copy_icon, R.string.copy, { handleActionItemClicked(Action.COPY_MESSAGE) }) } // Copy Account ID - if (recipient.isGroupRecipient && !recipient.isCommunityRecipient && message.recipient.address.toString() != userPublicKey) { - items += ActionItem(R.attr.menu_copy_icon, R.string.activity_conversation_menu_copy_account_id, { handleActionItemClicked(Action.COPY_ACCOUNT_ID) }) + if (!recipient.isCommunityRecipient && message.isIncoming) { + items += ActionItem(R.attr.menu_copy_icon, R.string.accountIDCopy, { handleActionItemClicked(Action.COPY_ACCOUNT_ID) }) } // Delete message if (userCanDeleteSelectedItems(context, message, openGroup, userPublicKey, blindedPublicKey)) { items += ActionItem(R.attr.menu_trash_icon, R.string.delete, { handleActionItemClicked(Action.DELETE) }, - R.string.AccessibilityId_delete_message, message.subtitle, ThemeUtil.getThemedColor(context, R.attr.danger)) + R.string.AccessibilityId_deleteMessage, message.subtitle, ThemeUtil.getThemedColor(context, R.attr.danger)) } // Ban user if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) { - items += ActionItem(R.attr.menu_block_icon, R.string.conversation_context__menu_ban_user, { handleActionItemClicked(Action.BAN_USER) }) + items += ActionItem(R.attr.menu_block_icon, R.string.banUser, { handleActionItemClicked(Action.BAN_USER) }) } // Ban and delete all if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) { - items += ActionItem(R.attr.menu_trash_icon, R.string.conversation_context__menu_ban_and_delete_all, { handleActionItemClicked(Action.BAN_AND_DELETE_ALL) }) + items += ActionItem(R.attr.menu_trash_icon, R.string.banDeleteAll, { handleActionItemClicked(Action.BAN_AND_DELETE_ALL) }) } // Message detail - items += ActionItem(R.attr.menu_info_icon, R.string.conversation_context__menu_message_details, { handleActionItemClicked(Action.VIEW_INFO) }) + items += ActionItem(R.attr.menu_info_icon, R.string.messageInfo, { handleActionItemClicked(Action.VIEW_INFO) }) // Resend if (message.isFailed) { - items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_resend_message, { handleActionItemClicked(Action.RESEND) }) + items += ActionItem(R.attr.menu_reply_icon, R.string.resend, { handleActionItemClicked(Action.RESEND) }) } // Resync if (message.isSyncFailed) { - items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_resync_message, { handleActionItemClicked(Action.RESYNC) }) + items += ActionItem(R.attr.menu_reply_icon, R.string.resync, { handleActionItemClicked(Action.RESYNC) }) } - // Save media - if (message.isMms && (message as MediaMmsMessageRecord).containsMediaSlide()) { - items += ActionItem(R.attr.menu_save_icon, R.string.conversation_context_image__save_attachment, { handleActionItemClicked(Action.DOWNLOAD) }, R.string.AccessibilityId_save_attachment) + // Save media.. + if (message.isMms) { + // ..but only provide the save option if the there is a media attachment which has finished downloading. + val mmsMessage = message as MediaMmsMessageRecord + if (mmsMessage.containsMediaSlide() && !mmsMessage.isMediaPending) { + items += ActionItem(R.attr.menu_save_icon, + R.string.save, + { handleActionItemClicked(Action.DOWNLOAD) }, + R.string.AccessibilityId_saveAttachment + ) + } } backgroundView.visibility = VISIBLE foregroundView.visibility = VISIBLE @@ -704,10 +710,6 @@ class ConversationReactionOverlay : FrameLayout { } } -private fun Duration.to2partString(): String? = - toComponents { days, hours, minutes, seconds, nanoseconds -> listOf(days.days, hours.hours, minutes.minutes, seconds.seconds) } - .filter { it.inWholeSeconds > 0L }.take(2).takeIf { it.isNotEmpty() }?.joinToString(" ") - private val MessageRecord.subtitle: ((Context) -> CharSequence?)? get() = if (expiresIn <= 0) { null @@ -715,6 +717,10 @@ private val MessageRecord.subtitle: ((Context) -> CharSequence?)? (expiresIn - (SnodeAPI.nowWithOffset - (expireStarted.takeIf { it > 0 } ?: timestamp))) .coerceAtLeast(0L) .milliseconds - .to2partString() - ?.let { context.getString(R.string.auto_deletes_in, it) } + .toShortTwoPartString() + .let { + Phrase.from(context, R.string.disappearingMessagesCountdownBigMobile) + .put(TIME_LARGE_KEY, it) + .format().toString() + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index b0a541a9e8..514dc24ea6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -9,8 +9,12 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject 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 org.session.libsession.database.MessageDataProvider @@ -29,6 +33,7 @@ import org.thoughtcrime.securesms.audio.AudioSlidePlayer import org.thoughtcrime.securesms.database.Storage 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 @@ -65,6 +70,8 @@ class ConversationViewModel( } } + private var communityWriteAccessJob: Job? = null + private var _openGroup: RetrieveOnce = RetrieveOnce { storage.getOpenGroup(threadId) } @@ -105,6 +112,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()) + } + } + } } override fun onCleared() { @@ -267,7 +295,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 { @@ -311,7 +339,8 @@ data class UiMessage(val id: Long, val message: String) data class ConversationUiState( val uiMessages: List = emptyList(), val isMessageRequestAccepted: Boolean? = null, - val conversationExists: Boolean + val conversationExists: Boolean, + val hideInputBar: Boolean = false ) data class RetrieveOnce(val retrieval: () -> T?) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt index ca5b1cec11..58c5536248 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt @@ -58,7 +58,7 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen } if (!recipient.isGroupRecipient && !contact.isNullOrEmpty()) { binding.deleteForEveryoneTextView.text = - resources.getString(R.string.delete_message_for_me_and_recipient, contact) + resources.getString(R.string.clearMessagesForEveryone, contact) } binding.deleteForEveryoneTextView.isVisible = !recipient.isClosedGroupRecipient binding.deleteForMeTextView.setOnClickListener(this) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt index 9514552d28..bd491bbe70 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation.v2 import android.annotation.SuppressLint import android.content.Intent +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.MotionEvent.ACTION_UP @@ -15,10 +16,13 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.pager.HorizontalPager @@ -28,6 +32,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -35,6 +40,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource @@ -42,6 +48,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.lifecycleScope import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi @@ -54,27 +61,26 @@ import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAt import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.ui.Avatar import org.thoughtcrime.securesms.ui.CarouselNextButton import org.thoughtcrime.securesms.ui.CarouselPrevButton import org.thoughtcrime.securesms.ui.Cell -import org.thoughtcrime.securesms.ui.CellNoMargin -import org.thoughtcrime.securesms.ui.CellWithPaddingAndMargin import org.thoughtcrime.securesms.ui.Divider import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.HorizontalPagerIndicator import org.thoughtcrime.securesms.ui.LargeItemButton +import org.thoughtcrime.securesms.ui.TitledText +import org.thoughtcrime.securesms.ui.setComposeContent +import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider -import org.thoughtcrime.securesms.ui.TitledText import org.thoughtcrime.securesms.ui.theme.ThemeColors -import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.blackAlpha40 -import org.thoughtcrime.securesms.ui.theme.dangerButtonColors -import org.thoughtcrime.securesms.ui.setComposeContent -import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.bold +import org.thoughtcrime.securesms.ui.theme.dangerButtonColors import org.thoughtcrime.securesms.ui.theme.monospace import javax.inject.Inject @@ -93,12 +99,14 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() { const val ON_REPLY = 1 const val ON_RESEND = 2 const val ON_DELETE = 3 + const val ON_COPY = 4 + const val ON_SAVE = 5 } override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { super.onCreate(savedInstanceState, ready) - title = resources.getString(R.string.conversation_context__menu_message_details) + title = resources.getString(R.string.messageInfo) viewModel.timestamp = intent.getLongExtra(MESSAGE_TIMESTAMP, -1L) @@ -119,11 +127,18 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() { @Composable private fun MessageDetailsScreen() { val state by viewModel.stateFlow.collectAsState() + + // can only save if the there is a media attachment which has finished downloading. + val canSave = state.mmsRecord?.containsMediaSlide() == true + && state.mmsRecord?.isMediaPending == false + MessageDetails( state = state, onReply = if (state.canReply) { { setResultAndFinish(ON_REPLY) } } else null, onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } }, + onSave = if(canSave) { { setResultAndFinish(ON_SAVE) } } else null, onDelete = { setResultAndFinish(ON_DELETE) }, + onCopy = { setResultAndFinish(ON_COPY) }, onClickImage = { viewModel.onClickImage(it) }, onAttachmentNeedsDownload = viewModel::onAttachmentNeedsDownload, ) @@ -144,7 +159,9 @@ fun MessageDetails( state: MessageDetailsState, onReply: (() -> Unit)? = null, onResend: (() -> Unit)? = null, + onSave: (() -> Unit)? = null, onDelete: () -> Unit = {}, + onCopy: () -> Unit = {}, onClickImage: (Int) -> Unit = {}, onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit = { _ -> } ) { @@ -178,9 +195,11 @@ fun MessageDetails( state.nonImageAttachmentFileDetails?.let { FileDetails(it) } CellMetadata(state) CellButtons( - onReply, - onResend, - onDelete, + onReply = onReply, + onResend = onResend, + onSave = onSave, + onDelete = onDelete, + onCopy = onCopy ) } } @@ -191,15 +210,26 @@ fun CellMetadata( ) { state.apply { if (listOfNotNull(sent, received, error, senderInfo).isEmpty()) return - CellWithPaddingAndMargin { - Column(verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing)) { + Cell(modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing)) { + Column( + modifier = Modifier.padding(LocalDimensions.current.spacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) + ) { TitledText(sent) TitledText(received) TitledErrorText(error) senderInfo?.let { TitledView(state.fromTitle) { Row { - sender?.let { Avatar(it) } + sender?.let { + Avatar( + recipient = it, + modifier = Modifier + .align(Alignment.CenterVertically) + .size(46.dp) + ) + Spacer(modifier = Modifier.width(LocalDimensions.current.smallSpacing)) + } TitledMonospaceText(it) } } @@ -213,9 +243,11 @@ fun CellMetadata( fun CellButtons( onReply: (() -> Unit)? = null, onResend: (() -> Unit)? = null, - onDelete: () -> Unit = {}, + onSave: (() -> Unit)? = null, + onDelete: () -> Unit, + onCopy: () -> Unit ) { - Cell { + Cell(modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing)) { Column { onReply?.let { LargeItemButton( @@ -225,6 +257,23 @@ fun CellButtons( ) Divider() } + + LargeItemButton( + R.string.copy, + R.drawable.ic_copy, + onClick = onCopy + ) + Divider() + + onSave?.let { + LargeItemButton( + R.string.save, + R.drawable.ic_baseline_save_24, + onClick = it + ) + Divider() + } + onResend?.let { LargeItemButton( R.string.resend, @@ -233,9 +282,10 @@ fun CellButtons( ) Divider() } + LargeItemButton( R.string.delete, - R.drawable.ic_message_details__trash, + R.drawable.ic_delete, colors = dangerButtonColors(), onClick = onDelete ) @@ -254,8 +304,11 @@ fun Carousel(attachments: List, onClick: (Int) -> Unit) { Row { CarouselPrevButton(pagerState) Box(modifier = Modifier.weight(1f)) { - CellCarousel(pagerState, attachments, onClick) - HorizontalPagerIndicator(pagerState) + CarouselPager(pagerState, attachments, onClick) + HorizontalPagerIndicator( + pagerState = pagerState, + modifier = Modifier.padding(bottom = LocalDimensions.current.xxsSpacing) + ) ExpandButton( modifier = Modifier .align(Alignment.BottomEnd) @@ -273,12 +326,15 @@ fun Carousel(attachments: List, onClick: (Int) -> Unit) { ExperimentalGlideComposeApi::class ) @Composable -private fun CellCarousel( +private fun CarouselPager( pagerState: PagerState, attachments: List, onClick: (Int) -> Unit ) { - CellNoMargin { + Cell( + modifier = Modifier + .clip(MaterialTheme.shapes.small) + ) { HorizontalPager(state = pagerState) { i -> GlideImage( contentScale = ContentScale.Crop, @@ -302,12 +358,27 @@ fun ExpandButton(modifier: Modifier = Modifier, onClick: () -> Unit) { ) { Icon( painter = painterResource(id = R.drawable.ic_expand), - contentDescription = stringResource(id = R.string.expand), + contentDescription = stringResource(id = R.string.AccessibilityId_expand), modifier = Modifier.clickable { onClick() }, ) } } +@Preview +@Composable +fun PreviewMessageDetailsButtons( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + CellButtons( + onReply = {}, + onResend = {}, + onSave = {}, + onDelete = {}, + onCopy = {} + ) + } +} @Preview @Composable @@ -317,15 +388,42 @@ fun PreviewMessageDetails( PreviewTheme(colors) { MessageDetails( state = MessageDetailsState( + imageAttachments = listOf( + Attachment( + fileDetails = listOf( + TitledText(R.string.attachmentsFileId, "Screen Shot 2023-07-06 at 11.35.50 am.png") + ), + fileName = "Screen Shot 2023-07-06 at 11.35.50 am.png", + uri = Uri.parse(""), + hasImage = true + ), + Attachment( + fileDetails = listOf( + TitledText(R.string.attachmentsFileId, "Screen Shot 2023-07-06 at 11.35.50 am.png") + ), + fileName = "Screen Shot 2023-07-06 at 11.35.50 am.png", + uri = Uri.parse(""), + hasImage = true + ), + Attachment( + fileDetails = listOf( + TitledText(R.string.attachmentsFileId, "Screen Shot 2023-07-06 at 11.35.50 am.png") + ), + fileName = "Screen Shot 2023-07-06 at 11.35.50 am.png", + uri = Uri.parse(""), + hasImage = true + ) + + ), nonImageAttachmentFileDetails = listOf( - TitledText(R.string.message_details_header__file_id, "Screen Shot 2023-07-06 at 11.35.50 am.png"), - TitledText(R.string.message_details_header__file_type, "image/png"), - TitledText(R.string.message_details_header__file_size, "195.6kB"), - TitledText(R.string.message_details_header__resolution, "342x312"), + TitledText(R.string.attachmentsFileId, "Screen Shot 2023-07-06 at 11.35.50 am.png"), + TitledText(R.string.attachmentsFileType, "image/png"), + TitledText(R.string.attachmentsFileSize, "195.6kB"), + TitledText(R.string.attachmentsResolution, "342x312"), ), - sent = TitledText(R.string.message_details_header__sent, "6:12 AM Tue, 09/08/2022"), - received = TitledText(R.string.message_details_header__received, "6:12 AM Tue, 09/08/2022"), - error = TitledText(R.string.message_details_header__error, "Message failed to send"), + sent = TitledText(R.string.sent, "6:12 AM Tue, 09/08/2022"), + received = TitledText(R.string.received, "6:12 AM Tue, 09/08/2022"), + error = TitledText(R.string.error, "Message failed to send"), senderInfo = TitledText("Connor", "d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54"), ) ) @@ -337,7 +435,7 @@ fun PreviewMessageDetails( fun FileDetails(fileDetails: List) { if (fileDetails.isEmpty()) return - Cell { + Cell(modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing)) { FlowRow( modifier = Modifier.padding(horizontal = LocalDimensions.current.xsSpacing, vertical = LocalDimensions.current.spacing), verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt index fc54b652ae..fcaca71c6a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt @@ -78,9 +78,9 @@ class MessageDetailsViewModel @Inject constructor( MessageDetailsState( attachments = slides.map(::Attachment), record = record, - sent = dateSent.let(::Date).toString().let { TitledText(R.string.message_details_header__sent, it) }, - received = dateReceived.let(::Date).toString().let { TitledText(R.string.message_details_header__received, it) }, - error = lokiMessageDatabase.getErrorMessage(id)?.let { TitledText(R.string.message_details_header__error, it) }, + sent = dateSent.let(::Date).toString().let { TitledText(R.string.sent, it) }, + received = dateReceived.let(::Date).toString().let { TitledText(R.string.received, it) }, + error = lokiMessageDatabase.getErrorMessage(id)?.let { TitledText(R.string.theError, it) }, senderInfo = individualRecipient.run { name?.let { TitledText(it, address.serialize()) } }, sender = individualRecipient, thread = threadDb.getRecipientForThreadId(threadId)!!, @@ -90,14 +90,14 @@ class MessageDetailsViewModel @Inject constructor( private val Slide.details: List get() = listOfNotNull( - fileName.orNull()?.let { TitledText(R.string.message_details_header__file_id, it) }, - TitledText(R.string.message_details_header__file_type, asAttachment().contentType), - TitledText(R.string.message_details_header__file_size, Util.getPrettyFileSize(fileSize)), + fileName.orNull()?.let { TitledText(R.string.attachmentsFileId, it) }, + TitledText(R.string.attachmentsFileType, asAttachment().contentType), + TitledText(R.string.attachmentsFileSize, Util.getPrettyFileSize(fileSize)), takeIf { it is ImageSlide } ?.let(Slide::asAttachment) ?.run { "${width}x$height" } - ?.let { TitledText(R.string.message_details_header__resolution, it) }, - attachmentDb.duration(this)?.let { TitledText(R.string.message_details_header__duration, it) }, + ?.let { TitledText(R.string.attachmentsResolution, it) }, + attachmentDb.duration(this)?.let { TitledText(R.string.attachmentsDuration, it) }, ) private fun AttachmentDatabase.duration(slide: Slide): String? = @@ -157,7 +157,7 @@ data class MessageDetailsState( val sender: Recipient? = null, val thread: Recipient? = null, ) { - val fromTitle = GetString(R.string.message_details_header__from) + val fromTitle = GetString(R.string.from) val canReply = record?.isOpenGroupInvitation != true } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ModalUrlBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ModalUrlBottomSheet.kt index 54deea1c8d..b31c298f26 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ModalUrlBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ModalUrlBottomSheet.kt @@ -15,9 +15,12 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.squareup.phrase.Phrase import network.loki.messenger.R import network.loki.messenger.databinding.FragmentModalUrlBottomSheetBinding -import org.thoughtcrime.securesms.util.UiModeUtilities +import org.session.libsession.utilities.StringSubstitutionConstants.URL_KEY +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.ui.getSubbedString class ModalUrlBottomSheet(private val url: String): BottomSheetDialogFragment(), View.OnClickListener { private lateinit var binding: FragmentModalUrlBottomSheetBinding @@ -29,7 +32,8 @@ class ModalUrlBottomSheet(private val url: String): BottomSheetDialogFragment(), override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val explanation = resources.getString(R.string.dialog_open_url_explanation, url) + if (context == null) { return Log.w("MUBS", "Context is null") } + val explanation = requireContext().getSubbedString(R.string.urlOpenDescription, URL_KEY to url) val spannable = SpannableStringBuilder(explanation) val startIndex = explanation.indexOf(url) spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + url.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) @@ -44,7 +48,7 @@ class ModalUrlBottomSheet(private val url: String): BottomSheetDialogFragment(), val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) requireContext().startActivity(intent) } catch (e: Exception) { - Toast.makeText(context, R.string.invalid_url, Toast.LENGTH_SHORT).show() + Toast.makeText(context, R.string.communityEnterUrlErrorInvalid, Toast.LENGTH_SHORT).show() } dismiss() } @@ -53,7 +57,7 @@ class ModalUrlBottomSheet(private val url: String): BottomSheetDialogFragment(), val clip = ClipData.newPlainText("URL", url) val manager = requireContext().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager manager.setPrimaryClip(clip) - Toast.makeText(requireContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), R.string.copied, Toast.LENGTH_SHORT).show() dismiss() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt index da8852d1d6..f2b19f539b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt @@ -17,22 +17,12 @@ package org.thoughtcrime.securesms.conversation.v2 import android.content.Context -import android.graphics.Typeface import android.net.Uri -import android.text.Spannable -import android.text.SpannableString import android.text.TextUtils -import android.text.style.StyleSpan import android.view.View import com.annimon.stream.Stream -import com.google.android.mms.pdu_alt.CharacterSets -import com.google.android.mms.pdu_alt.EncodedStringValue -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.components.ComposeText -import java.io.ByteArrayOutputStream -import java.io.IOException -import java.io.UnsupportedEncodingException import java.util.Collections +import org.session.libsignal.utilities.Log object Util { private val TAG: String = Log.tag(Util::class.java) @@ -92,22 +82,6 @@ object Util { return sb.toString() } - fun isEmpty(value: Array?): Boolean { - return value == null || value.size == 0 - } - - fun isEmpty(value: ComposeText?): Boolean { - return value == null || value.text == null || TextUtils.isEmpty(value.textTrimmed) - } - - fun isEmpty(collection: Collection<*>?): Boolean { - return collection == null || collection.isEmpty() - } - - fun isEmpty(charSequence: CharSequence?): Boolean { - return charSequence == null || charSequence.length == 0 - } - fun wait(lock: Any, timeout: Long) { try { (lock as Object).wait(timeout) @@ -123,8 +97,7 @@ object Util { return results } - val elements = - source.split(delimiter.toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val elements = source.split(delimiter.toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() Collections.addAll(results, *elements) return results diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt index dba6bf5b7b..0b61dec9d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt @@ -11,10 +11,12 @@ import android.widget.RelativeLayout import android.widget.TextView import androidx.core.view.children import androidx.core.view.isVisible +import com.squareup.phrase.Phrase import network.loki.messenger.R import network.loki.messenger.databinding.AlbumThumbnailViewBinding import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment +import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.MediaPreviewActivity import org.thoughtcrime.securesms.components.CornerMask @@ -97,7 +99,10 @@ class AlbumThumbnailView : RelativeLayout { binding.albumCellContainer.findViewById(R.id.album_cell_overflow_text)?.let { overflowText -> // overflowText will be null if !overflowed overflowText.isVisible = overflowed // more than max album size - overflowText.text = context.getString(R.string.AlbumThumbnailView_plus, slides.size - MAX_ALBUM_DISPLAY_SIZE) + val txt = Phrase.from(context, R.string.andMore) + .put(COUNT_KEY, slides.size - MAX_ALBUM_DISPLAY_SIZE) + .format() + overflowText.text = txt } this.slideSize = slides.size } @@ -110,10 +115,9 @@ class AlbumThumbnailView : RelativeLayout { // endregion - fun layoutRes(slideCount: Int) = when (slideCount) { - 1 -> R.layout.album_thumbnail_1 // single - 2 -> R.layout.album_thumbnail_2// two sidebyside + 1 -> R.layout.album_thumbnail_1 // single + 2 -> R.layout.album_thumbnail_2 // two side-by-side else -> R.layout.album_thumbnail_3 // three stacked with additional text } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt index 46feefb608..9113f8ed46 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt @@ -11,9 +11,11 @@ import androidx.fragment.app.DialogFragment import network.loki.messenger.R import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.ui.getSubbedCharSequence /** Shown upon sending a message to a user that's blocked. */ class BlockedDialog(private val recipient: Recipient, private val context: Context) : DialogFragment() { @@ -24,14 +26,14 @@ class BlockedDialog(private val recipient: Recipient, private val context: Conte val contact = contactDB.getContactWithAccountID(accountID) val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: accountID - val explanation = resources.getString(R.string.dialog_blocked_explanation, name) - val spannable = SpannableStringBuilder(explanation) - val startIndex = explanation.indexOf(name) + val explanationCS = context.getSubbedCharSequence(R.string.blockUnblockName, NAME_KEY to name) + val spannable = SpannableStringBuilder(explanationCS) + val startIndex = explanationCS.indexOf(name) spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - title(resources.getString(R.string.dialog_blocked_title, name)) + title(resources.getString(R.string.blockUnblock)) text(spannable) - button(R.string.ConversationActivity_unblock) { unblock() } + dangerButton(R.string.blockUnblock, R.string.AccessibilityId_unblockConfirm) { unblock() } cancelButton { dismiss() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt index 1af1d669cf..d3e1de9912 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt @@ -7,12 +7,14 @@ import android.text.Spannable import android.text.SpannableStringBuilder import android.text.style.StyleSpan import androidx.fragment.app.DialogFragment +import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.StringSubstitutionConstants.CONVERSATION_NAME_KEY import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.dependencies.DatabaseComponent @@ -29,15 +31,19 @@ class DownloadDialog(private val recipient: Recipient) : DialogFragment() { val accountID = recipient.address.toString() val contact = contactDB.getContactWithAccountID(accountID) val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: accountID - title(resources.getString(R.string.dialog_download_title, name)) - val explanation = resources.getString(R.string.dialog_download_explanation, name) + title(getString(R.string.attachmentsAutoDownloadModalTitle)) + + val explanation = Phrase.from(context, R.string.attachmentsAutoDownloadModalDescription) + .put(CONVERSATION_NAME_KEY, recipient.name) + .format() val spannable = SpannableStringBuilder(explanation) + val startIndex = explanation.indexOf(name) spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) text(spannable) - button(R.string.dialog_download_button_title, R.string.AccessibilityId_download_media) { trust() } + button(R.string.download, R.string.AccessibilityId_download) { trust() } cancelButton { dismiss() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt index a886e89192..21405b26c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.conversation.v2.dialogs +import org.thoughtcrime.securesms.createSessionDialog import android.app.Dialog import android.graphics.Typeface import android.os.Bundle @@ -8,11 +9,13 @@ import android.text.SpannableStringBuilder import android.text.style.StyleSpan import android.widget.Toast import androidx.fragment.app.DialogFragment +import com.squareup.phrase.Phrase import network.loki.messenger.R import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.utilities.OpenGroupUrlParser +import org.session.libsession.utilities.StringSubstitutionConstants.COMMUNITY_NAME_KEY +import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ThreadUtils -import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities @@ -20,14 +23,18 @@ import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities class JoinOpenGroupDialog(private val name: String, private val url: String) : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { - title(resources.getString(R.string.dialog_join_open_group_title, name)) - val explanation = resources.getString(R.string.dialog_join_open_group_explanation, name) + title(resources.getString(R.string.communityJoin)) + val explanation = Phrase.from(context, R.string.communityJoinDescription).put(COMMUNITY_NAME_KEY, name).format() val spannable = SpannableStringBuilder(explanation) - val startIndex = explanation.indexOf(name) + var startIndex = explanation.indexOf(name) + if (startIndex < 0) { + Log.w("JoinOpenGroupDialog", "Could not find $name in explanation dialog: $explanation") + startIndex = 0 // Limit the startIndex to zero if not found (will be -1) to prevent a crash + } spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) text(spannable) cancelButton { dismiss() } - button(R.string.open_group_invitation_view__join_accessibility_description) { join() } + button(R.string.join) { join() } } private fun join() { @@ -39,7 +46,7 @@ class JoinOpenGroupDialog(private val name: String, private val url: String) : D MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(openGroup.server, openGroup.room) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(activity) } catch (e: Exception) { - Toast.makeText(activity, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show() + Toast.makeText(activity, R.string.communityErrorDescription, Toast.LENGTH_SHORT).show() } } dismiss() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/LinkPreviewDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/LinkPreviewDialog.kt index 996dd41f94..d9e6e22a4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/LinkPreviewDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/LinkPreviewDialog.kt @@ -4,18 +4,22 @@ import android.app.Dialog import android.os.Bundle import androidx.fragment.app.DialogFragment import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants.APP_NAME +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.createSessionDialog +import org.thoughtcrime.securesms.ui.getSubbedCharSequence /** Shown the first time the user inputs a URL that could generate a link preview, to * let them know that Session offers the ability to send and receive link previews. */ class LinkPreviewDialog(private val onEnabled: () -> Unit) : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { - title(R.string.dialog_link_preview_title) - text(R.string.dialog_link_preview_explanation) - button(R.string.dialog_link_preview_enable_button_title) { enable() } - cancelButton { dismiss() } + title(R.string.linkPreviewsEnable) + val txt = context.getSubbedCharSequence(R.string.linkPreviewsFirstDescription, APP_NAME_KEY to APP_NAME) + text(txt) + dangerButton(R.string.enable) { enable() } + cancelButton { dismiss() } } private fun enable() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index c8aacdeb6e..cd911b2ace 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -79,9 +79,9 @@ class InputBar @JvmOverloads constructor( var voiceMessageDurationMS = 0L var voiceRecorderState = VoiceRecorderState.Idle - private val attachmentsButton = InputBarButton(context, R.drawable.ic_plus_24).apply { contentDescription = context.getString(R.string.AccessibilityId_attachments_button)} - val microphoneButton = InputBarButton(context, R.drawable.ic_microphone).apply { contentDescription = context.getString(R.string.AccessibilityId_microphone_button)} - private val sendButton = InputBarButton(context, R.drawable.ic_arrow_up, true).apply { contentDescription = context.getString(R.string.AccessibilityId_send_message_button)} + private val attachmentsButton = InputBarButton(context, R.drawable.ic_plus_24).apply { contentDescription = context.getString(R.string.AccessibilityId_attachmentsButton)} + val microphoneButton = InputBarButton(context, R.drawable.ic_microphone).apply { contentDescription = context.getString(R.string.AccessibilityId_voiceMessageNew)} + private val sendButton = InputBarButton(context, R.drawable.ic_arrow_up, true).apply { contentDescription = context.getString(R.string.AccessibilityId_send)} init { // Attachments button diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt index 24b48ecdf7..f245dcadf4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt @@ -19,7 +19,6 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ViewInputBarRecordingBinding -import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.animateSizeChange import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.toPx @@ -106,8 +105,7 @@ class InputBarRecordingView : RelativeLayout { timerJob = scope.launch { while (isActive) { val duration = (Date().time - startTimestamp) / 1000L - binding.recordingViewDurationTextView.text = DateUtils.formatElapsedTime(duration) - + binding.recordingViewDurationTextView.text = android.text.format.DateUtils.formatElapsedTime(duration) delay(500) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt index d4068a3e6c..e3e5df0458 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt @@ -202,13 +202,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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt index c7862ca22e..21d5de52cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt @@ -40,6 +40,8 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p val edKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!! val blindedPublicKey = openGroup?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, edKeyPair)?.publicKey?.asBytes } ?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString + + // Embedded function fun userCanDeleteSelectedItems(): Boolean { val allSentByCurrentUser = selectedItems.all { it.isOutgoing } val allReceivedByCurrentUser = selectedItems.all { !it.isOutgoing } @@ -47,6 +49,8 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p if (allSentByCurrentUser) { return true } return OpenGroupManager.isUserModerator(context, openGroup.groupId, userPublicKey, blindedPublicKey) } + + // Embedded function fun userCanBanSelectedUsers(): Boolean { if (openGroup == null) { return false } val anySentByCurrentUser = selectedItems.any { it.isOutgoing } @@ -55,6 +59,9 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p if (selectedUsers.size > 1) { return false } return OpenGroupManager.isUserModerator(context, openGroup.groupId, userPublicKey, blindedPublicKey) } + + + // Delete message menu.findItem(R.id.menu_context_delete_message).isVisible = userCanDeleteSelectedItems() // Ban user @@ -95,7 +102,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p R.id.menu_context_resync -> delegate?.resyncMessage(selectedItems) R.id.menu_context_resend -> delegate?.resendMessage(selectedItems) R.id.menu_message_details -> delegate?.showMessageDetail(selectedItems) - R.id.menu_context_save_attachment -> delegate?.saveAttachment(selectedItems) + R.id.menu_context_save_attachment -> delegate?.saveAttachmentsIfPossible(selectedItems) R.id.menu_context_reply -> delegate?.reply(selectedItems) } return true @@ -119,7 +126,7 @@ interface ConversationActionModeCallbackDelegate { fun resyncMessage(messages: Set) fun resendMessage(messages: Set) fun showMessageDetail(messages: Set) - fun saveAttachment(messages: Set) + fun saveAttachmentsIfPossible(messages: Set) fun reply(messages: Set) fun destroyActionMode() } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index 8d018d6813..0997db1871 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.conversation.v2.menus +import android.Manifest import android.annotation.SuppressLint import android.content.Context import android.content.Intent @@ -16,15 +17,18 @@ import androidx.appcompat.widget.SearchView.OnQueryTextListener import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat +import com.squareup.phrase.Phrase import network.loki.messenger.R import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.leave import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.toHexString -import org.thoughtcrime.securesms.media.MediaOverviewActivity import org.thoughtcrime.securesms.ShortcutLauncherActivity import org.thoughtcrime.securesms.calls.WebRtcCallActivity import org.thoughtcrime.securesms.contacts.SelectContactsActivity @@ -33,10 +37,14 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.groups.EditClosedGroupActivity import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.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 import java.io.IOException @@ -50,11 +58,11 @@ object ConversationMenuHelper { ) { // Prepare menu.clear() - val isOpenGroup = thread.isCommunityRecipient + val isCommunity = thread.isCommunityRecipient // Base menu (options that should always be present) inflater.inflate(R.menu.menu_conversation, menu) // Expiring messages - if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) { + if (!isCommunity && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) { inflater.inflate(R.menu.menu_conversation_expiration, menu) } // One-on-one chat menu allows copying the account id @@ -74,7 +82,7 @@ object ConversationMenuHelper { inflater.inflate(R.menu.menu_conversation_closed_group, menu) } // Open group menu - if (isOpenGroup) { + if (isCommunity) { inflater.inflate(R.menu.menu_conversation_open_group, menu) } // Muting @@ -160,17 +168,32 @@ object ConversationMenuHelper { private fun call(context: Context, thread: Recipient) { + // if the user has not enabled voice/video calls if (!TextSecurePreferences.isCallNotificationsEnabled(context)) { context.showSessionDialog { - title(R.string.ConversationActivity_call_title) - text(R.string.ConversationActivity_call_prompt) - button(R.string.activity_settings_title, R.string.AccessibilityId_settings) { + title(R.string.callsPermissionsRequired) + text(R.string.callsPermissionsRequiredDescription) + button(R.string.sessionSettings, R.string.AccessibilityId_sessionSettings) { Intent(context, PrivacySettingsActivity::class.java).let(context::startActivity) } cancelButton() } return } + // 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") + + 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 + } WebRtcCallService.createCall(context, thread) .let(context::startService) @@ -178,7 +201,6 @@ object ConversationMenuHelper { Intent(context, WebRtcCallActivity::class.java) .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } .let(context::startActivity) - } @SuppressLint("StaticFieldLeak") @@ -215,7 +237,7 @@ object ConversationMenuHelper { .setIntent(ShortcutLauncherActivity.createIntent(context, thread.address)) .build() if (ShortcutManagerCompat.requestPinShortcut(context, shortcutInfo, null)) { - Toast.makeText(context, context.resources.getString(R.string.ConversationActivity_added_to_home_screen), Toast.LENGTH_LONG).show() + Toast.makeText(context, context.resources.getString(R.string.conversationsAddedToHome), Toast.LENGTH_LONG).show() } } }.execute() @@ -272,17 +294,26 @@ object ConversationMenuHelper { val accountID = TextSecurePreferences.getLocalNumber(context) val isCurrentUserAdmin = admins.any { it.toString() == accountID } val message = if (isCurrentUserAdmin) { - "Because you are the creator of this group it will be deleted for everyone. This cannot be undone." + Phrase.from(context, R.string.groupDeleteDescription) + .put(GROUP_NAME_KEY, group.title) + .format() } else { - context.resources.getString(R.string.ConversationActivity_are_you_sure_you_want_to_leave_this_group) + Phrase.from(context, R.string.groupLeaveDescription) + .put(GROUP_NAME_KEY, group.title) + .format() } - fun onLeaveFailed() = Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show() + fun onLeaveFailed() { + val txt = Phrase.from(context, R.string.groupLeaveErrorFailed) + .put(GROUP_NAME_KEY, group.title) + .format().toString() + Toast.makeText(context, txt, Toast.LENGTH_LONG).show() + } context.showSessionDialog { - title(R.string.ConversationActivity_leave_group) + title(R.string.groupLeave) text(message) - button(R.string.yes) { + dangerButton(R.string.leave) { try { val groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString() val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey) @@ -293,7 +324,7 @@ object ConversationMenuHelper { onLeaveFailed() } } - button(R.string.no) + button(R.string.cancel) } } @@ -309,7 +340,7 @@ object ConversationMenuHelper { } private fun mute(context: Context, thread: Recipient) { - showMuteDialog(ContextThemeWrapper(context, context.theme)) { until -> + showMuteDialog(ContextThemeWrapper(context, context.theme)) { until: Long -> DatabaseComponent.get(context).recipientDatabase().setMuted(thread, until) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt index 1177b4afc9..1a7040b031 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt @@ -1,25 +1,40 @@ package org.thoughtcrime.securesms.conversation.v2.messages +import android.Manifest import android.content.Context +import android.content.Intent import android.util.AttributeSet +import android.util.Log import android.view.LayoutInflater import android.widget.LinearLayout import androidx.core.content.res.ResourcesCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView +import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ViewControlMessageBinding 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.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.conversation.disappearingmessages.DisappearingMessages import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode import org.thoughtcrime.securesms.database.model.MessageRecord 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 + @AndroidEntryPoint class ControlMessageView : LinearLayout { @@ -27,6 +42,12 @@ class ControlMessageView : LinearLayout { private val binding = ViewControlMessageBinding.inflate(LayoutInflater.from(context), this, true) + private val infoDrawable by lazy { + val d = ResourcesCompat.getDrawable(resources, R.drawable.ic_info_outline_white_24dp, context.theme) + d?.setTint(context.getColorFromAttr(R.attr.message_received_text_color)) + d + } + constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet) : super(context, attrs) constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) @@ -75,24 +96,104 @@ class ControlMessageView : LinearLayout { } } message.isMessageRequestResponse -> { - binding.textView.text = context.getString(R.string.message_requests_accepted) - binding.root.contentDescription=context.getString(R.string.AccessibilityId_message_request_config_message) + val msgRecipient = message.recipient.address.serialize() + val me = TextSecurePreferences.getLocalNumber(context) + binding.textView.text = if(me == msgRecipient) { // you accepted the user's request + val threadRecipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(message.threadId) + context.getSubbedCharSequence( + R.string.messageRequestYouHaveAccepted, + NAME_KEY to (threadRecipient?.name ?: "") + ) + } else { // they accepted your request + context.getString(R.string.messageRequestsAccepted) + } + + binding.root.contentDescription = context.getString(R.string.AccessibilityId_message_request_config_message) } message.isCallLog -> { val drawable = when { message.isIncomingCall -> R.drawable.ic_incoming_call message.isOutgoingCall -> R.drawable.ic_outgoing_call - message.isFirstMissedCall -> R.drawable.ic_info_outline_light else -> R.drawable.ic_missed_call } binding.textView.isVisible = false - binding.callTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(ResourcesCompat.getDrawable(resources, drawable, context.theme), null, null, null) + binding.callTextView.setCompoundDrawablesRelativeWithIntrinsicBounds( + ResourcesCompat.getDrawable(resources, drawable, context.theme), + null, null, null) binding.callTextView.text = messageBody if (message.expireStarted > 0 && message.expiresIn > 0) { binding.expirationTimerView.isVisible = true binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) } + + // remove clicks by default + setOnClickListener(null) + hideInfo() + + // handle click behaviour depending on criteria + if (message.isMissedCall || message.isFirstMissedCall) { + when { + // when the call toggle is disabled in the privacy screen, + // show a dedicated privacy dialog + !TextSecurePreferences.isCallNotificationsEnabled(context) -> { + 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.callsYouMissedCallPermissions, + NAME_KEY to message.individualRecipient.name!! + ) + text(bodyTxt) + + button(R.string.sessionSettings) { + Intent(context, PrivacySettingsActivity::class.java) + .let(context::startActivity) + } + cancelButton() + } + } + } + + // 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() + } + } + } + } + } } } @@ -100,6 +201,24 @@ class ControlMessageView : LinearLayout { binding.callView.isVisible = message.isCallLog } + fun showInfo(){ + binding.callTextView.setCompoundDrawablesRelativeWithIntrinsicBounds( + binding.callTextView.compoundDrawablesRelative.first(), + null, + infoDrawable, + null + ) + } + + fun hideInfo(){ + binding.callTextView.setCompoundDrawablesRelativeWithIntrinsicBounds( + binding.callTextView.compoundDrawablesRelative.first(), + null, + null, + null + ) + } + fun recycle() { } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt index 9c725ee048..5b64df059e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt @@ -21,7 +21,7 @@ class DeletedMessageView : LinearLayout { // region Updating fun bind(message: MessageRecord, @ColorInt textColor: Int) { assert(message.isDeleted) - binding.deleteTitleTextView.text = context.getString(R.string.deleted_message) + binding.deleteTitleTextView.text = context.resources.getQuantityString(R.plurals.deleteMessageDeleted, 1, 1) binding.deleteTitleTextView.setTextColor(textColor) binding.deletedMessageViewIconImageView.imageTintList = ColorStateList.valueOf(textColor) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.kt index 49e4b1044f..27714fbc05 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.kt @@ -10,9 +10,12 @@ import android.widget.LinearLayout import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat +import androidx.core.view.setPadding import com.google.android.flexbox.JustifyContent +import com.squareup.phrase.Phrase import network.loki.messenger.R import network.loki.messenger.databinding.ViewEmojiReactionsBinding +import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber import org.session.libsession.utilities.ThemeUtil import org.thoughtcrime.securesms.components.emoji.EmojiImageView @@ -43,6 +46,8 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { private var onDownTimestamp: Long = 0 private var extended = false + private val overflowItemSize = ViewUtil.dpToPx(24) + constructor(context: Context) : super(context) { init(null) } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { init(attrs) } constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { init(attrs) } @@ -81,7 +86,9 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { if (v.tag == null) return false val reaction = v.tag as Reaction val action = event.action - if (action == MotionEvent.ACTION_DOWN) onDown(MessageId(reaction.messageId, reaction.isMms)) else if (action == MotionEvent.ACTION_CANCEL) removeLongPressCallback() else if (action == MotionEvent.ACTION_UP) onUp(reaction) + if (action == MotionEvent.ACTION_DOWN) onDown(MessageId(reaction.messageId, reaction.isMms), reaction.emoji) + else if (action == MotionEvent.ACTION_CANCEL) removeLongPressCallback() + else if (action == MotionEvent.ACTION_UP) onUp(reaction) return true } @@ -91,18 +98,15 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { binding.layoutEmojiContainer.removeAllViews() val overflowContainer = LinearLayout(context) overflowContainer.orientation = LinearLayout.HORIZONTAL - val innerPadding = ViewUtil.dpToPx(4) - overflowContainer.setPaddingRelative(innerPadding, innerPadding, innerPadding, innerPadding) val pixelSize = ViewUtil.dpToPx(1) - for (reaction in reactions) { + reactions.forEachIndexed { index, reaction -> if (binding.layoutEmojiContainer.childCount + 1 >= DEFAULT_THRESHOLD && threshold != Int.MAX_VALUE && reactions.size > threshold) { if (overflowContainer.parent == null) { binding.layoutEmojiContainer.addView(overflowContainer) val overflowParams = overflowContainer.layoutParams as MarginLayoutParams - overflowParams.height = ViewUtil.dpToPx(26) + overflowParams.height = MarginLayoutParams.WRAP_CONTENT overflowParams.setMargins(pixelSize, pixelSize, pixelSize, pixelSize) overflowContainer.layoutParams = overflowParams - overflowContainer.background = ContextCompat.getDrawable(context, R.drawable.reaction_pill_background) } val pill = buildPill(context, this, reaction, true) pill.setOnClickListener { v: View? -> @@ -111,6 +115,7 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { } pill.findViewById(R.id.reactions_pill_count).visibility = GONE pill.findViewById(R.id.reactions_pill_spacer).visibility = GONE + pill.z = reaction.count - index.toFloat() // make sure the overflow is stacked properly overflowContainer.addView(pill) } else { val pill = buildPill(context, this, reaction, false) @@ -179,9 +184,10 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { val countView = root.findViewById(R.id.reactions_pill_count) val spacer = root.findViewById(R.id.reactions_pill_spacer) if (isCompact) { - root.setPaddingRelative(1, 1, 1, 1) + root.setPadding(0) val layoutParams = root.layoutParams - layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT + layoutParams.height = overflowItemSize + layoutParams.width = overflowItemSize root.layoutParams = layoutParams } if (reaction.emoji != null) { @@ -195,15 +201,14 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { } else { emojiView.visibility = GONE spacer.visibility = GONE - countView.text = context.getString(R.string.ReactionsConversationView_plus, reaction.count) + countView.text = Phrase.from(context, R.string.andMore).put(COUNT_KEY, reaction.count.toInt()).format() } if (reaction.userWasSender && !isCompact) { root.background = ContextCompat.getDrawable(context, R.drawable.reaction_pill_background_selected) countView.setTextColor(ThemeUtil.getThemedColor(context, R.attr.reactionsPillSelectedTextColor)) } else { - if (!isCompact) { - root.background = ContextCompat.getDrawable(context, R.drawable.reaction_pill_background) - } + root.background = if(isCompact) ContextCompat.getDrawable(context, R.drawable.reaction_pill_background_bordered) + else ContextCompat.getDrawable(context, R.drawable.reaction_pill_background) } return root } @@ -215,12 +220,12 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { } } - private fun onDown(messageId: MessageId) { + private fun onDown(messageId: MessageId, emoji: String?) { removeLongPressCallback() val newLongPressCallback = Runnable { performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) if (delegate != null) { - delegate!!.onReactionLongClicked(messageId) + delegate!!.onReactionLongClicked(messageId, emoji) } } longPressCallback = newLongPressCallback diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt index 8cf80dc090..d064d02872 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt @@ -6,16 +6,16 @@ import android.graphics.Rect import android.util.AttributeSet import android.view.MotionEvent import android.widget.LinearLayout -import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isVisible +import com.bumptech.glide.RequestManager import network.loki.messenger.R import network.loki.messenger.databinding.ViewLinkPreviewBinding import org.session.libsession.utilities.getColorFromAttr +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.components.CornerMask -import org.thoughtcrime.securesms.conversation.v2.ModalUrlBottomSheet +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.utilities.MessageBubbleUtilities import org.thoughtcrime.securesms.database.model.MmsMessageRecord -import com.bumptech.glide.RequestManager import org.thoughtcrime.securesms.mms.ImageSlide class LinkPreviewView : LinearLayout { @@ -84,10 +84,11 @@ class LinkPreviewView : LinearLayout { } } - fun openURL() { - val url = this.url ?: return - val activity = context as AppCompatActivity - ModalUrlBottomSheet(url).show(activity.supportFragmentManager, "Open URL Dialog") + // Method to show the open or copy URL dialog + private fun openURL() { + val url = this.url ?: return Log.w("LinkPreviewView", "Cannot open a null URL") + val activity = context as? ConversationActivityV2 + activity?.showOpenUrlDialog(url) } // endregion } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt index 40cf4bc1e0..dc6b05b444 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt @@ -75,13 +75,13 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? val quoteIsLocalUser = localNumber != null && authorPublicKey == localNumber val authorDisplayName = - if (quoteIsLocalUser) context.getString(R.string.QuoteView_you) + if (quoteIsLocalUser) context.getString(R.string.you) else author?.displayName(Contact.contextForRecipient(thread)) ?: "${authorPublicKey.take(4)}...${authorPublicKey.takeLast(4)}" binding.quoteViewAuthorTextView.text = authorDisplayName binding.quoteViewAuthorTextView.setTextColor(getTextColor(isOutgoingMessage)) // Body binding.quoteViewBodyTextView.text = if (isOpenGroupInvitation) - resources.getString(R.string.open_group_invitation_view__open_group_invitation) + resources.getString(R.string.communityInvitation) else MentionUtilities.highlightMentions( text = (body ?: "").toSpannable(), isOutgoingMessage = isOutgoingMessage, @@ -106,7 +106,16 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? attachments.audioSlide != null -> { binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_microphone) binding.quoteViewAttachmentPreviewImageView.isVisible = true - binding.quoteViewBodyTextView.text = resources.getString(R.string.Slide_audio) + // A missing file name is the legacy way to determine if an audio attachment is + // a voice note vs. other arbitrary audio attachments. + val attachment = attachments.asAttachments().firstOrNull() + val isVoiceNote = attachment?.isVoiceNote == true || + attachment != null && attachment.fileName.isNullOrEmpty() + binding.quoteViewBodyTextView.text = if (isVoiceNote) { + resources.getString(R.string.messageVoice) + } else { + resources.getString(R.string.audio) + } } attachments.documentSlide != null -> { binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_document_large_light) @@ -120,7 +129,7 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? .root.setRoundedCorners(toPx(4, resources)) binding.quoteViewAttachmentThumbnailImageView.root.setImageResource(glide, slide, false) binding.quoteViewAttachmentThumbnailImageView.root.isVisible = true - binding.quoteViewBodyTextView.text = if (MediaUtil.isVideo(slide.asAttachment())) resources.getString(R.string.Slide_video) else resources.getString(R.string.Slide_image) + binding.quoteViewBodyTextView.text = if (MediaUtil.isVideo(slide.asAttachment())) resources.getString(R.string.video) else resources.getString(R.string.image) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt index 47034cf8ed..7d1dc625f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt @@ -5,12 +5,13 @@ import android.util.AttributeSet import android.widget.LinearLayout import androidx.annotation.ColorInt import androidx.core.content.ContextCompat +import com.squareup.phrase.Phrase import network.loki.messenger.R import network.loki.messenger.databinding.ViewUntrustedAttachmentBinding +import org.session.libsession.utilities.StringSubstitutionConstants.FILE_TYPE_KEY import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.dialogs.DownloadDialog import org.thoughtcrime.securesms.util.ActivityDispatcher -import java.util.Locale class UntrustedAttachmentView: LinearLayout { private val binding: ViewUntrustedAttachmentBinding by lazy { ViewUntrustedAttachmentBinding.bind(this) } @@ -30,13 +31,17 @@ class UntrustedAttachmentView: LinearLayout { // region Updating fun bind(attachmentType: AttachmentType, @ColorInt textColor: Int) { val (iconRes, stringRes) = when (attachmentType) { - AttachmentType.AUDIO -> R.drawable.ic_microphone to R.string.Slide_audio - AttachmentType.DOCUMENT -> R.drawable.ic_document_large_light to R.string.document + AttachmentType.AUDIO -> R.drawable.ic_microphone to R.string.audio + AttachmentType.DOCUMENT -> R.drawable.ic_document_large_light to R.string.files AttachmentType.MEDIA -> R.drawable.ic_image_white_24dp to R.string.media } val iconDrawable = ContextCompat.getDrawable(context,iconRes)!! iconDrawable.mutate().setTint(textColor) - val text = context.getString(R.string.UntrustedAttachmentView_download_attachment, context.getString(stringRes).toLowerCase(Locale.ROOT)) + + val text = Phrase.from(context, R.string.attachmentsTapToDownload) + .put(FILE_TYPE_KEY, context.getString(stringRes)) + .format() + binding.untrustedAttachmentTitle.text = text binding.untrustedAttachmentIcon.setImageDrawable(iconDrawable) binding.untrustedAttachmentTitle.text = text diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index dcce528234..d62cc532c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -12,34 +12,29 @@ import android.util.AttributeSet import android.view.MotionEvent import android.view.View import androidx.annotation.ColorInt -import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.graphics.ColorUtils import androidx.core.text.getSpans import androidx.core.text.toSpannable import androidx.core.view.children import androidx.core.view.isVisible +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestManager import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageContentBinding -import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.modifyLayoutParams import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.conversation.v2.ModalUrlBottomSheet import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities import org.thoughtcrime.securesms.conversation.v2.utilities.ModalURLSpan import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getIntersectedModalSpans import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.SmsMessageRecord -import com.bumptech.glide.Glide -import com.bumptech.glide.RequestManager import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.SearchUtil import org.thoughtcrime.securesms.util.getAccentColor @@ -117,7 +112,7 @@ class VisibleMessageContentView : ConstraintLayout { binding.quoteView.root.isVisible = true val quote = message.quote!! val quoteText = if (quote.isOriginalMissing) { - context.getString(R.string.QuoteView_original_missing) + context.getString(R.string.messageErrorOriginal) } else { quote.text } @@ -292,8 +287,8 @@ class VisibleMessageContentView : ConstraintLayout { body.getSpans(0, body.length).toList().forEach { urlSpan -> val updatedUrl = urlSpan.url.let { it.toHttpUrlOrNull().toString() } val replacementSpan = ModalURLSpan(updatedUrl) { url -> - val activity = context as AppCompatActivity - ModalUrlBottomSheet(url).show(activity.supportFragmentManager, "Open URL Dialog") + val activity = context as? ConversationActivityV2 + activity?.showOpenUrlDialog(url) } val start = body.getSpanStart(urlSpan) val end = body.getSpanEnd(urlSpan) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 9f7f620ab5..1734d75b08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -27,6 +27,13 @@ import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.core.view.marginBottom import dagger.hilt.android.AndroidEntryPoint +import java.util.Date +import java.util.Locale +import javax.inject.Inject +import kotlin.math.abs +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sqrt import network.loki.messenger.R import network.loki.messenger.databinding.ViewEmojiReactionsBinding import network.loki.messenger.databinding.ViewVisibleMessageBinding @@ -54,17 +61,12 @@ import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.home.UserDetailsBottomSheet import com.bumptech.glide.Glide import com.bumptech.glide.RequestManager +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.toDp import org.thoughtcrime.securesms.util.toPx -import java.util.Date -import java.util.Locale -import javax.inject.Inject -import kotlin.math.abs -import kotlin.math.min -import kotlin.math.roundToInt -import kotlin.math.sqrt private const val TAG = "VisibleMessageView" @@ -269,8 +271,7 @@ class VisibleMessageView : FrameLayout { // Method to display or hide the status of a message. // Note: Although most commonly used to display the delivery status of a message, we also use the // message status area to display the disappearing messages state - so in this latter case we'll - // be displaying the "Sent" and the animating clock icon for outgoing messages or "Read" and the - // animated clock icon for incoming messages. + // be displaying either "Sent" or "Read" and the animating clock icon. private fun showStatusMessage(message: MessageRecord) { // We'll start by hiding everything and then only make visible what we need binding.messageStatusTextView.isVisible = false @@ -384,37 +385,48 @@ class VisibleMessageView : FrameLayout { message.isFailed -> MessageStatusInfo(R.drawable.ic_delivery_status_failed, getThemedColor(context, R.attr.danger), - R.string.delivery_status_failed + R.string.messageStatusFailedToSend ) message.isSyncFailed -> MessageStatusInfo( R.drawable.ic_delivery_status_failed, context.getColor(R.color.accent_orange), - R.string.delivery_status_sync_failed - ) - message.isPending -> - MessageStatusInfo( - R.drawable.ic_delivery_status_sending, - context.getColorFromAttr(R.attr.message_status_color), - R.string.delivery_status_sending + R.string.messageStatusFailedToSync ) + message.isPending -> { + // Non-mms messages (or quote messages, which happen to be mms for some reason) display 'Sending'.. + if (!message.isMms || (message as? MmsMessageRecord)?.quote != null) { + MessageStatusInfo( + R.drawable.ic_delivery_status_sending, + context.getColorFromAttr(R.attr.message_status_color), + R.string.sending + ) + } else { + // ..and Mms messages display 'Uploading'. + MessageStatusInfo( + R.drawable.ic_delivery_status_sending, + context.getColorFromAttr(R.attr.message_status_color), + R.string.uploading + ) + } + } message.isSyncing || message.isResyncing -> MessageStatusInfo( R.drawable.ic_delivery_status_sending, context.getColorFromAttr(R.attr.message_status_color), - R.string.delivery_status_sending // We COULD tell the user that we're `syncing` (R.string.delivery_status_syncing) but it will likely make more sense to them if we say "Sending" + R.string.messageStatusSyncing ) message.isRead || message.isIncoming -> MessageStatusInfo( R.drawable.ic_delivery_status_read, context.getColorFromAttr(R.attr.message_status_color), - R.string.delivery_status_read + R.string.read ) message.isSent -> MessageStatusInfo( R.drawable.ic_delivery_status_sent, context.getColorFromAttr(R.attr.message_status_color), - R.string.delivery_status_sent + R.string.disappearingMessagesSent ) else -> { // The message isn't one we care about for message statuses we display to the user (i.e., diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageViewDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageViewDelegate.kt index 6788dd3f38..69797b8848 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageViewDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageViewDelegate.kt @@ -10,6 +10,6 @@ interface VisibleMessageViewDelegate { fun onReactionClicked(emoji: String, messageId: MessageId, userWasSender: Boolean) - fun onReactionLongClicked(messageId: MessageId) + fun onReactionLongClicked(messageId: MessageId, emoji: String?) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt index afed74b1cc..a379a23445 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt @@ -5,9 +5,11 @@ import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.widget.LinearLayout +import com.squareup.phrase.Phrase import network.loki.messenger.R import network.loki.messenger.databinding.ViewSearchBottomBarBinding - +import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.TOTAL_COUNT_KEY class SearchBottomBar : LinearLayout { private lateinit var binding: ViewSearchBottomBarBinding @@ -35,7 +37,7 @@ class SearchBottomBar : LinearLayout { } } if (count > 0) { - searchPosition.text = resources.getString(R.string.ConversationActivity_search_position, position + 1, count) + searchPosition.text = resources.getQuantityString(R.plurals.searchMatches, count, position + 1, count) } else { searchPosition.text = "" } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt index 48bb731c68..82156b32e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt @@ -94,6 +94,8 @@ class SearchViewModel @Inject constructor( } } + public fun getActiveQuery() = activeQuery + class SearchResult(private val results: CursorList, val position: Int) : Closeable { fun getResults(): List { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java index ee98f623f2..ccbba13b3a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java @@ -16,6 +16,8 @@ */ package org.thoughtcrime.securesms.conversation.v2.utilities; +import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; + import android.Manifest; import android.annotation.SuppressLint; import android.app.Activity; @@ -30,10 +32,14 @@ import android.text.TextUtils; import android.util.Pair; import android.widget.Toast; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; - +import com.squareup.phrase.Phrase; +import java.io.IOException; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import network.loki.messenger.R; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsignal.utilities.ListenableFuture; import org.session.libsignal.utilities.Log; @@ -55,17 +61,13 @@ import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.util.MediaUtil; -import java.io.IOException; -import java.util.Iterator; -import java.util.LinkedList; -import java.util.List; - -import network.loki.messenger.R; - public class AttachmentManager { private final static String TAG = AttachmentManager.class.getSimpleName(); + // Max attachment size is 10MB, above which we display a warning toast rather than sending the msg + private final long MAX_ATTACHMENTS_FILE_SIZE_BYTES = 10 * 1024 * 1024; + private final @NonNull Context context; private final @NonNull AttachmentListener attachmentListener; @@ -242,33 +244,58 @@ SlideDeck buildSlideDeck() { public static void selectDocument(Activity activity, int requestCode) { Permissions.PermissionsBuilder builder = Permissions.with(activity); + Context c = activity.getApplicationContext(); // The READ_EXTERNAL_STORAGE permission is deprecated (and will AUTO-FAIL if requested!) on // Android 13 and above (API 33 - 'Tiramisu') we must ask for READ_MEDIA_VIDEO/IMAGES/AUDIO instead. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { builder = builder.request(Manifest.permission.READ_MEDIA_VIDEO) .request(Manifest.permission.READ_MEDIA_IMAGES) - .request(Manifest.permission.READ_MEDIA_AUDIO); + .request(Manifest.permission.READ_MEDIA_AUDIO) + .withRationaleDialog( + Phrase.from(c, R.string.permissionsMusicAudio) + .put(APP_NAME_KEY, c.getString(R.string.app_name)).format().toString() + ) + .withPermanentDenialDialog( + Phrase.from(c, R.string.permissionMusicAudioDenied) + .put(APP_NAME_KEY, c.getString(R.string.app_name)) + .format().toString() + ); } else { - builder = builder.request(Manifest.permission.READ_EXTERNAL_STORAGE); + builder = builder.request(Manifest.permission.READ_EXTERNAL_STORAGE) + .withPermanentDenialDialog( + Phrase.from(c, R.string.permissionsStorageDeniedLegacy) + .put(APP_NAME_KEY, c.getString(R.string.app_name)) + .format().toString() + ); } - builder.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) - .withRationaleDialog(activity.getString(R.string.ConversationActivity_to_send_photos_and_video_allow_signal_access_to_storage), R.drawable.ic_baseline_photo_library_24) - .onAllGranted(() -> selectMediaType(activity, "*/*", null, requestCode)) // Note: We can use startActivityForResult w/ the ACTION_OPEN_DOCUMENT or ACTION_OPEN_DOCUMENT_TREE intent if we need to modernise this. + + builder.onAllGranted(() -> selectMediaType(activity, "*/*", null, requestCode)) // Note: We can use startActivityForResult w/ the ACTION_OPEN_DOCUMENT or ACTION_OPEN_DOCUMENT_TREE intent if we need to modernise this. .execute(); } public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body) { + + Context c = activity.getApplicationContext(); + Permissions.PermissionsBuilder builder = Permissions.with(activity); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { builder = builder.request(Manifest.permission.READ_MEDIA_VIDEO) - .request(Manifest.permission.READ_MEDIA_IMAGES); + .request(Manifest.permission.READ_MEDIA_IMAGES) + .withPermanentDenialDialog( + Phrase.from(c, R.string.permissionsStorageDenied) + .put(APP_NAME_KEY, c.getString(R.string.app_name)) + .format().toString() + ); } else { - builder = builder.request(Manifest.permission.READ_EXTERNAL_STORAGE); + builder = builder.request(Manifest.permission.READ_EXTERNAL_STORAGE) + .withPermanentDenialDialog( + Phrase.from(c, R.string.permissionsStorageDeniedLegacy) + .put(APP_NAME_KEY, c.getString(R.string.app_name)) + .format().toString() + ); } - builder.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) - .withRationaleDialog(activity.getString(R.string.ConversationActivity_to_send_photos_and_video_allow_signal_access_to_storage), R.drawable.ic_baseline_photo_library_24) - .onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode)) + builder.onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode)) .execute(); } @@ -291,10 +318,14 @@ public static void selectGif(Activity activity, int requestCode) { } public void capturePhoto(Activity activity, int requestCode, Recipient recipient) { + + String cameraPermissionDeniedTxt = Phrase.from(context, R.string.permissionsCameraDenied) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .format().toString(); + Permissions.with(activity) .request(Manifest.permission.CAMERA) - .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_camera_permission_in_order_to_take_photos_but_it_has_been_permanently_denied)) - .withRationaleDialog(activity.getString(R.string.ConversationActivity_to_capture_photos_and_video_allow_signal_access_to_the_camera),R.drawable.ic_baseline_photo_camera_24) + .withPermanentDenialDialog(cameraPermissionDeniedTxt) .onAllGranted(() -> { Intent captureIntent = MediaSendActivity.buildCameraIntent(activity, recipient); if (captureIntent.resolveActivity(activity.getPackageManager()) != null) { @@ -326,7 +357,7 @@ private static void selectMediaType(Activity activity, @NonNull String type, @Nu activity.startActivityForResult(intent, requestCode); } catch (ActivityNotFoundException anfe) { Log.w(TAG, "couldn't complete ACTION_GET_CONTENT intent, no activity found. falling back."); - Toast.makeText(activity, R.string.AttachmentManager_cant_open_media_selection, Toast.LENGTH_LONG).show(); + Toast.makeText(activity, R.string.attachmentsErrorNoApp, Toast.LENGTH_LONG).show(); } } @@ -334,9 +365,21 @@ private boolean areConstraintsSatisfied(final @NonNull Context context, final @Nullable Slide slide, final @NonNull MediaConstraints constraints) { - return slide == null || - constraints.isSatisfied(context, slide.asAttachment()) || - constraints.canResize(slide.asAttachment()); + // Null attachment? Not satisfied. + if (slide == null) return false; + + // Attachments are excessively large? Not satisfied. + // Note: This file size test must come BEFORE the `constraints.isSatisfied` check below because + // it is a more specific type of check. + if (slide.asAttachment().getSize() > MAX_ATTACHMENTS_FILE_SIZE_BYTES) { + Toast.makeText(context, R.string.attachmentsErrorSize, Toast.LENGTH_SHORT).show(); + return false; + } + + // Otherwise we return whether our constraints are satisfied OR if we can resize the attachment + // (in the case of one or more images) - either one will be acceptable, but if both aren't then + // we fail the constraint test. + return constraints.isSatisfied(context, slide.asAttachment()) || constraints.canResize(slide.asAttachment()); } public interface AttachmentListener { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt index 4d3e48bc5b..39301cd69f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt @@ -54,13 +54,14 @@ object MentionUtilities { val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! val openGroup by lazy { DatabaseComponent.get(context).storage().getOpenGroup(threadID) } - // format the mention text + // Format the mention text if (matcher.find(startIndex)) { while (true) { val publicKey = text.subSequence(matcher.start() + 1, matcher.end()).toString() // +1 to get rid of the @ + val isYou = isYou(publicKey, userPublicKey, openGroup) val userDisplayName: String? = if (isYou) { - context.getString(R.string.MessageRecord_you) + context.getString(R.string.you) } else { val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey) @Suppress("NAME_SHADOWING") val context = if (openGroup != null) Contact.ContactContext.OPEN_GROUP else Contact.ContactContext.REGULAR diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/NotificationUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/NotificationUtils.kt index c0ce83f631..f012f925ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/NotificationUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/NotificationUtils.kt @@ -8,7 +8,7 @@ import org.thoughtcrime.securesms.showSessionDialog object NotificationUtils { fun showNotifyDialog(context: Context, thread: Recipient, notifyTypeHandler: (Int)->Unit) { context.showSessionDialog { - title(R.string.RecipientPreferenceActivity_notification_settings) + title(R.string.sessionNotifications) singleChoiceItems( context.resources.getStringArray(R.array.notify_types), thread.notifyType diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/TextUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/TextUtilities.kt index 7a47b92756..23d81e2513 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/TextUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/TextUtilities.kt @@ -1,9 +1,13 @@ package org.thoughtcrime.securesms.conversation.v2.utilities import android.graphics.Rect +import android.graphics.Typeface import android.text.Layout +import android.text.SpannableString +import android.text.Spanned import android.text.StaticLayout import android.text.TextPaint +import android.text.style.StyleSpan import android.view.MotionEvent import android.widget.TextView import androidx.core.text.getSpans diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt index 83932b2ce4..b7103b9c23 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt @@ -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 { - 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java index 822e40129e..be083256db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java @@ -3,14 +3,8 @@ import android.content.ContentValues; import android.content.Context; import android.database.Cursor; -import android.net.Uri; -import androidx.annotation.Nullable; - import net.zetetic.database.sqlcipher.SQLiteDatabase; - -import network.loki.messenger.R; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; - import java.util.LinkedList; import java.util.List; import java.util.Set; @@ -24,10 +18,10 @@ public class DraftDatabase extends Database { public static final String DRAFT_VALUE = "value"; public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " + - THREAD_ID + " INTEGER, " + DRAFT_TYPE + " TEXT, " + DRAFT_VALUE + " TEXT);"; + THREAD_ID + " INTEGER, " + DRAFT_TYPE + " TEXT, " + DRAFT_VALUE + " TEXT);"; public static final String[] CREATE_INDEXS = { - "CREATE INDEX IF NOT EXISTS draft_thread_index ON " + TABLE_NAME + " (" + THREAD_ID + ");", + "CREATE INDEX IF NOT EXISTS draft_thread_index ON " + TABLE_NAME + " (" + THREAD_ID + ");", }; public DraftDatabase(Context context, SQLCipherOpenHelper databaseHelper) { @@ -59,8 +53,8 @@ void clearDrafts(Set threadIds) { for (long threadId : threadIds) { where.append(" OR ") - .append(THREAD_ID) - .append(" = ?"); + .append(THREAD_ID) + .append(" = ?"); arguments.add(String.valueOf(threadId)); } @@ -95,12 +89,10 @@ public List getDrafts(long threadId) { } } + // Class to save drafts of text (only) messages if the user is in the middle of writing a message + // and then the app loses focus or is closed. public static class Draft { - public static final String TEXT = "text"; - public static final String IMAGE = "image"; - public static final String VIDEO = "video"; - public static final String AUDIO = "audio"; - public static final String QUOTE = "quote"; + public static final String TEXT = "text"; private final String type; private final String value; @@ -117,48 +109,10 @@ public String getType() { public String getValue() { return value; } - - String getSnippet(Context context) { - switch (type) { - case TEXT: return value; - case IMAGE: return context.getString(R.string.DraftDatabase_Draft_image_snippet); - case VIDEO: return context.getString(R.string.DraftDatabase_Draft_video_snippet); - case AUDIO: return context.getString(R.string.DraftDatabase_Draft_audio_snippet); - case QUOTE: return context.getString(R.string.DraftDatabase_Draft_quote_snippet); - default: return null; - } - } } public static class Drafts extends LinkedList { - private Draft getDraftOfType(String type) { - for (Draft draft : this) { - if (type.equals(draft.getType())) { - return draft; - } - } - return null; - } - - public String getSnippet(Context context) { - Draft textDraft = getDraftOfType(Draft.TEXT); - if (textDraft != null) { - return textDraft.getSnippet(context); - } else if (size() > 0) { - return get(0).getSnippet(context); - } else { - return ""; - } - } - - public @Nullable Uri getUriSnippet() { - Draft imageDraft = getDraftOfType(Draft.IMAGE); - - if (imageDraft != null && imageDraft.getValue() != null) { - return Uri.parse(imageDraft.getValue()); - } - - return null; - } + // We don't do anything with drafts of a given type anymore (image, audio etc.) - we store TEXT + // drafts, and any files or audio get sent to the recipient when added as a message. } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java index e6bc04e364..de5094fbd6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java @@ -234,7 +234,8 @@ public static boolean isIdentityUpdate(long type) { public static boolean isCallLog(long type) { long baseType = type & BASE_TYPE_MASK; - return baseType == INCOMING_CALL_TYPE || baseType == OUTGOING_CALL_TYPE || baseType == MISSED_CALL_TYPE || baseType == FIRST_MISSED_CALL_TYPE; + return baseType == INCOMING_CALL_TYPE || baseType == OUTGOING_CALL_TYPE || + baseType == MISSED_CALL_TYPE || baseType == FIRST_MISSED_CALL_TYPE; } public static boolean isExpirationTimerUpdate(long type) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 08aad8b6df..8fdbe2accc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.database import android.content.Context import android.net.Uri +import network.loki.messenger.R import java.security.MessageDigest import network.loki.messenger.libsession_util.ConfigBase import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN @@ -633,7 +634,11 @@ open class Storage( // Notify the user val threadID = getOrCreateThreadIdFor(Address.fromSerialized(groupId)) threadDb.setDate(threadID, formationTimestamp) - insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, title, members.map { it.serialize() }, admins.map { it.serialize() }, threadID, formationTimestamp) + + // Note: Commenting out this line prevents the timestamp of room creation being added to a new closed group, + // which in turn allows us to show the `groupNoMessages` control message text. + //insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, title, members.map { it.serialize() }, admins.map { it.serialize() }, threadID, formationTimestamp) + // Don't create config group here, it's from a config update // Start polling ClosedGroupPollerV2.shared.startPolling(group.accountId) @@ -1444,7 +1449,10 @@ open class Storage( SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode) } - override fun insertMessageRequestResponse(response: MessageRequestResponse) { + /** + * This will create a control message used to indicate that a contact has accepted our message request + */ + override fun insertMessageRequestResponseFromContact(response: MessageRequestResponse) { val userPublicKey = getUserPublicKey() val senderPublicKey = response.sender!! val recipientPublicKey = response.recipient!! @@ -1538,6 +1546,34 @@ open class Storage( } } + /** + * This will create a control message used to indicate that you have accepted a message request + */ + override fun insertMessageRequestResponseFromYou(threadId: Long){ + val userPublicKey = getUserPublicKey() ?: return + + val mmsDb = DatabaseComponent.get(context).mmsDatabase() + val message = IncomingMediaMessage( + fromSerialized(userPublicKey), + SnodeAPI.nowWithOffset, + -1, + 0, + 0, + false, + false, + true, + false, + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent() + ) + mmsDb.insertSecureDecryptedMessageInbox(message, threadId, runThreadUpdate = false) + } + override fun getRecipientApproved(address: Address): Boolean { return DatabaseComponent.get(context).recipientDatabase().getApproved(address) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index f5c6da5fb9..f48686aded 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -808,8 +808,8 @@ private boolean possibleToDeleteThreadOnEmpty(long threadId) { private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) { if (messageRecord.isMms()) { MmsMessageRecord record = (MmsMessageRecord) messageRecord; - if (record.getSharedContacts().size() > 0) { - Contact contact = ((MmsMessageRecord) messageRecord).getSharedContacts().get(0); + if (!record.getSharedContacts().isEmpty()) { + Contact contact = ((MmsMessageRecord)messageRecord).getSharedContacts().get(0); return ContactUtil.getStringSummary(context, contact).toString(); } String attachmentString = record.getSlideDeck().getBody(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 9ee3a6957c..b6ebd6db84 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -1,18 +1,20 @@ package org.thoughtcrime.securesms.database.helpers; +import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; + import android.app.NotificationChannel; import android.app.NotificationManager; import android.content.Context; import android.database.Cursor; - import androidx.annotation.NonNull; import androidx.core.app.NotificationCompat; - +import com.squareup.phrase.Phrase; +import java.io.File; import net.zetetic.database.sqlcipher.SQLiteConnection; import net.zetetic.database.sqlcipher.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabaseHook; import net.zetetic.database.sqlcipher.SQLiteOpenHelper; - +import network.loki.messenger.R; import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.crypto.DatabaseSecret; @@ -39,13 +41,8 @@ import org.thoughtcrime.securesms.database.SessionJobDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities; -import java.io.File; - -import network.loki.messenger.R; - public class SQLCipherOpenHelper extends SQLiteOpenHelper { @SuppressWarnings("unused") @@ -250,18 +247,22 @@ public static void migrateSqlCipher3To4IfNeeded(@NonNull Context context, @NonNu // Notify the user of the issue so they know they can downgrade until the issue is fixed NotificationManager notificationManager = context.getSystemService(NotificationManager.class); - String channelId = context.getString(R.string.NotificationChannel_failures); + String channelId = context.getString(R.string.failures); NotificationChannel channel = new NotificationChannel(channelId, channelId, NotificationManager.IMPORTANCE_HIGH); channel.enableVibration(true); notificationManager.createNotificationChannel(channel); + CharSequence errorTxt = Phrase.from(context, R.string.databaseErrorGeneric) + .put(APP_NAME_KEY, R.string.app_name) + .format(); + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId) .setSmallIcon(R.drawable.ic_notification) .setColor(context.getResources().getColor(R.color.textsecure_primary)) .setCategory(NotificationCompat.CATEGORY_ERROR) - .setContentTitle(context.getString(R.string.ErrorNotifier_migration)) - .setContentText(context.getString(R.string.ErrorNotifier_migration_downgrade)) + .setContentTitle(context.getString(R.string.errorDatabase)) + .setContentText(errorTxt) .setAutoCancel(true); notificationManager.notify(5874, builder.build()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/BucketedThreadMediaLoader.java b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/BucketedThreadMediaLoader.java deleted file mode 100644 index 05c13cc55d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/BucketedThreadMediaLoader.java +++ /dev/null @@ -1,232 +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 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 { - - @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) { - this.TODAY = new TimeBucket(context.getString(R.string.BucketedThreadMedia_Today), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -1), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, 1000)); - this.YESTERDAY = new TimeBucket(context.getString(R.string.BucketedThreadMedia_Yesterday), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -2), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -1)); - this.THIS_WEEK = new TimeBucket(context.getString(R.string.BucketedThreadMedia_This_week), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -7), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -2)); - this.THIS_MONTH = new TimeBucket(context.getString(R.string.BucketedThreadMedia_This_month), 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 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 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 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 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> 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 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 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); - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java index 639ea0db09..6ae671c065 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java @@ -17,15 +17,11 @@ package org.thoughtcrime.securesms.database.model; import android.content.Context; -import android.text.SpannableString; import androidx.annotation.NonNull; import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.MmsSmsColumns; -import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; /** @@ -68,7 +64,7 @@ public abstract class DisplayRecord { public @NonNull String getBody() { return body == null ? "" : body; } - public abstract SpannableString getDisplayBody(@NonNull Context context); + public abstract CharSequence getDisplayBody(@NonNull Context context); public Recipient getRecipient() { return recipient; } public long getDateSent() { return dateSent; } public long getDateReceived() { return dateReceived; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java index 1b566169d7..0383d17bda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java @@ -17,7 +17,6 @@ package org.thoughtcrime.securesms.database.model; import android.content.Context; -import android.text.SpannableString; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -27,14 +26,11 @@ import org.session.libsession.utilities.IdentityKeyMismatch; import org.session.libsession.utilities.NetworkFailure; import org.session.libsession.utilities.recipients.Recipient; -import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase.Status; import org.thoughtcrime.securesms.mms.SlideDeck; import java.util.List; -import network.loki.messenger.R; - /** * Represents the message record model for MMS messages that contain * media (ie: they've been downloaded). @@ -76,15 +72,7 @@ public boolean isMmsNotification() { } @Override - public SpannableString getDisplayBody(@NonNull Context context) { - if (MmsDatabase.Types.isFailedDecryptType(type)) { - return emphasisAdded(context.getString(R.string.MmsMessageRecord_bad_encrypted_mms_message)); - } else if (MmsDatabase.Types.isDuplicateMessageType(type)) { - return emphasisAdded(context.getString(R.string.SmsMessageRecord_duplicate_message)); - } else if (MmsDatabase.Types.isNoRemoteSessionType(type)) { - return emphasisAdded(context.getString(R.string.MmsMessageRecord_mms_message_encrypted_for_non_existing_session)); - } - + public CharSequence getDisplayBody(@NonNull Context context) { return super.getDisplayBody(context); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index a61b78b4b6..5f6257ee92 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -115,7 +115,7 @@ public boolean isUpdate() { } @Override - public SpannableString getDisplayBody(@NonNull Context context) { + public CharSequence getDisplayBody(@NonNull Context context) { if (isGroupUpdateMessage()) { UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(getBody()); return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing())); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java index 83ee921a2a..70e80d720e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java @@ -18,14 +18,13 @@ package org.thoughtcrime.securesms.database.model; import android.content.Context; -import android.text.SpannableString; + import androidx.annotation.NonNull; import org.session.libsession.utilities.IdentityKeyMismatch; import org.session.libsession.utilities.recipients.Recipient; -import org.thoughtcrime.securesms.database.SmsDatabase; + import java.util.LinkedList; import java.util.List; -import network.loki.messenger.R; /** * The message record model which represents standard SMS messages. @@ -56,16 +55,8 @@ public long getType() { } @Override - public SpannableString getDisplayBody(@NonNull Context context) { - if (SmsDatabase.Types.isFailedDecryptType(type)) { - return emphasisAdded(context.getString(R.string.MessageDisplayHelper_bad_encrypted_message)); - } else if (SmsDatabase.Types.isDuplicateMessageType(type)) { - return emphasisAdded(context.getString(R.string.SmsMessageRecord_duplicate_message)); - } else if (SmsDatabase.Types.isNoRemoteSessionType(type)) { - return emphasisAdded(context.getString(R.string.MessageDisplayHelper_message_encrypted_for_non_existing_session)); - } else { - return super.getDisplayBody(context); - } + public CharSequence getDisplayBody(@NonNull Context context) { + return super.getDisplayBody(context); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java index 0c023a8f29..d91f4c428c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java @@ -17,21 +17,31 @@ */ package org.thoughtcrime.securesms.database.model; +import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; +import static org.session.libsession.utilities.StringSubstitutionConstants.AUTHOR_KEY; +import static org.session.libsession.utilities.StringSubstitutionConstants.DISAPPEARING_MESSAGES_TYPE_KEY; +import static org.session.libsession.utilities.StringSubstitutionConstants.MESSAGE_SNIPPET_KEY; +import static org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY; +import static org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY; + import android.content.Context; import android.net.Uri; import android.text.Spannable; import android.text.SpannableString; import android.text.TextUtils; import android.text.style.StyleSpan; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; - +import com.squareup.phrase.Phrase; import org.session.libsession.utilities.ExpirationUtil; +import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.recipients.Recipient; +import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.ui.UtilKt; +import kotlin.Pair; import network.loki.messenger.R; /** @@ -42,146 +52,170 @@ */ public class ThreadRecord extends DisplayRecord { - private @Nullable final Uri snippetUri; - public @Nullable final MessageRecord lastMessage; - private final long count; - private final int unreadCount; - private final int unreadMentionCount; - private final int distributionType; - private final boolean archived; - private final long expiresIn; - private final long lastSeen; - private final boolean pinned; - private final int initialRecipientHash; - - public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri, - @Nullable MessageRecord lastMessage, @NonNull Recipient recipient, long date, long count, int unreadCount, - int unreadMentionCount, long threadId, int deliveryReceiptCount, int status, - long snippetType, int distributionType, boolean archived, long expiresIn, - long lastSeen, int readReceiptCount, boolean pinned) - { - super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount); - this.snippetUri = snippetUri; - this.lastMessage = lastMessage; - this.count = count; - this.unreadCount = unreadCount; - this.unreadMentionCount = unreadMentionCount; - this.distributionType = distributionType; - this.archived = archived; - this.expiresIn = expiresIn; - this.lastSeen = lastSeen; - this.pinned = pinned; - this.initialRecipientHash = recipient.hashCode(); - } - - public @Nullable Uri getSnippetUri() { - return snippetUri; - } - - @Override - public SpannableString getDisplayBody(@NonNull Context context) { - if (isGroupUpdateMessage()) { - return emphasisAdded(context.getString(R.string.ThreadRecord_group_updated)); - } else if (isOpenGroupInvitation()) { - return emphasisAdded(context.getString(R.string.ThreadRecord_open_group_invitation)); - } else if (SmsDatabase.Types.isFailedDecryptType(type)) { - return emphasisAdded(context.getString(R.string.MessageDisplayHelper_bad_encrypted_message)); - } else if (SmsDatabase.Types.isNoRemoteSessionType(type)) { - return emphasisAdded(context.getString(R.string.MessageDisplayHelper_message_encrypted_for_non_existing_session)); - } else if (SmsDatabase.Types.isEndSessionType(type)) { - return emphasisAdded(context.getString(R.string.ThreadRecord_secure_session_reset)); - } else if (MmsSmsColumns.Types.isLegacyType(type)) { - return emphasisAdded(context.getString(R.string.MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported)); - } else if (MmsSmsColumns.Types.isDraftMessageType(type)) { - String draftText = context.getString(R.string.ThreadRecord_draft); - return emphasisAdded(draftText + " " + getBody(), 0, draftText.length()); - } else if (SmsDatabase.Types.isOutgoingCall(type)) { - return emphasisAdded(context.getString(network.loki.messenger.R.string.ThreadRecord_called)); - } else if (SmsDatabase.Types.isIncomingCall(type)) { - return emphasisAdded(context.getString(network.loki.messenger.R.string.ThreadRecord_called_you)); - } else if (SmsDatabase.Types.isMissedCall(type)) { - return emphasisAdded(context.getString(network.loki.messenger.R.string.ThreadRecord_missed_call)); - } else if (SmsDatabase.Types.isJoinedType(type)) { - return emphasisAdded(context.getString(R.string.ThreadRecord_s_is_on_signal, getRecipient().toShortString())); - } else if (SmsDatabase.Types.isExpirationTimerUpdate(type)) { - int seconds = (int) (getExpiresIn() / 1000); - if (seconds <= 0) { - return emphasisAdded(context.getString(R.string.ThreadRecord_disappearing_messages_disabled)); - } - String time = ExpirationUtil.getExpirationDisplayValue(context, seconds); - return emphasisAdded(context.getString(R.string.ThreadRecord_disappearing_message_time_updated_to_s, time)); - } else if (MmsSmsColumns.Types.isMediaSavedExtraction(type)) { - return emphasisAdded(context.getString(R.string.ThreadRecord_media_saved_by_s, getRecipient().toShortString())); - } else if (MmsSmsColumns.Types.isScreenshotExtraction(type)) { - return emphasisAdded(context.getString(R.string.ThreadRecord_s_took_a_screenshot, getRecipient().toShortString())); - } else if (SmsDatabase.Types.isIdentityUpdate(type)) { - if (getRecipient().isGroupRecipient()) return emphasisAdded(context.getString(R.string.ThreadRecord_safety_number_changed)); - else return emphasisAdded(context.getString(R.string.ThreadRecord_your_safety_number_with_s_has_changed, getRecipient().toShortString())); - } else if (SmsDatabase.Types.isIdentityVerified(type)) { - return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_verified)); - } else if (SmsDatabase.Types.isIdentityDefault(type)) { - return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_unverified)); - } else if (MmsSmsColumns.Types.isMessageRequestResponse(type)) { - return emphasisAdded(context.getString(R.string.message_requests_accepted)); - } else if (getCount() == 0) { - return new SpannableString(context.getString(R.string.ThreadRecord_empty_message)); - } else { - if (TextUtils.isEmpty(getBody())) { - return new SpannableString(emphasisAdded(context.getString(R.string.ThreadRecord_media_message))); - } else { - return new SpannableString(getBody()); - } + private @Nullable final Uri snippetUri; + public @Nullable final MessageRecord lastMessage; + private final long count; + private final int unreadCount; + private final int unreadMentionCount; + private final int distributionType; + private final boolean archived; + private final long expiresIn; + private final long lastSeen; + private final boolean pinned; + private final int initialRecipientHash; + private final long dateSent; + + public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri, + @Nullable MessageRecord lastMessage, @NonNull Recipient recipient, long date, long count, int unreadCount, + int unreadMentionCount, long threadId, int deliveryReceiptCount, int status, + long snippetType, int distributionType, boolean archived, long expiresIn, + long lastSeen, int readReceiptCount, boolean pinned) + { + super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount); + this.snippetUri = snippetUri; + this.lastMessage = lastMessage; + this.count = count; + this.unreadCount = unreadCount; + this.unreadMentionCount = unreadMentionCount; + this.distributionType = distributionType; + this.archived = archived; + this.expiresIn = expiresIn; + this.lastSeen = lastSeen; + this.pinned = pinned; + this.initialRecipientHash = recipient.hashCode(); + this.dateSent = date; + } + + public @Nullable Uri getSnippetUri() { + return snippetUri; + } + + private String getName() { + String name = getRecipient().getName(); + if (name == null) { + Log.w("ThreadRecord", "Got a null name - using: Unknown"); + name = "Unknown"; + } + return name; + } + + + @Override + public CharSequence getDisplayBody(@NonNull Context context) { + if (isGroupUpdateMessage()) { + return context.getString(R.string.groupUpdated); + } else if (isOpenGroupInvitation()) { + return context.getString(R.string.communityInvitation); + } else if (MmsSmsColumns.Types.isLegacyType(type)) { + return Phrase.from(context, R.string.messageErrorOld) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .format().toString(); + } else if (MmsSmsColumns.Types.isDraftMessageType(type)) { + String draftText = context.getString(R.string.draft); + return draftText + " " + getBody(); + } else if (SmsDatabase.Types.isOutgoingCall(type)) { + return Phrase.from(context, R.string.callsYouCalled) + .put(NAME_KEY, getName()) + .format().toString(); + } else if (SmsDatabase.Types.isIncomingCall(type)) { + return Phrase.from(context, R.string.callsCalledYou) + .put(NAME_KEY, getName()) + .format().toString(); + } else if (SmsDatabase.Types.isMissedCall(type)) { + return Phrase.from(context, R.string.callsMissedCallFrom) + .put(NAME_KEY, getName()) + .format().toString(); + } else if (SmsDatabase.Types.isExpirationTimerUpdate(type)) { + // Use the same message as we would for displaying on the conversation screen. + // lastMessage shouldn't be null here, but we'll check just in case. + if (lastMessage != null) { + return lastMessage.getDisplayBody(context).toString(); + } else { + return ""; + } + } else if (MmsSmsColumns.Types.isMediaSavedExtraction(type)) { + return Phrase.from(context, R.string.attachmentsMediaSaved) + .put(NAME_KEY, getName()) + .format().toString(); + + } else if (MmsSmsColumns.Types.isScreenshotExtraction(type)) { + return Phrase.from(context, R.string.screenshotTaken) + .put(NAME_KEY, getName()) + .format().toString(); + + } else if (MmsSmsColumns.Types.isMessageRequestResponse(type)) { + if (lastMessage.getRecipient().getAddress().serialize().equals( + TextSecurePreferences.getLocalNumber(context))) { + return UtilKt.getSubbedCharSequence( + context, + R.string.messageRequestYouHaveAccepted, + new Pair<>(NAME_KEY, getName()) + ); + } + + return context.getString(R.string.messageRequestsAccepted); + } else if (getCount() == 0) { + return new SpannableString(context.getString(R.string.messageEmpty)); + } else { + // This block hits when we receive a media message from an unaccepted contact - however, + // unaccepted contacts aren't allowed to send us media - so we'll return an empty string + // if it's JUST an image, or the body text that accompanied the image should any exist. + // We could return null here - but then we have to find all the usages of this + // `getDisplayBody` method and make sure it doesn't fall over if it has a null result. + if (TextUtils.isEmpty(getBody())) { + return new SpannableString(""); + // Old behaviour was: return new SpannableString(emphasisAdded(context.getString(R.string.mediaMessage))); + } else { + return getNonControlMessageDisplayBody(context); + } + } + } + + /** + * Logic to get the body for non control messages + */ + public CharSequence getNonControlMessageDisplayBody(@NonNull Context context) { + Recipient recipient = getRecipient(); + // The logic will differ depending on the type. + // 1-1, note to self and control messages (we shouldn't have any in here, but leaving the + // logic to be safe) do not need author details + if (recipient.isLocalNumber() || recipient.is1on1() || + (lastMessage != null && lastMessage.isControlMessage()) + ) { + return getBody(); + } else { // for groups (new, legacy, communities) show either 'You' or the contact's name + String prefix = ""; + if (lastMessage != null && lastMessage.isOutgoing()) { + prefix = context.getString(R.string.you); + } + else if(lastMessage != null){ + prefix = lastMessage.getIndividualRecipient().toShortString(); + } + + return Phrase.from(context.getString(R.string.messageSnippetGroup)) + .put(AUTHOR_KEY, prefix) + .put(MESSAGE_SNIPPET_KEY, getBody()) + .format().toString(); + } } - } - - private SpannableString emphasisAdded(String sequence) { - return emphasisAdded(sequence, 0, sequence.length()); - } - - private SpannableString emphasisAdded(String sequence, int start, int end) { - SpannableString spannable = new SpannableString(sequence); - spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), - start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - return spannable; - } - - public long getCount() { - return count; - } - - public int getUnreadCount() { - return unreadCount; - } - - public int getUnreadMentionCount() { - return unreadMentionCount; - } - - public long getDate() { - return getDateReceived(); - } - - public boolean isArchived() { - return archived; - } - - public int getDistributionType() { - return distributionType; - } - - public long getExpiresIn() { - return expiresIn; - } - - public long getLastSeen() { - return lastSeen; - } - - public boolean isPinned() { - return pinned; - } - - public int getInitialRecipientHash() { - return initialRecipientHash; - } + + public long getCount() { return count; } + + public int getUnreadCount() { return unreadCount; } + + public int getUnreadMentionCount() { return unreadMentionCount; } + + public long getDate() { return getDateReceived(); } + + public boolean isArchived() { return archived; } + + public int getDistributionType() { return distributionType; } + + public long getExpiresIn() { return expiresIn; } + + public long getLastSeen() { return lastSeen; } + + public boolean isPinned() { return pinned; } + + public int getInitialRecipientHash() { return initialRecipientHash; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt new file mode 100644 index 0000000000..828b3c3a1e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.debugmenu + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint +import org.thoughtcrime.securesms.ui.setComposeContent + + +@AndroidEntryPoint +class DebugActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setComposeContent { + DebugMenuScreen( + onClose = { finish() } + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt new file mode 100644 index 0000000000..f277d1f40b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt @@ -0,0 +1,184 @@ +package org.thoughtcrime.securesms.debugmenu + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import network.loki.messenger.BuildConfig +import network.loki.messenger.R +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.ChangeEnvironment +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.HideEnvironmentWarningDialog +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.ShowEnvironmentWarningDialog +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.DialogButtonModel +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.LoadingDialog +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.DropDown +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.bold + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DebugMenu( + uiState: DebugMenuViewModel.UIState, + sendCommand: (DebugMenuViewModel.Commands) -> Unit, + modifier: Modifier = Modifier, + onClose: () -> Unit +) { + val snackbarHostState = remember { SnackbarHostState() } + + Scaffold( + modifier = modifier.fillMaxSize(), + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + } + ) { contentPadding -> + // display a snackbar when required + LaunchedEffect(uiState.snackMessage) { + if (!uiState.snackMessage.isNullOrEmpty()) { + snackbarHostState.showSnackbar(uiState.snackMessage) + } + } + + // Alert dialogs + if (uiState.showEnvironmentWarningDialog) { + AlertDialog( + onDismissRequest = { sendCommand(HideEnvironmentWarningDialog) }, + title = "Are you sure you want to switch environments?", + text = "Changing this setting will result in all conversations and Snode data being cleared...", + showCloseButton = false, // don't display the 'x' button + buttons = listOf( + DialogButtonModel( + text = GetString(R.string.cancel), + contentDescription = GetString(R.string.cancel), + onClick = { sendCommand(HideEnvironmentWarningDialog) } + ), + DialogButtonModel( + text = GetString(R.string.ok), + contentDescription = GetString(R.string.ok), + onClick = { sendCommand(ChangeEnvironment) } + ) + ) + ) + } + + if (uiState.showEnvironmentLoadingDialog) { + LoadingDialog(title = "Changing Environment...") + } + + Column( + modifier = Modifier + .padding(contentPadding) + .fillMaxSize() + .background(color = LocalColors.current.background) + ) { + // App bar + BackAppBar(title = "Debug Menu", onBack = onClose) + + Column( + modifier = Modifier + .padding(horizontal = LocalDimensions.current.spacing) + .verticalScroll(rememberScrollState()) + ) { + // Info pane + val clipboardManager = LocalClipboardManager.current + val appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE} - ${ + BuildConfig.GIT_HASH.take( + 6 + ) + })" + + DebugCell( + modifier = Modifier.clickable { + // clicking the cell copies the version number to the clipboard + clipboardManager.setText(AnnotatedString(appVersion)) + }, + title = "App Info" + ) { + Text( + text = "Version: $appVersion", + style = LocalType.current.base + ) + } + + // Environment + DebugCell("Environment") { + DropDown( + modifier = Modifier.fillMaxWidth(0.6f), + selectedText = uiState.currentEnvironment, + values = uiState.environments, + onValueSelected = { + sendCommand(ShowEnvironmentWarningDialog(it)) + } + ) + } + } + } + } +} + +@Composable +fun ColumnScope.DebugCell( + title: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + Cell { + Column( + modifier = modifier.padding(LocalDimensions.current.spacing) + ) { + Text( + text = title, + style = LocalType.current.large.bold() + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + content() + } + } +} + +@Preview +@Composable +fun PreviewDebugMenu() { + PreviewTheme { + DebugMenu( + uiState = DebugMenuViewModel.UIState( + currentEnvironment = "Development", + environments = listOf("Development", "Production"), + snackMessage = null, + showEnvironmentWarningDialog = false, + showEnvironmentLoadingDialog = false + ), + sendCommand = {}, + onClose = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuScreen.kt new file mode 100644 index 0000000000..6c0f22805a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuScreen.kt @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.debugmenu + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel + +@Composable +fun DebugMenuScreen( + modifier: Modifier = Modifier, + debugMenuViewModel: DebugMenuViewModel = viewModel(), + onClose: () -> Unit +) { + val uiState by debugMenuViewModel.uiState.collectAsState() + + DebugMenu( + modifier = modifier, + uiState = uiState, + sendCommand = debugMenuViewModel::onCommand, + onClose = onClose + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt new file mode 100644 index 0000000000..750b3e20c7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.debugmenu + +import android.app.Application +import android.widget.Toast +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import network.loki.messenger.R +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.ApplicationContext +import org.session.libsession.utilities.Environment +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities +import javax.inject.Inject + +@HiltViewModel +class DebugMenuViewModel @Inject constructor( + private val application: Application, + private val textSecurePreferences: TextSecurePreferences +) : ViewModel() { + private val TAG = "DebugMenu" + + private val _uiState = MutableStateFlow( + UIState( + currentEnvironment = textSecurePreferences.getEnvironment().label, + environments = Environment.entries.map { it.label }, + snackMessage = null, + showEnvironmentWarningDialog = false, + showEnvironmentLoadingDialog = false + ) + ) + val uiState: StateFlow + get() = _uiState + + private var temporaryEnv: Environment? = null + + fun onCommand(command: Commands) { + when (command) { + is Commands.ChangeEnvironment -> changeEnvironment() + + is Commands.HideEnvironmentWarningDialog -> _uiState.value = + _uiState.value.copy(showEnvironmentWarningDialog = false) + + is Commands.ShowEnvironmentWarningDialog -> + showEnvironmentWarningDialog(command.environment) + } + } + + private fun showEnvironmentWarningDialog(environment: String) { + if(environment == _uiState.value.currentEnvironment) return + val env = Environment.entries.firstOrNull { it.label == environment } ?: return + + temporaryEnv = env + + _uiState.value = _uiState.value.copy(showEnvironmentWarningDialog = true) + } + + private fun changeEnvironment() { + val env = temporaryEnv ?: return + + // show a loading state + _uiState.value = _uiState.value.copy( + showEnvironmentWarningDialog = false, + showEnvironmentLoadingDialog = true + ) + + // clear remote and local data, then restart the app + viewModelScope.launch { + try { + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(application).get() + } catch (e: Exception) { + // we can ignore fails here as we might be switching environments before the user gets a public key + } + ApplicationContext.getInstance(application).clearAllData().let { success -> + if(success){ + // save the environment + textSecurePreferences.setEnvironment(env) + delay(500) + ApplicationContext.getInstance(application).restartApplication() + } else { + _uiState.value = _uiState.value.copy( + showEnvironmentWarningDialog = false, + showEnvironmentLoadingDialog = false + ) + Log.e(TAG, "Failed to force sync when deleting data") + _uiState.value = _uiState.value.copy(snackMessage = "Sorry, something went wrong...") + return@launch + } + } + } + } + + data class UIState( + val currentEnvironment: String, + val environments: List, + val snackMessage: String?, + val showEnvironmentWarningDialog: Boolean, + val showEnvironmentLoadingDialog: Boolean + ) + + sealed class Commands { + object ChangeEnvironment : Commands() + data class ShowEnvironmentWarningDialog(val environment: String) : Commands() + object HideEnvironmentWarningDialog : Commands() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiCategory.kt b/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiCategory.kt index 714996e6c8..c9a59b2107 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiCategory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiCategory.kt @@ -15,8 +15,7 @@ enum class EmojiCategory(val priority: Int, val key: String, @AttrRes val icon: PLACES(4, "Places", R.attr.emoji_category_places), OBJECTS(5, "Objects", R.attr.emoji_category_objects), SYMBOLS(6, "Symbols", R.attr.emoji_category_symbol), - FLAGS(7, "Flags", R.attr.emoji_category_flags), - EMOTICONS(8, "Emoticons", R.attr.emoji_category_emoticons); + FLAGS(7, "Flags", R.attr.emoji_category_flags); @StringRes fun getCategoryLabel(): Int { @@ -31,15 +30,14 @@ enum class EmojiCategory(val priority: Int, val key: String, @AttrRes val icon: @StringRes fun getCategoryLabel(@AttrRes iconAttr: Int): Int { return when (iconAttr) { - R.attr.emoji_category_people -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__smileys_and_people - R.attr.emoji_category_nature -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__nature - R.attr.emoji_category_foods -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__food - R.attr.emoji_category_activity -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__activities - R.attr.emoji_category_places -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__places - R.attr.emoji_category_objects -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__objects - R.attr.emoji_category_symbol -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__symbols - R.attr.emoji_category_flags -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__flags - R.attr.emoji_category_emoticons -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__emoticons + R.attr.emoji_category_people -> R.string.emojiCategorySmileys + R.attr.emoji_category_nature -> R.string.emojiCategoryAnimals + R.attr.emoji_category_foods -> R.string.emojiCategoryFood + R.attr.emoji_category_activity -> R.string.emojiCategoryActivities + R.attr.emoji_category_places -> R.string.emojiCategoryTravel + R.attr.emoji_category_objects -> R.string.emojiCategoryObjects + R.attr.emoji_category_symbol -> R.string.emojiCategorySymbols + R.attr.emoji_category_flags -> R.string.emojiCategoryFlags else -> throw AssertionError() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiSource.kt b/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiSource.kt index 0b221eb3d1..75b1496cc4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiSource.kt @@ -110,10 +110,12 @@ class EmojiSource( val parsedData: ParsedEmojiData = EmojiJsonParser.parse(it, ::getAssetsUri).getOrThrow() return EmojiSource( ScreenDensity.xhdpiRelativeDensityScaleFactor("xhdpi"), + parsedData.copy( - displayPages = parsedData.displayPages + PAGE_EMOTICONS, - dataPages = parsedData.dataPages + PAGE_EMOTICONS + displayPages = parsedData.displayPages, + dataPages = parsedData.dataPages ) + ) { uri: Uri -> EmojiPage.Asset(uri) } } } @@ -137,25 +139,3 @@ data class ObsoleteEmoji(val obsolete: String, val replaceWith: String) data class EmojiMetrics(val rawHeight: Int, val rawWidth: Int, val perRow: Int) private fun getAssetsUri(name: String, format: String): Uri = Uri.parse("file:///android_asset/emoji/$name.$format") - -private val PAGE_EMOTICONS: EmojiPageModel = StaticEmojiPageModel( - EmojiCategory.EMOTICONS, - arrayOf( - ":-)", ";-)", "(-:", ":->", ":-D", "\\o/", - ":-P", "B-)", ":-$", ":-*", "O:-)", "=-O", - "O_O", "O_o", "o_O", ":O", ":-!", ":-x", - ":-|", ":-\\", ":-(", ":'(", ":-[", ">:-(", - "^.^", "^_^", "\\(\u02c6\u02da\u02c6)/", - "\u30fd(\u00b0\u25c7\u00b0 )\u30ce", "\u00af\\(\u00b0_o)/\u00af", - "\u00af\\_(\u30c4)_/\u00af", "(\u00ac_\u00ac)", - "(>_<)", "(\u2565\ufe4f\u2565)", "(\u261e\uff9f\u30ee\uff9f)\u261e", - "\u261c(\uff9f\u30ee\uff9f\u261c)", "\u261c(\u2312\u25bd\u2312)\u261e", - "(\u256f\u00b0\u25a1\u00b0)\u256f\ufe35", "\u253b\u2501\u253b", - "\u252c\u2500\u252c", "\u30ce(\u00b0\u2013\u00b0\u30ce)", - "(^._.^)\uff89", "\u0e05^\u2022\ufecc\u2022^\u0e05", - "\u0295\u2022\u1d25\u2022\u0294", "(\u2022_\u2022)", - " \u25a0-\u25a0\u00ac <(\u2022_\u2022) ", "(\u25a0_\u25a0\u00ac)", - "\u01aa(\u0693\u05f2)\u200e\u01aa\u200b\u200b" - ), - null -) diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java index dcfdb66112..a9edadfcb1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java @@ -19,6 +19,7 @@ import com.google.android.material.tabs.TabLayout; import org.session.libsession.utilities.MediaTypes; +import org.session.libsession.utilities.NonTranslatableStringConstants; import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.providers.BlobProvider; @@ -120,7 +121,7 @@ protected Uri doInBackground(Void... params) { protected void onPostExecute(@Nullable Uri uri) { if (uri == null) { - Toast.makeText(GiphyActivity.this, R.string.GiphyActivity_error_while_retrieving_full_resolution_gif, Toast.LENGTH_LONG).show(); + Toast.makeText(GiphyActivity.this, R.string.errorUnknown, Toast.LENGTH_LONG).show(); } else if (viewHolder == finishingImage) { Intent intent = new Intent(); intent.setData(uri); @@ -165,8 +166,8 @@ public int getCount() { @Override public CharSequence getPageTitle(int position) { - if (position == 0) return context.getString(R.string.GiphyFragmentPagerAdapter_gifs); - else return context.getString(R.string.GiphyFragmentPagerAdapter_stickers); + if (position == 0) return NonTranslatableStringConstants.GIF; + else return context.getString(R.string.stickers); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt index acb3af8db4..0f562c80b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt @@ -76,17 +76,20 @@ class CreateGroupFragment : Fragment() { if (isLoading) return@setOnClickListener val name = binding.nameEditText.text.trim() if (name.isEmpty()) { - return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_group_name_missing_error, Toast.LENGTH_LONG).show() + return@setOnClickListener Toast.makeText(context, R.string.groupNameEnterPlease, Toast.LENGTH_LONG).show() } + + // Limit the group name length if it exceeds the limit if (name.length > resources.getInteger(R.integer.max_group_and_community_name_length_chars)) { - return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_group_name_too_long_error, Toast.LENGTH_LONG).show() + return@setOnClickListener Toast.makeText(context, R.string.groupNameEnterShorter, Toast.LENGTH_LONG).show() } + val selectedMembers = adapter.selectedMembers if (selectedMembers.isEmpty()) { - return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_not_enough_group_members_error, Toast.LENGTH_LONG).show() + return@setOnClickListener Toast.makeText(context, R.string.groupCreateErrorNoMembers, Toast.LENGTH_LONG).show() } if (selectedMembers.count() >= groupSizeLimit) { // Minus one because we're going to include self later - return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_too_many_group_members_error, Toast.LENGTH_LONG).show() + return@setOnClickListener Toast.makeText(context, R.string.groupAddMemberMaximum, Toast.LENGTH_LONG).show() } val userPublicKey = TextSecurePreferences.getLocalNumber(requireContext())!! isLoading = true diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt index 2d9192ac7a..11dde4b93e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt @@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.groups import android.content.Context import android.content.Intent import android.os.Bundle +import android.text.SpannableString +import android.text.style.StyleSpan import android.view.Menu import android.view.MenuItem import android.view.View @@ -16,7 +18,10 @@ import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint +import java.io.IOException +import javax.inject.Inject import network.loki.messenger.R import nl.komponents.kovenant.Promise import nl.komponents.kovenant.task @@ -26,6 +31,7 @@ import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.groupSizeLimit import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil +import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.recipients.Recipient @@ -40,8 +46,6 @@ import org.thoughtcrime.securesms.groups.ClosedGroupManager.updateLegacyGroup import com.bumptech.glide.Glide import org.thoughtcrime.securesms.util.fadeIn import org.thoughtcrime.securesms.util.fadeOut -import java.io.IOException -import javax.inject.Inject @AndroidEntryPoint class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { @@ -107,17 +111,17 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { groupID = intent.getStringExtra(groupIDKey)!! val groupInfo = DatabaseComponent.get(this).groupDatabase().getGroup(groupID).get() originalName = groupInfo.title - isSelfAdmin = groupInfo.admins.any{ it.serialize() == TextSecurePreferences.getLocalNumber(this) } + isSelfAdmin = groupInfo.admins.any { it.serialize() == TextSecurePreferences.getLocalNumber(this) } name = originalName mainContentContainer = findViewById(R.id.mainContentContainer) - cntGroupNameEdit = findViewById(R.id.cntGroupNameEdit) - cntGroupNameDisplay = findViewById(R.id.cntGroupNameDisplay) - edtGroupName = findViewById(R.id.edtGroupName) - emptyStateContainer = findViewById(R.id.emptyStateContainer) - lblGroupNameDisplay = findViewById(R.id.lblGroupNameDisplay) - loaderContainer = findViewById(R.id.loaderContainer) + cntGroupNameEdit = findViewById(R.id.cntGroupNameEdit) + cntGroupNameDisplay = findViewById(R.id.cntGroupNameDisplay) + edtGroupName = findViewById(R.id.edtGroupName) + emptyStateContainer = findViewById(R.id.emptyStateContainer) + lblGroupNameDisplay = findViewById(R.id.lblGroupNameDisplay) + loaderContainer = findViewById(R.id.loaderContainer) findViewById(R.id.addMembersClosedGroupButton).setOnClickListener { onAddMembersClick() @@ -129,7 +133,19 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { } lblGroupNameDisplay.text = originalName - cntGroupNameDisplay.setOnClickListener { isEditingName = true } + + // Only allow admins to click on the name of closed groups to edit them.. + if (isSelfAdmin) { + cntGroupNameDisplay.setOnClickListener { isEditingName = true } + } + else // ..and also hide the edit `drawableEnd` for non-admins. + { + // Note: compoundDrawables returns 4 drawables (drawablesStart/Top/End/Bottom) - + // so the `drawableEnd` component is at index 2, which we replace with null. + val cd = lblGroupNameDisplay.compoundDrawables + lblGroupNameDisplay.setCompoundDrawables(cd[0], cd[1], null, cd[3]) + } + findViewById(R.id.btnCancelGroupNameEdit).setOnClickListener { isEditingName = false } findViewById(R.id.btnSaveGroupNameEdit).setOnClickListener { saveName() } edtGroupName.setImeActionLabel(getString(R.string.save), EditorInfo.IME_ACTION_DONE) @@ -245,10 +261,10 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { private fun saveName() { val name = edtGroupName.text.toString().trim() if (name.isEmpty()) { - return Toast.makeText(this, R.string.activity_edit_closed_group_group_name_missing_error, Toast.LENGTH_SHORT).show() + return Toast.makeText(this, R.string.groupNameEnterPlease, Toast.LENGTH_SHORT).show() } if (name.length >= 64) { - return Toast.makeText(this, R.string.activity_edit_closed_group_group_name_too_long_error, Toast.LENGTH_SHORT).show() + return Toast.makeText(this, R.string.groupNameEnterShorter, Toast.LENGTH_SHORT).show() } this.name = name lblGroupNameDisplay.text = name @@ -283,20 +299,22 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { } if (members.isEmpty()) { - return Toast.makeText(this, R.string.activity_edit_closed_group_not_enough_group_members_error, Toast.LENGTH_LONG).show() + return Toast.makeText(this, R.string.groupCreateErrorNoMembers, Toast.LENGTH_LONG).show() } val maxGroupMembers = if (isClosedGroup) groupSizeLimit else legacyGroupSizeLimit if (members.size >= maxGroupMembers) { - return Toast.makeText(this, R.string.activity_create_closed_group_too_many_group_members_error, Toast.LENGTH_LONG).show() + return Toast.makeText(this, R.string.groupAddMemberMaximum, Toast.LENGTH_LONG).show() } val userPublicKey = TextSecurePreferences.getLocalNumber(this)!! val userAsRecipient = Recipient.from(this, Address.fromSerialized(userPublicKey), false) + // There's presently no way in the UI to get into the state whereby you could remove yourself from the group when removing any other members + // (you can't unselect yourself - the only way to leave is to "Leave Group" from the menu) - but it's possible that this was not always + // the case - so we can leave this in as defensive code in-case something goes screwy. if (!members.contains(userAsRecipient) && !members.map { it.address.toString() }.containsAll(originalMembers.minus(userPublicKey))) { - val message = "Can't leave while adding or removing other members." - return Toast.makeText(this@EditClosedGroupActivity, message, Toast.LENGTH_LONG).show() + return Log.w("EditClosedGroup", "Can't leave group while adding or removing other members.") } if (isClosedGroup) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt index 964e1e1770..bcf12b3920 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt @@ -12,6 +12,7 @@ import android.widget.Toast import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.google.android.material.tabs.TabLayoutMediator +import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -22,10 +23,12 @@ import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.OpenGroupUrlParser +import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.ui.getSubbedString import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities @AndroidEntryPoint @@ -35,6 +38,8 @@ class JoinCommunityFragment : Fragment() { lateinit var delegate: StartConversationDelegate + var lastUrl: String? = null + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? @@ -47,6 +52,7 @@ class JoinCommunityFragment : Fragment() { super.onViewCreated(view, savedInstanceState) binding.backButton.setOnClickListener { delegate.onDialogBackPressed() } binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() } + fun showLoader() { binding.loader.visibility = View.VISIBLE binding.loader.animate().setDuration(150).alpha(1.0f).start() @@ -61,41 +67,76 @@ class JoinCommunityFragment : Fragment() { } }) } + fun joinCommunityIfPossible(url: String) { - val openGroup = try { - OpenGroupUrlParser.parseUrl(url) - } catch (e: OpenGroupUrlParser.Error) { - when (e) { - is OpenGroupUrlParser.Error.MalformedURL -> return Toast.makeText(activity, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show() - is OpenGroupUrlParser.Error.InvalidPublicKey -> return Toast.makeText(activity, R.string.invalid_public_key, Toast.LENGTH_SHORT).show() - is OpenGroupUrlParser.Error.NoPublicKey -> return Toast.makeText(activity, R.string.invalid_public_key, Toast.LENGTH_SHORT).show() - is OpenGroupUrlParser.Error.NoRoom -> return Toast.makeText(activity, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show() - } - } - showLoader() - lifecycleScope.launch(Dispatchers.IO) { - try { - val sanitizedServer = openGroup.server.removeSuffix("/") - val openGroupID = "$sanitizedServer.${openGroup.room}" - OpenGroupManager.add(sanitizedServer, openGroup.room, openGroup.serverPublicKey, requireContext()) - val storage = MessagingModuleConfiguration.shared.storage - storage.onOpenGroupAdded(sanitizedServer, openGroup.room) - val threadID = GroupManager.getOpenGroupThreadID(openGroupID, requireContext()) - val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray()) - - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()) - withContext(Dispatchers.Main) { - val recipient = Recipient.from(requireContext(), Address.fromSerialized(groupID), false) - openConversationActivity(requireContext(), threadID, recipient) - delegate.onDialogClosePressed() + // Currently this won't try again on a failed URL but once we rework the whole + // fragment into Compose with a ViewModel this won't be an issue anymore as the error + // and state will come from Flows. + if(lastUrl == url) return + lastUrl = url + + lifecycleScope.launch(Dispatchers.Main) { + val openGroup = try { + OpenGroupUrlParser.parseUrl(url) + } catch (e: OpenGroupUrlParser.Error) { + when (e) { + is OpenGroupUrlParser.Error.MalformedURL, OpenGroupUrlParser.Error.NoRoom -> { + return@launch Toast.makeText( + activity, + context?.resources?.getString(R.string.communityJoinError), + Toast.LENGTH_SHORT + ).show() + } + + is OpenGroupUrlParser.Error.InvalidPublicKey, OpenGroupUrlParser.Error.NoPublicKey -> { + return@launch Toast.makeText( + activity, + R.string.communityEnterUrlErrorInvalidDescription, + Toast.LENGTH_SHORT + ).show() + } } - } catch (e: Exception) { - Log.e("Loki", "Couldn't join open group.", e) - withContext(Dispatchers.Main) { - hideLoader() - Toast.makeText(activity, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show() + } + + showLoader() + + withContext(Dispatchers.IO) { + try { + val sanitizedServer = openGroup.server.removeSuffix("/") + val openGroupID = "$sanitizedServer.${openGroup.room}" + OpenGroupManager.add( + sanitizedServer, + openGroup.room, + openGroup.serverPublicKey, + requireContext() + ) + val storage = MessagingModuleConfiguration.shared.storage + storage.onOpenGroupAdded(sanitizedServer, openGroup.room) + val threadID = + GroupManager.getOpenGroupThreadID(openGroupID, requireContext()) + val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray()) + + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded( + requireContext() + ) + withContext(Dispatchers.Main) { + val recipient = Recipient.from( + requireContext(), + Address.fromSerialized(groupID), + false + ) + openConversationActivity(requireContext(), threadID, recipient) + delegate.onDialogClosePressed() + } + } catch (e: Exception) { + Log.e("Loki", "Couldn't join community.", e) + withContext(Dispatchers.Main) { + hideLoader() + val txt = context?.getSubbedString(R.string.groupErrorJoin, + GROUP_NAME_KEY to url) + Toast.makeText(activity, txt, Toast.LENGTH_SHORT).show() + } } - return@launch } } } @@ -107,8 +148,8 @@ class JoinCommunityFragment : Fragment() { ) val mediator = TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, pos -> tab.text = when (pos) { - 0 -> getString(R.string.activity_join_public_chat_enter_community_url_tab_title) - 1 -> getString(R.string.activity_join_public_chat_scan_qr_code_tab_title) + 0 -> getString(R.string.communityUrl) + 1 -> getString(R.string.qrScan) else -> throw IllegalStateException() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt index 8bb7a39d4a..4b6f73bd2a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt @@ -1,18 +1,23 @@ package org.thoughtcrime.securesms.groups import android.content.Context +import android.widget.Toast import androidx.annotation.WorkerThread -import okhttp3.HttpUrl +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 import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.open_groups.GroupMemberRole import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller +import org.session.libsession.utilities.StringSubstitutionConstants.COMMUNITY_NAME_KEY import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities -import java.util.concurrent.Executors object OpenGroupManager { private val executorService = Executors.newScheduledThreadPool(4) @@ -37,6 +42,9 @@ object OpenGroupManager { return true } + // flow holding information on write access for our current communities + private val _communityWriteAccess: MutableStateFlow> = MutableStateFlow(emptyMap()) + fun startPolling() { if (isPolling) { return } isPolling = true @@ -64,6 +72,8 @@ object OpenGroupManager { } } + fun getCommunitiesWriteAccessFlow() = _communityWriteAccess.asStateFlow() + @WorkerThread fun add(server: String, room: String, publicKey: String, context: Context): Pair { val openGroupID = "$server.$room" @@ -111,35 +121,43 @@ object OpenGroupManager { @WorkerThread fun delete(server: String, room: String, context: Context) { - val storage = MessagingModuleConfiguration.shared.storage - val configFactory = MessagingModuleConfiguration.shared.configFactory - val threadDB = DatabaseComponent.get(context).threadDatabase() - val openGroupID = "${server.removeSuffix("/")}.$room" - val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context) - val recipient = threadDB.getRecipientForThreadId(threadID) ?: return - threadDB.setThreadArchived(threadID) - val groupID = recipient.address.serialize() - // Stop the poller if needed - val openGroups = storage.getAllOpenGroups().filter { it.value.server == server } - if (openGroups.isNotEmpty()) { - synchronized(pollUpdaterLock) { - val poller = pollers[server] - poller?.stop() - pollers.remove(server) + try { + val storage = MessagingModuleConfiguration.shared.storage + val configFactory = MessagingModuleConfiguration.shared.configFactory + val threadDB = DatabaseComponent.get(context).threadDatabase() + val openGroupID = "${server.removeSuffix("/")}.$room" + val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context) + val recipient = threadDB.getRecipientForThreadId(threadID) ?: return + threadDB.setThreadArchived(threadID) + val groupID = recipient.address.serialize() + // Stop the poller if needed + val openGroups = storage.getAllOpenGroups().filter { it.value.server == server } + if (openGroups.isNotEmpty()) { + synchronized(pollUpdaterLock) { + val poller = pollers[server] + poller?.stop() + pollers.remove(server) + } } + configFactory.userGroups?.eraseCommunity(server, room) + configFactory.convoVolatile?.eraseCommunity(server, room) + // Delete + storage.removeLastDeletionServerID(room, server) + storage.removeLastMessageServerID(room, server) + storage.removeLastInboxMessageId(server) + storage.removeLastOutboxMessageId(server) + val lokiThreadDB = DatabaseComponent.get(context).lokiThreadDatabase() + lokiThreadDB.removeOpenGroupChat(threadID) + storage.deleteConversation(threadID) // Must be invoked on a background thread + GroupManager.deleteGroup(groupID, context) // Must be invoked on a background thread + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } + catch (e: Exception) { + Log.e("Loki", "Failed to leave (delete) community", e) + val serverAndRoom = "$server.$room" + val txt = Phrase.from(context, R.string.communityLeaveError).put(COMMUNITY_NAME_KEY, serverAndRoom).format().toString() + Toast.makeText(context, txt, Toast.LENGTH_LONG).show() } - configFactory.userGroups?.eraseCommunity(server, room) - configFactory.convoVolatile?.eraseCommunity(server, room) - // Delete - storage.removeLastDeletionServerID(room, server) - storage.removeLastMessageServerID(room, server) - storage.removeLastInboxMessageId(server) - storage.removeLastOutboxMessageId(server) - val lokiThreadDB = DatabaseComponent.get(context).lokiThreadDatabase() - lokiThreadDB.removeOpenGroupChat(threadID) - storage.deleteConversation(threadID) // Must be invoked on a background thread - GroupManager.deleteGroup(groupID, context) // Must be invoked on a background thread - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) } @WorkerThread @@ -154,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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt index 82b9f16dcd..ed7abc7fcd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -8,6 +8,7 @@ import android.view.ViewGroup import androidx.core.view.isVisible import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.R import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory @@ -82,7 +83,33 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto binding.muteNotificationsTextView.setOnClickListener(this) binding.notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted binding.notificationsTextView.setOnClickListener(this) - binding.deleteTextView.setOnClickListener(this) + + // delete + binding.deleteTextView.apply { + setOnClickListener(this@ConversationOptionsBottomSheet) + + // the text and content description will change depending on the type + when{ + // groups and communities + recipient.isGroupRecipient -> { + text = context.getString(R.string.leave) + contentDescription = context.getString(R.string.AccessibilityId_leave) + } + + // note to self + recipient.isLocalNumber -> { + text = context.getString(R.string.clear) + contentDescription = context.getString(R.string.AccessibilityId_clear) + } + + // 1on1 + else -> { + text = context.getString(R.string.delete) + contentDescription = context.getString(R.string.AccessibilityId_delete) + } + } + } + binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 || configFactory.convoVolatile?.getConversationUnread(thread) == true binding.markAllAsReadTextView.setOnClickListener(this) binding.pinTextView.isVisible = !thread.isPinned diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index 68aea84417..011a8ae599 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -103,7 +103,7 @@ class ConversationView : LinearLayout { } binding.muteIndicatorImageView.setImageResource(drawableRes) binding.snippetTextView.text = highlightMentions( - text = thread.getSnippet(), + text = thread.getDisplayBody(context), formatOnly = true, // no styling here, only text formatting threadID = thread.threadId, context = context @@ -136,19 +136,8 @@ class ConversationView : LinearLayout { } private fun getTitle(recipient: Recipient): String? = when { - recipient.isLocalNumber -> context.getString(R.string.note_to_self) + recipient.isLocalNumber -> context.getString(R.string.noteToSelf) else -> recipient.toShortString() // Internally uses the Contact API } - - private fun ThreadRecord.getSnippet(): CharSequence = listOfNotNull( - getSnippetPrefix(), - getDisplayBody(context) - ).joinToString(": ") - - private fun ThreadRecord.getSnippetPrefix(): CharSequence? = when { - recipient.isLocalNumber || lastMessage?.isControlMessage == true -> null - lastMessage?.isOutgoing == true -> resources.getString(R.string.MessageRecord_you) - else -> lastMessage?.individualRecipient?.toShortString() - } // endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/EmptyView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/EmptyView.kt index aa4e0d9017..2ab5cad673 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/EmptyView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/EmptyView.kt @@ -9,20 +9,24 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import com.squareup.phrase.Phrase import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY import org.thoughtcrime.securesms.ui.Divider +import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors -import org.thoughtcrime.securesms.ui.theme.LocalColors -import org.thoughtcrime.securesms.ui.theme.LocalType @Composable internal fun EmptyView(newAccount: Boolean) { @@ -44,7 +48,13 @@ internal fun EmptyView(newAccount: Boolean) { textAlign = TextAlign.Center ) Text( - stringResource(R.string.welcome_to_session), + stringResource(R.string.onboardingBubbleWelcomeToSession).let { txt -> + val c = LocalContext.current + Phrase.from(txt) + .put(APP_NAME_KEY, c.getString(R.string.app_name)) + .put(EMOJI_KEY, "\uD83D\uDC4B") // this hardcoded emoji might be moved to NonTranslatableConstants eventually + .format().toString() + }, style = LocalType.current.base, color = LocalColors.current.primary, textAlign = TextAlign.Center diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 2d683abe8f..aeaa7338f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -8,8 +8,11 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle +import android.text.format.DateUtils import android.widget.Toast import androidx.activity.viewModels +import androidx.annotation.PluralsRes +import androidx.annotation.StringRes import androidx.core.os.bundleOf import androidx.core.view.isInvisible import androidx.core.view.isVisible @@ -17,6 +20,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager +import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest @@ -42,6 +46,9 @@ import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.toHexString +import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.conversation.start.StartConversationFragment @@ -73,13 +80,22 @@ import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.ui.setThemedContent import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.IP2Country +import org.thoughtcrime.securesms.util.RelativeDay import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.show import org.thoughtcrime.securesms.util.start import java.io.IOException +import java.util.Calendar +import java.util.Locale import javax.inject.Inject +import kotlin.math.abs +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +// Intent extra keys so we know where we came from private const val NEW_ACCOUNT = "HomeActivity_NEW_ACCOUNT" private const val FROM_ONBOARDING = "HomeActivity_FROM_ONBOARDING" @@ -88,6 +104,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickListener, GlobalSearchInputLayout.GlobalSearchInputLayoutListener { + private val TAG = "HomeActivity" + private lateinit var binding: ActivityHomeBinding private lateinit var glide: RequestManager @@ -244,17 +262,17 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), globalSearchViewModel.result.map { result -> result.query to when { result.query.isEmpty() -> buildList { - add(GlobalSearchAdapter.Model.Header(R.string.contacts)) + add(GlobalSearchAdapter.Model.Header(R.string.contactContacts)) add(GlobalSearchAdapter.Model.SavedMessages(publicKey)) addAll(result.groupedContacts) } else -> buildList { result.contactAndGroupList.takeUnless { it.isEmpty() }?.let { - add(GlobalSearchAdapter.Model.Header(R.string.conversations)) + add(GlobalSearchAdapter.Model.Header(R.string.sessionConversations)) addAll(it) } result.messageResults.takeUnless { it.isEmpty() }?.let { - add(GlobalSearchAdapter.Model.Header(R.string.global_search_messages)) + add(GlobalSearchAdapter.Model.Header(R.string.messages)) addAll(it) } } @@ -370,6 +388,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), super.onDestroy() EventBus.getDefault().unregister(this) } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) + } // endregion // region Updating @@ -427,7 +450,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), val clip = ClipData.newPlainText("Account ID", thread.recipient.address.toString()) val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager manager.setPrimaryClip(clip) - Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() + Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show() } else if (thread.recipient.isCommunityRecipient) { val threadId = threadDb.getThreadIdIfExistsFor(thread.recipient) @@ -436,7 +459,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), val clip = ClipData.newPlainText("Community URL", openGroup.joinURL) val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager manager.setPrimaryClip(clip) - Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() + Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show() } } bottomSheet.onBlockTapped = { @@ -482,9 +505,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private fun blockConversation(thread: ThreadRecord) { showSessionDialog { - title(R.string.RecipientPreferenceActivity_block_this_contact_question) - text(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact) - button(R.string.RecipientPreferenceActivity_block) { + title(R.string.block) + text(Phrase.from(context, R.string.blockDescription) + .put(NAME_KEY, thread.recipient.name) + .format()) + dangerButton(R.string.block, R.string.AccessibilityId_blockConfirm) { lifecycleScope.launch(Dispatchers.IO) { storage.setBlocked(listOf(thread.recipient), true) @@ -492,6 +517,9 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), binding.recyclerView.adapter!!.notifyDataSetChanged() } } + // Block confirmation toast added as per SS-64 + val txt = Phrase.from(context, R.string.blockBlockedUser).put(NAME_KEY, thread.recipient.name).format().toString() + Toast.makeText(context, txt, Toast.LENGTH_LONG).show() } cancelButton() } @@ -499,12 +527,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private fun unblockConversation(thread: ThreadRecord) { showSessionDialog { - title(R.string.RecipientPreferenceActivity_unblock_this_contact_question) - text(R.string.RecipientPreferenceActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact) - button(R.string.RecipientPreferenceActivity_unblock) { + title(R.string.blockUnblock) + text(Phrase.from(context, R.string.blockUnblockName).put(NAME_KEY, thread.recipient.name).format()) + dangerButton(R.string.blockUnblock, R.string.AccessibilityId_unblockConfirm) { lifecycleScope.launch(Dispatchers.IO) { storage.setBlocked(listOf(thread.recipient), false) - withContext(Dispatchers.Main) { binding.recyclerView.adapter!!.notifyDataSetChanged() } @@ -525,6 +552,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } else { showMuteDialog(this) { until -> lifecycleScope.launch(Dispatchers.IO) { + Log.d("", "**** until: $until") recipientDatabase.setMuted(thread.recipient, until) withContext(Dispatchers.Main) { binding.recyclerView.adapter!!.notifyDataSetChanged() @@ -559,20 +587,51 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private fun deleteConversation(thread: ThreadRecord) { val threadID = thread.threadId val recipient = thread.recipient - val message = if (recipient.isGroupRecipient) { + val title: String + val message: CharSequence + var positiveButtonId: Int = R.string.yes + var negativeButtonId: Int = R.string.no + + if (recipient.isGroupRecipient) { val group = groupDatabase.getGroup(recipient.address.toString()).orNull() + + // If you are an admin of this group you can delete it if (group != null && group.admins.map { it.toString() }.contains(textSecurePreferences.getLocalNumber())) { - getString(R.string.admin_group_leave_warning) + title = getString(R.string.groupLeave) + message = Phrase.from(this.applicationContext, R.string.groupDeleteDescription) + .put(GROUP_NAME_KEY, group.title) + .format() } else { - resources.getString(R.string.activity_home_leave_group_dialog_message) + // Otherwise this is either a community, or it's a group you're not an admin of + title = if (recipient.isCommunityRecipient) getString(R.string.communityLeave) else getString(R.string.groupLeave) + message = Phrase.from(this.applicationContext, R.string.groupLeaveDescription) + .put(GROUP_NAME_KEY, group.title) + .format() } + + positiveButtonId = R.string.leave + negativeButtonId = R.string.cancel } else { - resources.getString(R.string.activity_home_delete_conversation_dialog_message) + // If this is a 1-on-1 conversation + if (recipient.name != null) { + title = getString(R.string.conversationsDelete) + message = Phrase.from(this.applicationContext, R.string.conversationsDeleteDescription) + .put(NAME_KEY, recipient.name) + .format() + } + else { + // If not group-related and we don't have a recipient name then this must be our Note to Self conversation + title = getString(R.string.clearMessages) + message = getString(R.string.clearMessagesNoteToSelfDescription) + positiveButtonId = R.string.clear + negativeButtonId = R.string.cancel + } } showSessionDialog { + title(title) text(message) - button(R.string.yes) { + dangerButton(positiveButtonId) { lifecycleScope.launch(Dispatchers.Main) { val context = this@HomeActivity // Cancel any outstanding jobs @@ -583,7 +642,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), GroupUtil.doubleDecodeGroupID(recipient.address.toString()).toHexString() .takeIf(DatabaseComponent.get(context).lokiAPIDatabase()::isClosedGroup) ?.let { MessageSender.explicitLeave(it, false) } - } catch (_: IOException) { + } catch (ioe: IOException) { + Log.w(TAG, "Got an IOException while sending leave group message") } } // Delete the conversation @@ -597,12 +657,13 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } // Update the badge count ApplicationContext.getInstance(context).messageNotifier.updateNotification(context) + // Notify the user - val toastMessage = if (recipient.isGroupRecipient) R.string.MessageRecord_left_group else R.string.activity_home_conversation_deleted_message + val toastMessage = if (recipient.isGroupRecipient) R.string.groupMemberYouLeft else R.string.conversationsDeleted Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show() } } - button(R.string.no) + button(negativeButtonId) } } @@ -618,7 +679,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private fun hideMessageRequests() { showSessionDialog { - text(getString(R.string.hide_message_requests)) + text(getString(R.string.hide)) button(R.string.yes) { textSecurePreferences.setHasHiddenMessageRequests() homeViewModel.tryReload() diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt index f399b602b4..7c115736ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt @@ -27,9 +27,12 @@ import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.ActivityPathBinding import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.utilities.NonTranslatableStringConstants.APP_NAME +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.getColorFromAttr import org.session.libsignal.utilities.Snode import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.ui.getSubbedString import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.IP2Country import org.thoughtcrime.securesms.util.PathDotView @@ -49,7 +52,12 @@ class PathActivity : PassphraseRequiredActionBarActivity() { super.onCreate(savedInstanceState, isReady) binding = ActivityPathBinding.inflate(layoutInflater) setContentView(binding.root) - supportActionBar!!.title = resources.getString(R.string.activity_path_title) + supportActionBar!!.title = resources.getString(R.string.onionRoutingPath) + + // Substitute "Session" into the path description. Note: This is a non-translatable string. + val txt = applicationContext.getSubbedString(R.string.onionRoutingPathDescription,APP_NAME_KEY to APP_NAME) + binding.pathDescription.text = txt + binding.pathRowsContainer.disableClipping() binding.learnMoreButton.setOnClickListener { learnMore() } update(false) @@ -98,6 +106,7 @@ class PathActivity : PassphraseRequiredActionBarActivity() { private fun update(isAnimated: Boolean) { binding.pathRowsContainer.removeAllViews() + if (OnionRequestAPI.paths.isNotEmpty()) { val path = OnionRequestAPI.paths.firstOrNull() ?: return finish() val dotAnimationRepeatInterval = path.count().toLong() * 1000 + 1000 @@ -105,8 +114,8 @@ class PathActivity : PassphraseRequiredActionBarActivity() { val isGuardSnode = (OnionRequestAPI.guardSnodes.contains(snode)) getPathRow(snode, LineView.Location.Middle, index.toLong() * 1000 + 2000, dotAnimationRepeatInterval, isGuardSnode) } - val youRow = getPathRow(resources.getString(R.string.activity_path_device_row_title), null, LineView.Location.Top, 1000, dotAnimationRepeatInterval) - val destinationRow = getPathRow(resources.getString(R.string.activity_path_destination_row_title), null, LineView.Location.Bottom, path.count().toLong() * 1000 + 2000, dotAnimationRepeatInterval) + val youRow = getPathRow(resources.getString(R.string.you), null, LineView.Location.Top, 1000, dotAnimationRepeatInterval) + val destinationRow = getPathRow(resources.getString(R.string.onionRoutingPathDestination), null, LineView.Location.Bottom, path.count().toLong() * 1000 + 2000, dotAnimationRepeatInterval) val rows = listOf( youRow ) + pathRows + listOf( destinationRow ) for (row in rows) { binding.pathRowsContainer.addView(row) @@ -162,11 +171,11 @@ class PathActivity : PassphraseRequiredActionBarActivity() { } private fun getPathRow(snode: Snode, location: LineView.Location, dotAnimationStartDelay: Long, dotAnimationRepeatInterval: Long, isGuardSnode: Boolean): LinearLayout { - val title = if (isGuardSnode) resources.getString(R.string.activity_path_guard_node_row_title) else resources.getString(R.string.activity_path_service_node_row_title) + val title = if (isGuardSnode) resources.getString(R.string.onionRoutingPathEntryNode) else resources.getString(R.string.onionRoutingPathServiceNode) val subtitle = if (IP2Country.isInitialized) { - IP2Country.shared.countryNamesCache[snode.ip] ?: resources.getString(R.string.activity_path_resolving_progress) + IP2Country.shared.countryNamesCache[snode.ip] ?: resources.getString(R.string.resolving) } else { - resources.getString(R.string.activity_path_resolving_progress) + resources.getString(R.string.resolving) } return getPathRow(title, subtitle, location, dotAnimationStartDelay, dotAnimationRepeatInterval) } @@ -179,7 +188,7 @@ class PathActivity : PassphraseRequiredActionBarActivity() { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) startActivity(intent) } catch (e: Exception) { - Toast.makeText(this, R.string.invalid_url, Toast.LENGTH_SHORT).show() + Toast.makeText(this, R.string.communityEnterUrlErrorInvalid, Toast.LENGTH_SHORT).show() } } // endregion @@ -250,13 +259,11 @@ class PathActivity : PassphraseRequiredActionBarActivity() { override fun onAttachedToWindow() { super.onAttachedToWindow() - startAnimation() } override fun onDetachedFromWindow() { super.onDetachedFromWindow() - stopAnimation() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt b/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt index 33bdd2f2f6..a413d09d86 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt @@ -18,15 +18,16 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import network.loki.messenger.R -import org.thoughtcrime.securesms.ui.theme.LocalDimensions -import org.thoughtcrime.securesms.ui.theme.PreviewTheme -import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.SessionShieldIcon -import org.thoughtcrime.securesms.ui.theme.ThemeColors -import org.thoughtcrime.securesms.ui.theme.LocalColors +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 +import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors @Composable internal fun SeedReminder(startRecoveryPasswordActivity: () -> Unit) { @@ -49,23 +50,23 @@ internal fun SeedReminder(startRecoveryPasswordActivity: () -> Unit) { Column(Modifier.weight(1f)) { Row { Text( - stringResource(R.string.save_your_recovery_password), + stringResource(R.string.recoveryPasswordBannerTitle), style = LocalType.current.h8 ) Spacer(Modifier.requiredWidth(LocalDimensions.current.xxsSpacing)) SessionShieldIcon() } Text( - stringResource(R.string.save_your_recovery_password_to_make_sure_you_don_t_lose_access_to_your_account), + stringResource(R.string.recoveryPasswordBannerDescription), style = LocalType.current.small ) } - Spacer(Modifier.width(LocalDimensions.current.xsSpacing)) - SlimPrimaryOutlineButton( - text = stringResource(R.string.continue_2), + Spacer(Modifier.width(LocalDimensions.current.smallSpacing)) + PrimaryOutlineButton( + text = stringResource(R.string.theContinue), modifier = Modifier .align(Alignment.CenterVertically) - .contentDescription(R.string.AccessibilityId_reveal_recovery_phrase_button), + .contentDescription(R.string.AccessibilityId_recoveryPasswordBanner), onClick = startRecoveryPasswordActivity ) } @@ -78,6 +79,6 @@ private fun PreviewSeedReminder( @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors ) { PreviewTheme(colors) { - SeedReminder {} + SeedReminder { } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt index cae399dcbf..d5fad90c4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt @@ -100,7 +100,7 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText("Account ID", publicKey) clipboard.setPrimaryClip(clip) - Toast.makeText(requireContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT) + Toast.makeText(requireContext(), R.string.copied, Toast.LENGTH_SHORT) .show() true } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt index d390776d1c..947edc3d8e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt @@ -6,12 +6,14 @@ import android.text.SpannableStringBuilder import android.text.style.StyleSpan import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil +import java.util.Locale import network.loki.messenger.R import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.truncateIdForDisplay import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView +import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Contact as ContactModel import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message @@ -19,9 +21,6 @@ import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMes import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SubHeader import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.SearchUtil -import java.util.Locale -import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Contact as ContactModel - class GlobalSearchDiff( private val oldQuery: String?, @@ -78,8 +77,8 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) { } binding.searchResultSubtitle.text = getHighlight(query, membersString) } - is Header, // do nothing for header - is SubHeader, // do nothing for subheader + is Header, // do nothing for header + is SubHeader, // do nothing for subheader is SavedMessages -> Unit // do nothing for saved messages (displays note to self) } } @@ -112,7 +111,7 @@ fun ContentView.bindModel(query: String?, model: ContactModel) = binding.run { searchResultSubtitle.text = null val recipient = Recipient.from(root.context, Address.fromSerialized(model.contact.accountID), false) searchResultProfilePicture.update(recipient) - val nameString = if (model.isSelf) root.context.getString(R.string.note_to_self) + val nameString = if (model.isSelf) root.context.getString(R.string.noteToSelf) else model.contact.getSearchName() searchResultTitle.text = getHighlight(query, nameString) } @@ -120,7 +119,7 @@ fun ContentView.bindModel(query: String?, model: ContactModel) = binding.run { fun ContentView.bindModel(model: SavedMessages) { binding.searchResultSubtitle.isVisible = false binding.searchResultTimestamp.isVisible = false - binding.searchResultTitle.setText(R.string.note_to_self) + binding.searchResultTitle.setText(R.string.noteToSelf) binding.searchResultProfilePicture.update(Address.fromSerialized(model.currentUserPublicKey)) binding.searchResultProfilePicture.isVisible = true } @@ -128,11 +127,13 @@ fun ContentView.bindModel(model: SavedMessages) { fun ContentView.bindModel(query: String?, model: Message) = binding.apply { searchResultProfilePicture.isVisible = true searchResultTimestamp.isVisible = true + // val hasUnreads = model.unread > 0 // unreadCountIndicator.isVisible = hasUnreads // if (hasUnreads) { // unreadCountTextView.text = model.unread.toString() // } + searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(root.context, Locale.getDefault(), model.messageResult.sentTimestampMs) searchResultProfilePicture.update(model.messageResult.conversationRecipient) val textSpannable = SpannableStringBuilder() @@ -146,7 +147,7 @@ fun ContentView.bindModel(query: String?, model: Message) = binding.apply { model.messageResult.bodySnippet )) searchResultSubtitle.text = textSpannable - searchResultTitle.text = if (model.isSelf) root.context.getString(R.string.note_to_self) + searchResultTitle.text = if (model.isSelf) root.context.getString(R.string.noteToSelf) else model.messageResult.conversationRecipient.getSearchName() searchResultSubtitle.isVisible = true } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt index a85ea525ae..f34974667e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt @@ -108,9 +108,9 @@ class KeyboardPageSearchView @JvmOverloads constructor( fun showRequested(): Boolean = state == State.SHOW_REQUESTED fun enableBackNavigation(enable: Boolean = true) { - navButton.setImageResource(if (enable) R.drawable.ic_arrow_left_24 else R.drawable.ic_search_24) + navButton.setImageResource(if (enable) R.drawable.ic_arrow_left else R.drawable.ic_search_24) if (enable) { - navButton.setImageResource(R.drawable.ic_arrow_left_24) + navButton.setImageResource(R.drawable.ic_arrow_left) navButton.setOnClickListener { callbacks?.onNavigationClicked() } } else { navButton.setImageResource(R.drawable.ic_search_24) diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java index 807c4548a7..6f44f7523c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java @@ -1,216 +1,233 @@ package org.thoughtcrime.securesms.linkpreview; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - import android.annotation.SuppressLint; +import android.os.Build; import android.text.Html; import android.text.SpannableString; import android.text.TextUtils; import android.text.style.URLSpan; import android.text.util.Linkify; - +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.annimon.stream.Stream; - -import org.thoughtcrime.securesms.util.DateUtils; -import org.session.libsignal.utilities.guava.Optional; - -import org.session.libsession.utilities.Util; - +import java.text.ParseException; +import java.text.SimpleDateFormat; import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; - import okhttp3.HttpUrl; +import org.session.libsession.utilities.Util; +import org.session.libsignal.utilities.Log; +import org.session.libsignal.utilities.guava.Optional; public final class LinkPreviewUtil { - private static final Pattern DOMAIN_PATTERN = Pattern.compile("^(https?://)?([^/]+).*$", Pattern.CASE_INSENSITIVE); - private static final Pattern ALL_ASCII_PATTERN = Pattern.compile("^[\\x00-\\x7F]*$", Pattern.CASE_INSENSITIVE); - private static final Pattern ALL_NON_ASCII_PATTERN = Pattern.compile("^[^\\x00-\\x7F]*$", Pattern.CASE_INSENSITIVE); - private static final Pattern OPEN_GRAPH_TAG_PATTERN = Pattern.compile("<\\s*meta[^>]*property\\s*=\\s*\"\\s*og:([^\"]+)\"[^>]*/?\\s*>", Pattern.CASE_INSENSITIVE); - private static final Pattern ARTICLE_TAG_PATTERN = Pattern.compile("<\\s*meta[^>]*property\\s*=\\s*\"\\s*article:([^\"]+)\"[^>]*/?\\s*>", Pattern.CASE_INSENSITIVE); - private static final Pattern OPEN_GRAPH_CONTENT_PATTERN = Pattern.compile("content\\s*=\\s*\"([^\"]*)\"", Pattern.CASE_INSENSITIVE); - private static final Pattern TITLE_PATTERN = Pattern.compile("<\\s*title[^>]*>(.*)<\\s*/title[^>]*>", Pattern.CASE_INSENSITIVE); - private static final Pattern FAVICON_PATTERN = Pattern.compile("<\\s*link[^>]*rel\\s*=\\s*\".*icon.*\"[^>]*>", Pattern.CASE_INSENSITIVE); - private static final Pattern FAVICON_HREF_PATTERN = Pattern.compile("href\\s*=\\s*\"([^\"]*)\"", Pattern.CASE_INSENSITIVE); - - /** - * @return All whitelisted URLs in the source text. - */ - public static @NonNull List findWhitelistedUrls(@NonNull String text) { - SpannableString spannable = new SpannableString(text); - boolean found = Linkify.addLinks(spannable, Linkify.WEB_URLS); - - if (!found) { - return Collections.emptyList(); + private static final Pattern DOMAIN_PATTERN = Pattern.compile("^(https?://)?([^/]+).*$", Pattern.CASE_INSENSITIVE); + private static final Pattern ALL_ASCII_PATTERN = Pattern.compile("^[\\x00-\\x7F]*$", Pattern.CASE_INSENSITIVE); + private static final Pattern ALL_NON_ASCII_PATTERN = Pattern.compile("^[^\\x00-\\x7F]*$", Pattern.CASE_INSENSITIVE); + private static final Pattern OPEN_GRAPH_TAG_PATTERN = Pattern.compile("<\\s*meta[^>]*property\\s*=\\s*\"\\s*og:([^\"]+)\"[^>]*/?\\s*>", Pattern.CASE_INSENSITIVE); + private static final Pattern ARTICLE_TAG_PATTERN = Pattern.compile("<\\s*meta[^>]*property\\s*=\\s*\"\\s*article:([^\"]+)\"[^>]*/?\\s*>", Pattern.CASE_INSENSITIVE); + private static final Pattern OPEN_GRAPH_CONTENT_PATTERN = Pattern.compile("content\\s*=\\s*\"([^\"]*)\"", Pattern.CASE_INSENSITIVE); + private static final Pattern TITLE_PATTERN = Pattern.compile("<\\s*title[^>]*>(.*)<\\s*/title[^>]*>", Pattern.CASE_INSENSITIVE); + private static final Pattern FAVICON_PATTERN = Pattern.compile("<\\s*link[^>]*rel\\s*=\\s*\".*icon.*\"[^>]*>", Pattern.CASE_INSENSITIVE); + private static final Pattern FAVICON_HREF_PATTERN = Pattern.compile("href\\s*=\\s*\"([^\"]*)\"", Pattern.CASE_INSENSITIVE); + + /** + * @return All whitelisted URLs in the source text. + */ + public static @NonNull List findWhitelistedUrls(@NonNull String text) { + SpannableString spannable = new SpannableString(text); + boolean found = Linkify.addLinks(spannable, Linkify.WEB_URLS); + + if (!found) { + return Collections.emptyList(); + } + + return Stream.of(spannable.getSpans(0, spannable.length(), URLSpan.class)) + .map(span -> new Link(span.getURL(), spannable.getSpanStart(span))) + .filter(link -> isValidLinkUrl(link.getUrl())) + .toList(); } - return Stream.of(spannable.getSpans(0, spannable.length(), URLSpan.class)) - .map(span -> new Link(span.getURL(), spannable.getSpanStart(span))) - .filter(link -> isValidLinkUrl(link.getUrl())) - .toList(); - } - - /** - * @return True if the host is valid. - */ - public static boolean isValidLinkUrl(@Nullable String linkUrl) { - if (linkUrl == null) return false; - - HttpUrl url = HttpUrl.parse(linkUrl); - return url != null && - !TextUtils.isEmpty(url.scheme()) && - "https".equals(url.scheme()) && - isLegalUrl(linkUrl); - } - - /** - * @return True if the top-level domain is valid. - */ - public static boolean isValidMediaUrl(@Nullable String mediaUrl) { - if (mediaUrl == null) return false; - - HttpUrl url = HttpUrl.parse(mediaUrl); - return url != null && - !TextUtils.isEmpty(url.scheme()) && - "https".equals(url.scheme()) && - isLegalUrl(mediaUrl); - } - - public static boolean isLegalUrl(@NonNull String url) { - Matcher matcher = DOMAIN_PATTERN.matcher(url); - - if (matcher.matches()) { - String domain = matcher.group(2); - String cleanedDomain = domain.replaceAll("\\.", ""); - - return ALL_ASCII_PATTERN.matcher(cleanedDomain).matches() || - ALL_NON_ASCII_PATTERN.matcher(cleanedDomain).matches(); - } else { - return false; + /** + * @return True if the host is valid. + */ + public static boolean isValidLinkUrl(@Nullable String linkUrl) { + if (linkUrl == null) return false; + + HttpUrl url = HttpUrl.parse(linkUrl); + return url != null && + !TextUtils.isEmpty(url.scheme()) && + "https".equals(url.scheme()) && + isLegalUrl(linkUrl); } - } - - public static boolean isValidMimeType(@NonNull String url) { - String[] validMimeType = {".jpg", ".png", ".gif", ".jpeg"}; - if (url.contains(".")) { - for (String mimeType : validMimeType) { - if (url.contains(mimeType)) { - return true; + + /** + * @return True if the top-level domain is valid. + */ + public static boolean isValidMediaUrl(@Nullable String mediaUrl) { + if (mediaUrl == null) return false; + + HttpUrl url = HttpUrl.parse(mediaUrl); + return url != null && + !TextUtils.isEmpty(url.scheme()) && + "https".equals(url.scheme()) && + isLegalUrl(mediaUrl); + } + + public static boolean isLegalUrl(@NonNull String url) { + Matcher matcher = DOMAIN_PATTERN.matcher(url); + + if (matcher.matches()) { + String domain = matcher.group(2); + String cleanedDomain = domain.replaceAll("\\.", ""); + + return ALL_ASCII_PATTERN.matcher(cleanedDomain).matches() || + ALL_NON_ASCII_PATTERN.matcher(cleanedDomain).matches(); + } else { + return false; } - } - return false; } - return true; - } - public static @NonNull OpenGraph parseOpenGraphFields(@Nullable String html) { - return parseOpenGraphFields(html, text -> Html.fromHtml(text).toString()); - } + public static boolean isValidMimeType(@NonNull String url) { + String[] validMimeType = {".jpg", ".png", ".gif", ".jpeg"}; + if (url.contains(".")) { + for (String mimeType : validMimeType) { + if (url.contains(mimeType)) { + return true; + } + } + return false; + } + return true; + } - static @NonNull OpenGraph parseOpenGraphFields(@Nullable String html, @NonNull HtmlDecoder htmlDecoder) { - if (html == null) { - return new OpenGraph(Collections.emptyMap(), null, null); + public static @NonNull OpenGraph parseOpenGraphFields(@Nullable String html) { + return parseOpenGraphFields(html, text -> Html.fromHtml(text).toString()); } - Map openGraphTags = new HashMap<>(); - Matcher openGraphMatcher = OPEN_GRAPH_TAG_PATTERN.matcher(html); + static @NonNull OpenGraph parseOpenGraphFields(@Nullable String html, @NonNull HtmlDecoder htmlDecoder) { + if (html == null) { + return new OpenGraph(Collections.emptyMap(), null, null); + } + + Map openGraphTags = new HashMap<>(); + Matcher openGraphMatcher = OPEN_GRAPH_TAG_PATTERN.matcher(html); - while (openGraphMatcher.find()) { - String tag = openGraphMatcher.group(); - String property = openGraphMatcher.groupCount() > 0 ? openGraphMatcher.group(1) : null; + while (openGraphMatcher.find()) { + String tag = openGraphMatcher.group(); + String property = openGraphMatcher.groupCount() > 0 ? openGraphMatcher.group(1) : null; - if (property != null) { - Matcher contentMatcher = OPEN_GRAPH_CONTENT_PATTERN.matcher(tag); - if (contentMatcher.find() && contentMatcher.groupCount() > 0) { - String content = htmlDecoder.fromEncoded(contentMatcher.group(1)); - openGraphTags.put(property.toLowerCase(), content); + if (property != null) { + Matcher contentMatcher = OPEN_GRAPH_CONTENT_PATTERN.matcher(tag); + if (contentMatcher.find() && contentMatcher.groupCount() > 0) { + String content = htmlDecoder.fromEncoded(contentMatcher.group(1)); + openGraphTags.put(property.toLowerCase(), content); + } + } } - } - } - Matcher articleMatcher = ARTICLE_TAG_PATTERN.matcher(html); + Matcher articleMatcher = ARTICLE_TAG_PATTERN.matcher(html); - while (articleMatcher.find()) { - String tag = articleMatcher.group(); - String property = articleMatcher.groupCount() > 0 ? articleMatcher.group(1) : null; + while (articleMatcher.find()) { + String tag = articleMatcher.group(); + String property = articleMatcher.groupCount() > 0 ? articleMatcher.group(1) : null; - if (property != null) { - Matcher contentMatcher = OPEN_GRAPH_CONTENT_PATTERN.matcher(tag); - if (contentMatcher.find() && contentMatcher.groupCount() > 0) { - String content = htmlDecoder.fromEncoded(contentMatcher.group(1)); - openGraphTags.put(property.toLowerCase(), content); + if (property != null) { + Matcher contentMatcher = OPEN_GRAPH_CONTENT_PATTERN.matcher(tag); + if (contentMatcher.find() && contentMatcher.groupCount() > 0) { + String content = htmlDecoder.fromEncoded(contentMatcher.group(1)); + openGraphTags.put(property.toLowerCase(), content); + } + } } - } - } - String htmlTitle = ""; - String faviconUrl = ""; + String htmlTitle = ""; + String faviconUrl = ""; - Matcher titleMatcher = TITLE_PATTERN.matcher(html); - if (titleMatcher.find() && titleMatcher.groupCount() > 0) { - htmlTitle = htmlDecoder.fromEncoded(titleMatcher.group(1)); - } + Matcher titleMatcher = TITLE_PATTERN.matcher(html); + if (titleMatcher.find() && titleMatcher.groupCount() > 0) { + htmlTitle = htmlDecoder.fromEncoded(titleMatcher.group(1)); + } + + Matcher faviconMatcher = FAVICON_PATTERN.matcher(html); + if (faviconMatcher.find()) { + Matcher faviconHrefMatcher = FAVICON_HREF_PATTERN.matcher(faviconMatcher.group()); + if (faviconHrefMatcher.find() && faviconHrefMatcher.groupCount() > 0) { + faviconUrl = faviconHrefMatcher.group(1); + } + } - Matcher faviconMatcher = FAVICON_PATTERN.matcher(html); - if (faviconMatcher.find()) { - Matcher faviconHrefMatcher = FAVICON_HREF_PATTERN.matcher(faviconMatcher.group()); - if (faviconHrefMatcher.find() && faviconHrefMatcher.groupCount() > 0) { - faviconUrl = faviconHrefMatcher.group(1); - } + return new OpenGraph(openGraphTags, htmlTitle, faviconUrl); } - return new OpenGraph(openGraphTags, htmlTitle, faviconUrl); - } + public static final class OpenGraph { - public static final class OpenGraph { + private final Map values; - private final Map values; + private final @Nullable String htmlTitle; + private final @Nullable String faviconUrl; - private final @Nullable String htmlTitle; - private final @Nullable String faviconUrl; + private static final String KEY_TITLE = "title"; + private static final String KEY_DESCRIPTION_URL = "description"; + private static final String KEY_IMAGE_URL = "image"; + private static final String KEY_PUBLISHED_TIME_1 = "published_time"; + private static final String KEY_PUBLISHED_TIME_2 = "article:published_time"; + private static final String KEY_MODIFIED_TIME_1 = "modified_time"; + private static final String KEY_MODIFIED_TIME_2 = "article:modified_time"; - private static final String KEY_TITLE = "title"; - private static final String KEY_DESCRIPTION_URL = "description"; - private static final String KEY_IMAGE_URL = "image"; - private static final String KEY_PUBLISHED_TIME_1 = "published_time"; - private static final String KEY_PUBLISHED_TIME_2 = "article:published_time"; - private static final String KEY_MODIFIED_TIME_1 = "modified_time"; - private static final String KEY_MODIFIED_TIME_2 = "article:modified_time"; + public OpenGraph(@NonNull Map values, @Nullable String htmlTitle, @Nullable String faviconUrl) { + this.values = values; + this.htmlTitle = htmlTitle; + this.faviconUrl = faviconUrl; + } - public OpenGraph(@NonNull Map values, @Nullable String htmlTitle, @Nullable String faviconUrl) { - this.values = values; - this.htmlTitle = htmlTitle; - this.faviconUrl = faviconUrl; - } + public @NonNull Optional getTitle() { + return Optional.of(Util.getFirstNonEmpty(values.get(KEY_TITLE), htmlTitle)); + } - public @NonNull Optional getTitle() { - return Optional.of(Util.getFirstNonEmpty(values.get(KEY_TITLE), htmlTitle)); - } + public @NonNull Optional getImageUrl() { + return Optional.of(Util.getFirstNonEmpty(values.get(KEY_IMAGE_URL), faviconUrl)); + } - public @NonNull Optional getImageUrl() { - return Optional.of(Util.getFirstNonEmpty(values.get(KEY_IMAGE_URL), faviconUrl)); - } + private static long parseISO8601(String date) { + + if (date == null || date.isEmpty()) { return -1L; } - @SuppressLint("ObsoleteSdkInt") - public long getDate() { - return Stream.of(values.get(KEY_PUBLISHED_TIME_1), - values.get(KEY_PUBLISHED_TIME_2), - values.get(KEY_MODIFIED_TIME_1), - values.get(KEY_MODIFIED_TIME_2)) - .map(DateUtils::parseIso8601) - .filter(time -> time > 0) - .findFirst() - .orElse(0L); + SimpleDateFormat format; + if (Build.VERSION.SDK_INT >= 24) { + format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX", Locale.getDefault()); + } else { + format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()); + } + + try { + return format.parse(date).getTime(); + } catch (ParseException pe) { + Log.w("OpenGraph", "Failed to parse date.", pe); + return -1L; + } + } + + @SuppressLint("ObsoleteSdkInt") + public long getDate() { + return Stream.of(values.get(KEY_PUBLISHED_TIME_1), + values.get(KEY_PUBLISHED_TIME_2), + values.get(KEY_MODIFIED_TIME_1), + values.get(KEY_MODIFIED_TIME_2)) + .map(OpenGraph::parseISO8601) + .filter(time -> time > 0) + .findFirst() + .orElse(0L); + } } - } - public interface HtmlDecoder { - @NonNull String fromEncoded(@NonNull String html); - } + public interface HtmlDecoder { + @NonNull String fromEncoded(@NonNull String html); + } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/DocumentsPage.kt b/app/src/main/java/org/thoughtcrime/securesms/media/DocumentsPage.kt index 0f4b8c58b8..4d132f009b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/DocumentsPage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/DocumentsPage.kt @@ -44,7 +44,7 @@ fun DocumentsPage( content.isEmpty() -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text( - text = stringResource(R.string.media_overview_documents_fragment__no_documents_found), + text = stringResource(R.string.attachmentsFilesEmpty), style = LocalType.current.base, color = LocalColors.current.text ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/FixedTimeBuckets.kt b/app/src/main/java/org/thoughtcrime/securesms/media/FixedTimeBuckets.kt index f01276f78e..ecfab34aab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/FixedTimeBuckets.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/FixedTimeBuckets.kt @@ -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.BucketedThreadMedia_This_week - time >= startOfThisMonth -> R.string.BucketedThreadMedia_This_month + 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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt index 2f72751265..34ccc1c1c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt @@ -68,7 +68,7 @@ fun MediaOverviewScreen( } else { Toast.makeText( context, - R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, + R.string.permissionsCameraDenied, Toast.LENGTH_LONG ).show() } @@ -101,31 +101,18 @@ fun MediaOverviewScreen( } catch (e: ActivityNotFoundException) { Toast.makeText( context, - R.string.ConversationItem_unable_to_open_media, + R.string.attachmentsErrorOpen, Toast.LENGTH_LONG ).show() } } is MediaOverviewEvent.ShowSaveAttachmentError -> { - val message = context.resources.getQuantityText( - R.plurals.ConversationFragment_error_while_saving_attachments_to_sd_card, - event.errorCount - ) - Toast.makeText(context, message, Toast.LENGTH_LONG).show() + Toast.makeText(context, R.string.attachmentsSaveError, Toast.LENGTH_LONG).show() } is MediaOverviewEvent.ShowSaveAttachmentSuccess -> { - val message = if (event.directory.isNotBlank()) { - context.resources.getString( - R.string.SaveAttachmentTask_saved_to, - event.directory - ) - } else { - context.resources.getString(R.string.SaveAttachmentTask_saved) - } - - Toast.makeText(context, message, Toast.LENGTH_LONG).show() + Toast.makeText(context, R.string.saved, Toast.LENGTH_LONG).show() } } } @@ -241,15 +228,11 @@ private fun SaveAttachmentWarningDialog( val context = LocalContext.current AlertDialog( onDismissRequest = onDismissRequest, - title = context.getString(R.string.ConversationFragment_save_to_sd_card), - text = context.resources.getQuantityString( - R.plurals.ConversationFragment_saving_n_media_to_storage_warning, - numSelected, - numSelected - ), + title = context.getString(R.string.warning), + text = context.resources.getString(R.string.attachmentsWarning), buttons = listOf( - DialogButtonModel(GetString(R.string.save), onClick = onAccepted), - DialogButtonModel(GetString(android.R.string.cancel), dismissOnClick = true) + DialogButtonModel(GetString(R.string.save), GetString(R.string.AccessibilityId_saveAttachment), color = LocalColors.current.danger, onClick = onAccepted), + DialogButtonModel(GetString(android.R.string.cancel), GetString(R.string.AccessibilityId_cancel), dismissOnClick = true) ) ) } @@ -264,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), @@ -305,7 +283,6 @@ private fun ActionProgressDialog( private val MediaOverviewTab.titleResId: Int get() = when (this) { - MediaOverviewTab.Media -> R.string.MediaOverviewActivity_Media - MediaOverviewTab.Documents -> R.string.MediaOverviewActivity_Documents - } - + MediaOverviewTab.Media -> R.string.media + MediaOverviewTab.Documents -> R.string.files + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewTopAppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewTopAppBar.kt index 570885f212..c6fa7d4a1f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewTopAppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewTopAppBar.kt @@ -50,7 +50,7 @@ fun MediaOverviewTopAppBar( IconButton(onClick = onSelectAllClicked) { Icon( painterResource(R.drawable.ic_baseline_select_all_24), - contentDescription = stringResource(R.string.MediaOverviewActivity_Select_all), + contentDescription = stringResource(R.string.selectAll), tint = LocalColors.current.text, ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt index a0b629737d..b856745e47 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt @@ -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 } @@ -234,11 +234,7 @@ class MediaOverviewViewModel( viewModelScope.launch { val selectedMedia = selectedMedia.toList() - mutableShowingActionProgress.value = application.resources.getQuantityString( - R.plurals.ConversationFragment_saving_n_attachments, - selectedMedia.size, - selectedMedia.size, - ) + mutableShowingActionProgress.value = application.resources.getString(R.string.saving) val attachments = selectedMedia .asSequence() @@ -308,7 +304,7 @@ class MediaOverviewViewModel( } viewModelScope.launch { - mutableShowingActionProgress.value = application.getString(R.string.MediaOverviewActivity_Media_delete_progress_message) + mutableShowingActionProgress.value = application.getString(R.string.deleting) // Delete the selected media items, and retrieve the thread ID for the address if any val threadId = withContext(Dispatchers.Default) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt index 49b44b5055..35479ae503 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt @@ -64,7 +64,7 @@ fun MediaPage( state.isEmpty() -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text( - text = stringResource(R.string.media_overview_activity__no_media), + text = stringResource(R.string.attachmentsMediaEmpty), style = LocalType.current.base, color = LocalColors.current.text ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java index 290d09778a..82d6b93085 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.mediasend; +import static org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY; + import androidx.appcompat.app.ActionBar; import androidx.lifecycle.ViewModelProvider; @@ -20,7 +22,10 @@ import android.view.WindowManager; import com.bumptech.glide.Glide; +import com.squareup.phrase.Phrase; + import org.session.libsession.utilities.recipients.Recipient; +import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.guava.Optional; import network.loki.messenger.R; @@ -110,11 +115,15 @@ public void onConfigurationChanged(Configuration newConfig) { private void initToolbar(Toolbar toolbar) { ((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar); ActionBar actionBar = ((AppCompatActivity) requireActivity()).getSupportActionBar(); - actionBar.setTitle(getString(R.string.MediaPickerActivity_send_to, recipientName)); - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setHomeButtonEnabled(true); - - toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed()); + if (actionBar == null) { + Log.w("MediaPickerFolderFragment", "ActionBar is null in initToolbar - cannot continue."); + } else { + CharSequence txt = Phrase.from(requireContext(), R.string.attachmentsSendTo).put(NAME_KEY, recipientName).format(); + actionBar.setTitle(txt); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setHomeButtonEnabled(true); + toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed()); + } } private void onScreenWidthChanged(int newWidth) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java index 8fd7559b4d..b1c104e32e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java @@ -163,7 +163,7 @@ public void onMediaSelectionChanged(@NonNull List selected) { @Override public void onMediaSelectionOverflow(int maxSelection) { - Toast.makeText(requireContext(), getResources().getQuantityString(R.plurals.MediaSendActivity_cant_share_more_than_n_items, maxSelection, maxSelection), Toast.LENGTH_SHORT).show(); + Toast.makeText(requireContext(), getString(R.string.attachmentsErrorNumber), Toast.LENGTH_SHORT).show(); } private void initToolbar(Toolbar toolbar) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java index a9e7081ef8..f74910f870 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java @@ -94,7 +94,7 @@ void getPopulatedMedia(@NonNull Context context, @NonNull List media, @No Uri allMediaThumbnail = imageFolders.getThumbnailTimestamp() > videoFolders.getThumbnailTimestamp() ? imageFolders.getThumbnail() : videoFolders.getThumbnail(); if (allMediaThumbnail != null) { int allMediaCount = Stream.of(mediaFolders).reduce(0, (count, folder) -> count + folder.getItemCount()); - mediaFolders.add(0, new MediaFolder(allMediaThumbnail, context.getString(R.string.MediaRepository_all_media), allMediaCount, Media.ALL_MEDIA_BUCKET_ID)); + mediaFolders.add(0, new MediaFolder(allMediaThumbnail, context.getString(R.string.conversationsSettingsAllMedia), allMediaCount, Media.ALL_MEDIA_BUCKET_ID)); } return mediaFolders; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java index 3e9fd7905e..3a333cd8f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.mediasend; +import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; + import android.Manifest; import android.content.Context; import android.content.Intent; @@ -20,6 +22,8 @@ import androidx.fragment.app.FragmentManager; import androidx.lifecycle.ViewModelProvider; +import com.squareup.phrase.Phrase; + import org.session.libsession.utilities.Address; import org.session.libsession.utilities.MediaTypes; import org.session.libsession.utilities.Util; @@ -159,7 +163,7 @@ protected void onCreate(Bundle savedInstanceState, boolean ready) { int maxSelection = viewModel.getMaxSelection(); if (viewModel.getSelectedMedia().getValue() != null && viewModel.getSelectedMedia().getValue().size() >= maxSelection) { - Toast.makeText(this, getResources().getQuantityString(R.plurals.MediaSendActivity_cant_share_more_than_n_items, maxSelection, maxSelection), Toast.LENGTH_SHORT).show(); + Toast.makeText(this, getString(R.string.attachmentsErrorNumber), Toast.LENGTH_SHORT).show(); } else { navigateToCamera(); } @@ -252,7 +256,7 @@ public void onTouchEventsNeeded(boolean needed) { @Override public void onCameraError() { - Toast.makeText(this, R.string.MediaSendActivity_camera_unavailable, Toast.LENGTH_SHORT).show(); + Toast.makeText(this, R.string.cameraErrorUnavailable, Toast.LENGTH_SHORT).show(); setResult(RESULT_CANCELED, new Intent()); finish(); } @@ -328,11 +332,12 @@ private void initializeErrorObserver() { switch (error) { case ITEM_TOO_LARGE: - Toast.makeText(this, R.string.MediaSendActivity_an_item_was_removed_because_it_exceeded_the_size_limit, Toast.LENGTH_LONG).show(); + Toast.makeText(this, R.string.attachmentsErrorSize, Toast.LENGTH_LONG).show(); break; case TOO_MANY_ITEMS: - int maxSelection = viewModel.getMaxSelection(); - Toast.makeText(this, getResources().getQuantityString(R.plurals.MediaSendActivity_cant_share_more_than_n_items, maxSelection, maxSelection), Toast.LENGTH_SHORT).show(); + // In modern session we'll say you can't sent more than 32 items, but if we ever want + // the exact count of how many items the user attempted to send it's: viewModel.getMaxSelection() + Toast.makeText(this, getString(R.string.attachmentsErrorNumber), Toast.LENGTH_SHORT).show(); break; } }); @@ -355,10 +360,18 @@ private void navigateToMediaSend(@NonNull Recipient recipient) { } private void navigateToCamera() { + + Context c = getApplicationContext(); + String permanentDenialTxt = Phrase.from(c, R.string.permissionsCameraDenied) + .put(APP_NAME_KEY, c.getString(R.string.app_name)) + .format().toString(); + String requireCameraPermissionsTxt = Phrase.from(c, R.string.cameraGrantAccessDescription) + .put(APP_NAME_KEY, c.getString(R.string.app_name)) + .format().toString(); + Permissions.with(this) .request(Manifest.permission.CAMERA) - .withRationaleDialog(getString(R.string.ConversationActivity_to_capture_photos_and_video_allow_signal_access_to_the_camera), R.drawable.ic_baseline_photo_camera_48) - .withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video)) + .withPermanentDenialDialog(permanentDenialTxt) .onAllGranted(() -> { Camera1Fragment fragment = getOrCreateCameraFragment(); getSupportFragmentManager().beginTransaction() @@ -367,7 +380,7 @@ private void navigateToCamera() { .addToBackStack(null) .commit(); }) - .onAnyDenied(() -> Toast.makeText(MediaSendActivity.this, R.string.ConversationActivity_signal_needs_camera_permissions_to_take_photos_or_video, Toast.LENGTH_LONG).show()) + .onAnyDenied(() -> Toast.makeText(MediaSendActivity.this, requireCameraPermissionsTxt, Toast.LENGTH_LONG).show()) .execute(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java index 84722c24c9..169ac83ead 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java @@ -208,7 +208,7 @@ public void onTextChanged(String text) { String displayName = Optional.fromNullable(recipient.getName()) .or(Optional.fromNullable(recipient.getProfileName()) .or(recipient.getAddress().serialize())); - composeText.setHint(getString(R.string.MediaSendActivity_message_to_s, displayName), null); + composeText.setHint(getString(R.string.message, displayName), null); composeText.setOnEditorActionListener((v, actionId, event) -> { boolean isSend = actionId == EditorInfo.IME_ACTION_SEND; if (isSend) sendButton.performClick(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt index 1fb1f38ce4..2faac58487 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt @@ -59,7 +59,7 @@ class MessageRequestView : LinearLayout { private fun getUserDisplayName(recipient: Recipient): String? { return if (recipient.isLocalNumber) { - context.getString(R.string.note_to_self) + context.getString(R.string.noteToSelf) } else { recipient.name // Internally uses the Contact API } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt index 233222dabb..4d280a47ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt @@ -8,11 +8,14 @@ import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.loader.app.LoaderManager import androidx.loader.content.Loader +import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ActivityMessageRequestsBinding +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.ThreadDatabase @@ -22,7 +25,6 @@ import com.bumptech.glide.RequestManager import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.push -import javax.inject.Inject @AndroidEntryPoint class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), ConversationClickListener, LoaderManager.LoaderCallbacks { @@ -83,10 +85,14 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat } showSessionDialog { - title(R.string.RecipientPreferenceActivity_block_this_contact_question) - text(R.string.message_requests_block_message) - button(R.string.recipient_preferences__block) { doBlock() } - button(R.string.no) + title(R.string.block) + text(Phrase.from(context, R.string.blockDescription) + .put(NAME_KEY, thread.recipient.name) + .format()) + dangerButton(R.string.block, R.string.AccessibilityId_blockConfirm) { + doBlock() + } + button(R.string.no) } } @@ -100,10 +106,10 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat } showSessionDialog { - title(R.string.decline) - text(resources.getString(R.string.message_requests_decline_message)) - button(R.string.decline) { doDecline() } - button(R.string.no) + title(R.string.delete) + text(resources.getString(R.string.messageRequestsDelete)) + dangerButton(R.string.delete) { doDecline() } + button(R.string.cancel) } } @@ -123,9 +129,10 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat } showSessionDialog { - text(resources.getString(R.string.message_requests_clear_all_message)) - button(R.string.yes) { doDeleteAllAndBlock() } - button(R.string.no) + title(resources.getString(R.string.clearAll)) + text(resources.getString(R.string.messageRequestsClearAllExplanation)) + dangerButton(R.string.clear) { doDeleteAllAndBlock() } + button(R.string.cancel) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java index 3d45e6a6e7..de3d1c3925 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java @@ -69,7 +69,7 @@ public boolean hasAudio() { @NonNull @Override public String getContentDescription() { - return context.getString(R.string.Slide_audio); + return context.getString(R.string.audio); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/ImageSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/ImageSlide.java index 87d66743c0..042486dc36 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/ImageSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/ImageSlide.java @@ -62,6 +62,6 @@ public boolean hasImage() { @NonNull @Override public String getContentDescription() { - return context.getString(R.string.Slide_image); + return context.getString(R.string.image); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt index 05d8167d9a..d97fd94722 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt @@ -49,14 +49,11 @@ abstract class Slide(@JvmField protected val context: Context, protected val att // A missing file name is the legacy way to determine if an audio attachment is // a voice note vs. other arbitrary audio attachments. if (attachment.isVoiceNote || attachment.fileName.isNullOrEmpty()) { - val baseString = context.getString(R.string.attachment_type_voice_message) - val languageIsLTR = Util.usingLeftToRightLanguage(context) - val attachmentString = if (languageIsLTR) { - "🎙 $baseString" - } else { - "$baseString 🎙" - } - return Optional.fromNullable(attachmentString) + val voiceTxt = Phrase.from(context, R.string.messageVoiceSnippet) + .put(EMOJI_KEY, "🎙") + .format().toString() + + return Optional.fromNullable(voiceTxt) } } val txt = Phrase.from(context, R.string.attachmentsNotification) @@ -66,19 +63,19 @@ abstract class Slide(@JvmField protected val context: Context, protected val att } private fun emojiForMimeType(): String { - return if (MediaUtil.isGif(attachment)) { - "🎡" - } else if (MediaUtil.isImage(attachment)) { - "📷" - } else if (MediaUtil.isVideo(attachment)) { - "🎥" - } else if (MediaUtil.isAudio(attachment)) { - "🎧" - } else if (MediaUtil.isFile(attachment)) { - "📎" - } else { + return when{ + MediaUtil.isGif(attachment) -> "🎡" + + MediaUtil.isImage(attachment) -> "📷" + + MediaUtil.isVideo(attachment) -> "🎥" + + MediaUtil.isAudio(attachment) -> "🎧" + + MediaUtil.isFile(attachment) -> "📎" + // We don't provide emojis for other mime-types such as VCARD - "" + else -> "" } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java index 1017ad1107..8bd3afb4fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java @@ -70,6 +70,6 @@ public boolean hasVideo() { @NonNull @Override public String getContentDescription() { - return context.getString(R.string.Slide_video); + return context.getString(R.string.video); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java index d67ad47832..2e2440d6a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java @@ -72,9 +72,9 @@ public void setTicker(@NonNull Recipient recipient, @Nullable CharSequence messa if (privacy.isDisplayMessage()) { setTicker(getStyledMessage(recipient, trimToDisplayLength(message))); } else if (privacy.isDisplayContact()) { - setTicker(getStyledMessage(recipient, context.getString(R.string.AbstractNotificationBuilder_new_message))); + setTicker(getStyledMessage(recipient, context.getResources().getQuantityString(R.plurals.messageNew, 1, 1))); } else { - setTicker(context.getString(R.string.AbstractNotificationBuilder_new_message)); + setTicker(context.getResources().getQuantityString(R.plurals.messageNew, 1, 1)); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java deleted file mode 100644 index e80c47a9f6..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ /dev/null @@ -1,727 +0,0 @@ -/* - * Copyright (C) 2011 Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.thoughtcrime.securesms.notifications; - -import android.annotation.SuppressLint; -import android.app.AlarmManager; -import android.app.Notification; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.database.Cursor; -import android.net.Uri; -import android.os.AsyncTask; -import android.os.Build; -import android.service.notification.StatusBarNotification; -import android.text.SpannableString; -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; - -import com.annimon.stream.Optional; -import com.annimon.stream.Stream; -import com.goterl.lazysodium.utils.KeyPair; - -import org.session.libsession.messaging.open_groups.OpenGroup; -import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; -import org.session.libsession.messaging.utilities.AccountId; -import org.session.libsession.messaging.utilities.SodiumUtilities; -import org.session.libsession.snode.SnodeAPI; -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.Contact; -import org.session.libsession.utilities.ServiceUtil; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsignal.utilities.IdPrefix; -import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.Util; -import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.contacts.ContactUtil; -import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2; -import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities; -import org.thoughtcrime.securesms.crypto.KeyPairUtilities; -import org.thoughtcrime.securesms.database.LokiThreadDatabase; -import org.thoughtcrime.securesms.database.MmsSmsDatabase; -import org.thoughtcrime.securesms.database.RecipientDatabase; -import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; -import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.database.model.MmsMessageRecord; -import org.thoughtcrime.securesms.database.model.Quote; -import org.thoughtcrime.securesms.database.model.ReactionRecord; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.mms.SlideDeck; -import org.thoughtcrime.securesms.service.KeyCachingService; -import org.thoughtcrime.securesms.util.SessionMetaProtocol; -import org.thoughtcrime.securesms.util.SpanUtil; - -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.ListIterator; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; - -import me.leolin.shortcutbadger.ShortcutBadger; -import network.loki.messenger.R; - -/** - * Handles posting system notifications for new messages. - * - * - * @author Moxie Marlinspike - */ - -public class DefaultMessageNotifier implements MessageNotifier { - - private static final String TAG = DefaultMessageNotifier.class.getSimpleName(); - - public static final String EXTRA_REMOTE_REPLY = "extra_remote_reply"; - public static final String LATEST_MESSAGE_ID_TAG = "extra_latest_message_id"; - - private static final int FOREGROUND_ID = 313399; - private static final int SUMMARY_NOTIFICATION_ID = 1338; - private static final int PENDING_MESSAGES_ID = 1111; - private static final String NOTIFICATION_GROUP = "messages"; - private static final long MIN_AUDIBLE_PERIOD_MILLIS = TimeUnit.SECONDS.toMillis(5); - private static final long DESKTOP_ACTIVITY_PERIOD = TimeUnit.MINUTES.toMillis(1); - - private volatile static long visibleThread = -1; - private volatile static boolean homeScreenVisible = false; - private volatile static long lastDesktopActivityTimestamp = -1; - private volatile static long lastAudibleNotification = -1; - private static final CancelableExecutor executor = new CancelableExecutor(); - - @Override - public void setVisibleThread(long threadId) { - visibleThread = threadId; - } - - @Override - public void setHomeScreenVisible(boolean isVisible) { - homeScreenVisible = isVisible; - } - - @Override - public void setLastDesktopActivityTimestamp(long timestamp) { - lastDesktopActivityTimestamp = timestamp; - } - - @Override - public void notifyMessageDeliveryFailed(Context context, Recipient recipient, long threadId) { - if (visibleThread != threadId) { - Intent intent = new Intent(context, ConversationActivityV2.class); - intent.putExtra(ConversationActivityV2.ADDRESS, recipient.getAddress()); - intent.putExtra(ConversationActivityV2.THREAD_ID, threadId); - intent.setData((Uri.parse("custom://" + SnodeAPI.getNowWithOffset()))); - - FailedNotificationBuilder builder = new FailedNotificationBuilder(context, TextSecurePreferences.getNotificationPrivacy(context), intent); - ((NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE)) - .notify((int)threadId, builder.build()); - } - } - - public void notifyMessagesPending(Context context) { - - if (!TextSecurePreferences.isNotificationsEnabled(context)) { return; } - - PendingMessageNotificationBuilder builder = new PendingMessageNotificationBuilder(context, TextSecurePreferences.getNotificationPrivacy(context)); - ServiceUtil.getNotificationManager(context).notify(PENDING_MESSAGES_ID, builder.build()); - } - - @Override - public void cancelDelayedNotifications() { - executor.cancel(); - } - - private boolean cancelActiveNotifications(@NonNull Context context) { - NotificationManager notifications = ServiceUtil.getNotificationManager(context); - boolean hasNotifications = notifications.getActiveNotifications().length > 0; - notifications.cancel(SUMMARY_NOTIFICATION_ID); - - try { - StatusBarNotification[] activeNotifications = notifications.getActiveNotifications(); - - for (StatusBarNotification activeNotification : activeNotifications) { - notifications.cancel(activeNotification.getId()); - } - } catch (Throwable e) { - // XXX Appears to be a ROM bug, see #6043 - Log.w(TAG, e); - notifications.cancelAll(); - } - return hasNotifications; - } - - private void cancelOrphanedNotifications(@NonNull Context context, NotificationState notificationState) { - try { - NotificationManager notifications = ServiceUtil.getNotificationManager(context); - StatusBarNotification[] activeNotifications = notifications.getActiveNotifications(); - - for (StatusBarNotification notification : activeNotifications) { - boolean validNotification = false; - - if (notification.getId() != SUMMARY_NOTIFICATION_ID && - notification.getId() != KeyCachingService.SERVICE_RUNNING_ID && - notification.getId() != FOREGROUND_ID && - notification.getId() != PENDING_MESSAGES_ID) - { - for (NotificationItem item : notificationState.getNotifications()) { - if (notification.getId() == (SUMMARY_NOTIFICATION_ID + item.getThreadId())) { - validNotification = true; - break; - } - } - - if (!validNotification) { notifications.cancel(notification.getId()); } - } - } - } catch (Throwable e) { - // XXX Android ROM Bug, see #6043 - Log.w(TAG, e); - } - } - - @Override - public void updateNotification(@NonNull Context context) { - if (!TextSecurePreferences.isNotificationsEnabled(context)) { - return; - } - - updateNotification(context, false, 0); - } - - @Override - public void updateNotification(@NonNull Context context, long threadId) - { - if (System.currentTimeMillis() - lastDesktopActivityTimestamp < DESKTOP_ACTIVITY_PERIOD) { - Log.i(TAG, "Scheduling delayed notification..."); - executor.execute(new DelayedNotification(context, threadId)); - } else { - updateNotification(context, threadId, true); - } - } - - @Override - public void updateNotification(@NonNull Context context, long threadId, boolean signal) - { - boolean isVisible = visibleThread == threadId; - - ThreadDatabase threads = DatabaseComponent.get(context).threadDatabase(); - Recipient recipient = threads.getRecipientForThreadId(threadId); - - if (recipient != null && !recipient.isGroupRecipient() && threads.getMessageCount(threadId) == 1 && - !(recipient.isApproved() || threads.getLastSeenAndHasSent(threadId).second())) { - TextSecurePreferences.removeHasHiddenMessageRequests(context); - } - - if (!TextSecurePreferences.isNotificationsEnabled(context) || - (recipient != null && recipient.isMuted())) - { - return; - } - - if ((!isVisible && !homeScreenVisible) || hasExistingNotifications(context)) { - updateNotification(context, signal, 0); - } - } - - private boolean hasExistingNotifications(Context context) { - NotificationManager notifications = ServiceUtil.getNotificationManager(context); - try { - StatusBarNotification[] activeNotifications = notifications.getActiveNotifications(); - return activeNotifications.length > 0; - } catch (Exception e) { - return false; - } - } - - @Override - public void updateNotification(@NonNull Context context, boolean signal, int reminderCount) - { - Cursor telcoCursor = null; - Cursor pushCursor = null; - - try { - telcoCursor = DatabaseComponent.get(context).mmsSmsDatabase().getUnread(); // TODO: add a notification specific lighter query here - - if ((telcoCursor == null || telcoCursor.isAfterLast()) || TextSecurePreferences.getLocalNumber(context) == null) - { - updateBadge(context, 0); - cancelActiveNotifications(context); - clearReminder(context); - return; - } - - NotificationState notificationState = constructNotificationState(context, telcoCursor); - - if (signal && (System.currentTimeMillis() - lastAudibleNotification) < MIN_AUDIBLE_PERIOD_MILLIS) { - signal = false; - } else if (signal) { - lastAudibleNotification = System.currentTimeMillis(); - } - - try { - if (notificationState.hasMultipleThreads()) { - for (long threadId : notificationState.getThreads()) { - sendSingleThreadNotification(context, new NotificationState(notificationState.getNotificationsForThread(threadId)), false, true); - } - sendMultipleThreadNotification(context, notificationState, signal); - } else if (notificationState.getMessageCount() > 0) { - sendSingleThreadNotification(context, notificationState, signal, false); - } else { - cancelActiveNotifications(context); - } - } catch (Exception e) { - Log.e(TAG, "Error creating notification", e); - } - cancelOrphanedNotifications(context, notificationState); - updateBadge(context, notificationState.getMessageCount()); - - if (signal) { - scheduleReminder(context, reminderCount); - } - } finally { - if (telcoCursor != null) telcoCursor.close(); - } - } - - private void sendSingleThreadNotification(@NonNull Context context, - @NonNull NotificationState notificationState, - boolean signal, boolean bundled) - { - Log.i(TAG, "sendSingleThreadNotification() signal: " + signal + " bundled: " + bundled); - - if (notificationState.getNotifications().isEmpty()) { - if (!bundled) cancelActiveNotifications(context); - Log.i(TAG, "Empty notification state. Skipping."); - return; - } - - SingleRecipientNotificationBuilder builder = new SingleRecipientNotificationBuilder(context, TextSecurePreferences.getNotificationPrivacy(context)); - List notifications = notificationState.getNotifications(); - Recipient recipient = notifications.get(0).getRecipient(); - int notificationId = (int) (SUMMARY_NOTIFICATION_ID + (bundled ? notifications.get(0).getThreadId() : 0)); - String messageIdTag = String.valueOf(notifications.get(0).getTimestamp()); - - NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); - for (StatusBarNotification notification: notificationManager.getActiveNotifications()) { - if ( (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && notification.isAppGroup() == bundled) - && messageIdTag.equals(notification.getNotification().extras.getString(LATEST_MESSAGE_ID_TAG))) { - return; - } - } - - long timestamp = notifications.get(0).getTimestamp(); - if (timestamp != 0) builder.setWhen(timestamp); - - builder.putStringExtra(LATEST_MESSAGE_ID_TAG, messageIdTag); - - CharSequence text = notifications.get(0).getText(); - - builder.setThread(notifications.get(0).getRecipient()); - builder.setMessageCount(notificationState.getMessageCount()); - - CharSequence builderCS = text == null ? "" : text; - SpannableString ss = MentionUtilities.highlightMentions( - builderCS, - false, - false, - true, - bundled ? notifications.get(0).getThreadId() : 0, - context - ); - - builder.setPrimaryMessageBody(recipient, - notifications.get(0).getIndividualRecipient(), - ss, - notifications.get(0).getSlideDeck()); - - builder.setContentIntent(notifications.get(0).getPendingIntent(context)); - builder.setDeleteIntent(notificationState.getDeleteIntent(context)); - builder.setOnlyAlertOnce(!signal); - builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY); - builder.setAutoCancel(true); - - ReplyMethod replyMethod = ReplyMethod.forRecipient(context, recipient); - - boolean canReply = SessionMetaProtocol.canUserReplyToNotification(recipient); - - PendingIntent quickReplyIntent = canReply ? notificationState.getQuickReplyIntent(context, recipient) : null; - PendingIntent remoteReplyIntent = canReply ? notificationState.getRemoteReplyIntent(context, recipient, replyMethod) : null; - - builder.addActions(notificationState.getMarkAsReadIntent(context, notificationId), - quickReplyIntent, - remoteReplyIntent, - replyMethod); - - if (canReply) { - builder.addAndroidAutoAction(notificationState.getAndroidAutoReplyIntent(context, recipient), - notificationState.getAndroidAutoHeardIntent(context, notificationId), - notifications.get(0).getTimestamp()); - } - - ListIterator iterator = notifications.listIterator(notifications.size()); - - while(iterator.hasPrevious()) { - NotificationItem item = iterator.previous(); - builder.addMessageBody(item.getRecipient(), item.getIndividualRecipient(), item.getText()); - } - - if (signal) { - builder.setAlarms(notificationState.getRingtone(context), notificationState.getVibrate()); - builder.setTicker(notifications.get(0).getIndividualRecipient(), - notifications.get(0).getText()); - } - - if (bundled) { - builder.setGroup(NOTIFICATION_GROUP); - builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY); - } - - Notification notification = builder.build(); - NotificationManagerCompat.from(context).notify(notificationId, notification); - Log.i(TAG, "Posted notification. " + notification.toString()); - } - - private void sendMultipleThreadNotification(@NonNull Context context, - @NonNull NotificationState notificationState, - boolean signal) - { - Log.i(TAG, "sendMultiThreadNotification() signal: " + signal); - - MultipleRecipientNotificationBuilder builder = new MultipleRecipientNotificationBuilder(context, TextSecurePreferences.getNotificationPrivacy(context)); - List notifications = notificationState.getNotifications(); - - builder.setMessageCount(notificationState.getMessageCount(), notificationState.getThreadCount()); - builder.setMostRecentSender(notifications.get(0).getIndividualRecipient(), notifications.get(0).getRecipient()); - builder.setGroup(NOTIFICATION_GROUP); - builder.setDeleteIntent(notificationState.getDeleteIntent(context)); - builder.setOnlyAlertOnce(!signal); - builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY); - builder.setAutoCancel(true); - - String messageIdTag = String.valueOf(notifications.get(0).getTimestamp()); - - NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); - for (StatusBarNotification notification: notificationManager.getActiveNotifications()) { - if (notification.getId() == SUMMARY_NOTIFICATION_ID - && messageIdTag.equals(notification.getNotification().extras.getString(LATEST_MESSAGE_ID_TAG))) { - return; - } - } - - long timestamp = notifications.get(0).getTimestamp(); - if (timestamp != 0) builder.setWhen(timestamp); - - builder.addActions(notificationState.getMarkAsReadIntent(context, SUMMARY_NOTIFICATION_ID)); - - ListIterator iterator = notifications.listIterator(notifications.size()); - - while(iterator.hasPrevious()) { - NotificationItem item = iterator.previous(); - builder.addMessageBody(item.getIndividualRecipient(), item.getRecipient(), - MentionUtilities.highlightMentions( - item.getText() != null ? item.getText() : "", - false, - false, - true, // no styling here, only text formatting - item.getThreadId(), - context - ) - ); - } - - if (signal) { - builder.setAlarms(notificationState.getRingtone(context), notificationState.getVibrate()); - CharSequence text = notifications.get(0).getText(); - builder.setTicker(notifications.get(0).getIndividualRecipient(), - MentionUtilities.highlightMentions( - text != null ? text : "", - false, - false, - true, // no styling here, only text formatting - notifications.get(0).getThreadId(), - context - ) - ); - } - - builder.putStringExtra(LATEST_MESSAGE_ID_TAG, messageIdTag); - - Notification notification = builder.build(); - NotificationManagerCompat.from(context).notify(SUMMARY_NOTIFICATION_ID, notification); - Log.i(TAG, "Posted notification. " + notification); - } - - private NotificationState constructNotificationState(@NonNull Context context, - @NonNull Cursor cursor) - { - NotificationState notificationState = new NotificationState(); - MmsSmsDatabase.Reader reader = DatabaseComponent.get(context).mmsSmsDatabase().readerFor(cursor); - ThreadDatabase threadDatabase = DatabaseComponent.get(context).threadDatabase(); - - MessageRecord record; - Map cache = new HashMap(); - - while ((record = reader.getNext()) != null) { - long id = record.getId(); - boolean mms = record.isMms() || record.isMmsNotification(); - Recipient recipient = record.getIndividualRecipient(); - Recipient conversationRecipient = record.getRecipient(); - long threadId = record.getThreadId(); - CharSequence body = record.getDisplayBody(context); - Recipient threadRecipients = null; - SlideDeck slideDeck = null; - long timestamp = record.getTimestamp(); - boolean messageRequest = false; - - if (threadId != -1) { - threadRecipients = threadDatabase.getRecipientForThreadId(threadId); - messageRequest = threadRecipients != null && !threadRecipients.isGroupRecipient() && - !threadRecipients.isApproved() && !threadDatabase.getLastSeenAndHasSent(threadId).second(); - if (messageRequest && (threadDatabase.getMessageCount(threadId) > 1 || !TextSecurePreferences.hasHiddenMessageRequests(context))) { - continue; - } - } - - // If this is a message request from an unknown user.. - if (messageRequest) { - body = SpanUtil.italic(context.getString(R.string.message_requests_notification)); - - // If we received some manner of notification but Session is locked.. - } else if (KeyCachingService.isLocked(context)) { - body = SpanUtil.italic(context.getString(R.string.MessageNotifier_locked_message)); - - // ----- All further cases assume we know the contact and that Session isn't locked ----- - - // If this is a notification about a multimedia message from a contact we know about.. - } else if (record.isMms() && !((MmsMessageRecord) record).getSharedContacts().isEmpty()) { - Contact contact = ((MmsMessageRecord) record).getSharedContacts().get(0); - body = ContactUtil.getStringSummary(context, contact); - - // If this is a notification about a multimedia message which contains no text but DOES contain a slide deck with at least one slide.. - } else if (record.isMms() && TextUtils.isEmpty(body) && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) { - slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck(); - body = SpanUtil.italic(slideDeck.getBody()); - - // If this is a notification about a multimedia message, but it's not ITSELF a multimedia notification AND it contains a slide deck with at least one slide.. - } else if (record.isMms() && !record.isMmsNotification() && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) { - slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck(); - String message = slideDeck.getBody() + ": " + record.getBody(); - int italicLength = message.length() - body.length(); - body = SpanUtil.italic(message, italicLength); - - // If this is a notification about an invitation to a community.. - } else if (record.isOpenGroupInvitation()) { - body = SpanUtil.italic(context.getString(R.string.ThreadRecord_open_group_invitation)); - } - - String userPublicKey = TextSecurePreferences.getLocalNumber(context); - String blindedPublicKey = cache.get(threadId); - if (blindedPublicKey == null) { - blindedPublicKey = generateBlindedId(threadId, context); - cache.put(threadId, blindedPublicKey); - } - if (threadRecipients == null || !threadRecipients.isMuted()) { - if (threadRecipients != null && threadRecipients.notifyType == RecipientDatabase.NOTIFY_TYPE_MENTIONS) { - // check if mentioned here - boolean isQuoteMentioned = false; - if (record instanceof MmsMessageRecord) { - Quote quote = ((MmsMessageRecord) record).getQuote(); - Address quoteAddress = quote != null ? quote.getAuthor() : null; - String serializedAddress = quoteAddress != null ? quoteAddress.serialize() : null; - isQuoteMentioned = (serializedAddress!= null && Objects.equals(userPublicKey, serializedAddress)) || - (blindedPublicKey != null && Objects.equals(userPublicKey, blindedPublicKey)); - } - if (body.toString().contains("@"+userPublicKey) || body.toString().contains("@"+blindedPublicKey) || isQuoteMentioned) { - notificationState.addNotification(new NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, slideDeck)); - } - } else if (threadRecipients != null && threadRecipients.notifyType == RecipientDatabase.NOTIFY_TYPE_NONE) { - // do nothing, no notifications - } else { - notificationState.addNotification(new NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, slideDeck)); - } - - String userBlindedPublicKey = blindedPublicKey; - Optional lastReact = Stream.of(record.getReactions()) - .filter(r -> !(r.getAuthor().equals(userPublicKey) || r.getAuthor().equals(userBlindedPublicKey))) - .findLast(); - - if (lastReact.isPresent()) { - if (threadRecipients != null && !threadRecipients.isGroupRecipient()) { - ReactionRecord reaction = lastReact.get(); - Recipient reactor = Recipient.from(context, Address.fromSerialized(reaction.getAuthor()), false); - String emoji = context.getString(R.string.reaction_notification, reactor.toShortString(), reaction.getEmoji()); - notificationState.addNotification(new NotificationItem(id, mms, reactor, reactor, threadRecipients, threadId, emoji, reaction.getDateSent(), slideDeck)); - } - } - } - } - - reader.close(); - return notificationState; - } - - private @Nullable String generateBlindedId(long threadId, Context context) { - LokiThreadDatabase lokiThreadDatabase = DatabaseComponent.get(context).lokiThreadDatabase(); - OpenGroup openGroup = lokiThreadDatabase.getOpenGroupChat(threadId); - KeyPair edKeyPair = KeyPairUtilities.INSTANCE.getUserED25519KeyPair(context); - if (openGroup != null && edKeyPair != null) { - KeyPair blindedKeyPair = SodiumUtilities.blindedKeyPair(openGroup.getPublicKey(), edKeyPair); - if (blindedKeyPair != null) { - return new AccountId(IdPrefix.BLINDED, blindedKeyPair.getPublicKey().getAsBytes()).getHexString(); - } - } - return null; - } - - private void updateBadge(Context context, int count) { - try { - if (count == 0) ShortcutBadger.removeCount(context); - else ShortcutBadger.applyCount(context, count); - } catch (Throwable t) { - // NOTE :: I don't totally trust this thing, so I'm catching - // everything. - Log.w("MessageNotifier", t); - } - } - - private void scheduleReminder(Context context, int count) { - if (count >= TextSecurePreferences.getRepeatAlertsCount(context)) { - return; - } - - AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - Intent alarmIntent = new Intent(ReminderReceiver.REMINDER_ACTION); - alarmIntent.putExtra("reminder_count", count); - - PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE); - long timeout = TimeUnit.MINUTES.toMillis(2); - - alarmManager.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + timeout, pendingIntent); - } - - @Override - public void clearReminder(Context context) { - Intent alarmIntent = new Intent(ReminderReceiver.REMINDER_ACTION); - PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE); - AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - alarmManager.cancel(pendingIntent); - } - - public static class ReminderReceiver extends BroadcastReceiver { - - public static final String REMINDER_ACTION = "network.loki.securesms.MessageNotifier.REMINDER_ACTION"; - - @SuppressLint("StaticFieldLeak") - @Override - public void onReceive(final Context context, final Intent intent) { - new AsyncTask() { - @Override - protected Void doInBackground(Void... params) { - int reminderCount = intent.getIntExtra("reminder_count", 0); - ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, true, reminderCount + 1); - - return null; - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - } - - private static class DelayedNotification implements Runnable { - - private static final long DELAY = TimeUnit.SECONDS.toMillis(5); - - private final AtomicBoolean canceled = new AtomicBoolean(false); - - private final Context context; - private final long threadId; - private final long delayUntil; - - private DelayedNotification(Context context, long threadId) { - this.context = context; - this.threadId = threadId; - this.delayUntil = System.currentTimeMillis() + DELAY; - } - - @Override - public void run() { - long delayMillis = delayUntil - System.currentTimeMillis(); - Log.i(TAG, "Waiting to notify: " + delayMillis); - - if (delayMillis > 0) { - Util.sleep(delayMillis); - } - - if (!canceled.get()) { - Log.i(TAG, "Not canceled, notifying..."); - ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, threadId, true); - ApplicationContext.getInstance(context).messageNotifier.cancelDelayedNotifications(); - } else { - Log.w(TAG, "Canceled, not notifying..."); - } - } - - public void cancel() { - canceled.set(true); - } - } - - private static class CancelableExecutor { - - private final Executor executor = Executors.newSingleThreadExecutor(); - private final Set tasks = new HashSet<>(); - - public void execute(final DelayedNotification runnable) { - synchronized (tasks) { - tasks.add(runnable); - } - - Runnable wrapper = new Runnable() { - @Override - public void run() { - runnable.run(); - - synchronized (tasks) { - tasks.remove(runnable); - } - } - }; - - executor.execute(wrapper); - } - - public void cancel() { - synchronized (tasks) { - for (DelayedNotification task : tasks) { - task.cancel(); - } - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt new file mode 100644 index 0000000000..cbf53e6a8b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt @@ -0,0 +1,725 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.notifications + +import android.Manifest +import android.annotation.SuppressLint +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.database.Cursor +import android.os.AsyncTask +import android.os.Build +import android.text.TextUtils +import android.widget.Toast +import androidx.camera.core.impl.utils.ContextUtil.getApplicationContext +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.annimon.stream.Stream +import com.squareup.phrase.Phrase +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.Volatile +import me.leolin.shortcutbadger.ShortcutBadger +import network.loki.messenger.R +import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier +import org.session.libsession.messaging.utilities.AccountId +import org.session.libsession.messaging.utilities.SodiumUtilities.blindedKeyPair +import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.ServiceUtil +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY +import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber +import org.session.libsession.utilities.TextSecurePreferences.Companion.getNotificationPrivacy +import org.session.libsession.utilities.TextSecurePreferences.Companion.getRepeatAlertsCount +import org.session.libsession.utilities.TextSecurePreferences.Companion.hasHiddenMessageRequests +import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotificationsEnabled +import org.session.libsession.utilities.TextSecurePreferences.Companion.removeHasHiddenMessageRequests +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Util +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.contacts.ContactUtil +import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions +import org.thoughtcrime.securesms.crypto.KeyPairUtilities.getUserED25519KeyPair +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get +import org.thoughtcrime.securesms.mms.SlideDeck +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.preferences.ShareLogsDialog +import org.thoughtcrime.securesms.service.KeyCachingService +import org.thoughtcrime.securesms.util.SessionMetaProtocol.canUserReplyToNotification +import org.thoughtcrime.securesms.util.SpanUtil + +/** + * Handles posting system notifications for new messages. + * + * + * @author Moxie Marlinspike + */ +class DefaultMessageNotifier : MessageNotifier { + override fun setVisibleThread(threadId: Long) { + visibleThread = threadId + } + + override fun setHomeScreenVisible(isVisible: Boolean) { + homeScreenVisible = isVisible + } + + override fun setLastDesktopActivityTimestamp(timestamp: Long) { + lastDesktopActivityTimestamp = timestamp + } + + override fun notifyMessageDeliveryFailed(context: Context?, recipient: Recipient?, threadId: Long) { + // We do not provide notifications for message delivery failure. + } + + override fun cancelDelayedNotifications() { + executor.cancel() + } + + private fun cancelActiveNotifications(context: Context): Boolean { + val notifications = ServiceUtil.getNotificationManager(context) + val hasNotifications = notifications.activeNotifications.size > 0 + notifications.cancel(SUMMARY_NOTIFICATION_ID) + + try { + val activeNotifications = notifications.activeNotifications + + for (activeNotification in activeNotifications) { + notifications.cancel(activeNotification.id) + } + } catch (e: Throwable) { + // XXX Appears to be a ROM bug, see #6043 + Log.w(TAG, e) + notifications.cancelAll() + } + return hasNotifications + } + + private fun cancelOrphanedNotifications(context: Context, notificationState: NotificationState) { + try { + val notifications = ServiceUtil.getNotificationManager(context) + val activeNotifications = notifications.activeNotifications + + for (notification in activeNotifications) { + var validNotification = false + + if (notification.id != SUMMARY_NOTIFICATION_ID && notification.id != KeyCachingService.SERVICE_RUNNING_ID && notification.id != FOREGROUND_ID && notification.id != PENDING_MESSAGES_ID) { + for (item in notificationState.notifications) { + if (notification.id.toLong() == (SUMMARY_NOTIFICATION_ID + item.threadId)) { + validNotification = true + break + } + } + + if (!validNotification) { + notifications.cancel(notification.id) + } + } + } + } catch (e: Throwable) { + // XXX Android ROM Bug, see #6043 + Log.w(TAG, e) + } + } + + override fun updateNotification(context: Context) { + if (!isNotificationsEnabled(context)) { + return + } + + updateNotification(context, false, 0) + } + + override fun updateNotification(context: Context, threadId: Long) { + if (System.currentTimeMillis() - lastDesktopActivityTimestamp < DESKTOP_ACTIVITY_PERIOD) { + Log.i(TAG, "Scheduling delayed notification...") + executor.execute(DelayedNotification(context, threadId)) + } else { + updateNotification(context, threadId, true) + } + } + + override fun updateNotification(context: Context, threadId: Long, signal: Boolean) { + val isVisible = visibleThread == threadId + + val threads = get(context).threadDatabase() + val recipient = threads.getRecipientForThreadId(threadId) + + if (recipient != null && !recipient.isGroupRecipient && threads.getMessageCount(threadId) == 1 && + !(recipient.isApproved || threads.getLastSeenAndHasSent(threadId).second()) + ) { + removeHasHiddenMessageRequests(context) + } + + if (!isNotificationsEnabled(context) || + (recipient != null && recipient.isMuted) + ) { + return + } + + if ((!isVisible && !homeScreenVisible) || hasExistingNotifications(context)) { + updateNotification(context, signal, 0) + } + } + + private fun hasExistingNotifications(context: Context): Boolean { + val notifications = ServiceUtil.getNotificationManager(context) + try { + val activeNotifications = notifications.activeNotifications + return activeNotifications.isNotEmpty() + } catch (e: Exception) { + return false + } + } + + override fun updateNotification(context: Context, signal: Boolean, reminderCount: Int) { + var playNotificationAudio = signal // Local copy of the argument so we can modify it + var telcoCursor: Cursor? = null + val pushCursor: Cursor? = null + + try { + telcoCursor = get(context).mmsSmsDatabase().unread // TODO: add a notification specific lighter query here + + if ((telcoCursor == null || telcoCursor.isAfterLast) || getLocalNumber(context) == null) { + updateBadge(context, 0) + cancelActiveNotifications(context) + clearReminder(context) + return + } + + try { + val notificationState = constructNotificationState(context, telcoCursor) + + if (playNotificationAudio && (System.currentTimeMillis() - lastAudibleNotification) < MIN_AUDIBLE_PERIOD_MILLIS) { + playNotificationAudio = false + } else if (playNotificationAudio) { + lastAudibleNotification = System.currentTimeMillis() + } + + if (notificationState.hasMultipleThreads()) { + for (threadId in notificationState.threads) { + sendSingleThreadNotification(context, NotificationState(notificationState.getNotificationsForThread(threadId)), false, true) + } + sendMultipleThreadNotification(context, notificationState, playNotificationAudio) + } else if (notificationState.messageCount > 0) { + sendSingleThreadNotification(context, notificationState, playNotificationAudio, false) + } else { + cancelActiveNotifications(context) + } + + cancelOrphanedNotifications(context, notificationState) + updateBadge(context, notificationState.messageCount) + + if (playNotificationAudio) { + scheduleReminder(context, reminderCount) + } + } + catch (e: Exception) { + Log.e(TAG, "Error creating notification", e) + } + + } finally { + telcoCursor?.close() + } + } + + // Note: The `signal` parameter means "play an audio signal for the notification". + private fun sendSingleThreadNotification( + context: Context, + notificationState: NotificationState, + signal: Boolean, + bundled: Boolean + ) { + Log.i(TAG, "sendSingleThreadNotification() signal: $signal bundled: $bundled") + + if (notificationState.notifications.isEmpty()) { + if (!bundled) cancelActiveNotifications(context) + Log.i(TAG, "Empty notification state. Skipping.") + return + } + + val builder = SingleRecipientNotificationBuilder(context, getNotificationPrivacy(context)) + val notifications = notificationState.notifications + val recipient = notifications[0].recipient + val notificationId = (SUMMARY_NOTIFICATION_ID + (if (bundled) notifications[0].threadId else 0)).toInt() + val messageIdTag = notifications[0].timestamp.toString() + + val notificationManager = ServiceUtil.getNotificationManager(context) + for (notification in notificationManager.activeNotifications) { + if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && notification.isAppGroup == bundled) + && (messageIdTag == notification.notification.extras.getString(LATEST_MESSAGE_ID_TAG)) + ) { + return + } + } + + val timestamp = notifications[0].timestamp + if (timestamp != 0L) builder.setWhen(timestamp) + + builder.putStringExtra(LATEST_MESSAGE_ID_TAG, messageIdTag) + + val text = notifications[0].text + + builder.setThread(notifications[0].recipient) + builder.setMessageCount(notificationState.messageCount) + + val builderCS = text ?: "" + val ss = highlightMentions( + builderCS, + false, + false, + true, + if (bundled) notifications[0].threadId else 0, + context + ) + + builder.setPrimaryMessageBody( + recipient, + notifications[0].individualRecipient, + ss, + notifications[0].slideDeck + ) + + builder.setContentIntent(notifications[0].getPendingIntent(context)) + builder.setDeleteIntent(notificationState.getDeleteIntent(context)) + builder.setOnlyAlertOnce(!signal) + builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + builder.setAutoCancel(true) + + val replyMethod = ReplyMethod.forRecipient(context, recipient) + + val canReply = canUserReplyToNotification(recipient) + + val quickReplyIntent = if (canReply) notificationState.getQuickReplyIntent(context, recipient) else null + val remoteReplyIntent = if (canReply) notificationState.getRemoteReplyIntent(context, recipient, replyMethod) else null + + builder.addActions( + notificationState.getMarkAsReadIntent(context, notificationId), + quickReplyIntent, + remoteReplyIntent, + replyMethod + ) + + if (canReply) { + builder.addAndroidAutoAction( + notificationState.getAndroidAutoReplyIntent(context, recipient), + notificationState.getAndroidAutoHeardIntent(context, notificationId), + notifications[0].timestamp + ) + } + + val iterator: ListIterator = notifications.listIterator(notifications.size) + + while (iterator.hasPrevious()) { + val item = iterator.previous() + builder.addMessageBody(item.recipient, item.individualRecipient, item.text) + } + + if (signal) { + builder.setAlarms(notificationState.getRingtone(context), notificationState.vibrate) + builder.setTicker( + notifications[0].individualRecipient, + notifications[0].text + ) + } + + if (bundled) { + builder.setGroup(NOTIFICATION_GROUP) + builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + } + + val notification = builder.build() + + // TODO - ACL to fix this properly & will do on 2024-08-26, but just skipping for now so review can start + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + // TODO: Consider calling + // ActivityCompat#requestPermissions + // here to request the missing permissions, and then overriding + // public void onRequestPermissionsResult(int requestCode, String[] permissions, + // int[] grantResults) + // to handle the case where the user grants the permission. See the documentation + // for ActivityCompat#requestPermissions for more details. + return + } + NotificationManagerCompat.from(context).notify(notificationId, notification) + Log.i(TAG, "Posted notification. $notification") + } + + // Note: The `signal` parameter means "play an audio signal for the notification". + private fun sendMultipleThreadNotification( + context: Context, + notificationState: NotificationState, + signal: Boolean + ) { + Log.i(TAG, "sendMultiThreadNotification() signal: $signal") + + val builder = MultipleRecipientNotificationBuilder(context, getNotificationPrivacy(context)) + val notifications = notificationState.notifications + + builder.setMessageCount(notificationState.messageCount, notificationState.threadCount) + builder.setMostRecentSender(notifications[0].individualRecipient, notifications[0].recipient) + builder.setGroup(NOTIFICATION_GROUP) + builder.setDeleteIntent(notificationState.getDeleteIntent(context)) + builder.setOnlyAlertOnce(!signal) + builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + builder.setAutoCancel(true) + + val messageIdTag = notifications[0].timestamp.toString() + + val notificationManager = ServiceUtil.getNotificationManager(context) + for (notification in notificationManager.activeNotifications) { + if (notification.id == SUMMARY_NOTIFICATION_ID && messageIdTag == notification.notification.extras.getString(LATEST_MESSAGE_ID_TAG)) { + return + } + } + + val timestamp = notifications[0].timestamp + if (timestamp != 0L) builder.setWhen(timestamp) + + builder.addActions(notificationState.getMarkAsReadIntent(context, SUMMARY_NOTIFICATION_ID)) + + val iterator: ListIterator = notifications.listIterator(notifications.size) + while (iterator.hasPrevious()) { + val item = iterator.previous() + builder.addMessageBody( + item.individualRecipient, item.recipient, + highlightMentions( + (if (item.text != null) item.text else "")!!, + false, + false, + true, // no styling here, only text formatting + item.threadId, + context + ) + ) + } + + if (signal) { + builder.setAlarms(notificationState.getRingtone(context), notificationState.vibrate) + val text = notifications[0].text + builder.setTicker( + notifications[0].individualRecipient, + highlightMentions( + text ?: "", + false, + false, + true, // no styling here, only text formatting + notifications[0].threadId, + context + ) + ) + } + + builder.putStringExtra(LATEST_MESSAGE_ID_TAG, messageIdTag) + + // TODO - ACL to fix this properly & will do on 2024-08-26, but just skipping for now so review can start + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + // TODO: Consider calling + // ActivityCompat#requestPermissions + // here to request the missing permissions, and then overriding + // public void onRequestPermissionsResult(int requestCode, String[] permissions, + // int[] grantResults) + // to handle the case where the user grants the permission. See the documentation + // for ActivityCompat#requestPermissions for more details. + return + } + + val notification = builder.build() + NotificationManagerCompat.from(context).notify(SUMMARY_NOTIFICATION_ID, notification) + Log.i(TAG, "Posted notification. $notification") + } + + private fun constructNotificationState(context: Context, cursor: Cursor): NotificationState { + val notificationState = NotificationState() + val reader = get(context).mmsSmsDatabase().readerFor(cursor) + if (reader == null) { + Log.e(TAG, "No reader for cursor - aborting constructNotificationState") + return NotificationState() + } + + val threadDatabase = get(context).threadDatabase() + val cache: MutableMap = HashMap() + + // CAREFUL: Do not put this loop back as `while ((reader.next.also { record = it }) != null) {` because it breaks with a Null Pointer Exception! + var record: MessageRecord? = null + do { + record = reader.next + if (record == null) break // Bail if there are no more MessageRecords + + val id = record.getId() + val mms = record.isMms || record.isMmsNotification + val recipient = record.individualRecipient + val conversationRecipient = record.recipient + val threadId = record.threadId + var body: CharSequence = record.getDisplayBody(context) + var threadRecipients: Recipient? = null + var slideDeck: SlideDeck? = null + val timestamp = record.timestamp + var messageRequest = false + + if (threadId != -1L) { + threadRecipients = threadDatabase.getRecipientForThreadId(threadId) + messageRequest = threadRecipients != null && !threadRecipients.isGroupRecipient && + !threadRecipients.isApproved && !threadDatabase.getLastSeenAndHasSent(threadId).second() + if (messageRequest && (threadDatabase.getMessageCount(threadId) > 1 || !hasHiddenMessageRequests(context))) { + continue + } + } + + // If this is a message request from an unknown user.. + if (messageRequest) { + body = SpanUtil.italic(context.getString(R.string.messageRequestsNew)) + + // If we received some manner of notification but Session is locked.. + } else if (KeyCachingService.isLocked(context)) { + // Note: We provide 1 because `messageNewYouveGot` is now a plurals string and we don't have a count yet, so just + // giving it 1 will result in "You got a new message". + body = SpanUtil.italic(context.resources.getQuantityString(R.plurals.messageNewYouveGot, 1, 1)) + + // ----- Note: All further cases assume we know the contact and that Session isn't locked ----- + + // If this is a notification about a multimedia message from a contact we know about.. + } else if (record.isMms && !(record as MmsMessageRecord).sharedContacts.isEmpty()) { + val contact = (record as MmsMessageRecord).sharedContacts[0] + body = ContactUtil.getStringSummary(context, contact) + + // If this is a notification about a multimedia message which contains no text but DOES contain a slide deck with at least one slide.. + } else if (record.isMms && TextUtils.isEmpty(body) && !(record as MmsMessageRecord).slideDeck.slides.isEmpty()) { + slideDeck = (record as MediaMmsMessageRecord).slideDeck + body = SpanUtil.italic(slideDeck.body) + + // If this is a notification about a multimedia message, but it's not ITSELF a multimedia notification AND it contains a slide deck with at least one slide.. + } else if (record.isMms && !record.isMmsNotification && !(record as MmsMessageRecord).slideDeck.slides.isEmpty()) { + slideDeck = (record as MediaMmsMessageRecord).slideDeck + val message = slideDeck.body + ": " + record.body + val italicLength = message.length - body.length + body = SpanUtil.italic(message, italicLength) + + // If this is a notification about an invitation to a community.. + } else if (record.isOpenGroupInvitation) { + body = SpanUtil.italic(context.getString(R.string.communityInvitation)) + } + + val userPublicKey = getLocalNumber(context) + var blindedPublicKey = cache[threadId] + if (blindedPublicKey == null) { + blindedPublicKey = generateBlindedId(threadId, context) + cache[threadId] = blindedPublicKey + } + if (threadRecipients == null || !threadRecipients.isMuted) { + if (threadRecipients != null && threadRecipients.notifyType == RecipientDatabase.NOTIFY_TYPE_MENTIONS) { + // check if mentioned here + var isQuoteMentioned = false + if (record is MmsMessageRecord) { + val quote = (record as MmsMessageRecord).quote + val quoteAddress = quote?.author + val serializedAddress = quoteAddress?.serialize() + isQuoteMentioned = (serializedAddress != null && userPublicKey == serializedAddress) || + (blindedPublicKey != null && userPublicKey == blindedPublicKey) + } + if (body.toString().contains("@$userPublicKey") || body.toString().contains("@$blindedPublicKey") || isQuoteMentioned) { + notificationState.addNotification(NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, slideDeck)) + } + } else if (threadRecipients != null && threadRecipients.notifyType == RecipientDatabase.NOTIFY_TYPE_NONE) { + // do nothing, no notifications + } else { + notificationState.addNotification(NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, slideDeck)) + } + + val userBlindedPublicKey = blindedPublicKey + val lastReact = Stream.of(record.reactions) + .filter { r: ReactionRecord -> !(r.author == userPublicKey || r.author == userBlindedPublicKey) } + .findLast() + + if (lastReact.isPresent) { + if (threadRecipients != null && !threadRecipients.isGroupRecipient) { + val reaction = lastReact.get() + val reactor = Recipient.from(context, fromSerialized(reaction.author), false) + val emoji = Phrase.from(context, R.string.emojiReactsNotification).put(EMOJI_KEY, reaction.emoji).format().toString() + notificationState.addNotification(NotificationItem(id, mms, reactor, reactor, threadRecipients, threadId, emoji, reaction.dateSent, slideDeck)) + } + } + } + } while (record != null) // This will never hit because we break early if we get a null record at the start of the do..while loop + + reader.close() + return notificationState + } + + private fun generateBlindedId(threadId: Long, context: Context): String? { + val lokiThreadDatabase = get(context).lokiThreadDatabase() + val openGroup = lokiThreadDatabase.getOpenGroupChat(threadId) + val edKeyPair = getUserED25519KeyPair(context) + if (openGroup != null && edKeyPair != null) { + val blindedKeyPair = blindedKeyPair(openGroup.publicKey, edKeyPair) + if (blindedKeyPair != null) { + return AccountId(IdPrefix.BLINDED, blindedKeyPair.publicKey.asBytes).hexString + } + } + return null + } + + private fun updateBadge(context: Context, count: Int) { + try { + if (count == 0) ShortcutBadger.removeCount(context) + else ShortcutBadger.applyCount(context, count) + } catch (t: Throwable) { + Log.w("MessageNotifier", t) + } + } + + private fun scheduleReminder(context: Context, count: Int) { + if (count >= getRepeatAlertsCount(context)) { + return + } + + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val alarmIntent = Intent(ReminderReceiver.REMINDER_ACTION) + alarmIntent.putExtra("reminder_count", count) + + val pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val timeout = TimeUnit.MINUTES.toMillis(2) + + alarmManager[AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + timeout] = pendingIntent + } + + override fun clearReminder(context: Context) { + val alarmIntent = Intent(ReminderReceiver.REMINDER_ACTION) + val pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmManager.cancel(pendingIntent) + } + + class ReminderReceiver : BroadcastReceiver() { + @SuppressLint("StaticFieldLeak") + override fun onReceive(context: Context, intent: Intent) { + object : AsyncTask() { + + override fun doInBackground(vararg params: Void?): Void? { + val reminderCount = intent.getIntExtra("reminder_count", 0) + ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, true, reminderCount + 1) + return null + } + + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + } + + companion object { + const val REMINDER_ACTION: String = "network.loki.securesms.MessageNotifier.REMINDER_ACTION" + } + } + + // ACL: What is the concept behind delayed notifications? Why would we ever want this? To batch them up so + // that we get a bunch of notifications once per minute or something rather than a constant stream of them + // if that's what was incoming?!? + private class DelayedNotification(private val context: Context, private val threadId: Long) : Runnable { + private val canceled = AtomicBoolean(false) + + private val delayUntil: Long + + init { + this.delayUntil = System.currentTimeMillis() + DELAY + } + + override fun run() { + val delayMillis = delayUntil - System.currentTimeMillis() + Log.i(TAG, "Waiting to notify: $delayMillis") + + if (delayMillis > 0) { Util.sleep(delayMillis) } + + if (!canceled.get()) { + Log.i(TAG, "Not canceled, notifying...") + ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, threadId, true) + ApplicationContext.getInstance(context).messageNotifier.cancelDelayedNotifications() + } else { + Log.w(TAG, "Canceled, not notifying...") + } + } + + fun cancel() { + canceled.set(true) + } + + companion object { + private val DELAY = TimeUnit.SECONDS.toMillis(5) + } + } + + private class CancelableExecutor { + private val executor: Executor = Executors.newSingleThreadExecutor() + private val tasks: MutableSet = HashSet() + + fun execute(runnable: DelayedNotification) { + synchronized(tasks) { tasks.add(runnable) } + + val wrapper = Runnable { + runnable.run() + synchronized(tasks) { + tasks.remove(runnable) + } + } + + executor.execute(wrapper) + } + + fun cancel() { + synchronized(tasks) { + for (task in tasks) { task.cancel() } + } + } + } + + companion object { + private val TAG: String = DefaultMessageNotifier::class.java.simpleName + + const val EXTRA_REMOTE_REPLY: String = "extra_remote_reply" + const val LATEST_MESSAGE_ID_TAG: String = "extra_latest_message_id" + + private const val FOREGROUND_ID = 313399 + private const val SUMMARY_NOTIFICATION_ID = 1338 + private const val PENDING_MESSAGES_ID = 1111 + private const val NOTIFICATION_GROUP = "messages" + private val MIN_AUDIBLE_PERIOD_MILLIS = TimeUnit.SECONDS.toMillis(5) + private val DESKTOP_ACTIVITY_PERIOD = TimeUnit.MINUTES.toMillis(1) + + @Volatile + private var visibleThread: Long = -1 + + @Volatile + private var homeScreenVisible = false + + @Volatile + private var lastDesktopActivityTimestamp: Long = -1 + + @Volatile + private var lastAudibleNotification: Long = -1 + private val executor = CancelableExecutor() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/FailedNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/FailedNotificationBuilder.java deleted file mode 100644 index dc0e52abc6..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/FailedNotificationBuilder.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.thoughtcrime.securesms.notifications; - -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.graphics.BitmapFactory; - -import org.session.libsession.utilities.NotificationPrivacyPreference; -import org.session.libsession.utilities.recipients.Recipient; - -import network.loki.messenger.R; - -public class FailedNotificationBuilder extends AbstractNotificationBuilder { - - public FailedNotificationBuilder(Context context, NotificationPrivacyPreference privacy, Intent intent) { - super(context, privacy); - - setSmallIcon(R.drawable.ic_notification); - setLargeIcon(BitmapFactory.decodeResource(context.getResources(), - R.drawable.ic_action_warning_red)); - setContentTitle(context.getString(R.string.MessageNotifier_message_delivery_failed)); - setContentText(context.getString(R.string.MessageNotifier_failed_to_deliver_message)); - setTicker(context.getString(R.string.MessageNotifier_error_delivering_message)); - setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)); - setAutoCancel(true); - setAlarms(null, Recipient.VibrateState.DEFAULT); - setChannelId(NotificationChannels.FAILURES); - } - - - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java deleted file mode 100644 index 0578bb2c9c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java +++ /dev/null @@ -1,124 +0,0 @@ -package org.thoughtcrime.securesms.notifications; - -import android.app.Notification; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.text.SpannableStringBuilder; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; - -import org.session.libsession.messaging.contacts.Contact; -import org.session.libsession.utilities.NotificationPrivacyPreference; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.Util; -import org.session.libsession.utilities.recipients.Recipient; -import org.thoughtcrime.securesms.database.SessionContactDatabase; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.home.HomeActivity; - -import java.util.LinkedList; -import java.util.List; - -import network.loki.messenger.R; - -public class MultipleRecipientNotificationBuilder extends AbstractNotificationBuilder { - - private final List messageBodies = new LinkedList<>(); - - public MultipleRecipientNotificationBuilder(Context context, NotificationPrivacyPreference privacy) { - super(context, privacy); - - setColor(context.getResources().getColor(R.color.textsecure_primary)); - setSmallIcon(R.drawable.ic_notification); - setContentTitle(context.getString(R.string.app_name)); - setContentIntent(PendingIntent.getActivity(context, 0, new Intent(context, HomeActivity.class), PendingIntent.FLAG_IMMUTABLE)); - setCategory(NotificationCompat.CATEGORY_MESSAGE); - setGroupSummary(true); - } - - public void setMessageCount(int messageCount, int threadCount) { - setSubText(context.getString(R.string.MessageNotifier_d_new_messages_in_d_conversations, - messageCount, threadCount)); - setContentInfo(String.valueOf(messageCount)); - setNumber(messageCount); - } - - public void setMostRecentSender(Recipient recipient, Recipient threadRecipient) { - String displayName = recipient.toShortString(); - if (threadRecipient.isGroupRecipient()) { - displayName = getGroupDisplayName(recipient, threadRecipient.isCommunityRecipient()); - } - if (privacy.isDisplayContact()) { - setContentText(context.getString(R.string.MessageNotifier_most_recent_from_s, displayName)); - } - - if (recipient.getNotificationChannel() != null) { - setChannelId(recipient.getNotificationChannel()); - } - } - - public void addActions(PendingIntent markAsReadIntent) { - NotificationCompat.Action markAllAsReadAction = new NotificationCompat.Action(R.drawable.check, - context.getString(R.string.MessageNotifier_mark_all_as_read), - markAsReadIntent); - addAction(markAllAsReadAction); - extend(new NotificationCompat.WearableExtender().addAction(markAllAsReadAction)); - } - - public void putStringExtra(String key, String value) { - extras.putString(key,value); - } - - public void addMessageBody(@NonNull Recipient sender, Recipient threadRecipient, @Nullable CharSequence body) { - String displayName = sender.toShortString(); - if (threadRecipient.isGroupRecipient()) { - displayName = getGroupDisplayName(sender, threadRecipient.isCommunityRecipient()); - } - if (privacy.isDisplayMessage()) { - SpannableStringBuilder builder = new SpannableStringBuilder(); - builder.append(Util.getBoldedString(displayName)); - builder.append(": "); - builder.append(body == null ? "" : body); - - messageBodies.add(builder); - } else if (privacy.isDisplayContact()) { - messageBodies.add(Util.getBoldedString(displayName)); - } - - if (privacy.isDisplayContact() && sender.getContactUri() != null) { -// addPerson(sender.getContactUri().toString()); - } - } - - @Override - public Notification build() { - if (privacy.isDisplayMessage() || privacy.isDisplayContact()) { - NotificationCompat.InboxStyle style = new NotificationCompat.InboxStyle(); - - for (CharSequence body : messageBodies) { - style.addLine(trimToDisplayLength(body)); - } - - setStyle(style); - } - - return super.build(); - } - - /** - * @param recipient the * individual * recipient for which to get the display name. - * @param openGroupRecipient whether in an open group context - */ - private String getGroupDisplayName(Recipient recipient, boolean openGroupRecipient) { - SessionContactDatabase contactDB = DatabaseComponent.get(context).sessionContactDatabase(); - String accountID = recipient.getAddress().serialize(); - Contact contact = contactDB.getContactWithAccountID(accountID); - if (contact == null) { return accountID; } - String displayName = contact.displayName(openGroupRecipient ? Contact.ContactContext.OPEN_GROUP : Contact.ContactContext.REGULAR); - if (displayName == null) { return accountID; } - return displayName; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.kt new file mode 100644 index 0000000000..a526f9e8f2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.kt @@ -0,0 +1,112 @@ +package org.thoughtcrime.securesms.notifications + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.text.SpannableStringBuilder +import androidx.core.app.NotificationCompat +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.utilities.NotificationPrivacyPreference +import org.session.libsession.utilities.StringSubstitutionConstants.CONVERSATION_COUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.MESSAGE_COUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.session.libsession.utilities.Util.getBoldedString +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get +import org.thoughtcrime.securesms.home.HomeActivity +import org.thoughtcrime.securesms.ui.getSubbedString +import java.util.LinkedList + +class MultipleRecipientNotificationBuilder(context: Context, privacy: NotificationPrivacyPreference?) : AbstractNotificationBuilder(context, privacy) { + private val messageBodies: MutableList = LinkedList() + + init { + color = context.resources.getColor(R.color.textsecure_primary) + setSmallIcon(R.drawable.ic_notification) + setContentTitle(context.getString(R.string.app_name)) + setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, HomeActivity::class.java), PendingIntent.FLAG_IMMUTABLE)) + setCategory(NotificationCompat.CATEGORY_MESSAGE) + setGroupSummary(true) + } + + fun setMessageCount(messageCount: Int, threadCount: Int) { + val txt = context.getSubbedString(R.string.notificationsSystem, MESSAGE_COUNT_KEY to messageCount.toString(), CONVERSATION_COUNT_KEY to threadCount.toString()) + setSubText(txt) + setNumber(messageCount) + } + + fun setMostRecentSender(recipient: Recipient, threadRecipient: Recipient) { + var displayName = recipient.toShortString() + if (threadRecipient.isGroupRecipient) { + displayName = getGroupDisplayName(recipient, threadRecipient.isCommunityRecipient) + } + if (privacy.isDisplayContact) { + val txt = Phrase.from(context, R.string.notificationsMostRecent) + .put(NAME_KEY, displayName) + .format().toString() + setContentText(txt) + } + + if (recipient.notificationChannel != null) { + setChannelId(recipient.notificationChannel!!) + } + } + + fun addActions(markAsReadIntent: PendingIntent?) { + val markAllAsReadAction = NotificationCompat.Action( + R.drawable.check, + context.getString(R.string.messageMarkRead), + markAsReadIntent + ) + addAction(markAllAsReadAction) + extend(NotificationCompat.WearableExtender().addAction(markAllAsReadAction)) + } + + fun putStringExtra(key: String?, value: String?) { extras.putString(key, value) } + + fun addMessageBody(sender: Recipient, threadRecipient: Recipient, body: CharSequence?) { + var displayName = sender.toShortString() + if (threadRecipient.isGroupRecipient) { + displayName = getGroupDisplayName(sender, threadRecipient.isCommunityRecipient) + } + if (privacy.isDisplayMessage) { + val builder = SpannableStringBuilder() + builder.append(getBoldedString(displayName)) + builder.append(": ") + builder.append(body ?: "") + messageBodies.add(builder) + } else if (privacy.isDisplayContact) { + messageBodies.add(getBoldedString(displayName)) + } + + // TODO: What on earth is this? Why is it commented out? It's also commented out in dev... remove? -ACL 2024-08-29 + if (privacy.isDisplayContact && sender.contactUri != null) { +// addPerson(sender.getContactUri().toString()); + } + } + + override fun build(): Notification { + if (privacy.isDisplayMessage || privacy.isDisplayContact) { + val style = NotificationCompat.InboxStyle() + for (body in messageBodies) { style.addLine(trimToDisplayLength(body)) } + setStyle(style) + } + return super.build() + } + + /** + * @param recipient the * individual * recipient for which to get the display name. + * @param openGroupRecipient whether in an open group context + */ + private fun getGroupDisplayName(recipient: Recipient, openGroupRecipient: Boolean): String { + val contactDB = get(context).sessionContactDatabase() + val accountID = recipient.address.serialize() + val contact = contactDB.getContactWithAccountID(accountID) ?: return accountID + val displayName = contact.displayName(if (openGroupRecipient) Contact.ContactContext.OPEN_GROUP else Contact.ContactContext.REGULAR) + if (displayName == null) { return accountID } + return displayName + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java index 51ff467549..0c3422b757 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java @@ -92,7 +92,7 @@ public static synchronized void create(@NonNull Context context) { } else if (!TextUtils.isEmpty(address.serialize())) { return address.serialize(); } else { - return context.getString(R.string.NotificationChannel_missing_display_name); + return context.getString(R.string.unknown); } } @@ -179,15 +179,14 @@ public static synchronized void ensureCustomChannelConsistency(@NonNull Context } private static void onCreate(@NonNull Context context, @NonNull NotificationManager notificationManager) { - NotificationChannelGroup messagesGroup = new NotificationChannelGroup(CATEGORY_MESSAGES, context.getResources().getString(R.string.NotificationChannel_group_messages)); + NotificationChannelGroup messagesGroup = new NotificationChannelGroup(CATEGORY_MESSAGES, context.getResources().getString(R.string.messages)); notificationManager.createNotificationChannelGroup(messagesGroup); - NotificationChannel messages = new NotificationChannel(getMessagesChannel(context), context.getString(R.string.NotificationChannel_messages), NotificationManager.IMPORTANCE_HIGH); - NotificationChannel calls = new NotificationChannel(CALLS, context.getString(R.string.NotificationChannel_calls), NotificationManager.IMPORTANCE_HIGH); - NotificationChannel failures = new NotificationChannel(FAILURES, context.getString(R.string.NotificationChannel_failures), NotificationManager.IMPORTANCE_HIGH); - NotificationChannel backups = new NotificationChannel(BACKUPS, context.getString(R.string.NotificationChannel_backups), NotificationManager.IMPORTANCE_LOW); - NotificationChannel lockedStatus = new NotificationChannel(LOCKED_STATUS, context.getString(R.string.NotificationChannel_locked_status), NotificationManager.IMPORTANCE_LOW); - NotificationChannel other = new NotificationChannel(OTHER, context.getString(R.string.NotificationChannel_other), NotificationManager.IMPORTANCE_LOW); + NotificationChannel messages = new NotificationChannel(getMessagesChannel(context), context.getString(R.string.theDefault), NotificationManager.IMPORTANCE_HIGH); + NotificationChannel calls = new NotificationChannel(CALLS, context.getString(R.string.callsSettings), NotificationManager.IMPORTANCE_HIGH); + NotificationChannel failures = new NotificationChannel(FAILURES, context.getString(R.string.failures), NotificationManager.IMPORTANCE_HIGH); + NotificationChannel lockedStatus = new NotificationChannel(LOCKED_STATUS, context.getString(R.string.lockAppStatus), NotificationManager.IMPORTANCE_LOW); + NotificationChannel other = new NotificationChannel(OTHER, context.getString(R.string.other), NotificationManager.IMPORTANCE_LOW); messages.setGroup(CATEGORY_MESSAGES); messages.enableVibration(TextSecurePreferences.isNotificationVibrateEnabled(context)); @@ -196,14 +195,13 @@ private static void onCreate(@NonNull Context context, @NonNull NotificationMana calls.setShowBadge(false); calls.setSound(null, null); - backups.setShowBadge(false); lockedStatus.setShowBadge(false); other.setShowBadge(false); - notificationManager.createNotificationChannels(Arrays.asList(messages, calls, failures, backups, lockedStatus, other)); + notificationManager.createNotificationChannels(Arrays.asList(messages, calls, failures, lockedStatus, other)); if (BuildConfig.PLAY_STORE_DISABLED) { - NotificationChannel appUpdates = new NotificationChannel(APP_UPDATES, context.getString(R.string.NotificationChannel_app_updates), NotificationManager.IMPORTANCE_HIGH); + NotificationChannel appUpdates = new NotificationChannel(APP_UPDATES, context.getString(R.string.updateApp), NotificationManager.IMPORTANCE_HIGH); notificationManager.createNotificationChannel(appUpdates); } else { notificationManager.deleteNotificationChannel(APP_UPDATES); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PendingMessageNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/PendingMessageNotificationBuilder.java deleted file mode 100644 index ef69abdc26..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PendingMessageNotificationBuilder.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.thoughtcrime.securesms.notifications; - - -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; - -import androidx.core.app.NotificationCompat; - -import org.session.libsession.utilities.NotificationPrivacyPreference; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.recipients.Recipient; -import org.thoughtcrime.securesms.home.HomeActivity; - -import network.loki.messenger.R; - -public class PendingMessageNotificationBuilder extends AbstractNotificationBuilder { - - public PendingMessageNotificationBuilder(Context context, NotificationPrivacyPreference privacy) { - super(context, privacy); - - Intent intent = new Intent(context, HomeActivity.class); - - setSmallIcon(R.drawable.ic_notification); - setColor(context.getResources().getColor(R.color.textsecure_primary)); - setCategory(NotificationCompat.CATEGORY_MESSAGE); - - setContentTitle(context.getString(R.string.MessageNotifier_pending_signal_messages)); - setContentText(context.getString(R.string.MessageNotifier_you_have_pending_signal_messages)); - setTicker(context.getString(R.string.MessageNotifier_you_have_pending_signal_messages)); - - setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)); - setAutoCancel(true); - setAlarms(null, Recipient.VibrateState.DEFAULT); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt index 5f218a7a9f..8eaca4000b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt @@ -3,14 +3,13 @@ package org.thoughtcrime.securesms.notifications import android.content.Context import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import com.goterl.lazysodium.LazySodiumAndroid -import com.goterl.lazysodium.SodiumAndroid +import androidx.core.content.ContextCompat.getString import com.goterl.lazysodium.interfaces.AEAD import com.goterl.lazysodium.utils.Key import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonBuilder +import network.loki.messenger.R import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.MessageReceiveParameters @@ -53,10 +52,13 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context) private fun onPush() { Log.d(TAG, "Failed to decode data for message.") val builder = NotificationCompat.Builder(context, NotificationChannels.OTHER) - .setSmallIcon(network.loki.messenger.R.drawable.ic_notification) - .setColor(context.getColor(network.loki.messenger.R.color.textsecure_primary)) - .setContentTitle("Session") - .setContentText("You've got a new message.") + .setSmallIcon(R.drawable.ic_notification) + .setColor(context.getColor(R.color.textsecure_primary)) + .setContentTitle(getString(context, R.string.app_name)) + + // Note: We set the count to 1 in the below plurals string so it says "You've got a new message" (singular) + .setContentText(context.resources.getQuantityString(R.plurals.messageNewYouveGot, 1, 1)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setAutoCancel(true) NotificationManagerCompat.from(context).notify(11111, builder.build()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java index 0ac16ea652..6764f46069 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java @@ -102,7 +102,7 @@ public void setThread(@NonNull Recipient recipient) { } } else { - setContentTitle(context.getString(R.string.SingleRecipientNotificationBuilder_signal)); + setContentTitle(context.getString(R.string.app_name)); setLargeIcon(AvatarPlaceholderGenerator.generate(context, ICON_SIZE, "", "Unknown")); } } @@ -128,7 +128,7 @@ public void setPrimaryMessageBody(@NonNull Recipient threadRecipient, setContentText(stringBuilder.append(message)); this.slideDeck = slideDeck; } else { - setContentText(stringBuilder.append(context.getString(R.string.SingleRecipientNotificationBuilder_new_message))); + setContentText(stringBuilder.append(context.getResources().getQuantityString(R.plurals.messageNew, 1, 1))); } } @@ -140,7 +140,7 @@ public void addAndroidAutoAction(@NonNull PendingIntent androidAutoReplyIntent, return; RemoteInput remoteInput = new RemoteInput.Builder(AndroidAutoReplyReceiver.VOICE_REPLY_KEY) - .setLabel(context.getString(R.string.MessageNotifier_reply)) + .setLabel(context.getString(R.string.reply)) .build(); NotificationCompat.CarExtender.UnreadConversation.Builder unreadConversationBuilder = @@ -159,7 +159,7 @@ public void addActions(@NonNull PendingIntent markReadIntent, @NonNull ReplyMethod replyMethod) { Action markAsReadAction = new Action(R.drawable.check, - context.getString(R.string.MessageNotifier_mark_read), + context.getString(R.string.messageMarkRead), markReadIntent); addAction(markAsReadAction); @@ -167,7 +167,7 @@ public void addActions(@NonNull PendingIntent markReadIntent, NotificationCompat.WearableExtender wearableExtender = new NotificationCompat.WearableExtender().addAction(markAsReadAction); if (quickReplyIntent != null) { - String actionName = context.getString(R.string.MessageNotifier_reply); + String actionName = context.getString(R.string.reply); String label = context.getString(replyMethodLongDescription(replyMethod)); Action replyAction = new Action(R.drawable.ic_reply_white_36dp, actionName, quickReplyIntent); @@ -194,7 +194,7 @@ public void addActions(@NonNull PendingIntent markReadIntent, @StringRes private static int replyMethodLongDescription(@NonNull ReplyMethod replyMethod) { - return R.string.MessageNotifier_reply; + return R.string.reply; } public void putStringExtra(String key, String value) { @@ -215,7 +215,7 @@ public void addMessageBody(@NonNull Recipient threadRecipient, if (privacy.isDisplayMessage()) { messageBodies.add(stringBuilder.append(messageBody == null ? "" : messageBody)); } else { - messageBodies.add(stringBuilder.append(context.getString(R.string.SingleRecipientNotificationBuilder_new_message))); + messageBodies.add(stringBuilder.append(context.getResources().getQuantityString(R.plurals.messageNew, 1, 1))); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/OnboardingBackPressAlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/OnboardingBackPressAlertDialog.kt index 2820a64a66..64d91c58af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/OnboardingBackPressAlertDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/OnboardingBackPressAlertDialog.kt @@ -2,26 +2,35 @@ package org.thoughtcrime.securesms.onboarding import androidx.annotation.StringRes import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import com.squareup.phrase.Phrase import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants.APP_NAME +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.DialogButtonModel import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.getSubbedString import org.thoughtcrime.securesms.ui.theme.LocalColors @Composable fun OnboardingBackPressAlertDialog( dismissDialog: () -> Unit, - @StringRes textId: Int = R.string.you_cannot_go_back_further_in_order_to_stop_loading_your_account_session_needs_to_quit, + @StringRes textId: Int, quit: () -> Unit ) { + val c = LocalContext.current + AlertDialog( onDismissRequest = dismissDialog, title = stringResource(R.string.warning), - text = stringResource(textId), + text = stringResource(textId).let { txt -> + Phrase.from(txt).put(APP_NAME_KEY, c.getString(R.string.app_name)).format().toString() + }, buttons = listOf( DialogButtonModel( - GetString(stringResource(R.string.quit)), + text = GetString(stringResource(id = R.string.quitButton)), color = LocalColors.current.danger, onClick = quit ), diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt index e40328b1d8..aab1421185 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt @@ -34,8 +34,11 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import com.squareup.phrase.Phrase import kotlinx.coroutines.delay import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.DialogButtonModel import org.thoughtcrime.securesms.ui.GetString @@ -81,13 +84,13 @@ internal fun LandingScreen( showCloseButton = true, // display the 'x' button buttons = listOf( DialogButtonModel( - text = GetString(R.string.activity_landing_terms_of_service), - contentDescription = GetString(R.string.AccessibilityId_terms_of_service_button), + text = GetString(R.string.onboardingTos), + contentDescription = GetString(R.string.AccessibilityId_onboardingTos), onClick = openTerms ), DialogButtonModel( - text = GetString(R.string.activity_landing_privacy_policy), - contentDescription = GetString(R.string.AccessibilityId_privacy_policy_button), + text = GetString(R.string.onboardingPrivacy), + contentDescription = GetString(R.string.AccessibilityId_onboardingPrivacy), onClick = openPrivacyPolicy ) ) @@ -129,8 +132,31 @@ internal fun LandingScreen( MESSAGES.take(count), key = { it.stringId } ) { item -> + // Perform string substitution only in the bubbles that require it + val bubbleTxt = when (item.stringId) { + R.string.onboardingBubbleWelcomeToSession -> { + Phrase.from(stringResource(item.stringId)) + .put(APP_NAME_KEY, stringResource(R.string.app_name)) + .put(EMOJI_KEY, "\uD83D\uDC4B") // this hardcoded emoji might be moved to NonTranslatableConstants eventually + .format().toString() + } + R.string.onboardingBubbleSessionIsEngineered -> { + Phrase.from(stringResource(item.stringId)) + .put(APP_NAME_KEY, stringResource(R.string.app_name)) + .format().toString() + } + R.string.onboardingBubbleCreatingAnAccountIsEasy -> { + Phrase.from(stringResource(item.stringId)) + .put(EMOJI_KEY, "\uD83D\uDC47") // this hardcoded emoji might be moved to NonTranslatableConstants eventually + .format().toString() + } + else -> { + stringResource(item.stringId) + } + } + AnimateMessageText( - stringResource(item.stringId), + bubbleTxt, item.isOutgoing ) } @@ -145,7 +171,7 @@ internal fun LandingScreen( modifier = Modifier .fillMaxWidth() .align(Alignment.CenterHorizontally) - .contentDescription(R.string.AccessibilityId_create_account_button), + .contentDescription(R.string.AccessibilityId_onboardingAccountCreate), onClick = createAccount ) Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) @@ -154,7 +180,7 @@ internal fun LandingScreen( modifier = Modifier .fillMaxWidth() .align(Alignment.CenterHorizontally) - .contentDescription(R.string.AccessibilityId_restore_account_button), + .contentDescription(R.string.AccessibilityId_onboardingAccountExists), onClick = loadAccount ) BorderlessHtmlButton( @@ -162,7 +188,7 @@ internal fun LandingScreen( modifier = Modifier .fillMaxWidth() .align(Alignment.CenterHorizontally) - .contentDescription(R.string.AccessibilityId_open_url), + .contentDescription(R.string.AccessibilityId_urlOpenBrowser), onClick = { isUrlDialogVisible = true } ) Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt index 56d1c54ea4..c87d5fc568 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt @@ -24,12 +24,12 @@ import androidx.compose.ui.tooling.preview.Preview import kotlinx.coroutines.flow.Flow import network.loki.messenger.R import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton -import org.thoughtcrime.securesms.ui.theme.LocalDimensions -import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.components.QRScannerScreen import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField import org.thoughtcrime.securesms.ui.components.SessionTabRow +import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme private val TITLES = listOf(R.string.sessionRecoveryPassword, R.string.qrScan) @@ -93,14 +93,14 @@ private fun RecoveryPassword(state: State, onChange: (String) -> Unit = {}, onCo } Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) Text( - stringResource(R.string.activity_link_enter_your_recovery_password_to_load_your_account_if_you_haven_t_saved_it_you_can_find_it_in_your_app_settings), + stringResource(R.string.recoveryPasswordRestoreDescription), style = LocalType.current.base ) Spacer(Modifier.height(LocalDimensions.current.spacing)) SessionOutlinedTextField( text = state.recoveryPhrase, modifier = Modifier.fillMaxWidth(), - contentDescription = stringResource(R.string.AccessibilityId_recovery_phrase_input), + contentDescription = stringResource(R.string.AccessibilityId_recoveryPasswordEnter), placeholder = stringResource(R.string.recoveryPasswordEnter), onChange = onChange, onContinue = onContinue, diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt index 8669db87e4..39b119e5b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt @@ -13,6 +13,7 @@ import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.onboarding.manager.LoadAccountManager import org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsActivity +import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.ui.setComposeContent import org.thoughtcrime.securesms.util.start @@ -28,7 +29,7 @@ class LoadAccountActivity : BaseActionBarActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - supportActionBar?.setTitle(R.string.activity_link_load_account) + supportActionBar?.setTitle(R.string.loadAccount) prefs.setConfigurationMessageSynced(false) prefs.setRestorationTime(System.currentTimeMillis()) prefs.setLastProfileUpdateTime(0) @@ -45,4 +46,9 @@ class LoadAccountActivity : BaseActionBarActivity() { LoadAccountScreen(state, viewModel.qrErrors, viewModel::onChange, viewModel::onContinue, viewModel::onScanQrCode) } } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/Loading.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/Loading.kt index 8c2e10e765..001562c7ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/Loading.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/Loading.kt @@ -20,7 +20,7 @@ internal fun LoadingScreen(progress: Float) { Spacer(modifier = Modifier.weight(1f)) ProgressArc( progress, - modifier = Modifier.contentDescription(R.string.AccessibilityId_loading_animation) + modifier = Modifier.contentDescription(R.string.AccessibilityId_loadAccountProgressMessage) ) Text( stringResource(R.string.waitOneMoment), diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt index e56b55aaab..436cb890be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt @@ -18,7 +18,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import com.squareup.phrase.Phrase import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.thoughtcrime.securesms.onboarding.OnboardingBackPressAlertDialog import org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsViewModel.UiState import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton @@ -45,39 +47,52 @@ internal fun MessageNotificationsScreen( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - CircularProgressIndicator(LocalColors.current.primary) + CircularProgressIndicator(color = LocalColors.current.primary) } return } - if (state.showDialog) OnboardingBackPressAlertDialog(dismissDialog, quit = quit) + if (state.showingBackWarningDialogText != null) { + OnboardingBackPressAlertDialog(dismissDialog, + textId = state.showingBackWarningDialogText, + quit = quit + ) + } Column { Spacer(Modifier.weight(1f)) - Column(modifier = Modifier.padding(horizontal = LocalDimensions.current.mediumSpacing)) { Text(stringResource(R.string.notificationsMessage), style = LocalType.current.h4) Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) - Text(stringResource(R.string.onboardingMessageNotificationExplaination), style = LocalType.current.base) + Text( + Phrase.from(stringResource(R.string.onboardingMessageNotificationExplanation)) + .put(APP_NAME_KEY, stringResource(R.string.app_name)) + .format().toString(), + style = LocalType.current.base + ) Spacer(Modifier.height(LocalDimensions.current.spacing)) } NotificationRadioButton( - R.string.activity_pn_mode_fast_mode, - R.string.activity_pn_mode_fast_mode_explanation, - modifier = Modifier.contentDescription(R.string.AccessibilityId_fast_mode_notifications_button), - tag = R.string.activity_pn_mode_recommended_option_tag, + R.string.notificationsFastMode, + R.string.notificationsFastModeDescription, + modifier = Modifier.contentDescription(R.string.AccessibilityId_notificationsFastMode), + tag = R.string.recommended, checked = state.pushEnabled, onClick = { setEnabled(true) } ) // spacing between buttons is provided by ripple/downstate of NotificationRadioButton + val explanationTxt = Phrase.from(stringResource(R.string.notificationsSlowModeDescription)) + .put(APP_NAME_KEY, stringResource(R.string.app_name)) + .format().toString() + NotificationRadioButton( - R.string.activity_pn_mode_slow_mode, - R.string.activity_pn_mode_slow_mode_explanation, - modifier = Modifier.contentDescription(R.string.AccessibilityId_slow_mode_notifications_button), + stringResource(R.string.notificationsSlowMode), + explanationTxt, + modifier = Modifier.contentDescription(R.string.AccessibilityId_notificationsSlowMode), checked = state.pushDisabled, onClick = { setEnabled(false) } ) @@ -90,8 +105,28 @@ internal fun MessageNotificationsScreen( @Composable private fun NotificationRadioButton( - @StringRes title: Int, - @StringRes explanation: Int, + @StringRes titleId: Int, + @StringRes explanationId: Int, + modifier: Modifier = Modifier, + @StringRes tag: Int? = null, + checked: Boolean = false, + onClick: () -> Unit = {} +) { + // Pass-through from this string ID version to the version that takes strings + NotificationRadioButton( + titleTxt = stringResource(titleId), + explanationTxt = stringResource(explanationId), + modifier = modifier, + tag = tag, + checked = checked, + onClick = onClick + ) +} + +@Composable +private fun NotificationRadioButton( + titleTxt: String, + explanationTxt: String, modifier: Modifier = Modifier, @StringRes tag: Int? = null, checked: Boolean = false, @@ -112,13 +147,19 @@ private fun NotificationRadioButton( RoundedCornerShape(8.dp) ), ) { - Column(modifier = Modifier - .padding(horizontal = LocalDimensions.current.smallSpacing, - vertical = LocalDimensions.current.xsSpacing) - ) { - Text(stringResource(title), style = LocalType.current.h8) + Column( + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing, vertical = LocalDimensions.current.xsSpacing)) { + Text( + titleTxt, + style = LocalType.current.h8 + ) + + Text( + explanationTxt, + style = LocalType.current.small, + modifier = Modifier.padding(top = LocalDimensions.current.xxsSpacing) + ) - Text(stringResource(explanation), style = LocalType.current.small, modifier = Modifier.padding(top = LocalDimensions.current.xxsSpacing)) tag?.let { Text( stringResource(it), diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsViewModel.kt index a39f270bf2..a48f047203 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsViewModel.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.notifications.PushRegistry @@ -58,27 +59,29 @@ internal class MessageNotificationsViewModel( fun onBackPressed(): Boolean = when (state) { is State.CreateAccount -> false is State.LoadAccount -> { - _uiStates.update { it.copy(showDialog = true) } + _uiStates.update { it.copy(showingBackWarningDialogText = R.string.onboardingBackLoadAccount) } true } } fun dismissDialog() { - _uiStates.update { it.copy(showDialog = false) } + _uiStates.update { + it.copy(showingBackWarningDialogText = null) + } } fun quit() { _uiStates.update { it.copy(clearData = true) } viewModelScope.launch(Dispatchers.IO) { - ApplicationContext.getInstance(application).clearAllData() + ApplicationContext.getInstance(application).clearAllDataAndRestart() } } data class UiState( val pushEnabled: Boolean = true, - val showDialog: Boolean = false, + val showingBackWarningDialogText: Int? = null, val clearData: Boolean = false ) { val pushDisabled get() = !pushEnabled diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt index 04124a5bfd..1481695a3a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt @@ -41,7 +41,7 @@ internal fun PickDisplayName( if (state.showDialog) OnboardingBackPressAlertDialog( dismissDialog, - R.string.you_cannot_go_back_further_cancel_account_creation, + R.string.onboardingBackAccountCreation, quit ) @@ -66,7 +66,7 @@ internal fun PickDisplayName( SessionOutlinedTextField( text = state.displayName, modifier = Modifier.fillMaxWidth(), - contentDescription = stringResource(R.string.AccessibilityId_enter_display_name), + contentDescription = stringResource(R.string.AccessibilityId_displayNameEnter), placeholder = stringResource(R.string.displayNameEnter), onChange = onChange, onContinue = onContinue, diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/ui/ContinueButton.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/ui/ContinueButton.kt index 0d31363d76..51a5d0f35b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/ui/ContinueButton.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/ui/ContinueButton.kt @@ -13,9 +13,9 @@ import org.thoughtcrime.securesms.ui.contentDescription @Composable fun ContinuePrimaryOutlineButton(modifier: Modifier, onContinue: () -> Unit) { PrimaryOutlineButton( - stringResource(R.string.continue_2), + stringResource(R.string.theContinue), modifier = modifier - .contentDescription(R.string.AccessibilityId_continue) + .contentDescription(R.string.AccessibilityId_theContinue) .fillMaxWidth() .padding(horizontal = LocalDimensions.current.xlargeSpacing) .padding(bottom = LocalDimensions.current.smallSpacing), diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java b/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java index f38d4c8613..bdc90830fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java @@ -73,7 +73,7 @@ public PermissionsBuilder request(String... requestedPermissions) { return this; } - public PermissionsBuilder withRationaleDialog(@NonNull String message, @NonNull @DrawableRes int... headers) { + public PermissionsBuilder withRationaleDialog(@NonNull String message, @DrawableRes int... headers) { this.rationalDialogHeader = headers; this.rationaleDialogMessage = message; return this; @@ -143,7 +143,7 @@ public void execute() { if (!isInTargetSDKRange || permissionObject.hasAll(requestedPermissions)) { executePreGrantedPermissionsRequest(request); - } else if (rationaleDialogMessage != null && rationalDialogHeader != null) { + } else if (rationaleDialogMessage != null) { executePermissionsRequestWithRationale(request); } else { executePermissionsRequest(request); diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/RationaleDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/permissions/RationaleDialog.kt index 373da62c12..08e8954dce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/permissions/RationaleDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/RationaleDialog.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.Color import android.util.TypedValue import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.LinearLayout @@ -25,36 +26,46 @@ object RationaleDialog { onNegative: Runnable, @DrawableRes vararg drawables: Int ): AlertDialog { - val view = LayoutInflater.from(context).inflate(R.layout.permissions_rationale_dialog, null) - .apply { clipToOutline = true } - val header = view.findViewById(R.id.header_container) - view.findViewById(R.id.message).text = message + var customView: View? = null + if (!drawables.isEmpty()) { + customView = LayoutInflater.from(context).inflate(R.layout.permissions_rationale_dialog, null) + .apply { clipToOutline = true } + val header = customView.findViewById(R.id.header_container) - fun addIcon(id: Int) { - ImageView(context).apply { - setImageDrawable(ResourcesCompat.getDrawable(context.resources, id, context.theme)) - layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT) - }.also(header::addView) - } + customView.findViewById(R.id.message).text = message - fun addPlus() { - TextView(context).apply { - text = "+" - setTextSize(TypedValue.COMPLEX_UNIT_SP, 40f) - setTextColor(Color.WHITE) - layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { - ViewUtil.dpToPx(context, 20).let { setMargins(it, 0, it, 0) } - } - }.also(header::addView) - } + fun addIcon(id: Int) { + ImageView(context).apply { + setImageDrawable(ResourcesCompat.getDrawable(context.resources, id, context.theme)) + layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT) + }.also(header::addView) + } - drawables.firstOrNull()?.let(::addIcon) - drawables.drop(1).forEach { addPlus(); addIcon(it) } + fun addPlus() { + TextView(context).apply { + text = "+" + setTextSize(TypedValue.COMPLEX_UNIT_SP, 40f) + setTextColor(Color.WHITE) + layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + ViewUtil.dpToPx(context, 20).let { setMargins(it, 0, it, 0) } + } + }.also(header::addView) + } + + drawables.firstOrNull()?.let(::addIcon) + drawables.drop(1).forEach { addPlus(); addIcon(it) } + } return context.showSessionDialog { - view(view) - button(R.string.Permissions_continue) { onPositive.run() } - button(R.string.Permissions_not_now) { onNegative.run() } + // show the generic title when there are no icons + if(customView != null){ + view(customView) + } else { + title(R.string.permissionsRequired) + text(message) + } + button(R.string.theContinue) { onPositive.run() } + button(R.string.cancel) { onNegative.run() } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/SettingsDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/permissions/SettingsDialog.kt index a4efd8d870..bc7a1c4191 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/permissions/SettingsDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/SettingsDialog.kt @@ -9,9 +9,9 @@ class SettingsDialog { @JvmStatic fun show(context: Context, message: String) { context.showSessionDialog { - title(R.string.Permissions_permission_required) + title(R.string.permissionsRequired) text(message) - button(R.string.Permissions_continue, R.string.AccessibilityId_continue) { + button(R.string.sessionSettings, R.string.AccessibilityId_sessionSettings) { context.startActivity(Permissions.getApplicationSettingsIntent(context)) } cancelButton() diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt index 16499cc4bc..d01990fe05 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt @@ -18,11 +18,11 @@ class BlockedContactsActivity: PassphraseRequiredActionBarActivity() { val adapter: BlockedContactsAdapter by lazy { BlockedContactsAdapter(viewModel) } - fun unblock() { + private fun unblock() { showSessionDialog { title(viewModel.getTitle(this@BlockedContactsActivity)) - text(viewModel.getMessage(this@BlockedContactsActivity)) - button(R.string.continue_2) { viewModel.unblock() } + text(viewModel.getText(context, viewModel.state.selectedItems)) + dangerButton(R.string.blockUnblock, R.string.AccessibilityId_unblockConfirm) { viewModel.unblock() } cancelButton() } } @@ -43,6 +43,5 @@ class BlockedContactsActivity: PassphraseRequiredActionBarActivity() { } binding.unblockButton.setOnClickListener { unblock() } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt index dbe09668c5..ae9dfe4760 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt @@ -6,11 +6,10 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.cash.copper.flow.observeQuery +import com.squareup.phrase.Phrase import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collect @@ -20,10 +19,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.withContext import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.Storage -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.adapter.SelectableItem import javax.inject.Inject @@ -75,36 +75,30 @@ class BlockedContactsViewModel @Inject constructor(private val storage: Storage) } } - fun getTitle(context: Context): String = - if (state.selectedItems.size == 1) { - context.getString(R.string.Unblock_dialog__title_single, state.selectedItems.first().name) - } else { - context.getString(R.string.Unblock_dialog__title_multiple) - } - - fun getMessage(context: Context): String { - if (state.selectedItems.size == 1) { - return context.getString(R.string.Unblock_dialog__message, state.selectedItems.first().name) - } - val stringBuilder = StringBuilder() - val iterator = state.selectedItems.iterator() - var numberAdded = 0 - while (iterator.hasNext() && numberAdded < 3) { - val nextRecipient = iterator.next() - if (numberAdded > 0) stringBuilder.append(", ") + fun getTitle(context: Context): String = context.getString(R.string.blockUnblock) - stringBuilder.append(nextRecipient.name) - numberAdded++ - } - val overflow = state.selectedItems.size - numberAdded - if (overflow > 0) { - stringBuilder.append(" ") - val string = context.resources.getQuantityString(R.plurals.Unblock_dialog__message_multiple_overflow, overflow) - stringBuilder.append(string.format(overflow)) + // Method to get the appropriate text to display when unblocking 1, 2, or several contacts + fun getText(context: Context, contactsToUnblock: Set): CharSequence { + return when (contactsToUnblock.size) { + // Note: We do not have to handle 0 because if no contacts are chosen then the unblock button is deactivated + 1 -> Phrase.from(context, R.string.blockUnblockName) + .put(NAME_KEY, contactsToUnblock.elementAt(0).name) + .format() + 2 -> Phrase.from(context, R.string.blockUnblockNameTwo) + .put(NAME_KEY, contactsToUnblock.elementAt(0).name) + .format() + else -> { + val othersCount = contactsToUnblock.size - 1 + Phrase.from(context, R.string.blockUnblockNameMultiple) + .put(NAME_KEY, contactsToUnblock.elementAt(0).name) + .put(COUNT_KEY, othersCount) + .format() + } } - return context.getString(R.string.Unblock_dialog__message, stringBuilder.toString()) } + fun getMessage(context: Context): String = context.getString(R.string.blockUnblock) + fun toggle(selectable: SelectableItem) { _state.value = state.run { if (selectable.item in selectedItems) copy(selectedItems = selectedItems - selectable.item) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/CallToggleListener.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/CallToggleListener.kt index ea747798c8..83f0ae3de3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/CallToggleListener.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/CallToggleListener.kt @@ -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, @@ -19,9 +22,9 @@ internal class CallToggleListener( // check if we've shown the info dialog and check for microphone permissions context.showSessionDialog { - title(R.string.dialog_voice_video_title) - text(R.string.dialog_voice_video_message) - button(R.string.dialog_link_preview_enable_button_title, R.string.AccessibilityId_enable) { requestMicrophonePermission() } + title(R.string.callsVoiceAndVideoBeta) + text(R.string.callsVoiceAndVideoModalDescription) + button(R.string.enable, R.string.AccessibilityId_enable) { requestMicrophonePermission() } cancelButton() } @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatSettingsActivity.kt index 6852d2f63f..f2217b66e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatSettingsActivity.kt @@ -9,7 +9,7 @@ class ChatSettingsActivity : PassphraseRequiredActionBarActivity() { override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) setContentView(R.layout.activity_fragment_wrapper) - supportActionBar!!.title = resources.getString(R.string.activity_conversations_settings_title) + supportActionBar!!.title = resources.getString(R.string.sessionConversations) val fragment = ChatsPreferenceFragment() val transaction = supportFragmentManager.beginTransaction() transaction.replace(R.id.fragmentContainer, fragment) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java index 25d21bbf6d..2c23188429 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java @@ -9,7 +9,7 @@ import network.loki.messenger.R; -public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment { +public class ChatsPreferenceFragment extends CorrectedPreferenceFragment { private static final String TAG = ChatsPreferenceFragment.class.getSimpleName(); @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt index 17d97dec7b..98ad62dcb3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt @@ -63,7 +63,6 @@ class ClearAllDataDialog : DialogFragment() { binding.recyclerView.apply { itemAnimator = null adapter = optionAdapter - addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) setHasFixedSize(true) } optionAdapter.submitList(listOf(device, network)) @@ -93,10 +92,10 @@ class ClearAllDataDialog : DialogFragment() { when (step) { Steps.INFO_PROMPT -> { - binding.dialogDescriptionText.setText(R.string.dialog_clear_all_data_message) + binding.dialogDescriptionText.setText(R.string.clearDataAllDescription) } Steps.NETWORK_PROMPT -> { - binding.dialogDescriptionText.setText(R.string.dialog_clear_all_data_clear_device_and_network_confirmation) + binding.dialogDescriptionText.text = getString(R.string.clearDeviceAndNetworkConfirm) } Steps.DELETING -> { /* do nothing intentionally */ } Steps.RETRY_LOCAL_DELETE_ONLY_PROMPT -> { @@ -125,7 +124,7 @@ class ClearAllDataDialog : DialogFragment() { } return } - ApplicationContext.getInstance(context).clearAllData().let { success -> + ApplicationContext.getInstance(context).clearAllDataAndRestart().let { success -> withContext(Main) { if (success) { dismiss() @@ -162,7 +161,7 @@ class ClearAllDataDialog : DialogFragment() { } else if (deletionResultMap.values.all { it }) { // ..otherwise if the network data deletion was successful proceed to delete the local data as well. - ApplicationContext.getInstance(context).clearAllData() + ApplicationContext.getInstance(context).clearAllDataAndRestart() withContext(Main) { dismiss() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java index 8c3e6190ed..d626b9ce5f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java @@ -21,18 +21,15 @@ import androidx.preference.PreferenceScreen; import androidx.preference.PreferenceViewHolder; import androidx.recyclerview.widget.RecyclerView; - -import org.thoughtcrime.securesms.components.CustomDefaultPreference; import org.thoughtcrime.securesms.conversation.v2.ViewUtil; - import network.loki.messenger.R; public abstract class CorrectedPreferenceFragment extends PreferenceFragmentCompat { - public static final int SINGLE_TYPE = 21; - public static final int TOP_TYPE = 22; - public static final int MIDDLE_TYPE = 23; - public static final int BOTTOM_TYPE = 24; + public static final int SINGLE_TYPE = 21; + public static final int TOP_TYPE = 22; + public static final int MIDDLE_TYPE = 23; + public static final int BOTTOM_TYPE = 24; public static final int CATEGORY_TYPE = 25; public int horizontalPadding; @@ -56,18 +53,7 @@ public void onActivityCreated(Bundle savedInstanceState) { @Override public void onDisplayPreferenceDialog(Preference preference) { - DialogFragment dialogFragment = null; - - if (preference instanceof CustomDefaultPreference) { - dialogFragment = CustomDefaultPreference.CustomDefaultPreferenceDialogFragmentCompat.newInstance(preference.getKey()); - } - - if (dialogFragment != null) { - dialogFragment.setTargetFragment(this, 0); - dialogFragment.show(getFragmentManager(), "android.support.v7.preference.PreferenceFragment.DIALOG"); - } else { - super.onDisplayPreferenceDialog(preference); - } + if (preference != null) super.onDisplayPreferenceDialog(preference); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt index 339047bbf6..2ae5604c06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt @@ -10,11 +10,13 @@ import android.widget.TextView import android.widget.Toast import androidx.core.view.isInvisible import androidx.preference.Preference - import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.ui.getSubbedCharSequence +import org.thoughtcrime.securesms.ui.getSubbedString class HelpSettingsActivity: PassphraseRequiredActionBarActivity() { @@ -30,20 +32,27 @@ class HelpSettingsActivity: PassphraseRequiredActionBarActivity() { class HelpSettingsFragment: CorrectedPreferenceFragment() { companion object { - private const val EXPORT_LOGS = "export_logs" - private const val TRANSLATE = "translate_session" - private const val FEEDBACK = "feedback" - private const val FAQ = "faq" - private const val SUPPORT = "support" - - private const val CROWDIN_URL = "https://getsession.org/translate" + private const val EXPORT_LOGS = "export_logs" + private const val TRANSLATE = "translate_session" + private const val FEEDBACK = "feedback" + private const val FAQ = "faq" + private const val SUPPORT = "support" + private const val CROWDIN_URL = "https://getsession.org/translate" private const val FEEDBACK_URL = "https://getsession.org/survey" - private const val FAQ_URL = "https://getsession.org/faq" - private const val SUPPORT_URL = "https://sessionapp.zendesk.com/hc/en-us" + private const val FAQ_URL = "https://getsession.org/faq" + private const val SUPPORT_URL = "https://sessionapp.zendesk.com/hc/en-us" } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.preferences_help) + + // String sub the summary text of the `export_logs` element in preferences_help.xml + val exportPref = preferenceScreen.findPreference(EXPORT_LOGS) + exportPref?.summary = context?.getSubbedCharSequence(R.string.helpReportABugExportLogsDescription, APP_NAME_KEY to getString(R.string.app_name)) + + // String sub the summary text of the `translate_session` element in preferences_help.xml + val translatePref = preferenceScreen.findPreference(TRANSLATE) + translatePref?.title = context?.getSubbedCharSequence(R.string.helpHelpUsTranslateSession, APP_NAME_KEY to getString(R.string.app_name)) } override fun onPreferenceTreeClick(preference: Preference): Boolean { @@ -77,7 +86,7 @@ class HelpSettingsFragment: CorrectedPreferenceFragment() { // Change export logs button text val exportLogsButton = this.activity?.findViewById(R.id.export_logs_button) as TextView? if (exportLogsButton == null) { Log.w("Loki", "Could not find export logs button view.") } - exportLogsButton?.text = if (exportJobRunning) getString(R.string.cancel) else getString(R.string.activity_help_settings__export_logs) + exportLogsButton?.text = if (exportJobRunning) getString(R.string.cancel) else getString(R.string.helpReportABugExportLogs) // Show progress bar val exportProgressBar = this.activity?.findViewById(R.id.export_progress_bar) as ProgressBar? @@ -89,9 +98,11 @@ class HelpSettingsFragment: CorrectedPreferenceFragment() { Permissions.with(this) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .maxSdkVersion(Build.VERSION_CODES.P) - .withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied)) + .withPermanentDenialDialog(requireContext().getSubbedString(R.string.permissionsStorageDeniedLegacy, APP_NAME_KEY to getString(R.string.app_name))) .onAnyDenied { - Toast.makeText(requireActivity(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show() + val c = requireContext() + val txt = c.getSubbedString(R.string.permissionsStorageDeniedLegacy, APP_NAME_KEY to getString(R.string.app_name)) + Toast.makeText(c, txt, Toast.LENGTH_LONG).show() } .onAllGranted { ShareLogsDialog(::updateExportButtonAndProgressBarUI).show(parentFragmentManager,"Share Logs Dialog") @@ -104,7 +115,7 @@ class HelpSettingsFragment: CorrectedPreferenceFragment() { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) startActivity(intent) } catch (e: Exception) { - Toast.makeText(requireActivity(), "Can't open URL", Toast.LENGTH_LONG).show() + Toast.makeText(requireActivity(), requireContext().getString(R.string.errorUnknown), Toast.LENGTH_LONG).show() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ListSummaryPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/ListSummaryPreferenceFragment.java deleted file mode 100644 index 4314b9ae62..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ListSummaryPreferenceFragment.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.thoughtcrime.securesms.preferences; - - -import androidx.preference.ListPreference; -import androidx.preference.Preference; - -import java.util.Arrays; - -import network.loki.messenger.R; - -public abstract class ListSummaryPreferenceFragment extends CorrectedPreferenceFragment { - - protected class ListSummaryListener implements Preference.OnPreferenceChangeListener { - @Override - public boolean onPreferenceChange(Preference preference, Object value) { - ListPreference listPref = (ListPreference) preference; - int entryIndex = Arrays.asList(listPref.getEntryValues()).indexOf(value); - - listPref.setSummary(entryIndex >= 0 && entryIndex < listPref.getEntries().length - ? listPref.getEntries()[entryIndex] - : getString(R.string.preferences__led_color_unknown)); - return true; - } - } - - protected void initializeListSummary(ListPreference pref) { - pref.setSummary(pref.getEntry()); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationSettingsActivity.kt index 2a34de808b..0e32cc4335 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationSettingsActivity.kt @@ -11,7 +11,7 @@ class NotificationSettingsActivity : PassphraseRequiredActionBarActivity() { override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) setContentView(R.layout.activity_fragment_wrapper) - supportActionBar!!.title = resources.getString(R.string.activity_notification_settings_title) + supportActionBar!!.title = resources.getString(R.string.sessionNotifications) val fragment = NotificationsPreferenceFragment() val transaction = supportFragmentManager.beginTransaction() transaction.replace(R.id.fragmentContainer, fragment) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt index 2d26284877..af282d4972 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.preferences import android.annotation.SuppressLint import android.app.Activity -import android.content.Context import android.content.Intent import android.media.RingtoneManager import android.net.Uri @@ -11,7 +10,6 @@ import android.os.Bundle import android.provider.Settings import android.text.TextUtils import androidx.lifecycle.lifecycleScope -import androidx.preference.ListPreference import androidx.preference.Preference import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers @@ -19,17 +17,19 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotificationsEnabled import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.components.SwitchPreferenceCompat import org.thoughtcrime.securesms.notifications.NotificationChannels import org.thoughtcrime.securesms.notifications.PushRegistry +import org.thoughtcrime.securesms.preferences.widgets.DropDownPreference +import java.util.Arrays import javax.inject.Inject @AndroidEntryPoint -class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() { +class NotificationsPreferenceFragment : CorrectedPreferenceFragment() { @Inject lateinit var pushRegistry: PushRegistry + @Inject lateinit var prefs: TextSecurePreferences @@ -41,22 +41,22 @@ class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() { val fcmPreference: SwitchPreferenceCompat = findPreference(fcmKey)!! fcmPreference.isChecked = prefs.isPushEnabled() fcmPreference.setOnPreferenceChangeListener { _: Preference, newValue: Any -> - prefs.setPushEnabled(newValue as Boolean) - val job = pushRegistry.refresh(true) + prefs.setPushEnabled(newValue as Boolean) + val job = pushRegistry.refresh(true) - fcmPreference.isEnabled = false + fcmPreference.isEnabled = false - lifecycleScope.launch(Dispatchers.IO) { - job.join() + lifecycleScope.launch(Dispatchers.IO) { + job.join() - withContext(Dispatchers.Main) { - fcmPreference.isEnabled = true - } + withContext(Dispatchers.Main) { + fcmPreference.isEnabled = true } - - true } + true + } + prefs.setNotificationRingtone( NotificationChannels.getMessageRingtone(requireContext()).toString() ) @@ -64,13 +64,22 @@ class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() { NotificationChannels.getMessageVibrate(requireContext()) ) - findPreference(TextSecurePreferences.RINGTONE_PREF)!!.onPreferenceChangeListener = RingtoneSummaryListener() - findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)!!.onPreferenceChangeListener = NotificationPrivacyListener() + findPreference(TextSecurePreferences.RINGTONE_PREF)?.apply { + setOnViewReady { updateRingtonePref() } + onPreferenceChangeListener = RingtoneSummaryListener() + } + + findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)?.apply { + setOnViewReady { setDropDownLabel(entry) } + onPreferenceChangeListener = NotificationPrivacyListener() + } + findPreference(TextSecurePreferences.VIBRATE_PREF)!!.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _: Preference?, newValue: Any -> NotificationChannels.updateMessageVibrate(requireContext(), newValue as Boolean) true } + findPreference(TextSecurePreferences.RINGTONE_PREF)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { val current = prefs.getNotificationRingtone() @@ -89,29 +98,19 @@ class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() { startActivityForResult(intent, 1) true } - findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)!!.onPreferenceClickListener = - Preference.OnPreferenceClickListener { preference: Preference -> - val listPreference = preference as ListPreference - listPreference.setDialogMessage(R.string.preferences_notifications__content_message) - listPreferenceDialog(requireContext(), listPreference) { - initializeListSummary(findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)) - } - true - } - initializeListSummary(findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF) as ListPreference?) findPreference(TextSecurePreferences.NOTIFICATION_PRIORITY_PREF)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) intent.putExtra( - Settings.EXTRA_CHANNEL_ID, NotificationChannels.getMessagesChannel(requireContext()) + Settings.EXTRA_CHANNEL_ID, + NotificationChannels.getMessagesChannel(requireContext()) ) intent.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName) startActivity(intent) true } - initializeRingtoneSummary(findPreference(TextSecurePreferences.RINGTONE_PREF)) initializeMessageVibrateSummary(findPreference(TextSecurePreferences.VIBRATE_PREF) as SwitchPreferenceCompat?) } @@ -130,54 +129,63 @@ class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() { NotificationChannels.updateMessageRingtone(requireContext(), uri) prefs.setNotificationRingtone(uri.toString()) } - initializeRingtoneSummary(findPreference(TextSecurePreferences.RINGTONE_PREF)) + updateRingtonePref() } } private inner class RingtoneSummaryListener : Preference.OnPreferenceChangeListener { override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean { + val pref = preference as? DropDownPreference ?: return false val value = newValue as? Uri if (value == null || TextUtils.isEmpty(value.toString())) { - preference.setSummary(R.string.preferences__silent) + pref.setDropDownLabel(context?.getString(R.string.none)) } else { RingtoneManager.getRingtone(activity, value) ?.getTitle(activity) - ?.let { preference.summary = it } + ?.let { pref.setDropDownLabel(it) } } return true } } - private fun initializeRingtoneSummary(pref: Preference?) { - val listener = pref!!.onPreferenceChangeListener as RingtoneSummaryListener? + private fun updateRingtonePref() { + val pref = findPreference(TextSecurePreferences.RINGTONE_PREF) + val listener: RingtoneSummaryListener = + (pref?.onPreferenceChangeListener) as? RingtoneSummaryListener + ?: return + val uri = prefs.getNotificationRingtone() - listener!!.onPreferenceChange(pref, uri) + listener.onPreferenceChange(pref, uri) } private fun initializeMessageVibrateSummary(pref: SwitchPreferenceCompat?) { pref!!.isChecked = prefs.isNotificationVibrateEnabled() } - private inner class NotificationPrivacyListener : ListSummaryListener() { + private inner class NotificationPrivacyListener : Preference.OnPreferenceChangeListener { @SuppressLint("StaticFieldLeak") override fun onPreferenceChange(preference: Preference, value: Any): Boolean { + // update drop down + val pref = preference as? DropDownPreference ?: return false + val entryIndex = Arrays.asList(*pref.entryValues).indexOf(value) + + pref.setDropDownLabel( + if (entryIndex >= 0 && entryIndex < pref.entries.size + ) pref.entries[entryIndex] + else getString(R.string.unknown) + ) + + // update notification object : AsyncTask() { override fun doInBackground(vararg params: Void?): Void? { - ApplicationContext.getInstance(activity).messageNotifier.updateNotification(activity!!) + ApplicationContext.getInstance(activity).messageNotifier.updateNotification( + activity!! + ) return null } }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) - return super.onPreferenceChange(preference, value) + return true } } - - companion object { - @Suppress("unused") - private val TAG = NotificationsPreferenceFragment::class.java.simpleName - fun getSummary(context: Context): CharSequence = when (isNotificationsEnabled(context)) { - true -> R.string.ApplicationPreferencesActivity_On - false -> R.string.ApplicationPreferencesActivity_Off - }.let(context::getString) - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsActivity.kt index de136694ff..7d8e254205 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsActivity.kt @@ -11,8 +11,7 @@ class PrivacySettingsActivity : PassphraseRequiredActionBarActivity() { override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) setContentView(R.layout.activity_fragment_wrapper) - val fragment = - PrivacySettingsPreferenceFragment() + val fragment = PrivacySettingsPreferenceFragment() val transaction = supportFragmentManager.beginTransaction() transaction.replace(R.id.fragmentContainer, fragment) transaction.commit() diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt index 8404a4f8e2..a2170e2cf7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt @@ -3,14 +3,13 @@ package org.thoughtcrime.securesms.preferences import android.app.KeyguardManager import android.content.Context import android.content.Intent -import android.net.Uri -import android.os.Build import android.os.Bundle import android.provider.Settings import androidx.preference.Preference import androidx.preference.PreferenceCategory import androidx.preference.PreferenceDataStore import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import network.loki.messenger.BuildConfig import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences @@ -24,10 +23,9 @@ import org.thoughtcrime.securesms.service.KeyCachingService import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.areNotificationsEnabled import org.thoughtcrime.securesms.util.IntentUtils -import javax.inject.Inject @AndroidEntryPoint -class PrivacySettingsPreferenceFragment : ListSummaryPreferenceFragment() { +class PrivacySettingsPreferenceFragment : CorrectedPreferenceFragment() { @Inject lateinit var configFactory: ConfigFactory @@ -39,7 +37,7 @@ class PrivacySettingsPreferenceFragment : ListSummaryPreferenceFragment() { .onPreferenceChangeListener = TypingIndicatorsToggleListener() findPreference(TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED)!! .onPreferenceChangeListener = CallToggleListener(this) { setCall(it) } - findPreference(getString(R.string.preferences__message_requests_category))?.let { category -> + findPreference(getString(R.string.sessionMessageRequests))?.let { category -> when (val user = configFactory.user) { null -> category.isVisible = false else -> SwitchPreferenceCompat(requireContext()).apply { @@ -61,8 +59,8 @@ class PrivacySettingsPreferenceFragment : ListSummaryPreferenceFragment() { super.putBoolean(key, value) } } - title = getString(R.string.preferences__message_requests_title) - summary = getString(R.string.preferences__message_requests_summary) + title = getString(R.string.messageRequestsCommunities) + summary = getString(R.string.messageRequestsCommunitiesDescription) }.let(category::addPreference) } } @@ -75,9 +73,9 @@ class PrivacySettingsPreferenceFragment : ListSummaryPreferenceFragment() { if (isEnabled && !areNotificationsEnabled(requireActivity())) { // show a dialog saying that calls won't work properly if you don't have notifications on at a system level showSessionDialog { - title(R.string.CallNotificationBuilder_system_notification_title) - text(R.string.CallNotificationBuilder_system_notification_message) - button(R.string.activity_notification_settings_title) { + title(R.string.sessionNotifications) + text(R.string.callsNotificationsRequired) + button(R.string.sessionNotifications) { Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) .putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) @@ -100,7 +98,7 @@ class PrivacySettingsPreferenceFragment : ListSummaryPreferenceFragment() { } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.preferences_app_protection) + addPreferencesFromResource(R.xml.preferences_privacy) } override fun onResume() { @@ -113,6 +111,8 @@ class PrivacySettingsPreferenceFragment : ListSummaryPreferenceFragment() { requireContext().getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager if (!keyguardManager.isKeyguardSecure) { findPreference(TextSecurePreferences.SCREEN_LOCK)!!.isChecked = false + + // TODO: Ticket SES-2182 raised to investigate & fix app lock / unlock functionality -ACL 2024/06/20 findPreference(TextSecurePreferences.SCREEN_LOCK)!!.isEnabled = false } } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt index 52cb345fab..045b08fcda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt @@ -25,6 +25,7 @@ import org.session.libsignal.utilities.PublicKeyValidation import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.threadDatabase +import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.components.QRScannerScreen @@ -43,7 +44,7 @@ class QRCodeActivity : PassphraseRequiredActionBarActivity() { override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) - supportActionBar!!.title = resources.getString(R.string.activity_qr_code_title) + supportActionBar!!.title = resources.getString(R.string.qrCode) setComposeContent { Tabs( @@ -56,7 +57,7 @@ class QRCodeActivity : PassphraseRequiredActionBarActivity() { private fun onScan(string: String) { if (!PublicKeyValidation.isValid(string)) { - errors.tryEmit(getString(R.string.this_qr_code_does_not_contain_an_account_id)) + errors.tryEmit(getString(R.string.qrNotAccountId)) } else if (!isFinishing) { val recipient = Recipient.from(this, Address.fromSerialized(string), false) start { @@ -68,6 +69,11 @@ class QRCodeActivity : PassphraseRequiredActionBarActivity() { finish() } } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) + } } @OptIn(ExperimentalFoundationApi::class) @@ -101,12 +107,12 @@ fun QrPage(string: String) { string = string, modifier = Modifier .padding(top = LocalDimensions.current.mediumSpacing, bottom = LocalDimensions.current.xsSpacing) - .contentDescription(R.string.AccessibilityId_qr_code), + .contentDescription(R.string.AccessibilityId_qrCode), icon = R.drawable.session ) Text( - text = stringResource(R.string.this_is_your_account_id_other_users_can_scan_it_to_start_a_conversation_with_you), + text = stringResource(R.string.accountIdYoursDescription), color = LocalColors.current.textSecondary, textAlign = TextAlign.Center, style = LocalType.current.small diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index fc52541987..83fca7fe7a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -6,6 +6,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.graphics.BitmapFactory import android.net.Uri import android.os.Bundle import android.os.Parcelable @@ -13,31 +14,49 @@ import android.util.SparseArray import android.view.ActionMode import android.view.Menu import android.view.MenuItem -import android.view.View import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.compose.animation.Crossfade +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.localbroadcastmanager.content.LocalBroadcastManager -import com.canhub.cropper.CropImage 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 @@ -47,51 +66,44 @@ import kotlinx.coroutines.launch import network.loki.messenger.BuildConfig import network.loki.messenger.R import network.loki.messenger.databinding.ActivitySettingsBinding -import network.loki.messenger.libsession_util.util.UserPic -import nl.komponents.kovenant.ui.alwaysUi -import nl.komponents.kovenant.ui.failUi -import nl.komponents.kovenant.ui.successUi -import org.session.libsession.avatars.AvatarHelper -import org.session.libsession.avatars.ProfileContactPhoto -import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.ProfileKeyUtil -import org.session.libsession.utilities.ProfilePictureUtilities import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol +import org.session.libsession.utilities.StringSubstitutionConstants.VERSION_KEY import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.recipients.Recipient -import org.session.libsession.utilities.truncateIdForDisplay import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.Util.SECURE_RANDOM import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.avatar.AvatarSelection -import org.thoughtcrime.securesms.components.ProfilePictureView -import org.thoughtcrime.securesms.dependencies.ConfigFactory +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.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.profiles.ProfileMediaConstraints import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity -import org.thoughtcrime.securesms.showSessionDialog +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.Avatar import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.DialogButtonModel 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.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 +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.ui.theme.dangerButtonColors -import org.thoughtcrime.securesms.util.BitmapDecodingException -import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.NetworkUtils import org.thoughtcrime.securesms.util.push -import org.thoughtcrime.securesms.util.show import java.io.File import javax.inject.Inject @@ -99,46 +111,17 @@ import javax.inject.Inject class SettingsActivity : PassphraseRequiredActionBarActivity() { private val TAG = "SettingsActivity" - @Inject - lateinit var configFactory: ConfigFactory @Inject lateinit var prefs: TextSecurePreferences + private val viewModel: SettingsViewModel by viewModels() + private lateinit var binding: ActivitySettingsBinding private var displayNameEditActionMode: ActionMode? = null set(value) { field = value; handleDisplayNameEditActionModeChanged() } - private var tempFile: File? = null - - private val hexEncodedPublicKey: String get() = TextSecurePreferences.getLocalNumber(this)!! private val onAvatarCropped = registerForActivityResult(CropImageContract()) { result -> - when { - result.isSuccessful -> { - Log.i(TAG, result.getUriFilePath(this).toString()) - - lifecycleScope.launch(Dispatchers.IO) { - try { - val profilePictureToBeUploaded = - BitmapUtil.createScaledBytes( - this@SettingsActivity, - result.getUriFilePath(this@SettingsActivity).toString(), - ProfileMediaConstraints() - ).bitmap - launch(Dispatchers.Main) { - updateProfilePicture(profilePictureToBeUploaded) - } - } catch (e: BitmapDecodingException) { - Log.e(TAG, e) - } - } - } - result is CropImage.CancelledResult -> { - Log.i(TAG, "Cropping image was cancelled by the user") - } - else -> { - Log.e(TAG, "Cropping image failed") - } - } + viewModel.onAvatarPicked(result) } private val onPickImage = registerForActivityResult( @@ -147,12 +130,14 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult val outputFile = Uri.fromFile(File(cacheDir, "cropped")) - val inputFile: Uri? = result.data?.data ?: tempFile?.let(Uri::fromFile) + val inputFile: Uri? = result.data?.data ?: viewModel.getTempFile()?.let(Uri::fromFile) cropImage(inputFile, outputFile) } private val avatarSelection = AvatarSelection(this, onAvatarCropped, onPickImage) + private var showAvatarDialog: Boolean by mutableStateOf(false) + companion object { private const val SCROLL_STATE = "SCROLL_STATE" } @@ -162,37 +147,70 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { super.onCreate(savedInstanceState, isReady) binding = ActivitySettingsBinding.inflate(layoutInflater) setContentView(binding.root) - } - override fun onStart() { - super.onStart() + // set the toolbar icon to a close icon + supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_baseline_close_24) + + // set the compose dialog content + binding.avatarDialog.setThemedContent { + if(showAvatarDialog){ + AvatarDialogContainer( + saveAvatar = viewModel::saveAvatar, + removeAvatar = viewModel::removeAvatar, + startAvatarSelection = ::startAvatarSelection + ) + } + } binding.run { - setupProfilePictureView(profilePictureView) - profilePictureView.setOnClickListener { showEditProfilePictureUI() } + profilePictureView.apply { + publicKey = viewModel.hexEncodedPublicKey + displayName = viewModel.getDisplayName() + update() + } + profilePictureView.setOnClickListener { + binding.avatarDialog.isVisible = true + showAvatarDialog = true + } ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) } - btnGroupNameDisplay.text = getDisplayName() - publicKeyTextView.text = hexEncodedPublicKey + btnGroupNameDisplay.text = viewModel.getDisplayName() + publicKeyTextView.text = viewModel.hexEncodedPublicKey val gitCommitFirstSixChars = BuildConfig.GIT_HASH.take(6) - versionTextView.text = String.format(getString(R.string.version_s), "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE} - $gitCommitFirstSixChars)") + val environment: String = if(BuildConfig.BUILD_TYPE == "release") "" else " - ${prefs.getEnvironment().label}" + val versionDetails = " ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE} - $gitCommitFirstSixChars) $environment" + val versionString = Phrase.from(applicationContext, R.string.updateVersion).put(VERSION_KEY, versionDetails).format() + versionTextView.text = versionString } binding.composeView.setThemedContent { Buttons() } - } - private fun getDisplayName(): String = - TextSecurePreferences.getProfileName(this) ?: truncateIdForDisplay(hexEncodedPublicKey) + lifecycleScope.launch { + viewModel.showLoader.collect { + binding.loader.isVisible = it + } + } - private fun setupProfilePictureView(view: ProfilePictureView) { - view.apply { - publicKey = hexEncodedPublicKey - displayName = getDisplayName() - update() + lifecycleScope.launch { + viewModel.refreshAvatar.collect { + binding.profilePictureView.recycle() + binding.profilePictureView.update() + } } } + override fun onStart() { + super.onStart() + + binding.profilePictureView.update() + } + + override fun finish() { + super.finish() + overridePendingTransition(R.anim.fade_scale_in, R.anim.slide_to_bottom) + } + override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) val scrollBundle = SparseArray() @@ -210,7 +228,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.settings_general, menu) if (BuildConfig.DEBUG) { - menu.findItem(R.id.action_qr_code)?.contentDescription = resources.getString(R.string.AccessibilityId_view_qr_code) + menu.findItem(R.id.action_qr_code)?.contentDescription = resources.getString(R.string.AccessibilityId_qrView) } return true } @@ -276,11 +294,13 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { } else { // if we have a network connection then attempt to update the display name TextSecurePreferences.setProfileName(this, displayName) - val user = configFactory.user + val user = viewModel.getUser() if (user == null) { Log.w(TAG, "Cannot update display name - missing user details from configFactory.") } else { user.setName(displayName) + // sync remote config + ConfigurationMessageUtilities.syncConfigurationIfNeeded(this) binding.btnGroupNameDisplay.text = displayName updateWasSuccessful = true } @@ -294,89 +314,6 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { binding.loader.isVisible = false return updateWasSuccessful } - - // Helper method used by updateProfilePicture and removeProfilePicture to sync it online - private fun syncProfilePicture(profilePicture: ByteArray, onFail: () -> Unit) { - binding.loader.isVisible = true - - // Grab the profile key and kick of the promise to update the profile picture - val encodedProfileKey = ProfileKeyUtil.generateEncodedProfileKey(this) - val updateProfilePicturePromise = ProfilePictureUtilities.upload(profilePicture, encodedProfileKey, this) - - // If the online portion of the update succeeded then update the local state - updateProfilePicturePromise.successUi { - - // When removing the profile picture the supplied ByteArray is empty so we'll clear the local data - if (profilePicture.isEmpty()) { - MessagingModuleConfiguration.shared.storage.clearUserPic() - } - - val userConfig = configFactory.user - AvatarHelper.setAvatar(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!), profilePicture) - prefs.setProfileAvatarId(SECURE_RANDOM.nextInt() ) - ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey) - - // Attempt to grab the details we require to update the profile picture - val url = prefs.getProfilePictureURL() - val profileKey = ProfileKeyUtil.getProfileKey(this) - - // If we have a URL and a profile key then set the user's profile picture - if (!url.isNullOrEmpty() && profileKey.isNotEmpty()) { - userConfig?.setPic(UserPic(url, profileKey)) - } - - if (userConfig != null && userConfig.needsDump()) { - configFactory.persist(userConfig, SnodeAPI.nowWithOffset) - } - - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@SettingsActivity) - - // Update our visuals - binding.profilePictureView.recycle() - binding.profilePictureView.update() - } - - // If the sync failed then inform the user - updateProfilePicturePromise.failUi { onFail() } - - // Finally, remove the loader animation after we've waited for the attempt to succeed or fail - updateProfilePicturePromise.alwaysUi { binding.loader.isVisible = false } - } - - private fun updateProfilePicture(profilePicture: ByteArray) { - - val haveNetworkConnection = NetworkUtils.haveValidNetworkConnection(this@SettingsActivity); - if (!haveNetworkConnection) { - Log.w(TAG, "Cannot update profile picture - no network connection.") - Toast.makeText(this@SettingsActivity, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show() - return - } - - val onFail: () -> Unit = { - Log.e(TAG, "Sync failed when uploading profile picture.") - Toast.makeText(this@SettingsActivity, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show() - } - - syncProfilePicture(profilePicture, onFail) - } - - private fun removeProfilePicture() { - - val haveNetworkConnection = NetworkUtils.haveValidNetworkConnection(this@SettingsActivity); - if (!haveNetworkConnection) { - Log.w(TAG, "Cannot remove profile picture - no network connection.") - Toast.makeText(this@SettingsActivity, R.string.profileDisplayPictureRemoveError, Toast.LENGTH_LONG).show() - return - } - - val onFail: () -> Unit = { - Log.e(TAG, "Sync failed when removing profile picture.") - Toast.makeText(this@SettingsActivity, R.string.profileDisplayPictureRemoveError, Toast.LENGTH_LONG).show() - } - - val emptyProfilePicture = ByteArray(0) - syncProfilePicture(emptyProfilePicture, onFail) - } // endregion // region Interaction @@ -386,39 +323,18 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { */ private fun saveDisplayName(): Boolean { val displayName = binding.displayNameEditText.text.toString().trim() + if (displayName.isEmpty()) { - Toast.makeText(this, R.string.activity_settings_display_name_missing_error, Toast.LENGTH_SHORT).show() + Toast.makeText(this, R.string.displayNameErrorDescription, Toast.LENGTH_SHORT).show() return false } + if (displayName.toByteArray().size > ProfileManagerProtocol.NAME_PADDED_LENGTH) { - Toast.makeText(this, R.string.activity_settings_display_name_too_long_error, Toast.LENGTH_SHORT).show() + Toast.makeText(this, R.string.displayNameErrorDescriptionShorter, Toast.LENGTH_SHORT).show() return false } - return updateDisplayName(displayName) - } - private fun showEditProfilePictureUI() { - showSessionDialog { - title(R.string.activity_settings_set_display_picture) - view(R.layout.dialog_change_avatar) - button(R.string.activity_settings_upload) { startAvatarSelection() } - if (prefs.getProfileAvatarId() != 0) { - button(R.string.activity_settings_remove) { removeProfilePicture() } - } - cancelButton() - }.apply { - val profilePic = findViewById(R.id.profile_picture_view) - ?.also(::setupProfilePictureView) - - val pictureIcon = findViewById(R.id.ic_pictures) - - val recipient = Recipient.from(context, Address.fromSerialized(hexEncodedPublicKey), false) - - val photoSet = (recipient.contactPhoto as ProfileContactPhoto).avatarObject !in setOf("0", "") - - profilePic?.isVisible = photoSet - pictureIcon?.isVisible = !photoSet - } + return updateDisplayName(displayName) } private fun startAvatarSelection() { @@ -426,7 +342,11 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { Permissions.with(this) .request(Manifest.permission.CAMERA) .onAnyResult { - tempFile = avatarSelection.startAvatarSelection( false, true) + avatarSelection.startAvatarSelection( + includeClear = false, + attemptToIncludeCamera = true, + createTempFile = viewModel::createTempFile + ) } .execute() } @@ -442,7 +362,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { private inner class DisplayNameEditActionModeCallback: ActionMode.Callback { override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - mode.title = getString(R.string.activity_settings_display_name_edit_text_hint) + mode.title = getString(R.string.displayNameEnter) mode.menuInflater.inflate(R.menu.menu_apply, menu) this@SettingsActivity.displayNameEditActionMode = mode return true @@ -471,10 +391,12 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { @Composable fun Buttons() { - Column { + Column( + modifier = Modifier + .padding(horizontal = LocalDimensions.current.spacing) + ) { Row( modifier = Modifier - .padding(horizontal = LocalDimensions.current.spacing) .padding(top = LocalDimensions.current.xxsSpacing), horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing), ) { @@ -496,33 +418,191 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { Cell { Column { + // add the debug menu in non release builds + if (BuildConfig.BUILD_TYPE != "release") { + LargeItemButton("Debug Menu", R.drawable.ic_settings) { push() } + Divider() + } + Crossfade(if (hasPaths) R.drawable.ic_status else R.drawable.ic_path_yellow, label = "path") { - LargeItemButtonWithDrawable(R.string.activity_path_title, it) { show() } + LargeItemButtonWithDrawable(R.string.onionRoutingPath, it) { push() } } Divider() - LargeItemButton(R.string.activity_settings_privacy_button_title, R.drawable.ic_privacy_icon) { show() } + + LargeItemButton(R.string.sessionPrivacy, R.drawable.ic_privacy_icon) { push() } Divider() - LargeItemButton(R.string.activity_settings_notifications_button_title, R.drawable.ic_speaker, Modifier.contentDescription(R.string.AccessibilityId_notifications)) { show() } + + LargeItemButton(R.string.sessionNotifications, R.drawable.ic_speaker, Modifier.contentDescription(R.string.AccessibilityId_notifications)) { push() } Divider() - LargeItemButton(R.string.activity_settings_conversations_button_title, R.drawable.ic_conversations, Modifier.contentDescription(R.string.AccessibilityId_conversations)) { show() } + + LargeItemButton(R.string.sessionConversations, R.drawable.ic_conversations, Modifier.contentDescription(R.string.AccessibilityId_sessionConversations)) { push() } Divider() - LargeItemButton(R.string.activity_settings_message_requests_button_title, R.drawable.ic_message_requests, Modifier.contentDescription(R.string.AccessibilityId_message_requests)) { show() } + + LargeItemButton(R.string.sessionMessageRequests, R.drawable.ic_message_requests, Modifier.contentDescription(R.string.AccessibilityId_sessionMessageRequests)) { push() } Divider() - LargeItemButton(R.string.activity_settings_message_appearance_button_title, R.drawable.ic_appearance, Modifier.contentDescription(R.string.AccessibilityId_appearance)) { show() } + + LargeItemButton(R.string.sessionAppearance, R.drawable.ic_appearance, Modifier.contentDescription(R.string.AccessibilityId_sessionAppearance)) { push() } Divider() - LargeItemButton(R.string.activity_settings_invite_button_title, R.drawable.ic_invite_friend, Modifier.contentDescription(R.string.AccessibilityId_invite_friend)) { sendInvitationToUseSession() } + + LargeItemButton( + R.string.sessionInviteAFriend, + R.drawable.ic_invite_friend, + Modifier.contentDescription(R.string.AccessibilityId_sessionInviteAFriend) + ) { sendInvitationToUseSession() } Divider() + + // Only show the recovery password option if the user has not chosen to permanently hide it if (!prefs.getHidePassword()) { - LargeItemButton(R.string.sessionRecoveryPassword, R.drawable.ic_shield_outline, Modifier.contentDescription(R.string.AccessibilityId_recovery_password_menu_item)) { show() } + LargeItemButton( + R.string.sessionRecoveryPassword, + R.drawable.ic_shield_outline, + Modifier.contentDescription(R.string.AccessibilityId_sessionRecoveryPasswordMenuItem) + ) { push() } Divider() } - LargeItemButton(R.string.activity_settings_help_button, R.drawable.ic_help, Modifier.contentDescription(R.string.AccessibilityId_help)) { show() } + + LargeItemButton(R.string.sessionHelp, R.drawable.ic_help, Modifier.contentDescription(R.string.AccessibilityId_help)) { push() } Divider() - LargeItemButton(R.string.activity_settings_clear_all_data_button_title, R.drawable.ic_message_details__trash, Modifier.contentDescription(R.string.AccessibilityId_clear_data), dangerButtonColors()) { ClearAllDataDialog().show(supportFragmentManager, "Clear All Data Dialog") } + + LargeItemButton(R.string.sessionClearData, + R.drawable.ic_delete, + Modifier.contentDescription(R.string.AccessibilityId_sessionClearData), + dangerButtonColors() + ) { ClearAllDataDialog().show(supportFragmentManager, "Clear All Data Dialog") } } } } } + + @Composable + fun AvatarDialogContainer( + startAvatarSelection: ()->Unit, + saveAvatar: ()->Unit, + removeAvatar: ()->Unit + ){ + val state by viewModel.avatarDialogState.collectAsState() + + AvatarDialog( + state = state, + startAvatarSelection = startAvatarSelection, + saveAvatar = saveAvatar, + removeAvatar = removeAvatar + ) + } + + @Composable + fun AvatarDialog( + state: SettingsViewModel.AvatarDialogState, + startAvatarSelection: ()->Unit, + saveAvatar: ()->Unit, + removeAvatar: ()->Unit + ){ + AlertDialog( + onDismissRequest = { + viewModel.onAvatarDialogDismissed() + showAvatarDialog = false + }, + title = stringResource(R.string.profileDisplayPictureSet), + content = { + // custom content that has the displayed images + + // main container that control the overall size and adds the rounded bg + Box( + modifier = Modifier + .padding(top = LocalDimensions.current.smallSpacing) + .size(dimensionResource(id = R.dimen.large_profile_picture_size)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null // the ripple doesn't look nice as a square with the plus icon on top too + ) { + startAvatarSelection() + } + .qaTag(stringResource(R.string.AccessibilityId_avatarPicker)) + .background( + shape = CircleShape, + color = LocalColors.current.backgroundBubbleReceived, + ), + contentAlignment = Alignment.Center + ) { + // the image content will depend on state type + when(val s = state){ + // user avatar + is UserAvatar -> { + Avatar(userAddress = s.address) + } + + // temporary image + is TempAvatar -> { + Image( + modifier = Modifier.size(dimensionResource(id = R.dimen.large_profile_picture_size)) + .clip(shape = CircleShape,), + bitmap = BitmapFactory.decodeByteArray(s.data, 0, s.data.size).asImageBitmap(), + contentDescription = null + ) + } + + // empty state + else -> { + Image( + modifier = Modifier.align(Alignment.Center), + painter = painterResource(id = R.drawable.ic_pictures), + contentDescription = null, + colorFilter = ColorFilter.tint(LocalColors.current.textSecondary) + ) + } + } + + // '+' button that sits atop the custom content + Image( + modifier = Modifier + .size(LocalDimensions.current.spacing) + .background( + shape = CircleShape, + color = LocalColors.current.primary + ) + .padding(LocalDimensions.current.xxxsSpacing) + .align(Alignment.BottomEnd) + , + painter = painterResource(id = R.drawable.ic_plus), + contentDescription = null, + colorFilter = ColorFilter.tint(Color.Black) + ) + } + }, + showCloseButton = true, // display the 'x' button + buttons = listOf( + DialogButtonModel( + text = GetString(R.string.save), + contentDescription = GetString(R.string.AccessibilityId_save), + enabled = state is TempAvatar, + onClick = saveAvatar + ), + 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 + ) + ) + ) + } + + @Preview + @Composable + fun PreviewAvatarDialog( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors + ){ + PreviewTheme(colors) { + AvatarDialog( + state = NoAvatar, + startAvatarSelection = {}, + saveAvatar = {}, + removeAvatar = {} + ) + } + } } private fun Context.hasPaths(): Flow = LocalBroadcastManager.getInstance(this).hasPaths() diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt new file mode 100644 index 0000000000..bedc913109 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt @@ -0,0 +1,241 @@ +package org.thoughtcrime.securesms.preferences + +import android.content.Context +import android.widget.Toast +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.canhub.cropper.CropImage +import com.canhub.cropper.CropImageView +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.UserPic +import org.session.libsession.avatars.AvatarHelper +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.ProfileKeyUtil +import org.session.libsession.utilities.ProfilePictureUtilities +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.truncateIdForDisplay +import org.session.libsignal.utilities.ExternalStorageUtil.getImageDir +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.NoExternalStorageException +import org.session.libsignal.utilities.Util.SECURE_RANDOM +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.TempAvatar +import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints +import org.thoughtcrime.securesms.util.BitmapDecodingException +import org.thoughtcrime.securesms.util.BitmapUtil +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities +import org.thoughtcrime.securesms.util.NetworkUtils +import java.io.File +import java.io.IOException +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val prefs: TextSecurePreferences, + private val configFactory: ConfigFactory +) : ViewModel() { + private val TAG = "SettingsViewModel" + + private var tempFile: File? = null + + val hexEncodedPublicKey: String get() = prefs.getLocalNumber() ?: "" + + private val userAddress = Address.fromSerialized(hexEncodedPublicKey) + + private val _avatarDialogState: MutableStateFlow = MutableStateFlow( + getDefaultAvatarDialogState() + ) + val avatarDialogState: StateFlow + get() = _avatarDialogState + + private val _showLoader: MutableStateFlow = MutableStateFlow(false) + val showLoader: StateFlow + get() = _showLoader + + /** + * Refreshes the avatar on the main settings page + */ + private val _refreshAvatar: MutableSharedFlow = MutableSharedFlow() + val refreshAvatar: SharedFlow + get() = _refreshAvatar.asSharedFlow() + + fun getDisplayName(): String = + prefs.getProfileName() ?: truncateIdForDisplay(hexEncodedPublicKey) + + fun hasAvatar() = prefs.getProfileAvatarId() != 0 + + fun createTempFile(): File? { + try { + tempFile = File.createTempFile("avatar-capture", ".jpg", getImageDir(context)) + } catch (e: IOException) { + Log.e("Cannot reserve a temporary avatar capture file.", e) + } catch (e: NoExternalStorageException) { + Log.e("Cannot reserve a temporary avatar capture file.", e) + } + + return tempFile + } + + fun getTempFile() = tempFile + + fun getUser() = configFactory.user + + fun onAvatarPicked(result: CropImageView.CropResult) { + when { + result.isSuccessful -> { + Log.i(TAG, result.getUriFilePath(context).toString()) + + viewModelScope.launch(Dispatchers.IO) { + try { + val profilePictureToBeUploaded = + BitmapUtil.createScaledBytes( + context, + result.getUriFilePath(context).toString(), + ProfileMediaConstraints() + ).bitmap + + // update dialog with temporary avatar (has not been saved/uploaded yet) + _avatarDialogState.value = + AvatarDialogState.TempAvatar(profilePictureToBeUploaded, hasAvatar()) + } catch (e: BitmapDecodingException) { + Log.e(TAG, e) + } + } + } + + result is CropImage.CancelledResult -> { + Log.i(TAG, "Cropping image was cancelled by the user") + } + + else -> { + Log.e(TAG, "Cropping image failed") + } + } + } + + fun onAvatarDialogDismissed() { + _avatarDialogState.value = getDefaultAvatarDialogState() + } + + fun getDefaultAvatarDialogState() = if (hasAvatar()) AvatarDialogState.UserAvatar(userAddress) + else AvatarDialogState.NoAvatar + + fun saveAvatar() { + val tempAvatar = (avatarDialogState.value as? TempAvatar)?.data + ?: return Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show() + + val haveNetworkConnection = NetworkUtils.haveValidNetworkConnection(context); + if (!haveNetworkConnection) { + Log.w(TAG, "Cannot update profile picture - no network connection.") + Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show() + return + } + + val onFail: () -> Unit = { + Log.e(TAG, "Sync failed when uploading profile picture.") + Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show() + } + + syncProfilePicture(tempAvatar, onFail) + } + + + fun removeAvatar() { + val haveNetworkConnection = NetworkUtils.haveValidNetworkConnection(context); + if (!haveNetworkConnection) { + Log.w(TAG, "Cannot remove profile picture - no network connection.") + Toast.makeText(context, R.string.profileDisplayPictureRemoveError, Toast.LENGTH_LONG).show() + return + } + + val onFail: () -> Unit = { + Log.e(TAG, "Sync failed when removing profile picture.") + Toast.makeText(context, R.string.profileDisplayPictureRemoveError, Toast.LENGTH_LONG).show() + } + + val emptyProfilePicture = ByteArray(0) + syncProfilePicture(emptyProfilePicture, onFail) + } + + // Helper method used by updateProfilePicture and removeProfilePicture to sync it online + private fun syncProfilePicture(profilePicture: ByteArray, onFail: () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + _showLoader.value = true + + try { + // Grab the profile key and kick of the promise to update the profile picture + val encodedProfileKey = ProfileKeyUtil.generateEncodedProfileKey(context) + ProfilePictureUtilities.upload(profilePicture, encodedProfileKey, context) + + // If the online portion of the update succeeded then update the local state + val userConfig = configFactory.user + AvatarHelper.setAvatar( + context, + Address.fromSerialized(TextSecurePreferences.getLocalNumber(context)!!), + profilePicture + ) + + // When removing the profile picture the supplied ByteArray is empty so we'll clear the local data + if (profilePicture.isEmpty()) { + MessagingModuleConfiguration.shared.storage.clearUserPic() + + // update dialog state + _avatarDialogState.value = AvatarDialogState.NoAvatar + } else { + prefs.setProfileAvatarId(SECURE_RANDOM.nextInt()) + ProfileKeyUtil.setEncodedProfileKey(context, encodedProfileKey) + + // Attempt to grab the details we require to update the profile picture + val url = prefs.getProfilePictureURL() + val profileKey = ProfileKeyUtil.getProfileKey(context) + + // If we have a URL and a profile key then set the user's profile picture + if (!url.isNullOrEmpty() && profileKey.isNotEmpty()) { + userConfig?.setPic(UserPic(url, profileKey)) + } + + // update dialog state + _avatarDialogState.value = AvatarDialogState.UserAvatar(userAddress) + } + + if (userConfig != null && userConfig.needsDump()) { + configFactory.persist(userConfig, SnodeAPI.nowWithOffset) + } + + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } catch (e: Exception){ // If the sync failed then inform the user + Log.d(TAG, "Error syncing avatar: $e") + withContext(Dispatchers.Main) { + onFail() + } + } + + // Finally update the main avatar + _refreshAvatar.emit(Unit) + // And remove the loader animation after we've waited for the attempt to succeed or fail + _showLoader.value = false + } + } + + sealed class AvatarDialogState() { + object NoAvatar : AvatarDialogState() + data class UserAvatar(val address: Address) : AvatarDialogState() + data class TempAvatar( + val data: ByteArray, + val hasAvatar: Boolean // true if the user has an avatar set already but is in this temp state because they are trying out a new avatar + ) : AvatarDialogState() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt index 9bfc1dabf2..29ce563c5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt @@ -11,22 +11,23 @@ import android.os.Bundle import android.os.Environment import android.provider.MediaStore import android.webkit.MimeTypeMap -import android.widget.ProgressBar -import android.widget.TextView import android.widget.Toast -import androidx.core.view.isInvisible import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope - +import com.squareup.phrase.Phrase +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.util.Objects +import java.util.concurrent.TimeUnit import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext - import network.loki.messenger.BuildConfig import network.loki.messenger.R - +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsignal.utilities.ExternalStorageUtil import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ApplicationContext @@ -34,21 +35,18 @@ import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.util.FileProviderUtil import org.thoughtcrime.securesms.util.StreamUtil -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.util.Objects -import java.util.concurrent.TimeUnit - - class ShareLogsDialog(private val updateCallback: (Boolean)->Unit): DialogFragment() { private val TAG = "ShareLogsDialog" private var shareJob: Job? = null override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { - title(R.string.dialog_share_logs_title) - text(R.string.dialog_share_logs_explanation) + title(R.string.helpReportABugExportLogs) + val appName = context.getString(R.string.app_name) + val txt = Phrase.from(context, R.string.helpReportABugDescription) + .put(APP_NAME_KEY, appName) + .format().toString() + text(txt) button(R.string.share, dismiss = false) { runShareLogsJob() } cancelButton { updateCallback(false) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/Util.kt index 1271ece02e..a136dab26b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/Util.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/Util.kt @@ -5,21 +5,31 @@ import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.widget.Toast +import com.squareup.phrase.Phrase import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.ACCOUNT_ID_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.DOWNLOAD_URL_KEY import org.session.libsession.utilities.TextSecurePreferences fun Context.sendInvitationToUseSession() { + + val DOWNLOAD_URL = "https://getsession.org/download" + + val txt = Phrase.from(getString(R.string.accountIdShare)) + .put(APP_NAME_KEY, getString(R.string.app_name)) + .put(ACCOUNT_ID_KEY, TextSecurePreferences.getLocalNumber(this@sendInvitationToUseSession)) + .put(DOWNLOAD_URL_KEY, DOWNLOAD_URL) + .format().toString() + Intent().apply { action = Intent.ACTION_SEND putExtra( Intent.EXTRA_TEXT, - getString( - R.string.accountIdShare, - TextSecurePreferences.getLocalNumber(this@sendInvitationToUseSession) - ) + txt ) type = "text/plain" - }.let { Intent.createChooser(it, getString(R.string.activity_settings_invite_button_title)) } + }.let { Intent.createChooser(it, getString(R.string.sessionInviteAFriend)) } .let(::startActivity) } @@ -27,5 +37,5 @@ fun Context.copyPublicKey() { val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText("Account ID", TextSecurePreferences.getLocalNumber(this)) clipboard.setPrimaryClip(clip) - Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() + Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt index 34547c999e..ddf28e9211 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt @@ -116,7 +116,7 @@ class AppearanceSettingsActivity: PassphraseRequiredActionBarActivity(), View.On setContentView(binding.root) savedInstanceState?.getSparseParcelableArray(SCROLL_PARCEL) ?.let(binding.scrollView::restoreHierarchyState) - supportActionBar!!.title = getString(R.string.activity_settings_message_appearance_button_title) + supportActionBar!!.title = getString(R.string.sessionAppearance) with (binding) { // accent toggles accentContainer.children.forEach { view -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/DropDownPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/DropDownPreference.kt new file mode 100644 index 0000000000..73eeb7d194 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/DropDownPreference.kt @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.preferences.widgets + +import android.content.Context +import android.util.AttributeSet +import android.widget.TextView +import androidx.preference.ListPreference +import androidx.preference.PreferenceViewHolder +import network.loki.messenger.R + +class DropDownPreference : ListPreference { + private var dropDownLabel: TextView? = null + private var clickListener: OnPreferenceClickListener? = null + private var onViewReady: (()->Unit)? = null + + constructor( + context: Context?, + attrs: AttributeSet?, + defStyleAttr: Int, + defStyleRes: Int + ) : super( + context!!, attrs, defStyleAttr, defStyleRes + ) { + initialize() + } + + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( + context!!, attrs, defStyleAttr + ) { + initialize() + } + + constructor(context: Context?, attrs: AttributeSet?) : super( + context!!, attrs + ) { + initialize() + } + + constructor(context: Context?) : super(context!!) { + initialize() + } + + private fun initialize() { + widgetLayoutResource = R.layout.preference_drop_down + } + + override fun onBindViewHolder(view: PreferenceViewHolder) { + super.onBindViewHolder(view) + this.dropDownLabel = view.findViewById(R.id.drop_down_label) as TextView + + onViewReady?.invoke() + } + + override fun setOnPreferenceClickListener(onPreferenceClickListener: OnPreferenceClickListener?) { + this.clickListener = onPreferenceClickListener + } + + fun setOnViewReady(init: (()->Unit)){ + this.onViewReady = init + } + + override fun onClick() { + if (clickListener == null || !clickListener!!.onPreferenceClick(this)) { + super.onClick() + } + } + + fun setDropDownLabel(label: CharSequence?){ + dropDownLabel?.text = label + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/SignalListPreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/SignalListPreference.java deleted file mode 100644 index 6cafc6035f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/SignalListPreference.java +++ /dev/null @@ -1,73 +0,0 @@ -package org.thoughtcrime.securesms.preferences.widgets; - - -import android.content.Context; -import android.util.AttributeSet; -import android.widget.TextView; - -import androidx.preference.ListPreference; -import androidx.preference.PreferenceViewHolder; - -import network.loki.messenger.R; - -public class SignalListPreference extends ListPreference { - - private TextView rightSummary; - private CharSequence summary; - private OnPreferenceClickListener clickListener; - - public SignalListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - initialize(); - } - - public SignalListPreference(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - initialize(); - } - - public SignalListPreference(Context context, AttributeSet attrs) { - super(context, attrs); - initialize(); - } - - public SignalListPreference(Context context) { - super(context); - initialize(); - } - - private void initialize() { - setWidgetLayoutResource(R.layout.preference_right_summary_widget); - } - - @Override - public void onBindViewHolder(PreferenceViewHolder view) { - super.onBindViewHolder(view); - - this.rightSummary = (TextView)view.findViewById(R.id.right_summary); - setSummary(this.summary); - } - - @Override - public void setSummary(CharSequence summary) { - super.setSummary(null); - - this.summary = summary; - - if (this.rightSummary != null) { - this.rightSummary.setText(summary); - } - } - - @Override - public void setOnPreferenceClickListener(OnPreferenceClickListener onPreferenceClickListener) { - this.clickListener = onPreferenceClickListener; - } - - @Override - protected void onClick() { - if (clickListener == null || !clickListener.onPreferenceClick(this)) { - super.onClick(); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/SignalPreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/SignalPreference.java deleted file mode 100644 index 90635d6d5b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/SignalPreference.java +++ /dev/null @@ -1,60 +0,0 @@ -package org.thoughtcrime.securesms.preferences.widgets; - - -import android.content.Context; -import androidx.preference.Preference; -import androidx.preference.PreferenceViewHolder; -import android.util.AttributeSet; -import android.widget.TextView; - -import network.loki.messenger.R; - -public class SignalPreference extends Preference { - - private TextView rightSummary; - private CharSequence summary; - - public SignalPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - initialize(); - } - - public SignalPreference(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - initialize(); - } - - public SignalPreference(Context context, AttributeSet attrs) { - super(context, attrs); - initialize(); - } - - public SignalPreference(Context context) { - super(context); - initialize(); - } - - private void initialize() { - setWidgetLayoutResource(R.layout.preference_right_summary_widget); - } - - @Override - public void onBindViewHolder(PreferenceViewHolder view) { - super.onBindViewHolder(view); - - this.rightSummary = (TextView)view.findViewById(R.id.right_summary); - setSummary(this.summary); - } - - @Override - public void setSummary(CharSequence summary) { - super.setSummary(null); - - this.summary = summary; - - if (this.rightSummary != null) { - this.rightSummary.setText(summary); - } - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/qr/ScanListener.java b/app/src/main/java/org/thoughtcrime/securesms/qr/ScanListener.java deleted file mode 100644 index 83faae9907..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/qr/ScanListener.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.thoughtcrime.securesms.qr; - -public interface ScanListener { - public void onQrDataFound(String data); -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/qr/ScanningThread.java b/app/src/main/java/org/thoughtcrime/securesms/qr/ScanningThread.java deleted file mode 100644 index 4e86941c5b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/qr/ScanningThread.java +++ /dev/null @@ -1,125 +0,0 @@ -package org.thoughtcrime.securesms.qr; - -import android.content.res.Configuration; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.zxing.BinaryBitmap; -import com.google.zxing.ChecksumException; -import com.google.zxing.DecodeHintType; -import com.google.zxing.FormatException; -import com.google.zxing.NotFoundException; -import com.google.zxing.PlanarYUVLuminanceSource; -import com.google.zxing.Result; -import com.google.zxing.common.HybridBinarizer; -import com.google.zxing.qrcode.QRCodeReader; - -import org.thoughtcrime.securesms.components.camera.CameraView; -import org.thoughtcrime.securesms.components.camera.CameraView.PreviewFrame; -import org.session.libsignal.utilities.Log; -import org.session.libsession.utilities.Util; - -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; - -public class ScanningThread extends Thread implements CameraView.PreviewCallback { - - private static final String TAG = ScanningThread.class.getSimpleName(); - - private final QRCodeReader reader = new QRCodeReader(); - private final AtomicReference scanListener = new AtomicReference<>(); - private final Map hints = new HashMap<>(); - - private boolean scanning = true; - private PreviewFrame previewFrame; - - public void setCharacterSet(String characterSet) { - hints.put(DecodeHintType.CHARACTER_SET, characterSet); - } - - public void setScanListener(ScanListener scanListener) { - this.scanListener.set(scanListener); - } - - @Override - public void onPreviewFrame(@NonNull PreviewFrame previewFrame) { - try { - synchronized (this) { - this.previewFrame = previewFrame; - this.notify(); - } - } catch (RuntimeException e) { - Log.w(TAG, e); - } - } - - - @Override - public void run() { - while (true) { - PreviewFrame ourFrame; - - synchronized (this) { - while (scanning && previewFrame == null) { - Util.wait(this, 0); - } - - if (!scanning) return; - else ourFrame = previewFrame; - - previewFrame = null; - } - - String data = getScannedData(ourFrame.getData(), ourFrame.getWidth(), ourFrame.getHeight(), ourFrame.getOrientation()); - ScanListener scanListener = this.scanListener.get(); - - if (data != null && scanListener != null) { - scanListener.onQrDataFound(data); - return; - } - } - } - - public void stopScanning() { - synchronized (this) { - scanning = false; - notify(); - } - } - - private @Nullable String getScannedData(byte[] data, int width, int height, int orientation) { - try { - if (orientation == Configuration.ORIENTATION_PORTRAIT) { - byte[] rotatedData = new byte[data.length]; - - for (int y = 0; y < height; y++) { - for (int x = 0; x < width; x++) { - rotatedData[x * height + height - y - 1] = data[x + y * width]; - } - } - - int tmp = width; - width = height; - height = tmp; - data = rotatedData; - } - - PlanarYUVLuminanceSource source = new PlanarYUVLuminanceSource(data, width, height, - 0, 0, width, height, - false); - - BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source)); - Result result = reader.decode(bitmap, hints); - - if (result != null) return result.getText(); - - } catch (NullPointerException | ChecksumException | FormatException | IndexOutOfBoundsException e) { - Log.w(TAG, e); - } catch (NotFoundException e) { - // Thanks ZXing... - } - - return null; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java index 79717eabb1..37bd7e4695 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java @@ -1,24 +1,20 @@ package org.thoughtcrime.securesms.reactions; +import static org.session.libsession.utilities.IdUtilKt.truncateIdForDisplay; + import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; - import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; - -import org.session.libsession.messaging.utilities.AccountId; -import org.thoughtcrime.securesms.components.ProfilePictureView; -import org.thoughtcrime.securesms.components.emoji.EmojiImageView; -import org.thoughtcrime.securesms.database.model.MessageId; -import com.bumptech.glide.Glide; - import java.util.Collections; import java.util.List; - import network.loki.messenger.R; +import org.thoughtcrime.securesms.components.ProfilePictureView; +import org.thoughtcrime.securesms.components.emoji.EmojiImageView; +import org.thoughtcrime.securesms.database.model.MessageId; final class ReactionRecipientsAdapter extends RecyclerView.Adapter { @@ -129,7 +125,7 @@ private void bind(@NonNull final EmojiCount emoji, final MessageId messageId, bo EmojiImageView emojiView = itemView.findViewById(R.id.header_view_emoji); emojiView.setImageEmoji(emoji.getDisplayEmoji()); TextView count = itemView.findViewById(R.id.header_view_emoji_count); - count.setText(String.format(" · %s", emoji.getCount())); + count.setText(String.format(" • %s", emoji.getCount())); } } @@ -157,12 +153,12 @@ void bind(@NonNull ReactionDetails reaction) { this.avatar.update(reaction.getSender()); if (reaction.getSender().isLocalNumber()) { - this.recipient.setText(R.string.ReactionsRecipientAdapter_you); + this.recipient.setText(R.string.you); this.remove.setVisibility(View.VISIBLE); } else { String name = reaction.getSender().getName(); - if (name != null && new AccountId(name).getPrefix() != null) { - name = name.substring(0, 4) + "..." + name.substring(name.length() - 4); + if (name == null){ + name = truncateIdForDisplay(reaction.getSender().getAddress().serialize()); } this.recipient.setText(name); this.remove.setVisibility(View.GONE); @@ -184,7 +180,12 @@ public FooterViewHolder(@NonNull View itemView) { private void bind(@NonNull final EmojiCount emoji) { if (emoji.getCount() > 5) { TextView count = itemView.findViewById(R.id.footer_view_emoji_count); - count.setText(itemView.getContext().getResources().getQuantityString(R.plurals.ReactionsRecipientAdapter_other_reactors, emoji.getCount() - 5, emoji.getCount() - 5, emoji.getBaseEmoji())); + + // We display the first 5 people to react w/ a given emoji so we'll subtract that to get the 'others' count + int othersCount = emoji.getCount() - 5; + String s = itemView.getResources().getQuantityString(R.plurals.emojiReactsCountOthers, othersCount, othersCount, emoji.getBaseEmoji()); + count.setText(s); + itemView.setVisibility(View.VISIBLE); } else { itemView.setVisibility(View.GONE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionViewPagerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionViewPagerAdapter.java index 330c1552ce..cd35609f6a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionViewPagerAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionViewPagerAdapter.java @@ -99,7 +99,7 @@ public ViewHolder(Listener callback, @NonNull View itemView) { recycler.setLayoutParams(params); DividerItemDecoration decoration = new DividerItemDecoration(itemView.getContext(), LinearLayoutManager.VERTICAL); - decoration.setDrawable(ContextUtil.requireDrawable(itemView.getContext(), R.drawable.vertical_divider)); + decoration.setDrawable(ContextUtil.requireDrawable(itemView.getContext(), R.drawable.horizontal_divider)); recycler.addItemDecoration(decoration); recycler.setAdapter(adapter); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsDialogFragment.java index 0d4afb5911..d68d467f27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsDialogFragment.java @@ -21,6 +21,7 @@ import com.google.android.material.tabs.TabLayoutMediator; import org.session.libsession.utilities.ThemeUtil; +import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.components.emoji.EmojiImageView; import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.util.LifecycleDisposable; @@ -35,6 +36,7 @@ public final class ReactionsDialogFragment extends BottomSheetDialogFragment imp private static final String ARGS_MESSAGE_ID = "reactions.args.message.id"; private static final String ARGS_IS_MMS = "reactions.args.is.mms"; private static final String ARGS_IS_MODERATOR = "reactions.args.is.moderator"; + private static final String ARGS_EMOJI = "reactions.args.emoji"; private ViewPager2 recipientPagerView; private ReactionViewPagerAdapter recipientsAdapter; @@ -42,13 +44,14 @@ public final class ReactionsDialogFragment extends BottomSheetDialogFragment imp private final LifecycleDisposable disposables = new LifecycleDisposable(); - public static DialogFragment create(MessageId messageId, boolean isUserModerator) { + public static DialogFragment create(MessageId messageId, boolean isUserModerator, @Nullable String emoji) { Bundle args = new Bundle(); DialogFragment fragment = new ReactionsDialogFragment(); args.putLong(ARGS_MESSAGE_ID, messageId.getId()); args.putBoolean(ARGS_IS_MMS, messageId.isMms()); args.putBoolean(ARGS_IS_MODERATOR, isUserModerator); + args.putString(ARGS_EMOJI, emoji); fragment.setArguments(args); @@ -68,7 +71,6 @@ public void onAttach(@NonNull Context context) { @Override public void onCreate(@Nullable Bundle savedInstanceState) { -// setStyle(DialogFragment.STYLE_NORMAL, R.style.Theme_Session_BottomSheet); super.onCreate(savedInstanceState); } @@ -101,8 +103,10 @@ private void setUpTabMediator(@Nullable Bundle savedInstanceState) { ViewCompat.setOnApplyWindowInsetsListener(container, (v, insets) -> insets.consumeSystemWindowInsets()); - TabLayoutMediator mediator = new TabLayoutMediator(emojiTabs, recipientPagerView, (tab, position) -> { - tab.setCustomView(R.layout.reactions_pill); + TabLayoutMediator mediator = new TabLayoutMediator( + emojiTabs, recipientPagerView, true, false, + (tab, position) -> { + tab.setCustomView(R.layout.reactions_pill_large); View customView = Objects.requireNonNull(tab.getCustomView()); EmojiImageView emoji = customView.findViewById(R.id.reactions_pill_emoji); @@ -118,17 +122,13 @@ private void setUpTabMediator(@Nullable Bundle savedInstanceState) { @Override public void onTabSelected(TabLayout.Tab tab) { View customView = tab.getCustomView(); - TextView text = customView.findViewById(R.id.reactions_pill_count); customView.setBackground(ContextCompat.getDrawable(requireContext(), R.drawable.reaction_pill_background_selected)); - text.setTextColor(ThemeUtil.getThemedColor(requireContext(), R.attr.reactionsPillSelectedTextColor)); } @Override public void onTabUnselected(TabLayout.Tab tab) { View customView = tab.getCustomView(); - TextView text = customView.findViewById(R.id.reactions_pill_count); customView.setBackground(ContextCompat.getDrawable(requireContext(), R.drawable.reaction_pill_dialog_background)); - text.setTextColor(ThemeUtil.getThemedColor(requireContext(), R.attr.reactionsPillNormalTextColor)); } @Override public void onTabReselected(TabLayout.Tab tab) {} @@ -139,21 +139,6 @@ public void onTabReselected(TabLayout.Tab tab) {} private void setUpRecipientsRecyclerView() { recipientsAdapter = new ReactionViewPagerAdapter(this); - - recipientPagerView.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { - @Override - public void onPageSelected(int position) { - recipientPagerView.post(() -> recipientsAdapter.enableNestedScrollingForPosition(position)); - } - - @Override - public void onPageScrollStateChanged(int state) { - if (state == ViewPager2.SCROLL_STATE_IDLE) { - recipientPagerView.requestLayout(); - } - } - }); - recipientPagerView.setAdapter(recipientsAdapter); } @@ -169,6 +154,18 @@ private void setUpViewModel(@NonNull MessageId messageId) { } recipientsAdapter.submitList(emojiCounts); + + // select the tab based on which emoji the user long pressed on + TabLayout emojiTabs = requireDialog().findViewById(R.id.emoji_tabs); + String emoji = requireArguments().getString(ARGS_EMOJI); + int tabIndex = 0; + for (int i = 0; i < emojiCounts.size(); i++) { + if(emojiCounts.get(i).getBaseEmoji().equals(emoji)){ + tabIndex = i; + break; + } + } + emojiTabs.selectTab(emojiTabs.getTabAt(tabIndex)); })); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiRepository.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiRepository.java index e17634a7a7..bab8591cb6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiRepository.java @@ -31,7 +31,6 @@ final class ReactWithAnyEmojiRepository { this.emojiPages = new LinkedList<>(); emojiPages.addAll(Stream.of(EmojiSource.getLatest().getDisplayPages()) - .filterNot(p -> p.getIconAttr() == EmojiCategory.EMOTICONS.getIcon()) .map(page -> new ReactWithAnyEmojiPage(Collections.singletonList(new ReactWithAnyEmojiPageBlock(EmojiCategory.getCategoryLabel(page.getIconAttr()), page)))) .toList()); } @@ -39,7 +38,7 @@ final class ReactWithAnyEmojiRepository { List getEmojiPageModels() { List pages = new LinkedList<>(); - pages.add(new ReactWithAnyEmojiPage(Collections.singletonList(new ReactWithAnyEmojiPageBlock(R.string.ReactWithAnyEmojiBottomSheetDialogFragment__recently_used, recentEmojiPageModel)))); + pages.add(new ReactWithAnyEmojiPage(Collections.singletonList(new ReactWithAnyEmojiPageBlock(R.string.emojiCategoryRecentlyUsed, recentEmojiPageModel)))); pages.addAll(emojiPages); return pages; diff --git a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt index 5f59943432..4bc2724d6d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.recoverypassword import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height @@ -25,19 +26,19 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import network.loki.messenger.R -import org.thoughtcrime.securesms.ui.CellWithPaddingAndMargin -import org.thoughtcrime.securesms.ui.theme.LocalDimensions -import org.thoughtcrime.securesms.ui.theme.PreviewTheme -import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.SessionShieldIcon -import org.thoughtcrime.securesms.ui.theme.ThemeColors -import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.components.QrImage import org.thoughtcrime.securesms.ui.components.SlimOutlineButton import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton import org.thoughtcrime.securesms.ui.components.border import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.ui.theme.monospace @Composable @@ -50,9 +51,10 @@ internal fun RecoveryPasswordScreen( Column( verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing), modifier = Modifier - .contentDescription(R.string.AccessibilityId_recovery_password) + .contentDescription(R.string.AccessibilityId_sessionRecoveryPassword) .verticalScroll(rememberScrollState()) .padding(bottom = LocalDimensions.current.smallSpacing) + .padding(horizontal = LocalDimensions.current.spacing) ) { RecoveryPasswordCell(mnemonic, seed, copyMnemonic) HideRecoveryPasswordCell(onHide) @@ -69,8 +71,10 @@ private fun RecoveryPasswordCell( mutableStateOf(false) } - CellWithPaddingAndMargin { - Column { + Cell { + Column( + modifier = Modifier.padding(LocalDimensions.current.smallSpacing) + ) { Row { Text( stringResource(R.string.sessionRecoveryPassword), @@ -99,7 +103,7 @@ private fun RecoveryPasswordCell( seed, modifier = Modifier .padding(vertical = LocalDimensions.current.spacing) - .contentDescription(R.string.AccessibilityId_qr_code), + .contentDescription(R.string.AccessibilityId_qrCode), contentPadding = 10.dp, icon = R.drawable.session_shield ) @@ -136,7 +140,7 @@ private fun RecoveryPassword(mnemonic: String) { Text( mnemonic, modifier = Modifier - .contentDescription(R.string.AccessibilityId_recovery_password_container) + .contentDescription(R.string.AccessibilityId_sessionRecoveryPasswordContainer) .padding(vertical = LocalDimensions.current.spacing) .border() .padding(LocalDimensions.current.spacing), @@ -148,8 +152,10 @@ private fun RecoveryPassword(mnemonic: String) { @Composable private fun HideRecoveryPasswordCell(onHide: () -> Unit = {}) { - CellWithPaddingAndMargin { - Row { + Cell { + Row( + modifier = Modifier.padding(LocalDimensions.current.smallSpacing) + ) { Column( Modifier.weight(1f) ) { @@ -168,7 +174,7 @@ private fun HideRecoveryPasswordCell(onHide: () -> Unit = {}) { modifier = Modifier .wrapContentWidth() .align(Alignment.CenterVertically) - .contentDescription(R.string.AccessibilityId_hide_recovery_password_button), + .contentDescription(R.string.AccessibilityId_recoveryPasswordHideRecoveryPassword), color = LocalColors.current.danger, onClick = onHide ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordActivity.kt index a46b4a1d63..543938757c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordActivity.kt @@ -33,8 +33,8 @@ class RecoveryPasswordActivity : BaseActionBarActivity() { private fun onHide() { showSessionDialog { title(R.string.recoveryPasswordHidePermanently) - htmlText(R.string.recoveryPasswordHidePermanentlyDescription1) - dangerButton(R.string.continue_2, R.string.AccessibilityId_continue) { onHideConfirm() } + text(R.string.recoveryPasswordHidePermanentlyDescription1) + dangerButton(R.string.theContinue, R.string.AccessibilityId_theContinue) { onHideConfirm() } cancelButton() } } @@ -46,7 +46,7 @@ class RecoveryPasswordActivity : BaseActionBarActivity() { cancelButton() dangerButton( R.string.yes, - contentDescription = R.string.AccessibilityId_confirm_button + contentDescription = R.string.AccessibilityId_recoveryPasswordHidePermanentlyConfirm ) { viewModel.permanentlyHidePassword() finish() diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 7f10b1eb20..b472c2c0c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -333,6 +333,8 @@ class DefaultConversationRepository @Inject constructor( MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = recipient.isLocalNumber) .success { threadDb.setHasSent(threadId, true) + // add a control message for our user + storage.insertMessageRequestResponseFromYou(threadId) continuation.resume(Result.success(Unit)) }.fail { error -> continuation.resume(Result.failure(error)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java b/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java index cf250665ef..9d95ac5d49 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java @@ -16,6 +16,8 @@ */ package org.thoughtcrime.securesms.service; +import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; + import android.annotation.SuppressLint; import android.app.AlarmManager; import android.app.Notification; @@ -31,12 +33,13 @@ import android.os.Build; import android.os.IBinder; import android.os.SystemClock; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; import androidx.core.app.ServiceCompat; - +import com.squareup.phrase.Phrase; +import java.util.concurrent.TimeUnit; +import network.loki.messenger.R; import org.session.libsession.utilities.ServiceUtil; import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsignal.utilities.Log; @@ -46,16 +49,12 @@ import org.thoughtcrime.securesms.home.HomeActivity; import org.thoughtcrime.securesms.notifications.NotificationChannels; -import java.util.concurrent.TimeUnit; - -import network.loki.messenger.R; - /** * Small service that stays running to keep a key cached in memory. * * @author Moxie Marlinspike */ -//TODO AC: This service does only serve one purpose now - to track the screen lock state and handle the timer. +// TODO: This service does only serve one purpose now - to track the screen lock state and handle the timer. // We need to refactor it and cleanup from all the old Signal code. public class KeyCachingService extends Service { @@ -71,7 +70,7 @@ public class KeyCachingService extends Service { private final IBinder binder = new KeySetBinder(); - // AC: This is a temporal drop off replacement for the refactoring time being. + // This is a temporal drop off replacement for the refactoring time being. // This field only indicates if the app was unlocked or not (null means locked). private static Object masterSecret = null; @@ -243,13 +242,19 @@ private void foregroundService() { Log.i(TAG, "foregrounding KCS"); NotificationCompat.Builder builder = new NotificationCompat.Builder(this, NotificationChannels.LOCKED_STATUS); - builder.setContentTitle(getString(R.string.KeyCachingService_passphrase_cached)); - builder.setContentText(getString(R.string.KeyCachingService_signal_passphrase_cached)); + // Replace app name in title string + Context c = getApplicationContext(); + String unlockedTxt = Phrase.from(c, R.string.lockAppUnlocked) + .put(APP_NAME_KEY, c.getString(R.string.app_name)) + .format().toString(); + builder.setContentTitle(unlockedTxt); + + builder.setContentText(getString(R.string.lockAppUnlock)); builder.setSmallIcon(R.drawable.icon_cached); builder.setWhen(0); builder.setPriority(Notification.PRIORITY_MIN); - builder.addAction(R.drawable.ic_menu_lock_dark, getString(R.string.KeyCachingService_lock), buildLockIntent()); + builder.addAction(R.drawable.ic_menu_lock_dark, getString(R.string.lockApp), buildLockIntent()); builder.setContentIntent(buildLaunchIntent()); stopForeground(true); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/QuickResponseService.java b/app/src/main/java/org/thoughtcrime/securesms/service/QuickResponseService.java index a56bc8c0de..22f0addd71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/QuickResponseService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/QuickResponseService.java @@ -1,23 +1,24 @@ package org.thoughtcrime.securesms.service; +import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; + import android.app.IntentService; +import android.content.Context; import android.content.Intent; import android.telephony.TelephonyManager; import android.text.TextUtils; import android.widget.Toast; - +import com.squareup.phrase.Phrase; +import java.net.URISyntaxException; +import java.net.URLDecoder; import network.loki.messenger.R; - import org.session.libsession.messaging.messages.visible.VisibleMessage; +import org.session.libsession.messaging.sending_receiving.MessageSender; import org.session.libsession.snode.SnodeAPI; import org.session.libsession.utilities.Address; import org.session.libsignal.utilities.Log; -import org.session.libsession.messaging.sending_receiving.MessageSender; import org.thoughtcrime.securesms.util.Rfc5724Uri; -import java.net.URISyntaxException; -import java.net.URLDecoder; - public class QuickResponseService extends IntentService { private static final String TAG = QuickResponseService.class.getSimpleName(); @@ -28,14 +29,29 @@ public QuickResponseService() { @Override protected void onHandleIntent(Intent intent) { - if (!TelephonyManager.ACTION_RESPOND_VIA_MESSAGE.equals(intent.getAction())) { + if (intent == null) { + Log.w(TAG, "Got null intent from QuickResponseService"); + return; + } + + String actionString = intent.getAction(); + if (actionString == null) { + Log.w(TAG, "Got null action from QuickResponseService intent"); + return; + } + + if (!TelephonyManager.ACTION_RESPOND_VIA_MESSAGE.equals(actionString)) { Log.w(TAG, "Received unknown intent: " + intent.getAction()); return; } if (KeyCachingService.isLocked(this)) { Log.w(TAG, "Got quick response request when locked..."); - Toast.makeText(this, R.string.QuickResponseService_quick_response_unavailable_when_Signal_is_locked, Toast.LENGTH_LONG).show(); + Context c = getApplicationContext(); + String txt = Phrase.from(c, R.string.lockAppQuickResponse) + .put(APP_NAME_KEY, c.getString(R.string.app_name)) + .format().toString(); + Toast.makeText(this, txt, Toast.LENGTH_LONG).show(); return; } @@ -55,7 +71,7 @@ protected void onHandleIntent(Intent intent) { MessageSender.send(message, Address.fromExternal(this, number)); } } catch (URISyntaxException e) { - Toast.makeText(this, R.string.QuickResponseService_problem_sending_message, Toast.LENGTH_LONG).show(); + Toast.makeText(this, R.string.errorUnknown, Toast.LENGTH_LONG).show(); Log.w(TAG, e); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java index eea6ba00f8..85d00ec8e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.service; +import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; import android.app.DownloadManager; import android.app.Notification; @@ -9,10 +10,14 @@ import android.content.Intent; import android.database.Cursor; import android.net.Uri; - import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; - +import com.squareup.phrase.Phrase; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.security.MessageDigest; +import network.loki.messenger.R; import org.session.libsession.utilities.FileUtils; import org.session.libsession.utilities.ServiceUtil; import org.session.libsession.utilities.TextSecurePreferences; @@ -21,13 +26,6 @@ import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.util.FileProviderUtil; -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.security.MessageDigest; - -import network.loki.messenger.R; - public class UpdateApkReadyListener extends BroadcastReceiver { private static final String TAG = UpdateApkReadyListener.class.getSimpleName(); @@ -64,10 +62,17 @@ private void displayInstallNotification(Context context, Uri uri) { PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE); + CharSequence title = Phrase.from(context, R.string.updateSession) + .put(APP_NAME_KEY, context.getString(R.string.app_name)).format(); + + CharSequence txt = Phrase.from(context, R.string.updateNewVersion) + .put(APP_NAME_KEY, context.getString(R.string.app_name)).format(); + + Notification notification = new NotificationCompat.Builder(context, NotificationChannels.APP_UPDATES) .setOngoing(true) - .setContentTitle(context.getString(R.string.UpdateApkReadyListener_Signal_update)) - .setContentText(context.getString(R.string.UpdateApkReadyListener_a_new_version_of_signal_is_available_tap_to_update)) + .setContentTitle(title) + .setContentText(txt) .setSmallIcon(R.drawable.ic_notification) .setColor(context.getResources().getColor(R.color.textsecure_primary)) .setPriority(NotificationCompat.PRIORITY_HIGH) @@ -118,4 +123,4 @@ private boolean isMatchingDigest(Context context, long downloadId, String theirE return false; } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt index 5894e1072a..c4770f2110 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt @@ -1,15 +1,20 @@ package org.thoughtcrime.securesms.ui +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -23,12 +28,24 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.max +import androidx.compose.ui.unit.times +import com.squareup.phrase.Phrase import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.URL_KEY +import org.thoughtcrime.securesms.copyURLToClipboard +import org.thoughtcrime.securesms.openUrl +import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator +import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -41,32 +58,50 @@ class DialogButtonModel( val contentDescription: GetString = text, val color: Color = Color.Unspecified, val dismissOnClick: Boolean = true, + val enabled: Boolean = true, val onClick: () -> Unit = {}, ) -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AlertDialog( onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, title: String? = null, text: String? = null, + maxLines: Int? = null, + buttons: List? = null, + showCloseButton: Boolean = false, + content: @Composable () -> Unit = {} +) { + AlertDialog( + onDismissRequest = onDismissRequest, + modifier = modifier, + title = if(title != null) AnnotatedString(title) else null, + text = if(text != null) AnnotatedString(text) else null, + maxLines = maxLines, + buttons = buttons, + showCloseButton = showCloseButton, + content = content + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AlertDialog( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + title: AnnotatedString? = null, + text: AnnotatedString? = null, + maxLines: Int? = null, buttons: List? = null, showCloseButton: Boolean = false, content: @Composable () -> Unit = {} ) { BasicAlertDialog( + modifier = modifier, onDismissRequest = onDismissRequest, content = { - Box( - modifier = Modifier.background( - color = LocalColors.current.backgroundSecondary, - shape = MaterialTheme.shapes.small) - .border( - width = 1.dp, - color = LocalColors.current.borders, - shape = MaterialTheme.shapes.small) - - ) { + DialogBg { // only show the 'x' button is required if (showCloseButton) { IconButton( @@ -91,18 +126,32 @@ fun AlertDialog( ) { title?.let { Text( - it, + text = it, textAlign = TextAlign.Center, style = LocalType.current.h7, modifier = Modifier.padding(bottom = LocalDimensions.current.xxsSpacing) ) } text?.let { + val textStyle = LocalType.current.large + var textModifier = Modifier.padding(bottom = LocalDimensions.current.xxsSpacing) + + // if we have a maxLines, make the text scrollable + if(maxLines != null) { + val textHeight = with(LocalDensity.current) { + textStyle.lineHeight.toDp() + } * maxLines + + textModifier = textModifier + .height(textHeight) + .verticalScroll(rememberScrollState()) + } + Text( - it, + text = it, textAlign = TextAlign.Center, - style = LocalType.current.large, - modifier = Modifier.padding(bottom = LocalDimensions.current.xxsSpacing) + style = textStyle, + modifier = textModifier ) } content() @@ -116,7 +165,8 @@ fun AlertDialog( .fillMaxHeight() .contentDescription(it.contentDescription()) .weight(1f), - color = it.color + color = it.color, + enabled = it.enabled ) { it.onClick() if (it.dismissOnClick) onDismissRequest() @@ -130,21 +180,68 @@ fun AlertDialog( ) } +@Composable +fun OpenURLAlertDialog( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + url: String, + content: @Composable () -> Unit = {} +) { + val context = LocalContext.current + val unformattedText = Phrase.from(context.getText(R.string.urlOpenDescription)) + .put(URL_KEY, url).format() + + + AlertDialog( + modifier = modifier, + title = AnnotatedString(stringResource(R.string.urlOpen)), + text = annotatedStringResource(text = unformattedText), + maxLines = 5, + showCloseButton = true, // display the 'x' button + buttons = listOf( + DialogButtonModel( + text = GetString(R.string.open), + contentDescription = GetString(R.string.AccessibilityId_urlOpenBrowser), + color = LocalColors.current.danger, + onClick = { context.openUrl(url) } + ), + DialogButtonModel( + text = GetString(android.R.string.copyUrl), + contentDescription = GetString(R.string.AccessibilityId_copy), + onClick = { + context.copyURLToClipboard(url) + Toast.makeText(context, R.string.copied, Toast.LENGTH_SHORT).show() + } + ) + ), + onDismissRequest = onDismissRequest, + content = content + ) +} + @Composable fun DialogButton( text: String, - modifier: Modifier, + modifier: Modifier = Modifier, color: Color = Color.Unspecified, + enabled: Boolean, onClick: () -> Unit ) { TextButton( modifier = modifier, shape = RectangleShape, + enabled = enabled, onClick = onClick ) { + val textColor = if(enabled) { + color.takeOrElse { LocalColors.current.text } + } else { + LocalColors.current.disabled + } + Text( text, - color = color.takeOrElse { LocalColors.current.text }, + color = textColor, style = LocalType.current.large.bold(), textAlign = TextAlign.Center, modifier = Modifier.padding( @@ -154,6 +251,62 @@ fun DialogButton( } } +@Composable +fun DialogBg( + content: @Composable BoxScope.() -> Unit +){ + Box( + modifier = Modifier + .background( + color = LocalColors.current.backgroundSecondary, + shape = MaterialTheme.shapes.small + ) + .border( + width = 1.dp, + color = LocalColors.current.borders, + shape = MaterialTheme.shapes.small + ) + + ) { + content() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoadingDialog( + modifier: Modifier = Modifier, + title: String? = null, +){ + BasicAlertDialog( + modifier = modifier, + onDismissRequest = {}, + content = { + DialogBg { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(LocalDimensions.current.spacing) + ) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + + title?.let { + Text( + it, + modifier = Modifier.align(Alignment.CenterHorizontally), + style = LocalType.current.large + ) + } + } + } + } + ) +} + @Preview @Composable fun PreviewSimpleDialog() { @@ -161,15 +314,15 @@ fun PreviewSimpleDialog() { AlertDialog( onDismissRequest = {}, title = stringResource(R.string.warning), - text = stringResource(R.string.you_cannot_go_back_further_in_order_to_stop_loading_your_account_session_needs_to_quit), + text = stringResource(R.string.onboardingBackAccountCreation), buttons = listOf( DialogButtonModel( - GetString(stringResource(R.string.quit)), + GetString(stringResource(R.string.cancel)), color = LocalColors.current.danger, - onClick = {} + onClick = { } ), DialogButtonModel( - GetString(stringResource(R.string.cancel)) + GetString(stringResource(R.string.ok)) ) ) ) @@ -186,13 +339,13 @@ fun PreviewXCloseDialog() { showCloseButton = true, // display the 'x' button buttons = listOf( DialogButtonModel( - text = GetString(R.string.activity_landing_terms_of_service), - contentDescription = GetString(R.string.AccessibilityId_terms_of_service_button), + text = GetString(R.string.onboardingTos), + contentDescription = GetString(R.string.AccessibilityId_onboardingTos), onClick = {} ), DialogButtonModel( - text = GetString(R.string.activity_landing_privacy_policy), - contentDescription = GetString(R.string.AccessibilityId_privacy_policy_button), + text = GetString(R.string.onboardingPrivacy), + contentDescription = GetString(R.string.AccessibilityId_onboardingPrivacy), onClick = {} ) ), @@ -200,3 +353,24 @@ fun PreviewXCloseDialog() { ) } } + +@Preview +@Composable +fun PreviewOpenURLDialog() { + PreviewTheme { + OpenURLAlertDialog( + url = "https://getsession.org/", + onDismissRequest = {} + ) + } +} + +@Preview +@Composable +fun PreviewLoadingDialog() { + PreviewTheme { + LoadingDialog( + title = stringResource(R.string.warning) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt index ea632d46e7..d94cfc929d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -42,14 +43,17 @@ import kotlin.math.sign @OptIn(ExperimentalFoundationApi::class) @Composable -fun BoxScope.HorizontalPagerIndicator(pagerState: PagerState) { - if (pagerState.pageCount >= 2) Box( - modifier = Modifier - .background(color = blackAlpha40, shape = pillShape) - .align(Alignment.BottomCenter) - .padding(LocalDimensions.current.xxsSpacing) - ) { - Box(modifier = Modifier.padding(LocalDimensions.current.xxsSpacing)) { +fun BoxScope.HorizontalPagerIndicator( + pagerState: PagerState, + modifier: Modifier = Modifier +) { + if (pagerState.pageCount >= 2){ + Box( + modifier = modifier + .background(color = blackAlpha40, shape = pillShape) + .align(Alignment.BottomCenter) + .padding(LocalDimensions.current.xxsSpacing) + ) { ClickableHorizontalPagerIndicator( pagerState = pagerState, pageCount = pagerState.pageCount diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index daeb6b853d..355d74947d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -11,9 +11,12 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -46,6 +49,7 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -61,6 +65,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import network.loki.messenger.R +import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.components.ProfilePictureView import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.OptionsCardData @@ -105,7 +110,7 @@ fun OptionsCard(card: OptionsCardData, callbacks: Callbacks) { Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) - CellNoMargin { + Cell { LazyColumn( modifier = Modifier.heightIn(max = 5000.dp) ) { @@ -127,7 +132,7 @@ fun LargeItemButtonWithDrawable( onClick: () -> Unit ) { ItemButtonWithDrawable( - textId, icon, modifier.heightIn(min = LocalDimensions.current.minLargeItemButtonHeight), + textId, icon, modifier, LocalType.current.h8, colors, onClick ) } @@ -168,8 +173,59 @@ fun LargeItemButton( onClick: () -> Unit ) { ItemButton( - textId, icon, modifier.heightIn(min = LocalDimensions.current.minLargeItemButtonHeight), - LocalType.current.h8, colors, onClick + textId = textId, + icon = icon, + modifier = modifier, + minHeight = LocalDimensions.current.minLargeItemButtonHeight, + textStyle = LocalType.current.h8, + colors = colors, + onClick = onClick + ) +} + +@Composable +fun LargeItemButton( + text: String, + @DrawableRes icon: Int, + modifier: Modifier = Modifier, + colors: ButtonColors = transparentButtonColors(), + onClick: () -> Unit +) { + ItemButton( + text = text, + icon = icon, + modifier = modifier, + minHeight = LocalDimensions.current.minLargeItemButtonHeight, + textStyle = LocalType.current.h8, + colors = colors, + onClick = onClick + ) +} + +@Composable +fun ItemButton( + text: String, + icon: Int, + modifier: Modifier, + minHeight: Dp = LocalDimensions.current.minItemButtonHeight, + textStyle: TextStyle = LocalType.current.xl, + colors: ButtonColors = transparentButtonColors(), + onClick: () -> Unit +) { + ItemButton( + text = text, + modifier = modifier, + icon = { + Icon( + painter = painterResource(id = icon), + contentDescription = null, + modifier = Modifier.align(Alignment.Center) + ) + }, + minHeight = minHeight, + textStyle = textStyle, + colors = colors, + onClick = onClick ) } @@ -181,6 +237,7 @@ fun ItemButton( @StringRes textId: Int, @DrawableRes icon: Int, modifier: Modifier = Modifier, + minHeight: Dp = LocalDimensions.current.minItemButtonHeight, textStyle: TextStyle = LocalType.current.xl, colors: ButtonColors = transparentButtonColors(), onClick: () -> Unit @@ -195,6 +252,7 @@ fun ItemButton( modifier = Modifier.align(Alignment.Center) ) }, + minHeight = minHeight, textStyle = textStyle, colors = colors, onClick = onClick @@ -211,20 +269,23 @@ fun ItemButton( text: String, icon: @Composable BoxScope.() -> Unit, modifier: Modifier = Modifier, + minHeight: Dp = LocalDimensions.current.minLargeItemButtonHeight, textStyle: TextStyle = LocalType.current.xl, colors: ButtonColors = transparentButtonColors(), onClick: () -> Unit ) { TextButton( - modifier = modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth() + .height(IntrinsicSize.Min) + .heightIn(min = minHeight) + .padding(horizontal = LocalDimensions.current.xsSpacing), colors = colors, onClick = onClick, shape = RectangleShape, ) { Box( - modifier = Modifier - .width(50.dp) - .wrapContentHeight() + modifier = Modifier.fillMaxHeight() + .aspectRatio(1f) .align(Alignment.CenterVertically) ) { icon() @@ -236,7 +297,6 @@ fun ItemButton( text, Modifier .fillMaxWidth() - .padding(vertical = LocalDimensions.current.xsSpacing) .align(Alignment.CenterVertically), style = textStyle ) @@ -245,10 +305,10 @@ fun ItemButton( @Preview @Composable -fun PrewviewItemButton() { +fun PreviewItemButton() { PreviewTheme { ItemButton( - textId = R.string.activity_create_group_title, + textId = R.string.groupCreate, icon = R.drawable.ic_group, onClick = {} ) @@ -257,32 +317,19 @@ fun PrewviewItemButton() { @Composable fun Cell( - padding: Dp = 0.dp, - margin: Dp = LocalDimensions.current.spacing, - content: @Composable () -> Unit -) { - CellWithPaddingAndMargin(padding, margin) { content() } -} -@Composable -fun CellNoMargin(content: @Composable () -> Unit) { - CellWithPaddingAndMargin(padding = 0.dp, margin = 0.dp) { content() } -} - -@Composable -fun CellWithPaddingAndMargin( - padding: Dp = LocalDimensions.current.spacing, - margin: Dp = LocalDimensions.current.spacing, + modifier: Modifier = Modifier, content: @Composable () -> Unit ) { Box( - modifier = Modifier - .padding(horizontal = margin) - .background(color = LocalColors.current.backgroundSecondary, - shape = MaterialTheme.shapes.small) + modifier = modifier + .background( + color = LocalColors.current.backgroundSecondary, + shape = MaterialTheme.shapes.small + ) .wrapContentHeight() .fillMaxWidth(), ) { - Box(Modifier.padding(padding)) { content() } + content() } } @@ -346,28 +393,38 @@ fun Modifier.fadingEdges( @Composable fun Divider(modifier: Modifier = Modifier, startIndent: Dp = 0.dp) { HorizontalDivider( - modifier = modifier.padding(horizontal = LocalDimensions.current.smallSpacing) + modifier = modifier + .padding(horizontal = LocalDimensions.current.smallSpacing) .padding(start = startIndent), color = LocalColors.current.borders, ) } +//TODO This component should be fully rebuilt in Compose at some point ~~ @Composable -fun RowScope.Avatar(recipient: Recipient) { - Box( - modifier = Modifier - .width(60.dp) - .align(Alignment.CenterVertically) - ) { - AndroidView( - factory = { - ProfilePictureView(it).apply { update(recipient) } - }, - modifier = Modifier - .width(46.dp) - .height(46.dp) - ) - } +fun Avatar( + recipient: Recipient, + modifier: Modifier = Modifier +) { + AndroidView( + factory = { + ProfilePictureView(it).apply { update(recipient) } + }, + modifier = modifier + ) +} + +@Composable +fun Avatar( + userAddress: Address, + modifier: Modifier = Modifier +) { + AndroidView( + factory = { + ProfilePictureView(it).apply { update(userAddress) } + }, + modifier = modifier + ) } @Composable @@ -446,4 +503,4 @@ fun LoadingArcOr(loading: Boolean, content: @Composable () -> Unit) { AnimatedVisibility(!loading) { content() } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt index b49f9c6d60..de2271162d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt @@ -2,9 +2,22 @@ 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 +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.shouldShowRationale +import com.squareup.phrase.Phrase import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme fun Activity.setComposeContent(content: @Composable () -> Unit) { @@ -18,8 +31,60 @@ fun Context.createThemedComposeView(content: @Composable () -> Unit): ComposeVie setThemedContent(content) } +// Extension method to use the Phrase library to substitute strings & return a CharSequence. +// The pair is the key name, such as APP_NAME_KEY and the value is the localised string, such as context.getString(R.string.app_name). +// Note: We cannot have Pair versions of this or the `getSubbedString` method because the JVM sees the signatures as identical. +fun Context.getSubbedCharSequence(stringId: Int, vararg substitutionPairs: Pair): CharSequence { + val phrase = Phrase.from(this, stringId) + for ((key, value) in substitutionPairs) { phrase.put(key, value) } + return phrase.format() +} + +// Extension method to use the Phrase library to substitute strings & return the substituted String. +// The pair is the key name, such as APP_NAME_KEY and the value is the localised string, such as context.getString(R.string.app_name). +fun Context.getSubbedString(stringId: Int, vararg substitutionPairs: Pair): String { + return getSubbedCharSequence(stringId, *substitutionPairs).toString() +} + fun ComposeView.setThemedContent(content: @Composable () -> Unit) = setContent { SessionMaterialTheme { content() } } + +@ExperimentalPermissionsApi +fun PermissionState.isPermanentlyDenied(): Boolean { + return !status.shouldShowRationale && !status.isGranted +} + +fun Context.findActivity(): Activity { + var context = this + while (context is ContextWrapper) { + if (context is Activity) return context + context = context.baseContext + } + throw IllegalStateException("Permissions should be called in the context of an Activity") +} + +inline fun 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) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt index ff9369e8a2..b039b5134f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt @@ -165,7 +165,7 @@ fun AppBarText(title: String) { fun AppBarBackIcon(onBack: () -> Unit) { IconButton(onClick = onBack) { Icon( - painter = painterResource(id = R.drawable.ic_baseline_arrow_back_24), + painter = painterResource(id = R.drawable.ic_arrow_left), contentDescription = stringResource(R.string.back) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt index 5834f2f859..a65b341a54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt @@ -177,7 +177,7 @@ fun OutlineCopyButton( val interactionSource = remember { MutableInteractionSource() } Button( - modifier = modifier.contentDescription(R.string.AccessibilityId_copy_button), + modifier = modifier.contentDescription(R.string.AccessibilityId_copy), interactionSource = interactionSource, style = style, type = ButtonType.Outline(color), diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt index bbad82d29e..f555c82426 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt @@ -8,18 +8,24 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @Composable -fun CircularProgressIndicator(color: Color = LocalContentColor.current) { +fun CircularProgressIndicator( + modifier: Modifier = Modifier, + color: Color = LocalContentColor.current +) { androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(40.dp), + modifier = modifier.size(40.dp), color = color, strokeWidth = 2.dp ) } @Composable -fun SmallCircularProgressIndicator(color: Color = LocalContentColor.current) { +fun SmallCircularProgressIndicator( + modifier: Modifier = Modifier, + color: Color = LocalContentColor.current +) { androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(20.dp), + modifier = modifier.size(20.dp), color = color, strokeWidth = 2.dp ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/DropDown.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/DropDown.kt new file mode 100644 index 0000000000..de7f437356 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/DropDown.kt @@ -0,0 +1,109 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.compose.foundation.border +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.bold + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DropDown( + modifier: Modifier = Modifier, + selectedText: String, + values: List, + onValueSelected: (String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + modifier = modifier, + expanded = expanded, + onExpandedChange = { + expanded = !expanded + } + ) { + TextField( + value = selectedText, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor() + .border( + 1.dp, + color = LocalColors.current.borders, + shape = MaterialTheme.shapes.medium + ), + shape = MaterialTheme.shapes.medium, + colors = ExposedDropdownMenuDefaults.textFieldColors( + focusedContainerColor = LocalColors.current.backgroundSecondary, + unfocusedContainerColor = LocalColors.current.backgroundSecondary, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledTrailingIconColor = LocalColors.current.primary, + errorTrailingIconColor = LocalColors.current.primary, + focusedTrailingIconColor = LocalColors.current.primary, + unfocusedTrailingIconColor = LocalColors.current.primary, + disabledTextColor = LocalColors.current.text, + errorTextColor = LocalColors.current.text, + focusedTextColor = LocalColors.current.text, + unfocusedTextColor = LocalColors.current.text + ), + textStyle = LocalType.current.base.bold() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + values.forEach { item -> + DropdownMenuItem( + text = { + Text( + text = item, + style = LocalType.current.base + ) + }, + colors = MenuDefaults.itemColors( + textColor = LocalColors.current.text + ), + onClick = { + expanded = false + onValueSelected(item) + } + ) + } + } + } +} + +@Preview +@Composable +fun PreviewDropDown() { + PreviewTheme { + DropDown( + selectedText = "Hello", + values = listOf("First Item", "Second Item", "Third Item"), + onValueSelected = {}) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Html.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Html.kt index 951db1816e..15f0292853 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Html.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Html.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -26,6 +27,9 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.core.text.HtmlCompat +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.URL_KEY // TODO Remove this file once we update to composeVersion=1.7.0-alpha06 fixes https://issuetracker.google.com/issues/139320238?pli=1 // which allows Stylized string in string resources @@ -71,6 +75,14 @@ fun annotatedStringResource(@StringRes id: Int): AnnotatedString { } } +@Composable +fun annotatedStringResource(text: CharSequence): AnnotatedString { + val density = LocalDensity.current + return remember(text.hashCode()) { + spannableStringToAnnotatedString(text, density) + } +} + private fun spannableStringToAnnotatedString( text: CharSequence, density: Density diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt index 9661b3bc06..a3b508f481 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt @@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.ui.components import android.Manifest import android.annotation.SuppressLint +import android.app.Activity import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import android.provider.Settings import androidx.camera.core.CameraSelector @@ -20,7 +22,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar @@ -30,8 +31,11 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -43,10 +47,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign 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.accompanist.permissions.shouldShowRationale import com.google.zxing.BinaryBitmap import com.google.zxing.ChecksumException import com.google.zxing.FormatException @@ -55,13 +60,21 @@ import com.google.zxing.PlanarYUVLuminanceSource import com.google.zxing.Result import com.google.zxing.common.HybridBinarizer import com.google.zxing.qrcode.QRCodeReader -import java.util.concurrent.Executors +import com.squareup.phrase.Phrase import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.DialogButtonModel +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.findActivity +import org.thoughtcrime.securesms.ui.getSubbedString import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType +import java.util.concurrent.Executors private const val TAG = "NewMessageFragment" @@ -82,28 +95,14 @@ fun QRScannerScreen( ) { LocalSoftwareKeyboardController.current?.hide() - val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) + val context = LocalContext.current + val permission = Manifest.permission.CAMERA + val cameraPermissionState = rememberPermissionState(permission) + + var showCameraPermissionDialog by remember { mutableStateOf(false) } if (cameraPermissionState.status.isGranted) { ScanQrCode(errors, onScan) - } else if (cameraPermissionState.status.shouldShowRationale) { - Column( - modifier = Modifier - .align(Alignment.Center) - .padding(horizontal = 60.dp) - ) { - Text( - stringResource(R.string.activity_link_camera_permission_permanently_denied_configure_in_settings), - style = LocalType.current.base, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.size(LocalDimensions.current.spacing)) - OutlineButton( - stringResource(R.string.sessionSettings), - modifier = Modifier.align(Alignment.CenterHorizontally), - onClick = onClickSettings - ) - } } else { Column( modifier = Modifier @@ -112,17 +111,55 @@ fun QRScannerScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.weight(1f)) - Text(stringResource(R.string.fragment_scan_qr_code_camera_access_explanation), - style = LocalType.current.xl, textAlign = TextAlign.Center) + Text( + stringResource(R.string.cameraGrantAccessQr).let { txt -> + val c = LocalContext.current + Phrase.from(txt).put(APP_NAME_KEY, c.getString(R.string.app_name)).format().toString() + }, + style = LocalType.current.xl, + textAlign = TextAlign.Center + ) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) PrimaryOutlineButton( stringResource(R.string.cameraGrantAccess), modifier = Modifier.fillMaxWidth(), - onClick = { cameraPermissionState.run { launchPermissionRequest() } } + onClick = { + // NOTE: We used to use the Accompanist's way to handle permissions in compose + // but it doesn't seem to offer a solution when a user manually changes a permission + // to 'Ask every time' form the app's settings. + // So we are using our custom implementation. ONE IMPORTANT THING with this approach + // is that we need to make sure every activity where this composable is used NEED to + // implement `onRequestPermissionsResult` (see LoadAccountActivity.kt for an example) + Permissions.with(context.findActivity()) + .request(permission) + .withPermanentDenialDialog( + context.getSubbedString(R.string.permissionsCameraDenied, + APP_NAME_KEY to context.getString(R.string.app_name)) + ).execute() + } ) Spacer(modifier = Modifier.weight(1f)) } } + + // camera permission denied permanently dialog + if(showCameraPermissionDialog){ + AlertDialog( + onDismissRequest = { showCameraPermissionDialog = false }, + title = stringResource(R.string.permissionsRequired), + text = context.getSubbedString(R.string.permissionsCameraDenied, + APP_NAME_KEY to context.getString(R.string.app_name)), + buttons = listOf( + DialogButtonModel( + text = GetString(stringResource(id = R.string.sessionSettings)), + onClick = onClickSettings + ), + DialogButtonModel( + GetString(stringResource(R.string.cancel)) + ) + ) + ) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt index 5e09f78b65..d2f7a7e73c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt @@ -100,8 +100,7 @@ fun SessionOutlinedTextField( ) .fillMaxWidth() .wrapContentHeight() - .padding(vertical = 28.dp) - .padding(horizontal = 21.dp) + .padding(LocalDimensions.current.spacing) ) { if (text.isEmpty()) { Text( @@ -134,7 +133,7 @@ fun SessionOutlinedTextField( Text( it, modifier = Modifier.fillMaxWidth() - .contentDescription(R.string.AccessibilityId_error_message), + .contentDescription(R.string.AccessibilityId_theError), textAlign = TextAlign.Center, style = LocalType.current.base.bold(), color = LocalColors.current.danger diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt index 66e5b74e8b..ac5ce8c4cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt @@ -17,6 +17,7 @@ data class Dimensions( val dividerIndent: Dp = 60.dp, val appBarHeight: Dp = 64.dp, + val minItemButtonHeight: Dp = 50.dp, val minLargeItemButtonHeight: Dp = 60.dp, val indicatorHeight: Dp = 4.dp, diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/SessionTypography.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/SessionTypography.kt index 602affa6af..50c3957cbd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/SessionTypography.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/SessionTypography.kt @@ -8,11 +8,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -fun TextStyle.bold() = TextStyle.Default.copy( +fun TextStyle.bold() = copy( fontWeight = FontWeight.Bold ) -fun TextStyle.monospace() = TextStyle.Default.copy( +fun TextStyle.monospace() = copy( fontFamily = FontFamily.Monospace ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ActivityUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ActivityUtilities.kt index c3b7eaca96..f50ac33e28 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ActivityUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ActivityUtilities.kt @@ -53,7 +53,7 @@ fun AppCompatActivity.push(intent: Intent, isForResult: Boolean = false) { } else { startActivity(intent) } - overridePendingTransition(R.anim.slide_from_right, R.anim.fade_scale_out) + overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) } fun AppCompatActivity.show(intent: Intent, isForResult: Boolean = false) { @@ -108,5 +108,5 @@ data class ThemeState ( ) inline fun Activity.show() = Intent(this, T::class.java).also(::startActivity).let { overridePendingTransition(R.anim.slide_from_bottom, R.anim.fade_scale_out) } -inline fun Activity.push(modify: Intent.() -> Unit = {}) = Intent(this, T::class.java).also(modify).also(::startActivity).let { overridePendingTransition(R.anim.slide_from_right, R.anim.fade_scale_out) } +inline fun Activity.push(modify: Intent.() -> Unit = {}) = Intent(this, T::class.java).also(modify).also(::startActivity).let { overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) } inline fun Context.start(modify: Intent.() -> Unit = {}) = Intent(this, T::class.java).also(modify).apply { addFlags(FLAG_ACTIVITY_SINGLE_TOP) }.let(::startActivity) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt index 0ba63fc549..4ffe12d006 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt @@ -9,12 +9,16 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import com.squareup.phrase.Phrase import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.calls.WebRtcCallActivity import org.thoughtcrime.securesms.notifications.NotificationChannels import org.thoughtcrime.securesms.preferences.SettingsActivity import org.thoughtcrime.securesms.service.WebRtcCallService +import org.thoughtcrime.securesms.ui.getSubbedCharSequence +import org.thoughtcrime.securesms.ui.getSubbedString class CallNotificationBuilder { @@ -34,21 +38,25 @@ class CallNotificationBuilder { } @JvmStatic - fun getFirstCallNotification(context: Context): Notification { + fun getFirstCallNotification(context: Context, callerName: String): Notification { val contentIntent = Intent(context, SettingsActivity::class.java) val pendingIntent = PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - val text = context.getString(R.string.CallNotificationBuilder_first_call_message) + val titleTxt = context.getSubbedString(R.string.callsMissedCallFrom, NAME_KEY to callerName) + val bodyTxt = context.getSubbedCharSequence( + R.string.callsYouMissedCallPermissions, + NAME_KEY to callerName + ) val builder = NotificationCompat.Builder(context, NotificationChannels.CALLS) .setSound(null) .setSmallIcon(R.drawable.ic_baseline_call_24) .setContentIntent(pendingIntent) .setPriority(NotificationCompat.PRIORITY_HIGH) - .setContentTitle(context.getString(R.string.CallNotificationBuilder_first_call_title)) - .setContentText(text) - .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setContentTitle(titleTxt) + .setContentText(bodyTxt) + .setStyle(NotificationCompat.BigTextStyle().bigText(bodyTxt)) .setAutoCancel(true) return builder.build() @@ -67,27 +75,29 @@ class CallNotificationBuilder { .setContentIntent(pendingIntent) .setOngoing(true) - + var recipName = "Unknown" recipient?.name?.let { name -> builder.setContentTitle(name) + recipName = name } when (type) { TYPE_INCOMING_CONNECTING -> { - builder.setContentText(context.getString(R.string.CallNotificationBuilder_connecting)) + builder.setContentText(context.getString(R.string.callsConnecting)) .setNotificationSilent() } TYPE_INCOMING_PRE_OFFER, TYPE_INCOMING_RINGING -> { - builder.setContentText(context.getString(R.string.NotificationBarManager__incoming_signal_call)) + val txt = Phrase.from(context, R.string.callsIncoming).put(NAME_KEY, recipName).format() + builder.setContentText(txt) .setCategory(NotificationCompat.CATEGORY_CALL) builder.addAction(getServiceNotificationAction( context, WebRtcCallService.ACTION_DENY_CALL, R.drawable.ic_close_grey600_32dp, - R.string.NotificationBarManager__deny_call + R.string.decline )) - // if notifications aren't enabled, we will trigger the intent from WebRtcCallService + // If notifications aren't enabled, we will trigger the intent from WebRtcCallService builder.setFullScreenIntent(getFullScreenPendingIntent( context ), true) @@ -95,26 +105,26 @@ class CallNotificationBuilder { context, if (type == TYPE_INCOMING_PRE_OFFER) WebRtcCallActivity.ACTION_PRE_OFFER else WebRtcCallActivity.ACTION_ANSWER, R.drawable.ic_phone_grey600_32dp, - R.string.NotificationBarManager__answer_call + R.string.accept )) builder.priority = NotificationCompat.PRIORITY_MAX } TYPE_OUTGOING_RINGING -> { - builder.setContentText(context.getString(R.string.NotificationBarManager__establishing_signal_call)) + builder.setContentText(context.getString(R.string.callsConnecting)) builder.addAction(getServiceNotificationAction( context, WebRtcCallService.ACTION_LOCAL_HANGUP, R.drawable.ic_call_end_grey600_32dp, - R.string.NotificationBarManager__cancel_call + R.string.cancel )) } else -> { - builder.setContentText(context.getString(R.string.NotificationBarManager_call_in_progress)) + builder.setContentText(context.getString(R.string.callsInProgress)) builder.addAction(getServiceNotificationAction( context, WebRtcCallService.ACTION_LOCAL_HANGUP, R.drawable.ic_call_end_grey600_32dp, - R.string.NotificationBarManager__end_call + R.string.callsEnd )).setUsesChronometer(true) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java index 7e65e63e8b..0d900ea391 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java @@ -1,22 +1,15 @@ package org.thoughtcrime.securesms.util; -import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; -import android.net.Uri; import android.os.AsyncTask; -import android.widget.Toast; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.app.TaskStackBuilder; - import org.session.libsession.utilities.recipients.Recipient; import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import network.loki.messenger.R; - public class CommunicationActions { public static void startConversation(@NonNull Context context, @@ -45,13 +38,4 @@ protected void onPostExecute(Long threadId) { } }.execute(); } - - public static void openBrowserLink(@NonNull Context context, @NonNull String link) { - try { - Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(link)); - context.startActivity(intent); - } catch (ActivityNotFoundException e) { - Toast.makeText(context, R.string.CommunicationActions_no_browser_found, Toast.LENGTH_SHORT).show(); - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java deleted file mode 100644 index 6fe2193c9b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java +++ /dev/null @@ -1,180 +0,0 @@ -/* - * Copyright (C) 2014 Open Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.thoughtcrime.securesms.util; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.text.format.DateFormat; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.session.libsignal.utilities.Log; - -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; -import java.util.concurrent.TimeUnit; - -import network.loki.messenger.R; - -/** - * Utility methods to help display dates in a nice, easily readable way. - */ -public class DateUtils extends android.text.format.DateUtils { - - @SuppressWarnings("unused") - private static final String TAG = DateUtils.class.getSimpleName(); - private static final SimpleDateFormat DAY_PRECISION_DATE_FORMAT = new SimpleDateFormat("yyyyMMdd"); - private static final SimpleDateFormat HOUR_PRECISION_DATE_FORMAT = new SimpleDateFormat("yyyyMMddHH"); - - private static boolean isWithin(final long millis, final long span, final TimeUnit unit) { - return System.currentTimeMillis() - millis <= unit.toMillis(span); - } - - private static boolean isYesterday(final long when) { - return DateUtils.isToday(when + TimeUnit.DAYS.toMillis(1)); - } - - private static int convertDelta(final long millis, TimeUnit to) { - return (int) to.convert(System.currentTimeMillis() - millis, TimeUnit.MILLISECONDS); - } - - public static String getFormattedDateTime(long time, String template, Locale locale) { - final String localizedPattern = getLocalizedPattern(template, locale); - return new SimpleDateFormat(localizedPattern, locale).format(new Date(time)); - } - - public static String getHourFormat(Context c) { - return (DateFormat.is24HourFormat(c)) ? "HH:mm" : "hh:mm a"; - } - - public static String getDisplayFormattedTimeSpanString(final Context c, final Locale locale, final long timestamp) { - // If the timestamp is invalid (ie. 0) then assume we're waiting on data and just use the 'Now' copy - if (timestamp == 0 || isWithin(timestamp, 1, TimeUnit.MINUTES)) { - return c.getString(R.string.DateUtils_just_now); - } else if (isToday(timestamp)) { - return getFormattedDateTime(timestamp, getHourFormat(c), locale); - } else if (isWithin(timestamp, 6, TimeUnit.DAYS)) { - return getFormattedDateTime(timestamp, "EEE " + getHourFormat(c), locale); - } else if (isWithin(timestamp, 365, TimeUnit.DAYS)) { - return getFormattedDateTime(timestamp, "MMM d " + getHourFormat(c), locale); - } else { - return getFormattedDateTime(timestamp, "MMM d " + getHourFormat(c) + ", yyyy", locale); - } - } - - public static SimpleDateFormat getDetailedDateFormatter(Context context, Locale locale) { - String dateFormatPattern; - - if (DateFormat.is24HourFormat(context)) { - dateFormatPattern = getLocalizedPattern("MMM d, yyyy HH:mm:ss zzz", locale); - } else { - dateFormatPattern = getLocalizedPattern("MMM d, yyyy hh:mm:ss a zzz", locale); - } - - return new SimpleDateFormat(dateFormatPattern, locale); - } - - public static String getRelativeDate(@NonNull Context context, - @NonNull Locale locale, - long timestamp) - { - if (isToday(timestamp)) { - return context.getString(R.string.DateUtils_today); - } else if (isYesterday(timestamp)) { - return context.getString(R.string.DateUtils_yesterday); - } else { - return getFormattedDateTime(timestamp, "EEE, MMM d, yyyy", locale); - } - } - - public static boolean isSameDay(long t1, long t2) { - return DAY_PRECISION_DATE_FORMAT.format(new Date(t1)).equals(DAY_PRECISION_DATE_FORMAT.format(new Date(t2))); - } - - public static boolean isSameHour(long t1, long t2) { - return HOUR_PRECISION_DATE_FORMAT.format(new Date(t1)).equals(HOUR_PRECISION_DATE_FORMAT.format(new Date(t2))); - } - - private static String getLocalizedPattern(String template, Locale locale) { - 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") - public static long parseIso8601(@Nullable String date) { - SimpleDateFormat format; - format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX", Locale.getDefault()); - - if (date.isEmpty()) { - return -1; - } - - try { - return format.parse(date).getTime(); - } catch (ParseException e) { - Log.w(TAG, "Failed to parse date.", e); - return -1; - } - } - - // region Deprecated - public static String getBriefRelativeTimeSpanString(final Context c, final Locale locale, final long timestamp) { - if (isWithin(timestamp, 1, TimeUnit.MINUTES)) { - return c.getString(R.string.DateUtils_just_now); - } else if (isWithin(timestamp, 1, TimeUnit.HOURS)) { - int mins = convertDelta(timestamp, TimeUnit.MINUTES); - return c.getResources().getString(R.string.DateUtils_minutes_ago, mins); - } else if (isWithin(timestamp, 1, TimeUnit.DAYS)) { - int hours = convertDelta(timestamp, TimeUnit.HOURS); - return c.getResources().getQuantityString(R.plurals.hours_ago, hours, hours); - } else if (isWithin(timestamp, 6, TimeUnit.DAYS)) { - return getFormattedDateTime(timestamp, "EEE", locale); - } else if (isWithin(timestamp, 365, TimeUnit.DAYS)) { - return getFormattedDateTime(timestamp, "MMM d", locale); - } else { - return getFormattedDateTime(timestamp, "MMM d, yyyy", locale); - } - } - - public static String getExtendedRelativeTimeSpanString(final Context c, final Locale locale, final long timestamp) { - if (isWithin(timestamp, 1, TimeUnit.MINUTES)) { - return c.getString(R.string.DateUtils_just_now); - } else if (isWithin(timestamp, 1, TimeUnit.HOURS)) { - int mins = (int)TimeUnit.MINUTES.convert(System.currentTimeMillis() - timestamp, TimeUnit.MILLISECONDS); - return c.getResources().getString(R.string.DateUtils_minutes_ago, mins); - } else { - StringBuilder format = new StringBuilder(); - if (isWithin(timestamp, 6, TimeUnit.DAYS)) format.append("EEE "); - else if (isWithin(timestamp, 365, TimeUnit.DAYS)) format.append("MMM d, "); - else format.append("MMM d, yyyy, "); - - if (DateFormat.is24HourFormat(c)) format.append("HH:mm"); - else format.append("hh:mm a"); - - return getFormattedDateTime(timestamp, format.toString(), locale); - } - } - // endregion -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt new file mode 100644 index 0000000000..36c64f7fbd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.util + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.text.format.DateFormat +import androidx.compose.ui.text.capitalize +import org.session.libsignal.utilities.Log +import java.text.DateFormat.SHORT +import java.text.DateFormat.getTimeInstance +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.concurrent.TimeUnit + +// Enums used to get the locale-aware String for one of the three relative days +enum class RelativeDay { TODAY, YESTERDAY, TOMORROW } + +/** + * Utility methods to help display dates in a nice, easily readable way. + */ +object DateUtils : android.text.format.DateUtils() { + + @Suppress("unused") + private val TAG: String = DateUtils::class.java.simpleName + private val DAY_PRECISION_DATE_FORMAT = SimpleDateFormat("yyyyMMdd") + private val HOUR_PRECISION_DATE_FORMAT = SimpleDateFormat("yyyyMMddHH") + + private fun isWithin(millis: Long, span: Long, unit: TimeUnit): Boolean { + return System.currentTimeMillis() - millis <= unit.toMillis(span) + } + + private fun isYesterday(`when`: Long): Boolean { + return isToday(`when` + TimeUnit.DAYS.toMillis(1)) + } + + + // Method to get the String for a relative day in a locale-aware fashion + public fun getLocalisedRelativeDayString(relativeDay: RelativeDay): String { + + val now = Calendar.getInstance() + + // To compare a time to 'now' we need to use get a date relative it, so plus or minus a day, or not + val dayAddition = when (relativeDay) { + RelativeDay.TOMORROW -> { 1 } + RelativeDay.YESTERDAY -> { -1 } + else -> 0 // Today + } + + val comparisonTime = Calendar.getInstance().apply { + add(Calendar.DAY_OF_YEAR, dayAddition) + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + + 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 { + val localizedPattern = getLocalizedPattern(template, locale) + return SimpleDateFormat(localizedPattern, locale).format(Date(time)) + } + + fun getHourFormat(c: Context?): String { + return if ((DateFormat.is24HourFormat(c))) "HH:mm" else "hh:mm a" + } + + fun getDisplayFormattedTimeSpanString(c: Context, locale: Locale, timestamp: Long): String { + // If the timestamp is within the last 24 hours we just give the time, e.g, "1:23 PM" or + // "13:23" depending on 12/24 hour formatting. + return if (isToday(timestamp)) { + getFormattedDateTime(timestamp, getHourFormat(c), locale) + } else if (isWithin(timestamp, 6, TimeUnit.DAYS)) { + getFormattedDateTime(timestamp, "EEE " + getHourFormat(c), locale) + } else if (isWithin(timestamp, 365, TimeUnit.DAYS)) { + getFormattedDateTime(timestamp, "MMM d " + getHourFormat(c), locale) + } else { + getFormattedDateTime(timestamp, "MMM d " + getHourFormat(c) + ", yyyy", locale) + } + } + + fun getDetailedDateFormatter(context: Context?, locale: Locale): SimpleDateFormat { + val dateFormatPattern = if (DateFormat.is24HourFormat(context)) { + getLocalizedPattern("MMM d, yyyy HH:mm:ss zzz", locale) + } else { + getLocalizedPattern("MMM d, yyyy hh:mm:ss a zzz", locale) + } + + return SimpleDateFormat(dateFormatPattern, locale) + } + + // Method to get the String for a relative day in a locale-aware fashion, including using the + // auto-localised words for "today" and "yesterday" as appropriate. + fun getRelativeDate( + context: Context, + locale: Locale, + timestamp: Long + ): String { + return if (isToday(timestamp)) { + getLocalisedRelativeDayString(RelativeDay.TODAY) + } else if (isYesterday(timestamp)) { + getLocalisedRelativeDayString(RelativeDay.YESTERDAY) + } else { + getFormattedDateTime(timestamp, "EEE, MMM d, yyyy", locale) + } + } + + fun isSameDay(t1: Long, t2: Long): Boolean { + return DAY_PRECISION_DATE_FORMAT.format(Date(t1)) == DAY_PRECISION_DATE_FORMAT.format(Date(t2)) + } + + fun isSameHour(t1: Long, t2: Long): Boolean { + return HOUR_PRECISION_DATE_FORMAT.format(Date(t1)) == HOUR_PRECISION_DATE_FORMAT.format(Date(t2)) + } + + private fun getLocalizedPattern(template: String, locale: Locale): String { + return DateFormat.getBestDateTimePattern(locale, template) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt index ccbab486c6..2f89ccbc43 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt @@ -8,22 +8,21 @@ import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore -import android.text.TextUtils import android.webkit.MimeTypeMap import android.widget.Toast -import network.loki.messenger.R -import org.session.libsession.utilities.task.ProgressDialogAsyncTask -import org.session.libsignal.utilities.ExternalStorageUtil -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.mms.PartAuthority -import org.thoughtcrime.securesms.showSessionDialog import java.io.File import java.io.FileOutputStream import java.io.IOException import java.lang.ref.WeakReference import java.text.SimpleDateFormat -import java.util.* import java.util.concurrent.TimeUnit +import network.loki.messenger.R +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.task.ProgressDialogAsyncTask +import org.session.libsignal.utilities.ExternalStorageUtil +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.mms.PartAuthority +import org.thoughtcrime.securesms.showSessionDialog /** * Saves attachment files to an external storage using [MediaStore] API. @@ -32,8 +31,8 @@ import java.util.concurrent.TimeUnit class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int = 1) : ProgressDialogAsyncTask>( context, - context.resources.getQuantityString(R.plurals.ConversationFragment_saving_n_attachments, count, count), - context.resources.getQuantityString(R.plurals.ConversationFragment_saving_n_attachments_to_sd_card, count, count) + context.resources.getString(R.string.saving), + context.resources.getString(R.string.saving) ) { companion object { @@ -45,16 +44,26 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int @JvmStatic @JvmOverloads - fun showWarningDialog(context: Context, count: Int = 1, onAcceptListener: () -> Unit = {}) { - context.showSessionDialog { - title(R.string.ConversationFragment_save_to_sd_card) - iconAttribute(R.attr.dialog_alert_icon) - text(context.resources.getQuantityString( - R.plurals.ConversationFragment_saving_n_media_to_storage_warning, - count, - count)) - button(R.string.yes) { onAcceptListener() } - button(R.string.no) + fun showOneTimeWarningDialogOrSave(context: Context, count: Int = 1, onAcceptListener: () -> Unit = {}) { + // If we've already warned the user that saved attachments can be accessed by other apps + // then we'll just perform the save.. + val haveWarned = TextSecurePreferences.getHaveWarnedUserAboutSavingAttachments(context) + if (haveWarned) { + onAcceptListener() + } else { + // .. otherwise we'll show a warning dialog and only save if the user accepts the + // potential risks of other apps accessing their saved attachments. + context.showSessionDialog { + title(R.string.warning) + iconAttribute(R.attr.dialog_alert_icon) + text(context.getString(R.string.attachmentsWarning)) + dangerButton(R.string.save) { + // Set our 'haveWarned' SharedPref and perform the save on accept + TextSecurePreferences.setHaveWarnedUserAboutSavingAttachments(context) + onAcceptListener() + } + button(R.string.cancel) + } } } @@ -125,6 +134,11 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int } private fun createOutputUri(context: Context, outputUri: Uri, contentType: String, fileName: String): Uri? { + + // TODO: This method may pass an empty string as the filename in Android API 28 and below. This requires + // TODO: follow-up investigation, but has temporarily been worked around, see: + // TODO: https://github.com/oxen-io/session-android/commit/afbb71351a74220c312a09c25cc1c79738453c12 + val fileParts: Array = getFileNameParts(fileName) val base = fileParts[0] val extension = fileParts[1] @@ -233,18 +247,12 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int when (result.first) { RESULT_FAILURE -> { - val message = context.resources.getQuantityText( - R.plurals.ConversationFragment_error_while_saving_attachments_to_sd_card, - attachmentCount) + val message = context.resources.getString(R.string.attachmentsSaveError) Toast.makeText(context, message, Toast.LENGTH_LONG).show() } RESULT_SUCCESS -> { - val message = if (!TextUtils.isEmpty(result.second)) { - context.resources.getString(R.string.SaveAttachmentTask_saved_to, result.second) - } else { - context.resources.getString(R.string.SaveAttachmentTask_saved) - } + val message = context.resources.getString(R.string.saved) Toast.makeText(context, message, Toast.LENGTH_LONG).show() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeFragment.kt deleted file mode 100644 index 3b551427a1..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeFragment.kt +++ /dev/null @@ -1,65 +0,0 @@ -package org.thoughtcrime.securesms.util - -import android.content.res.Configuration -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.LinearLayout -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import network.loki.messenger.databinding.FragmentScanQrCodeBinding -import org.thoughtcrime.securesms.qr.ScanListener -import org.thoughtcrime.securesms.qr.ScanningThread - -class ScanQRCodeFragment : Fragment() { - private lateinit var binding: FragmentScanQrCodeBinding - private val scanningThread = ScanningThread() - var scanListener: ScanListener? = null - set(value) { field = value; scanningThread.setScanListener(scanListener) } - var message: CharSequence = "" - - override fun onCreateView(layoutInflater: LayoutInflater, viewGroup: ViewGroup?, bundle: Bundle?): View { - binding = FragmentScanQrCodeBinding.inflate(layoutInflater, viewGroup, false) - return binding.root - } - - override fun onViewCreated(view: View, bundle: Bundle?) { - super.onViewCreated(view, bundle) - when (resources.configuration.orientation) { - Configuration.ORIENTATION_LANDSCAPE -> binding.overlayView.orientation = LinearLayout.HORIZONTAL - else -> binding.overlayView.orientation = LinearLayout.VERTICAL - } - binding.messageTextView.text = message - binding.messageTextView.isVisible = message.isNotEmpty() - } - - override fun onResume() { - super.onResume() - binding.cameraView.onResume() - binding.cameraView.setPreviewCallback(scanningThread) - try { - scanningThread.start() - } catch (exception: Exception) { - // Do nothing - } - scanningThread.setScanListener(scanListener) - } - - override fun onConfigurationChanged(newConfiguration: Configuration) { - super.onConfigurationChanged(newConfiguration) - binding.cameraView.onPause() - when (newConfiguration.orientation) { - Configuration.ORIENTATION_LANDSCAPE -> binding.overlayView.orientation = LinearLayout.HORIZONTAL - else -> binding.overlayView.orientation = LinearLayout.VERTICAL - } - binding.cameraView.onResume() - binding.cameraView.setPreviewCallback(scanningThread) - } - - override fun onPause() { - super.onPause() - this.binding.cameraView.onPause() - this.scanningThread.stopScanning() - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodePlaceholderFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodePlaceholderFragment.kt deleted file mode 100644 index 7e14b7234f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodePlaceholderFragment.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.thoughtcrime.securesms.util - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import network.loki.messenger.databinding.FragmentScanQrCodePlaceholderBinding - -class ScanQRCodePlaceholderFragment: Fragment() { - private lateinit var binding: FragmentScanQrCodePlaceholderBinding - var delegate: ScanQRCodePlaceholderFragmentDelegate? = null - - override fun onCreateView(layoutInflater: LayoutInflater, viewGroup: ViewGroup?, bundle: Bundle?): View { - binding = FragmentScanQrCodePlaceholderBinding.inflate(layoutInflater, viewGroup, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.grantCameraAccessButton.setOnClickListener { delegate?.requestCameraAccess() } - } -} - -interface ScanQRCodePlaceholderFragmentDelegate { - - fun requestCameraAccess() -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt index e5de4c36d9..b17356618b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt @@ -1,89 +1,27 @@ package org.thoughtcrime.securesms.util -import android.Manifest -import android.content.pm.PackageManager import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment -import com.tbruyelle.rxpermissions2.RxPermissions -import network.loki.messenger.R -import org.thoughtcrime.securesms.qr.ScanListener +import kotlinx.coroutines.flow.emptyFlow +import org.thoughtcrime.securesms.ui.components.QRScannerScreen +import org.thoughtcrime.securesms.ui.createThemedComposeView -class ScanQRCodeWrapperFragment : Fragment(), ScanQRCodePlaceholderFragmentDelegate, ScanListener { +class ScanQRCodeWrapperFragment : Fragment() { companion object { const val FRAGMENT_TAG = "ScanQRCodeWrapperFragment_FRAGMENT_TAG" } var delegate: ScanQRCodeWrapperFragmentDelegate? = null - var message: CharSequence = "" - var enabled: Boolean = true - set(value) { - val shouldUpdate = field != value // update if value changes (view appears or disappears) - field = value - if (shouldUpdate) { - update() - } - } - - @Deprecated("Deprecated in Java") - override fun setUserVisibleHint(isVisibleToUser: Boolean) { - super.setUserVisibleHint(isVisibleToUser) - enabled = isVisibleToUser - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return inflater.inflate(R.layout.fragment_scan_qr_code_wrapper, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - update() - } - - private fun update() { - if (!this.isAdded) return - - val fragment: Fragment - if (!enabled) { - val manager = childFragmentManager - manager.findFragmentByTag(FRAGMENT_TAG)?.let { existingFragment -> - // remove existing camera fragment (if switching back to other page) - manager.beginTransaction().remove(existingFragment).commit() - } - return - } - if (ContextCompat.checkSelfPermission(requireActivity(), Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { - val scanQRCodeFragment = ScanQRCodeFragment() - scanQRCodeFragment.scanListener = this - scanQRCodeFragment.message = message - fragment = scanQRCodeFragment - } else { - val scanQRCodePlaceholderFragment = ScanQRCodePlaceholderFragment() - scanQRCodePlaceholderFragment.delegate = this - fragment = scanQRCodePlaceholderFragment - } - val transaction = childFragmentManager.beginTransaction() - transaction.replace(R.id.fragmentContainer, fragment, FRAGMENT_TAG) - transaction.commit() - } - - override fun requestCameraAccess() { - @SuppressWarnings("unused") - val unused = RxPermissions(this).request(Manifest.permission.CAMERA).subscribe { isGranted -> - if (isGranted) { - update() - } - } - } - override fun onQrDataFound(data: String) { - activity?.runOnUiThread { - delegate?.handleQRCodeScanned(data) - } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + createThemedComposeView { + QRScannerScreen(emptyFlow(), onScan = { + delegate?.handleQRCodeScanned(it) + }) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Trimmer.java b/app/src/main/java/org/thoughtcrime/securesms/util/Trimmer.java index dd5e146edd..6707a078c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Trimmer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Trimmer.java @@ -30,8 +30,8 @@ protected void onPreExecute() { progressDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); progressDialog.setCancelable(false); progressDialog.setIndeterminate(false); - progressDialog.setTitle(R.string.trimmer__deleting); - progressDialog.setMessage(context.getString(R.string.trimmer__deleting_old_messages)); + progressDialog.setTitle(R.string.deleting); + progressDialog.setMessage(context.getString(R.string.deleting)); progressDialog.setMax(100); progressDialog.show(); } @@ -53,9 +53,6 @@ protected void onProgressUpdate(Integer... progress) { @Override protected void onPostExecute(Void result) { progressDialog.dismiss(); - Toast.makeText(context, - R.string.trimmer__old_messages_successfully_deleted, - Toast.LENGTH_LONG).show(); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/dynamiclanguage/LocaleParseHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/util/dynamiclanguage/LocaleParseHelper.kt deleted file mode 100644 index 7e0b963aec..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/dynamiclanguage/LocaleParseHelper.kt +++ /dev/null @@ -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 - - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java b/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java index 2aa5531263..67a7e0335d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java @@ -160,7 +160,7 @@ private void setVideoViewSource(@NonNull VideoSlide videoSource, boolean autopla //noinspection ConstantConditions this.videoView.setVideoURI(videoSource.getUri()); } else { - Toast.makeText(getContext(), getContext().getString(R.string.VideoPlayer_error_playing_video), Toast.LENGTH_LONG).show(); + Toast.makeText(getContext(), getContext().getString(R.string.videoErrorPlay), Toast.LENGTH_LONG).show(); return; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt index 3d40b5f746..c99c063d8c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.webrtc +import android.Manifest import android.app.NotificationManager import android.content.Context import android.content.Intent @@ -24,6 +25,7 @@ import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.OFFER import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.PRE_OFFER import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.PROVISIONAL_ANSWER import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.service.WebRtcCallService import org.thoughtcrime.securesms.util.CallNotificationBuilder import org.webrtc.IceCandidate @@ -59,18 +61,16 @@ class CallMessageProcessor(private val context: Context, private val textSecureP Log.i("Loki", "Contact is approved?: $approvedContact") if (!approvedContact && storage.getUserPublicKey() != sender) continue - if (!textSecurePreferences.isCallNotificationsEnabled()) { + // if the user has not enabled voice/video calls + // or if the user has not granted audio/microphone permissions + if ( + !textSecurePreferences.isCallNotificationsEnabled() || + !Permissions.hasAll(context, Manifest.permission.RECORD_AUDIO) + ) { Log.d("Loki","Dropping call message if call notifications disabled") if (nextMessage.type != PRE_OFFER) continue val sentTimestamp = nextMessage.sentTimestamp ?: continue - if (textSecurePreferences.setShownCallNotification()) { - // first time call notification encountered - val notification = CallNotificationBuilder.getFirstCallNotification(context) - context.getSystemService(NotificationManager::class.java).notify(CallNotificationBuilder.WEBRTC_NOTIFICATION, notification) - insertMissedCall(sender, sentTimestamp, isFirstCall = true) - } else { - insertMissedCall(sender, sentTimestamp) - } + insertMissedCall(sender, sentTimestamp) continue } @@ -92,14 +92,10 @@ class CallMessageProcessor(private val context: Context, private val textSecureP } } - private fun insertMissedCall(sender: String, sentTimestamp: Long, isFirstCall: Boolean = false) { + private fun insertMissedCall(sender: String, sentTimestamp: Long) { val currentUserPublicKey = storage.getUserPublicKey() if (sender == currentUserPublicKey) return // don't insert a "missed" due to call notifications disabled if it's our own sender - if (isFirstCall) { - storage.insertCallMessage(sender, CallMessageType.CALL_FIRST_MISSED, sentTimestamp) - } else { - storage.insertCallMessage(sender, CallMessageType.CALL_MISSED, sentTimestamp) - } + storage.insertCallMessage(sender, CallMessageType.CALL_MISSED, sentTimestamp) } private fun incomingHangup(callMessage: CallMessage) { diff --git a/app/src/main/res/anim/fade_scale_in.xml b/app/src/main/res/anim/fade_scale_in.xml new file mode 100644 index 0000000000..3e004af2c2 --- /dev/null +++ b/app/src/main/res/anim/fade_scale_in.xml @@ -0,0 +1,21 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_to_bottom.xml b/app/src/main/res/anim/slide_to_bottom.xml new file mode 100644 index 0000000000..0f62dfa487 --- /dev/null +++ b/app/src/main/res/anim/slide_to_bottom.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/prominent_button_color.xml b/app/src/main/res/color/prominent_button_color.xml index 39985565d1..911a0c5429 100644 --- a/app/src/main/res/color/prominent_button_color.xml +++ b/app/src/main/res/color/prominent_button_color.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/ic_arrow_right.png b/app/src/main/res/drawable-hdpi/ic_arrow_right.png deleted file mode 100644 index bbc68bf0f6..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_arrow_right.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_content_copy_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_content_copy_white_24dp.png deleted file mode 100644 index aecd68fb57..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_content_copy_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_info_outline_light.png b/app/src/main/res/drawable-hdpi/ic_info_outline_light.png deleted file mode 100644 index 765f2008a6..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_info_outline_light.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_keyboard_arrow_left_grey600_24dp.png b/app/src/main/res/drawable-hdpi/ic_keyboard_arrow_left_grey600_24dp.png deleted file mode 100644 index 651cb3322e..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_keyboard_arrow_left_grey600_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_select_off.png b/app/src/main/res/drawable-hdpi/ic_select_off.png deleted file mode 100644 index 644c55711f..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_select_off.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_select_on.png b/app/src/main/res/drawable-hdpi/ic_select_on.png deleted file mode 100644 index a1357ccc9c..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_select_on.png and /dev/null differ diff --git a/app/src/main/res/drawable-ldrtl-hdpi/ic_keyboard_arrow_left_grey600_24dp.png b/app/src/main/res/drawable-ldrtl-hdpi/ic_keyboard_arrow_left_grey600_24dp.png deleted file mode 100644 index 1a14d39de9..0000000000 Binary files a/app/src/main/res/drawable-ldrtl-hdpi/ic_keyboard_arrow_left_grey600_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-ldrtl-xhdpi/ic_keyboard_arrow_left_grey600_24dp.png b/app/src/main/res/drawable-ldrtl-xhdpi/ic_keyboard_arrow_left_grey600_24dp.png deleted file mode 100644 index f5f0f219d7..0000000000 Binary files a/app/src/main/res/drawable-ldrtl-xhdpi/ic_keyboard_arrow_left_grey600_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-ldrtl-xxhdpi/ic_keyboard_arrow_left_grey600_24dp.png b/app/src/main/res/drawable-ldrtl-xxhdpi/ic_keyboard_arrow_left_grey600_24dp.png deleted file mode 100644 index 67676b203e..0000000000 Binary files a/app/src/main/res/drawable-ldrtl-xxhdpi/ic_keyboard_arrow_left_grey600_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-ldrtl-xxxhdpi/ic_keyboard_arrow_left_grey600_24dp.png b/app/src/main/res/drawable-ldrtl-xxxhdpi/ic_keyboard_arrow_left_grey600_24dp.png deleted file mode 100644 index b1a9cf4c6b..0000000000 Binary files a/app/src/main/res/drawable-ldrtl-xxxhdpi/ic_keyboard_arrow_left_grey600_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-ldrtl/ic_arrow_left.xml b/app/src/main/res/drawable-ldrtl/ic_arrow_left.xml new file mode 100644 index 0000000000..fed8ba3b3a --- /dev/null +++ b/app/src/main/res/drawable-ldrtl/ic_arrow_left.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-ldrtl/ic_arrow_right.xml b/app/src/main/res/drawable-ldrtl/ic_arrow_right.xml new file mode 100644 index 0000000000..aaa8c18c9a --- /dev/null +++ b/app/src/main/res/drawable-ldrtl/ic_arrow_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-ldrtl/search_bar_end.xml b/app/src/main/res/drawable-ldrtl/search_bar_end.xml new file mode 100644 index 0000000000..3abbe7ea96 --- /dev/null +++ b/app/src/main/res/drawable-ldrtl/search_bar_end.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-ldrtl/search_bar_start.xml b/app/src/main/res/drawable-ldrtl/search_bar_start.xml new file mode 100644 index 0000000000..3fb796d88c --- /dev/null +++ b/app/src/main/res/drawable-ldrtl/search_bar_start.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-mdpi/ic_arrow_right.png b/app/src/main/res/drawable-mdpi/ic_arrow_right.png deleted file mode 100644 index 0e3af28d8e..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_arrow_right.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_content_copy_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_content_copy_white_24dp.png deleted file mode 100644 index 66a0a250b9..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_content_copy_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_info_outline_light.png b/app/src/main/res/drawable-mdpi/ic_info_outline_light.png deleted file mode 100644 index 4d9f50cbc1..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_info_outline_light.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_keyboard_arrow_left_grey600_24dp.png b/app/src/main/res/drawable-mdpi/ic_keyboard_arrow_left_grey600_24dp.png deleted file mode 100644 index ebaae63359..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_keyboard_arrow_left_grey600_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_select_off.png b/app/src/main/res/drawable-mdpi/ic_select_off.png deleted file mode 100644 index fcb3c6a4ed..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_select_off.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_select_on.png b/app/src/main/res/drawable-mdpi/ic_select_on.png deleted file mode 100644 index b46179a7ee..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_select_on.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_arrow_right.png b/app/src/main/res/drawable-xhdpi/ic_arrow_right.png deleted file mode 100644 index 510690366d..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_arrow_right.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_content_copy_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_content_copy_white_24dp.png deleted file mode 100644 index b0b3718738..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_content_copy_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_info_outline_light.png b/app/src/main/res/drawable-xhdpi/ic_info_outline_light.png deleted file mode 100644 index a4759ec06e..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_info_outline_light.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_keyboard_arrow_left_grey600_24dp.png b/app/src/main/res/drawable-xhdpi/ic_keyboard_arrow_left_grey600_24dp.png deleted file mode 100644 index ff768e6a20..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_keyboard_arrow_left_grey600_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_select_off.png b/app/src/main/res/drawable-xhdpi/ic_select_off.png deleted file mode 100644 index 2368c9e3d6..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_select_off.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_select_on.png b/app/src/main/res/drawable-xhdpi/ic_select_on.png deleted file mode 100644 index cb9c641416..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_select_on.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_arrow_right.png b/app/src/main/res/drawable-xxhdpi/ic_arrow_right.png deleted file mode 100644 index daa544853a..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_arrow_right.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_content_copy_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_content_copy_white_24dp.png deleted file mode 100644 index 6f8c7a1dc9..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_content_copy_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_info_outline_light.png b/app/src/main/res/drawable-xxhdpi/ic_info_outline_light.png deleted file mode 100644 index 03b5165eb0..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_info_outline_light.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_left_grey600_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_left_grey600_24dp.png deleted file mode 100644 index eecee45e87..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_left_grey600_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_select_off.png b/app/src/main/res/drawable-xxhdpi/ic_select_off.png deleted file mode 100644 index a4bf4e53d9..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_select_off.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_select_on.png b/app/src/main/res/drawable-xxhdpi/ic_select_on.png deleted file mode 100644 index d8161d0a2d..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_select_on.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_arrow_right.png b/app/src/main/res/drawable-xxxhdpi/ic_arrow_right.png deleted file mode 100644 index f7ce1009b8..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_arrow_right.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_content_copy_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_content_copy_white_24dp.png deleted file mode 100644 index ff65f6d247..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_content_copy_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_left_grey600_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_left_grey600_24dp.png deleted file mode 100644 index 1d718b0ebe..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_left_grey600_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_select_off.png b/app/src/main/res/drawable-xxxhdpi/ic_select_off.png deleted file mode 100644 index da08bfe9a9..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_select_off.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_select_on.png b/app/src/main/res/drawable-xxxhdpi/ic_select_on.png deleted file mode 100644 index 4d8d48fbed..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_select_on.png and /dev/null differ diff --git a/app/src/main/res/drawable/default_dialog_background.xml b/app/src/main/res/drawable/default_dialog_background.xml index d2e3dfbfa7..01ae255c9f 100644 --- a/app/src/main/res/drawable/default_dialog_background.xml +++ b/app/src/main/res/drawable/default_dialog_background.xml @@ -4,7 +4,7 @@ android:shape="rectangle"> - + \ No newline at end of file diff --git a/app/src/main/res/drawable/default_dialog_background_inset.xml b/app/src/main/res/drawable/default_dialog_background_inset.xml new file mode 100644 index 0000000000..5352b3a3db --- /dev/null +++ b/app/src/main/res/drawable/default_dialog_background_inset.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dialog_background.xml b/app/src/main/res/drawable/dialog_background.xml deleted file mode 100644 index e546e1f84c..0000000000 --- a/app/src/main/res/drawable/dialog_background.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/vertical_divider.xml b/app/src/main/res/drawable/horizontal_divider.xml similarity index 72% rename from app/src/main/res/drawable/vertical_divider.xml rename to app/src/main/res/drawable/horizontal_divider.xml index 88e5898e99..2658006cb5 100644 --- a/app/src/main/res/drawable/vertical_divider.xml +++ b/app/src/main/res/drawable/horizontal_divider.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_left.xml b/app/src/main/res/drawable/ic_arrow_left.xml new file mode 100644 index 0000000000..b4d562a13e --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_left.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_left_24.xml b/app/src/main/res/drawable/ic_arrow_left_24.xml deleted file mode 100644 index 3b4a9cab4d..0000000000 --- a/app/src/main/res/drawable/ic_arrow_left_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_arrow_right.xml b/app/src/main/res/drawable/ic_arrow_right.xml new file mode 100644 index 0000000000..3710c7ecb7 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml deleted file mode 100644 index 2a31b2ef3e..0000000000 --- a/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_file_copy_24.xml b/app/src/main/res/drawable/ic_baseline_file_copy_24.xml deleted file mode 100644 index 0cd5895478..0000000000 --- a/app/src/main/res/drawable/ic_baseline_file_copy_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_info_24.xml b/app/src/main/res/drawable/ic_baseline_info_24.xml deleted file mode 100644 index 17255b7ae3..0000000000 --- a/app/src/main/res/drawable/ic_baseline_info_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24dp.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24dp.xml deleted file mode 100644 index f3dfdd0bdb..0000000000 --- a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24dp.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24dp.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24dp.xml deleted file mode 100644 index 19ec5a5eb8..0000000000 --- a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24dp.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_mic_48.xml b/app/src/main/res/drawable/ic_baseline_mic_48.xml deleted file mode 100644 index 2ac4dd40a0..0000000000 --- a/app/src/main/res/drawable/ic_baseline_mic_48.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_photo_camera_48.xml b/app/src/main/res/drawable/ic_baseline_photo_camera_48.xml deleted file mode 100644 index 33acb83243..0000000000 --- a/app/src/main/res/drawable/ic_baseline_photo_camera_48.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_copy.xml b/app/src/main/res/drawable/ic_copy.xml new file mode 100644 index 0000000000..d1d99d4327 --- /dev/null +++ b/app/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000000..1a3e8b4001 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_incoming_call.xml b/app/src/main/res/drawable/ic_incoming_call.xml index f9149818c5..73905f175d 100644 --- a/app/src/main/res/drawable/ic_incoming_call.xml +++ b/app/src/main/res/drawable/ic_incoming_call.xml @@ -5,8 +5,8 @@ android:viewportHeight="20"> + android:fillColor="?message_received_text_color"/> + android:fillColor="?message_received_text_color"/> diff --git a/app/src/main/res/drawable/ic_message_details__trash.xml b/app/src/main/res/drawable/ic_message_details__trash.xml deleted file mode 100644 index 85d4216958..0000000000 --- a/app/src/main/res/drawable/ic_message_details__trash.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_missed_call.xml b/app/src/main/res/drawable/ic_missed_call.xml index e63537737b..a9f3d654a8 100644 --- a/app/src/main/res/drawable/ic_missed_call.xml +++ b/app/src/main/res/drawable/ic_missed_call.xml @@ -5,8 +5,8 @@ android:viewportHeight="20"> + android:fillColor="?danger"/> + android:fillColor="?danger"/> diff --git a/app/src/main/res/drawable/ic_outgoing_call.xml b/app/src/main/res/drawable/ic_outgoing_call.xml index 26024999ee..b6962afb83 100644 --- a/app/src/main/res/drawable/ic_outgoing_call.xml +++ b/app/src/main/res/drawable/ic_outgoing_call.xml @@ -5,8 +5,8 @@ android:viewportHeight="20"> + android:fillColor="?message_received_text_color"/> + android:fillColor="?message_received_text_color"/> diff --git a/app/src/main/res/drawable/ic_radio_selected.xml b/app/src/main/res/drawable/ic_radio_selected.xml new file mode 100644 index 0000000000..82021810e9 --- /dev/null +++ b/app/src/main/res/drawable/ic_radio_selected.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_radio_unselected.xml b/app/src/main/res/drawable/ic_radio_unselected.xml new file mode 100644 index 0000000000..285dbb276e --- /dev/null +++ b/app/src/main/res/drawable/ic_radio_unselected.xml @@ -0,0 +1,11 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000000..9fd7185331 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/image_loading_background.xml b/app/src/main/res/drawable/image_loading_background.xml index d2e3dfbfa7..04c4a8a2a0 100644 --- a/app/src/main/res/drawable/image_loading_background.xml +++ b/app/src/main/res/drawable/image_loading_background.xml @@ -5,6 +5,4 @@ - - \ No newline at end of file diff --git a/app/src/main/res/drawable/media_camera_button_background.xml b/app/src/main/res/drawable/media_camera_button_background.xml index e129933aea..c60e199823 100644 --- a/app/src/main/res/drawable/media_camera_button_background.xml +++ b/app/src/main/res/drawable/media_camera_button_background.xml @@ -2,15 +2,10 @@ - - - - - - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/media_count_button_background.xml b/app/src/main/res/drawable/media_count_button_background.xml index a5fdc45a06..40e8c4ebb6 100644 --- a/app/src/main/res/drawable/media_count_button_background.xml +++ b/app/src/main/res/drawable/media_count_button_background.xml @@ -1,12 +1,12 @@ - - + + - + + - \ No newline at end of file diff --git a/app/src/main/res/drawable/media_count_number_background.xml b/app/src/main/res/drawable/media_count_number_background.xml index 1ffe671559..5eba0a6df3 100644 --- a/app/src/main/res/drawable/media_count_number_background.xml +++ b/app/src/main/res/drawable/media_count_number_background.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/media_selected_indicator_off.xml b/app/src/main/res/drawable/media_selected_indicator_off.xml new file mode 100644 index 0000000000..3bb5a47aa1 --- /dev/null +++ b/app/src/main/res/drawable/media_selected_indicator_off.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/media_selected_indicator_on.xml b/app/src/main/res/drawable/media_selected_indicator_on.xml new file mode 100644 index 0000000000..002385210b --- /dev/null +++ b/app/src/main/res/drawable/media_selected_indicator_on.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/new_conversation_button_background.xml b/app/src/main/res/drawable/new_conversation_button_background.xml deleted file mode 100644 index 4de519558a..0000000000 --- a/app/src/main/res/drawable/new_conversation_button_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/drawable/padded_circle_accent_select.xml b/app/src/main/res/drawable/padded_circle_accent_select.xml index 0d384e658f..ba8e121e16 100644 --- a/app/src/main/res/drawable/padded_circle_accent_select.xml +++ b/app/src/main/res/drawable/padded_circle_accent_select.xml @@ -2,10 +2,10 @@ - + diff --git a/app/src/main/res/drawable/prominent_outline_button_medium_background.xml b/app/src/main/res/drawable/prominent_outline_button_medium_background.xml index 4bde2f855c..503b30c158 100644 --- a/app/src/main/res/drawable/prominent_outline_button_medium_background.xml +++ b/app/src/main/res/drawable/prominent_outline_button_medium_background.xml @@ -1,13 +1,27 @@ - - + + - + - + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/quote_accent_line.xml b/app/src/main/res/drawable/quote_accent_line.xml deleted file mode 100644 index 5693f4991b..0000000000 --- a/app/src/main/res/drawable/quote_accent_line.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/radial_multi_select.xml b/app/src/main/res/drawable/radial_multi_select.xml index c05af4e763..5b2144e897 100644 --- a/app/src/main/res/drawable/radial_multi_select.xml +++ b/app/src/main/res/drawable/radial_multi_select.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/radio_states.xml b/app/src/main/res/drawable/radio_states.xml new file mode 100644 index 0000000000..4d20610ca2 --- /dev/null +++ b/app/src/main/res/drawable/radio_states.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pill.xml b/app/src/main/res/drawable/reaction_pill_background_bordered.xml similarity index 55% rename from app/src/main/res/drawable/pill.xml rename to app/src/main/res/drawable/reaction_pill_background_bordered.xml index f279f0f921..0adaee8025 100644 --- a/app/src/main/res/drawable/pill.xml +++ b/app/src/main/res/drawable/reaction_pill_background_bordered.xml @@ -1,5 +1,6 @@ - + + diff --git a/app/src/main/res/layout/activity_appearance_settings.xml b/app/src/main/res/layout/activity_appearance_settings.xml index c4886708a3..b58b3ca5c3 100644 --- a/app/src/main/res/layout/activity_appearance_settings.xml +++ b/app/src/main/res/layout/activity_appearance_settings.xml @@ -19,7 +19,7 @@ android:paddingVertical="@dimen/small_spacing" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="@string/activity_appearance_themes_category"/> + android:text="@string/appearanceThemes"/> + android:text="@string/appearancePrimaryColor"/> - + android:layout_height="wrap_content" > + + + + + + android:layout_height="wrap_content" /> + android:layout_height="wrap_content" /> @@ -312,7 +338,7 @@ android:paddingVertical="@dimen/small_spacing" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="@string/activity_appearance_follow_system_category"/> + android:text="@string/appearanceAutoDarkMode"/> diff --git a/app/src/main/res/layout/activity_blocked_contacts.xml b/app/src/main/res/layout/activity_blocked_contacts.xml index 8bc458da02..02caf4b7a8 100644 --- a/app/src/main/res/layout/activity_blocked_contacts.xml +++ b/app/src/main/res/layout/activity_blocked_contacts.xml @@ -35,14 +35,14 @@ android:layout_marginTop="@dimen/medium_spacing" app:layout_constraintStart_toStartOf="@+id/cardView" app:layout_constraintEnd_toEndOf="@+id/cardView" - android:text="@string/blocked_contacts_empty_state" + android:text="@string/blockBlockedNone" /> @@ -40,7 +41,6 @@ app:layout_constraintBottom_toTopOf="@+id/typingIndicatorViewContainer" app:layout_constraintTop_toBottomOf="@id/toolbar" /> - + android:backgroundTint="?backgroundSecondary"> + android:text="@string/block"/> @@ -326,7 +326,7 @@