diff --git a/.drone.jsonnet b/.drone.jsonnet index fcc0880394..b088e05023 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -38,7 +38,8 @@ 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 openjdk-17-jdk-headless', + 'apt-get install -y ninja-build openjdk-17-jdk', + 'update-java-alternatives -s java-1.17.0-openjdk-amd64', './gradlew testPlayDebugUnitTestCoverageReport' ], } @@ -78,7 +79,8 @@ 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 openjdk-17-jdk-headless', + '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 c46ec8d78f..da14a10dae 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,8 +15,8 @@ configurations.configureEach { 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, @@ -314,7 +314,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" @@ -376,7 +375,7 @@ dependencies { implementation "androidx.navigation:navigation-compose:$navVersion" 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/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index b4f8455f22..ca2550b70f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -483,39 +483,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO } private void resubmitProfilePictureIfNeeded() { - // Files expire on the file server after a while, so we simply re-upload the user's profile picture - // at a certain interval to ensure it's always available. - String userPublicKey = TextSecurePreferences.getLocalNumber(this); - if (userPublicKey == null) return; - long now = new Date().getTime(); - long lastProfilePictureUpload = TextSecurePreferences.getLastProfilePictureUpload(this); - if (now - lastProfilePictureUpload <= 14 * 24 * 60 * 60 * 1000) return; - ThreadUtils.queue(() -> { - // 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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index e215ff462e..2df6420de3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -413,14 +413,9 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im Permissions.with(this) .request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) .maxSdkVersion(Build.VERSION_CODES.P) - .withPermanentDenialDialog(Phrase.from(getApplicationContext(), R.string.permissionsStorageSaveDenied) - .put(APP_NAME_KEY, getString(R.string.app_name)) - .format().toString()) + .withPermanentDenialDialog(getPermanentlyDeniedStorageText()) .onAnyDenied(() -> { - String txt = Phrase.from(getApplicationContext(), R.string.permissionsStorageSaveDenied) - .put(APP_NAME_KEY, getString(R.string.app_name)) - .format().toString(); - Toast.makeText(this, txt, Toast.LENGTH_LONG).show(); + Toast.makeText(this, getPermanentlyDeniedStorageText(), Toast.LENGTH_LONG).show(); }) .onAllGranted(() -> { SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this); @@ -437,6 +432,12 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im }); } + 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())); diff --git a/app/src/main/java/org/thoughtcrime/securesms/MissingMicrophonePermissionDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/MissingMicrophonePermissionDialog.kt new file mode 100644 index 0000000000..eb7280f628 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/MissingMicrophonePermissionDialog.kt @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms + +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.thoughtcrime.securesms.permissions.SettingsDialog + +class MissingMicrophonePermissionDialog { + companion object { + @JvmStatic + fun show(context: Context) = SettingsDialog.show( + context, + Phrase.from(context, R.string.permissionsMicrophoneAccessRequired) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .format().toString() + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt index b45d1e9f8e..d5e551d02a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt @@ -22,7 +22,10 @@ fun showMuteDialog( if (entry.stringRes == R.string.notificationsMute) { context.getString(R.string.notificationsMute) } else { - val largeTimeUnitString = LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit(context, Option.entries[index].getTime().milliseconds) + val largeTimeUnitString = LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit( + context, + Option.entries[index].duration.milliseconds + ) context.getSubbedString(entry.stringRes, TIME_LARGE_KEY to largeTimeUnitString) } }.toTypedArray()) { @@ -33,16 +36,17 @@ fun showMuteDialog( // 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. - onMuteDuration(Option.entries[it].getTime() + System.currentTimeMillis() + 1.seconds.inWholeMilliseconds) + 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 getTime: () -> 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)), +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, getTime = { Long.MAX_VALUE } ); - - constructor(@StringRes stringRes: Int, duration: Long): this(stringRes, { duration } ) + FOREVER(R.string.notificationsMute, duration = Long.MAX_VALUE ); } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.java index 815cb5c947..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,14 +31,21 @@ import android.provider.OpenableColumns; 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; @@ -57,261 +66,261 @@ import org.thoughtcrime.securesms.util.MediaUtil; * @author Jake McGinty */ public class ShareActivity extends PassphraseRequiredActionBarActivity - implements ContactSelectionListFragment.OnContactSelectedListener -{ - private static final String TAG = ShareActivity.class.getSimpleName(); + 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"; + 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; + 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); - } - - 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.search_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(); - } - } - }); - } + getIntent().putExtra(ContactSelectionListFragment.REFRESHABLE, false); - private void initializeMedia() { - final Context context = this; - isPassingAlongMedia = false; + setContentView(R.layout.share_activity); - 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); - } - } - - 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(); - } - - boolean hasResolvedDestination = threadId != -1 && address != null && distributionType != -1; - - 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); - } - } - - 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"); - } - - return intent; - } - - 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()); - } - - @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; + initializeToolbar(); + initializeResources(); + initializeSearch(); + initializeMedia(); } @Override - protected Uri doInBackground(Uri... uris) { - try { - if (uris.length != 1 || uris[0] == null) { - return null; - } + protected void onNewIntent(Intent intent) { + Log.i(TAG, "onNewIntent()"); + super.onNewIntent(intent); + setIntent(intent); + initializeMedia(); + } - InputStream inputStream; + @Override + public void onPause() { + super.onPause(); + if (!isPassingAlongMedia && resolvedExtra != null) { + BlobProvider.getInstance().delete(this, resolvedExtra); - 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); + if (!isFinishing()) { + finish(); } - } - } 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); + 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() { + 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() + ); + } + + 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 + 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); + } + } + + 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(); + } + + boolean hasResolvedDestination = threadId != -1 && address != null && distributionType != -1; + + 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); + } + } + + 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"); + } + + return intent; + } + + 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()); + } + + @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; + } + + @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/avatar/AvatarSelection.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt index f6f9634ca4..bf19c3cc34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt @@ -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( 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 69bcef2242..9fdc6b1063 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt @@ -89,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) 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 37d5cfad05..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 @@ package org.thoughtcrime.securesms.components; 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 org.thoughtcrime.securesms.util.AnimationCompleteListener; import network.loki.messenger.R; -public class SearchToolbar extends LinearLayout { +public class SearchToolbar extends Toolbar { private float x, y; private MenuItem searchItem; @@ -41,15 +39,10 @@ public class SearchToolbar extends LinearLayout { } 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.search_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); @@ -82,7 +75,7 @@ public class SearchToolbar extends LinearLayout { } }); - toolbar.setNavigationOnClickListener(v -> hide()); + setNavigationOnClickListener(v -> hide()); } @MainThread 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 c9aca24f36..a79ca48888 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt @@ -107,14 +107,13 @@ class ConversationActionBarView @JvmOverloads constructor( if (config?.isEnabled == true) { // Get the type of disappearing message and the abbreviated duration.. val dmTypeString = when (config.expiryMode) { - is AfterRead -> context.getString(R.string.read) - else -> context.getString(R.string.send) + 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(R.string.disappearingMessagesDisappear, - DISAPPEARING_MESSAGES_TYPE_KEY to dmTypeString, + val subtitleTxt = context.getSubbedString(dmTypeString, TIME_KEY to durationAbbreviated ) @@ -131,9 +130,7 @@ class ConversationActionBarView @JvmOverloads constructor( settings += ConversationSetting( recipient.mutedUntil.takeUnless { it == Long.MAX_VALUE } ?.let { - val mutedDuration = (it - System.currentTimeMillis()).milliseconds - val durationString = LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit(context, mutedDuration) - context.getSubbedString(R.string.notificationsMuteFor, TIME_LARGE_KEY to durationString) + context.getString(R.string.notificationsHeaderMute) } ?: context.getString(R.string.notificationsMuted), ConversationSettingType.NOTIFICATION, 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 36b0710f88..f6ecca4ab4 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,9 +58,9 @@ class DisappearingMessagesViewModel( init { viewModelScope.launch { - val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode?.maybeConvertToLegacy(isNewConfigEnabled) ?: ExpiryMode.NONE - val recipient = threadDb.getRecipientForThreadId(threadId) ?: return@launch - val groupRecord = recipient.takeIf { it.isLegacyClosedGroupRecipient } + val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode ?: ExpiryMode.NONE + val recipient = threadDb.getRecipientForThreadId(threadId)?: return@launch + val groupRecord = recipient.takeIf { it.isLegacyClosedGroupRecipient || it.isClosedGroupV2Recipient } ?.run { groupDb.getGroup(address.toGroupString()).orNull() } val isAdmin = when { @@ -92,7 +92,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) @@ -104,8 +104,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 @@ -137,5 +135,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 739d7d6c9f..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 @@ -32,14 +32,13 @@ data class State( 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 } @@ -54,11 +53,6 @@ enum class ExpiryType( R.string.off, contentDescription = R.string.AccessibilityId_disappearingMessagesOff, ), - LEGACY( - ExpiryMode::Legacy, - R.string.expiration_type_disappear_legacy, - contentDescription = R.string.AccessibilityId_disappearingMessagesLegacy - ), AFTER_READ( ExpiryMode::AfterRead, R.string.disappearingMessagesDisappearAfterRead, @@ -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 dbd40354a2..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 @@ -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) 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 ace4097c48..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 @@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.ui.Callbacks import org.thoughtcrime.securesms.ui.NoOpCallbacks import org.thoughtcrime.securesms.ui.OptionsCard import org.thoughtcrime.securesms.ui.RadioOption +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 @@ -71,13 +72,15 @@ fun DisappearingMessages( } } - if (state.showSetButton) SlimOutlineButton( - stringResource(R.string.set), - modifier = Modifier - .contentDescription(R.string.AccessibilityId_setButton) - .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/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 7f0d67ace4..345253bda1 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 @@ -113,7 +113,6 @@ import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.hexEncodedPrivateKey import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.thoughtcrime.securesms.SessionDialogBuilder import org.thoughtcrime.securesms.attachments.ScreenshotObserver import org.thoughtcrime.securesms.audio.AudioRecorder import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel @@ -128,6 +127,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 @@ -1990,7 +1991,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } else { Permissions.with(this) .request(Manifest.permission.RECORD_AUDIO) - .withRationaleDialog(getString(R.string.permissionsMicrophoneAccessRequired), R.drawable.ic_baseline_mic_48) .withPermanentDenialDialog(Phrase.from(applicationContext, R.string.permissionsMicrophoneAccessRequired) .put(APP_NAME_KEY, getString(R.string.app_name)) .format().toString()) @@ -2259,6 +2259,10 @@ 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)) + } } } @@ -2293,7 +2297,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe return result == PackageManager.PERMISSION_GRANTED } - override fun saveAttachment(messages: Set) { + override fun saveAttachmentsIfPossible(messages: Set) { val message = messages.first() as MmsMessageRecord // Note: The save option is only added to the menu in ConversationReactionOverlay.getMenuActionItems @@ -2308,8 +2312,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // 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 30 we require the WRITE_EXTERNAL_STORAGE permission to save attachments. - if (Build.VERSION.SDK_INT < 30) { + // 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) @@ -2330,7 +2334,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe Permissions.with(this) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .maxSdkVersion(Build.VERSION_CODES.P) // P is 28 - .withPermanentDenialDialog(Phrase.from(applicationContext, R.string.permissionsStorageSaveDenied) + .withPermanentDenialDialog(Phrase.from(applicationContext, R.string.permissionsStorageDeniedLegacy) .put(APP_NAME_KEY, getString(R.string.app_name)) .format().toString()) .onAnyDenied { @@ -2340,7 +2344,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe showSessionDialog { title(R.string.permissionsRequired) - val txt = Phrase.from(applicationContext, R.string.permissionsStorageSaveDenied) + val txt = Phrase.from(applicationContext, R.string.permissionsStorageDeniedLegacy) .put(APP_NAME_KEY, getString(R.string.app_name)) .format().toString() text(txt) @@ -2480,7 +2484,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) 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 d75d14ea4a..8b468c3606 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,8 +1,10 @@ package org.thoughtcrime.securesms.conversation.v2 +import android.Manifest import android.content.Context import android.content.Intent import android.database.Cursor +import android.net.Uri import android.util.SparseArray import android.util.SparseBooleanArray import android.view.MotionEvent @@ -30,6 +32,11 @@ import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent import com.bumptech.glide.RequestManager +import com.squareup.phrase.Phrase +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.MissingMicrophonePermissionDialog +import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.ui.getSubbedCharSequence @@ -121,7 +128,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 } @@ -129,50 +140,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 { - 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() - } - } - } else { - viewHolder.view.setOnClickListener(null) - } } } } 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 8fff54833f..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 @@ -24,9 +24,6 @@ import androidx.core.view.doOnLayout import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint -import java.util.Locale -import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -52,6 +49,9 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.util.AnimationCompleteListener import org.thoughtcrime.securesms.util.DateUtils +import java.util.Locale +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds @AndroidEntryPoint class ConversationReactionOverlay : FrameLayout { @@ -213,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 @@ -538,7 +538,7 @@ class ConversationReactionOverlay : FrameLayout { 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) { + if (!recipient.isCommunityRecipient && message.isIncoming) { items += ActionItem(R.attr.menu_copy_icon, R.string.accountIDCopy, { handleActionItemClicked(Action.COPY_ACCOUNT_ID) }) } // Delete message @@ -572,7 +572,7 @@ class ConversationReactionOverlay : FrameLayout { items += ActionItem(R.attr.menu_save_icon, R.string.save, { handleActionItemClicked(Action.DOWNLOAD) }, - R.string.AccessibilityId_save + R.string.AccessibilityId_saveAttachment ) } } 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 a609f7f5b9..7d65f84baa 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 @@ -18,9 +18,11 @@ 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 @@ -46,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 @@ -59,6 +62,7 @@ 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 @@ -96,6 +100,8 @@ 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) { @@ -122,11 +128,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, ) @@ -147,7 +160,9 @@ fun MessageDetails( state: MessageDetailsState, onReply: (() -> Unit)? = null, onResend: (() -> Unit)? = null, + onSave: (() -> Unit)? = null, onDelete: () -> Unit = {}, + onCopy: () -> Unit = {}, onClickImage: (Int) -> Unit = {}, onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit = { _ -> } ) { @@ -181,9 +196,11 @@ fun MessageDetails( state.nonImageAttachmentFileDetails?.let { FileDetails(it) } CellMetadata(state) CellButtons( - onReply, - onResend, - onDelete, + onReply = onReply, + onResend = onResend, + onSave = onSave, + onDelete = onDelete, + onCopy = onCopy ) } } @@ -205,7 +222,15 @@ fun CellMetadata( 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) } } @@ -219,7 +244,9 @@ fun CellMetadata( fun CellButtons( onReply: (() -> Unit)? = null, onResend: (() -> Unit)? = null, - onDelete: () -> Unit = {}, + onSave: (() -> Unit)? = null, + onDelete: () -> Unit, + onCopy: () -> Unit ) { Cell(modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing)) { Column { @@ -231,6 +258,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, @@ -239,6 +283,7 @@ fun CellButtons( ) Divider() } + LargeItemButton( R.string.delete, R.drawable.ic_delete, @@ -320,6 +365,21 @@ fun ExpandButton(modifier: Modifier = Modifier, onClick: () -> Unit) { } } +@Preview +@Composable +fun PreviewMessageDetailsButtons( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + CellButtons( + onReply = {}, + onResend = {}, + onSave = {}, + onDelete = {}, + onCopy = {} + ) + } +} @Preview @Composable 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 90335dd44e..52a32fb9ab 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 @@ -102,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 @@ -126,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 3778eecc52..d16c46946b 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,9 +1,11 @@ package org.thoughtcrime.securesms.conversation.v2.menus +import android.Manifest import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.graphics.BitmapFactory +import android.net.Uri import android.os.AsyncTask import android.view.Menu import android.view.MenuInflater @@ -22,11 +24,14 @@ 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.MissingMicrophonePermissionDialog import org.thoughtcrime.securesms.media.MediaOverviewActivity import org.thoughtcrime.securesms.ShortcutLauncherActivity import org.thoughtcrime.securesms.calls.WebRtcCallActivity @@ -36,6 +41,7 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity.Companion.groupIDKey +import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity import org.thoughtcrime.securesms.service.WebRtcCallService import org.thoughtcrime.securesms.showMuteDialog @@ -162,6 +168,7 @@ 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.callsPermissionsRequired) @@ -173,6 +180,12 @@ object ConversationMenuHelper { } 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") + MissingMicrophonePermissionDialog.show(context) + return + } WebRtcCallService.createCall(context, thread) .let(context::startService) @@ -273,13 +286,13 @@ object ConversationMenuHelper { val accountID = TextSecurePreferences.getLocalNumber(context) val isCurrentUserAdmin = admins.any { it.toString() == accountID } val message = if (isCurrentUserAdmin) { - Phrase.from(context, R.string.groupLeaveDescriptionAdmin) + Phrase.from(context, R.string.groupDeleteDescription) .put(GROUP_NAME_KEY, group.title) - .format().toString() + .format() } else { Phrase.from(context, R.string.groupLeaveDescription) .put(GROUP_NAME_KEY, group.title) - .format().toString() + .format() } fun onLeaveFailed() { @@ -292,7 +305,7 @@ object ConversationMenuHelper { context.showSessionDialog { 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) @@ -303,7 +316,7 @@ object ConversationMenuHelper { onLeaveFailed() } } - button(R.string.no) + button(R.string.cancel) } } 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 1c7e19ad95..666bf9360c 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,7 +1,10 @@ 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 @@ -10,7 +13,6 @@ import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject import network.loki.messenger.R import network.loki.messenger.databinding.ViewControlMessageBinding import network.loki.messenger.libsession_util.util.ExpiryMode @@ -19,10 +21,20 @@ import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.getColorFromAttr +import org.thoughtcrime.securesms.MissingMicrophonePermissionDialog import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode import org.thoughtcrime.securesms.database.model.MessageRecord 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.getSubbedCharSequence +import org.thoughtcrime.securesms.ui.getSubbedString +import javax.inject.Inject + @AndroidEntryPoint class ControlMessageView : LinearLayout { @@ -31,6 +43,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) @@ -80,26 +98,81 @@ class ControlMessageView : LinearLayout { } } message.isMessageRequestResponse -> { - binding.textView.text = context.getString(R.string.messageRequestsAccepted) - binding.root.contentDescription = Phrase.from(context, R.string.messageRequestYouHaveAccepted) - .put(NAME_KEY, message.individualRecipient.name) - .format() + 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 { + // if we're currently missing the audio/microphone permission, + // show a dedicated permission dialog + !Permissions.hasAll(context, Manifest.permission.RECORD_AUDIO) -> { + showInfo() + setOnClickListener { + MissingMicrophonePermissionDialog.show(context) + } + } + + // when the call toggle is disabled in the privacy screen, + // show a dedicated privacy dialog + !TextSecurePreferences.isCallNotificationsEnabled(context) -> { + 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() + } + } + } + } + } } message.isGroupUpdateMessage -> { val updateMessageData: UpdateMessageData? = UpdateMessageData.fromJSON(message.body) @@ -113,6 +186,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/QuoteView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt index 6a5f852409..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 @@ -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.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) 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 3b74cbf4fb..8c9e3a4fa5 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 @@ -61,6 +61,8 @@ 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 @@ -390,9 +392,9 @@ class VisibleMessageView : FrameLayout { context.getColor(R.color.accent_orange), R.string.messageStatusFailedToSync ) - message.isPending -> - // Non-mms messages display 'Sending'.. - if (!message.isMms) { + 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), @@ -403,9 +405,10 @@ class VisibleMessageView : FrameLayout { MessageStatusInfo( R.drawable.ic_delivery_status_sending, context.getColorFromAttr(R.attr.message_status_color), - R.string.messageStatusUploading + R.string.uploading ) } + } message.isSyncing || message.isResyncing -> MessageStatusInfo( R.drawable.ic_delivery_status_sending, 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 221e1b76c2..d042a30196 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 @@ -245,51 +245,58 @@ public class AttachmentManager { 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.permissionsStorageSend) + .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() + ); } - Context c = activity.getApplicationContext(); - - String needStoragePermissionTxt = Phrase.from(c, R.string.permissionsStorageSend).put(APP_NAME_KEY, c.getString(R.string.app_name)).format().toString(); - - String storagePermissionDeniedTxt = Phrase.from(c, R.string.permissionsStorageSaveDenied) - .put(APP_NAME_KEY, c.getString(R.string.app_name)) - .format().toString(); - - builder.withPermanentDenialDialog(storagePermissionDeniedTxt) - .withRationaleDialog(needStoragePermissionTxt, 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(); - String needStoragePermissionTxt = Phrase.from(c, R.string.permissionsStorageSend) - .put(APP_NAME_KEY, c.getString(R.string.app_name)) - .format().toString(); - String cameraPermissionDeniedTxt = Phrase.from(c, R.string.cameraGrantAccessDenied) - .put(APP_NAME_KEY, c.getString(R.string.app_name)) - .format().toString(); 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(cameraPermissionDeniedTxt) - .withRationaleDialog(needStoragePermissionTxt, 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(); } @@ -313,18 +320,13 @@ public class AttachmentManager { public void capturePhoto(Activity activity, int requestCode, Recipient recipient) { - String cameraPermissionDeniedTxt = Phrase.from(context, R.string.cameraGrantAccessDenied) - .put(APP_NAME_KEY, context.getString(R.string.app_name)) - .format().toString(); - - String requireCameraPermissionTxt = Phrase.from(context, R.string.cameraGrantAccessDescription) + 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(cameraPermissionDeniedTxt) - .withRationaleDialog(requireCameraPermissionTxt, R.drawable.ic_baseline_photo_camera_24) .onAllGranted(() -> { Intent captureIntent = MediaSendActivity.buildCameraIntent(activity, recipient); if (captureIntent.resolveActivity(activity.getPackageManager()) != null) { 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 interface MmsSmsColumns { 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 693758bf27..59bd4671b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -5,6 +5,7 @@ import android.net.Uri import com.google.protobuf.ByteString import com.goterl.lazysodium.utils.KeyPair import network.loki.messenger.libsession_util.Config +import network.loki.messenger.R import java.security.MessageDigest import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED @@ -2568,7 +2569,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!! @@ -2668,6 +2672,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 address.isClosedGroupV2 || DatabaseComponent.get(context).recipientDatabase().getApproved(address) } 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 e56d9fc38e..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.Contact; 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,7 +72,7 @@ public class MediaMmsMessageRecord extends MmsMessageRecord { } @Override - public SpannableString getDisplayBody(@NonNull Context context) { + 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 67382e9851..24580218e2 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 abstract class MessageRecord extends DisplayRecord { } @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(), true)); 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 65c8861ff9..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,7 +55,7 @@ public class SmsMessageRecord extends MessageRecord { } @Override - public SpannableString getDisplayBody(@NonNull Context context) { + public CharSequence getDisplayBody(@NonNull Context context) { return super.getDisplayBody(context); } 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 ae11c1c0df..08b375781d 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 @@ -18,7 +18,9 @@ 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; @@ -35,10 +37,14 @@ import org.session.libsession.messaging.utilities.UpdateMessageBuilder; import org.session.libsession.messaging.utilities.UpdateMessageData; 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; /** @@ -118,79 +124,78 @@ public class ThreadRecord extends DisplayRecord { } @Override - public SpannableString getDisplayBody(@NonNull Context context) { + public CharSequence getDisplayBody(@NonNull Context context) { if (isGroupUpdateMessage()) { String body = getBody(); if (!body.isEmpty()) { UpdateMessageData updateMessageData = UpdateMessageData.fromJSON(body); if (updateMessageData != null) { - return emphasisAdded( - UpdateMessageBuilder.buildGroupUpdateMessage(context, updateMessageData, null, isOutgoing(), false) - .toString() - ); + return UpdateMessageBuilder.buildGroupUpdateMessage(context, updateMessageData, null, isOutgoing(), false) + .toString(); } else { return null; } } - return emphasisAdded(context.getString(R.string.groupUpdated)); + return context.getString(R.string.groupUpdated); } else if (isOpenGroupInvitation()) { - return emphasisAdded(context.getString(R.string.communityInvitation)); + return context.getString(R.string.communityInvitation); } else if (MmsSmsColumns.Types.isLegacyType(type)) { - String txt = Phrase.from(context, R.string.messageErrorOld) + return Phrase.from(context, R.string.messageErrorOld) .put(APP_NAME_KEY, context.getString(R.string.app_name)) .format().toString(); - return emphasisAdded(txt); } else if (MmsSmsColumns.Types.isDraftMessageType(type)) { String draftText = context.getString(R.string.draft); - return emphasisAdded(draftText + " " + getBody(), 0, draftText.length()); + return draftText + " " + getBody(); } else if (SmsDatabase.Types.isOutgoingCall(type)) { - String txt = Phrase.from(context, R.string.callsYouCalled) + return Phrase.from(context, R.string.callsYouCalled) .put(NAME_KEY, getName()) .format().toString(); - return emphasisAdded(txt); } else if (SmsDatabase.Types.isIncomingCall(type)) { - String txt = Phrase.from(context, R.string.callsCalledYou) + return Phrase.from(context, R.string.callsCalledYou) .put(NAME_KEY, getName()) .format().toString(); - return emphasisAdded(txt); } else if (SmsDatabase.Types.isMissedCall(type)) { - String txt = Phrase.from(context, R.string.callsMissedCallFrom) + return Phrase.from(context, R.string.callsMissedCallFrom) .put(NAME_KEY, getName()) .format().toString(); - return emphasisAdded(txt); } else if (SmsDatabase.Types.isExpirationTimerUpdate(type)) { int seconds = (int) (getExpiresIn() / 1000); if (seconds <= 0) { - String txt = Phrase.from(context, R.string.disappearingMessagesTurnedOff) + return Phrase.from(context, R.string.disappearingMessagesTurnedOff) .put(NAME_KEY, getName()) .format().toString(); - return emphasisAdded(txt); } // Implied that disappearing messages is enabled.. String time = ExpirationUtil.getExpirationDisplayValue(context, seconds); String disappearAfterWhat = getDisappearingMsgExpiryTypeString(context); // Disappear after send or read? - String txt = Phrase.from(context, R.string.disappearingMessagesSet) + return Phrase.from(context, R.string.disappearingMessagesSet) .put(NAME_KEY, getName()) .put(TIME_KEY, time) .put(DISAPPEARING_MESSAGES_TYPE_KEY, disappearAfterWhat) .format().toString(); - return emphasisAdded(txt); } else if (MmsSmsColumns.Types.isMediaSavedExtraction(type)) { - String txt = Phrase.from(context, R.string.attachmentsMediaSaved) + return Phrase.from(context, R.string.attachmentsMediaSaved) .put(NAME_KEY, getName()) .format().toString(); - return emphasisAdded(txt); } else if (MmsSmsColumns.Types.isScreenshotExtraction(type)) { - String txt = Phrase.from(context, R.string.screenshotTaken) + return Phrase.from(context, R.string.screenshotTaken) .put(NAME_KEY, getName()) .format().toString(); - return emphasisAdded(txt); } else if (MmsSmsColumns.Types.isMessageRequestResponse(type)) { - return emphasisAdded(context.getString(R.string.messageRequestsAccepted)); + 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 { @@ -203,20 +208,37 @@ public class ThreadRecord extends DisplayRecord { return new SpannableString(""); // Old behaviour was: return new SpannableString(emphasisAdded(context.getString(R.string.mediaMessage))); } else { - return new SpannableString(getBody()); + return getNonControlMessageDisplayBody(context); } } } - private SpannableString emphasisAdded(String sequence) { - return emphasisAdded(sequence, 0, sequence.length()); - } + /** + * 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(); + } - 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; + return Phrase.from(context.getString(R.string.messageSnippetGroup)) + .put(AUTHOR_KEY, prefix) + .put(MESSAGE_SNIPPET_KEY, getBody()) + .format().toString(); + } } public long getCount() { return count; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt index 5eb378ef30..f277d1f40b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt @@ -1,6 +1,7 @@ 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 @@ -19,6 +20,8 @@ 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 @@ -45,7 +48,7 @@ fun DebugMenu( sendCommand: (DebugMenuViewModel.Commands) -> Unit, modifier: Modifier = Modifier, onClose: () -> Unit -){ +) { val snackbarHostState = remember { SnackbarHostState() } Scaffold( @@ -56,7 +59,7 @@ fun DebugMenu( ) { contentPadding -> // display a snackbar when required LaunchedEffect(uiState.snackMessage) { - if(!uiState.snackMessage.isNullOrEmpty()){ + if (!uiState.snackMessage.isNullOrEmpty()) { snackbarHostState.showSnackbar(uiState.snackMessage) } } @@ -102,13 +105,22 @@ fun DebugMenu( .verticalScroll(rememberScrollState()) ) { // Info pane - DebugCell("App Info") { + 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: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE} - ${ - BuildConfig.GIT_HASH.take( - 6 - ) - })", + text = "Version: $appVersion", style = LocalType.current.base ) } @@ -155,7 +167,7 @@ fun ColumnScope.DebugCell( @Preview @Composable -fun PreviewDebugMenu(){ +fun PreviewDebugMenu() { PreviewTheme { DebugMenu( uiState = DebugMenuViewModel.UIState( 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 a968775ff1..bcf12b3920 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt @@ -28,6 +28,7 @@ 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 @@ -37,6 +38,8 @@ class JoinCommunityFragment : Fragment() { lateinit var delegate: StartConversationDelegate + var lastUrl: String? = null + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? @@ -66,45 +69,74 @@ class JoinCommunityFragment : Fragment() { } fun joinCommunityIfPossible(url: String) { - val openGroup = try { - OpenGroupUrlParser.parseUrl(url) - } catch (e: OpenGroupUrlParser.Error) { - when (e) { - is OpenGroupUrlParser.Error.MalformedURL, OpenGroupUrlParser.Error.NoRoom -> { - return Toast.makeText(activity, context?.resources?.getString(R.string.communityJoinError), Toast.LENGTH_SHORT).show() - } - is OpenGroupUrlParser.Error.InvalidPublicKey, OpenGroupUrlParser.Error.NoPublicKey -> { - return Toast.makeText(activity, R.string.communityEnterUrlErrorInvalidDescription, Toast.LENGTH_SHORT).show() + // 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() + } } } - } - showLoader() + 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()) + 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() + 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() + } } - } catch (e: Exception) { - Log.e("Loki", "Couldn't join community.", e) - withContext(Dispatchers.Main) { - hideLoader() - val txt = Phrase.from(context, R.string.groupErrorJoin).put(GROUP_NAME_KEY, url).format().toString() - Toast.makeText(activity, txt, Toast.LENGTH_SHORT).show() - } - return@launch } } } 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 3f6fe57352..a4c00f5845 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.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.TextSecurePreferences @@ -88,10 +89,36 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto binding.muteNotificationsTextView.setOnClickListener(this) binding.notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted binding.notificationsTextView.setOnClickListener(this) - binding.deleteTextView.isVisible = recipient.isContactRecipient || (recipient.isGroupRecipient && !isCurrentUserInGroup) - binding.deleteTextView.setOnClickListener(this) + + // delete + binding.deleteTextView.apply { + isVisible = recipient.isContactRecipient || (recipient.isGroupRecipient && !isCurrentUserInGroup) + 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.leaveTextView.isVisible = recipient.isGroupRecipient && isCurrentUserInGroup binding.leaveTextView.setOnClickListener(this) + 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 905f2d2dd1..66e121fc50 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -113,7 +113,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 @@ -149,16 +149,5 @@ class ConversationView : LinearLayout { 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.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 1457d731d7..2ab5cad673 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/EmptyView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/EmptyView.kt @@ -18,7 +18,6 @@ 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.NonTranslatableStringConstants.WAVING_HAND_EMOJI import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY import org.thoughtcrime.securesms.ui.Divider @@ -53,7 +52,7 @@ internal fun EmptyView(newAccount: Boolean) { val c = LocalContext.current Phrase.from(txt) .put(APP_NAME_KEY, c.getString(R.string.app_name)) - .put(EMOJI_KEY, WAVING_HAND_EMOJI) + .put(EMOJI_KEY, "\uD83D\uDC4B") // this hardcoded emoji might be moved to NonTranslatableConstants eventually .format().toString() }, style = LocalType.current.base, 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 d9cbd13cc5..1df32307e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -391,6 +391,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 @@ -552,6 +557,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() @@ -588,6 +594,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), val recipient = thread.recipient 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() @@ -595,7 +603,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // If you are an admin of this group you can delete it if (group != null && group.admins.map { it.toString() } .contains(textSecurePreferences.getLocalNumber())) { - title = getString(R.string.groupDelete) + title = getString(R.string.groupLeave) message = Phrase.from(this.applicationContext, R.string.groupDeleteDescription) .put(GROUP_NAME_KEY, group.title) .format() @@ -609,6 +617,9 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), .put(GROUP_NAME_KEY, group.title) .format() } + + positiveButtonId = R.string.leave + negativeButtonId = R.string.cancel } else { // If this is a 1-on-1 conversation if (recipient.name != null) { @@ -619,15 +630,17 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } 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.noteToSelf) + 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 @@ -668,7 +681,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show() } } - button(R.string.no) + button(negativeButtonId) } } 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 1530845300..7c115736ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt @@ -114,7 +114,7 @@ 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.onionRoutingPath), null, LineView.Location.Top, 1000, 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) { 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 e395987e3f..79697252bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext @@ -69,7 +68,7 @@ fun MediaOverviewScreen( } else { Toast.makeText( context, - R.string.cameraGrantAccessDenied, + R.string.permissionsCameraDenied, Toast.LENGTH_LONG ).show() } @@ -232,7 +231,7 @@ private fun SaveAttachmentWarningDialog( title = context.getString(R.string.warning), text = context.resources.getString(R.string.attachmentsWarning), buttons = listOf( - DialogButtonModel(GetString(R.string.save), GetString(R.string.AccessibilityId_save), color = LocalColors.current.danger, onClick = onAccepted), + 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) ) ) @@ -290,5 +289,5 @@ private fun ActionProgressDialog( private val MediaOverviewTab.titleResId: Int get() = when (this) { MediaOverviewTab.Media -> R.string.media - MediaOverviewTab.Documents -> R.string.document + MediaOverviewTab.Documents -> R.string.files } \ No newline at end of file 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 13783729fc..3a333cd8f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java @@ -362,7 +362,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple private void navigateToCamera() { Context c = getApplicationContext(); - String permanentDenialTxt = Phrase.from(c, R.string.cameraGrantAccessDenied) + 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) @@ -371,7 +371,6 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple Permissions.with(this) .request(Manifest.permission.CAMERA) - .withRationaleDialog(requireCameraPermissionsTxt, R.drawable.ic_baseline_photo_camera_48) .withPermanentDenialDialog(permanentDenialTxt) .onAllGranted(() -> { Camera1Fragment fragment = getOrCreateCameraFragment(); 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 9b3db2efd3..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.messageVoice) - 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/onboarding/OnboardingBackPressAlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/OnboardingBackPressAlertDialog.kt index 4481f524d8..64d91c58af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/OnboardingBackPressAlertDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/OnboardingBackPressAlertDialog.kt @@ -6,28 +6,31 @@ 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.onboardingBackAccountCreation, + @StringRes textId: Int, quit: () -> Unit ) { + val c = LocalContext.current + AlertDialog( onDismissRequest = dismissDialog, title = stringResource(R.string.warning), text = stringResource(textId).let { txt -> - val c = LocalContext.current 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 9a478e6ecc..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 @@ -37,8 +37,6 @@ 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.NonTranslatableStringConstants.BACKHAND_INDEX_POINTING_DOWN_EMOJI -import org.session.libsession.utilities.NonTranslatableStringConstants.WAVING_HAND_EMOJI import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY import org.thoughtcrime.securesms.ui.AlertDialog @@ -139,7 +137,7 @@ internal fun LandingScreen( R.string.onboardingBubbleWelcomeToSession -> { Phrase.from(stringResource(item.stringId)) .put(APP_NAME_KEY, stringResource(R.string.app_name)) - .put(EMOJI_KEY, WAVING_HAND_EMOJI) + .put(EMOJI_KEY, "\uD83D\uDC4B") // this hardcoded emoji might be moved to NonTranslatableConstants eventually .format().toString() } R.string.onboardingBubbleSessionIsEngineered -> { @@ -149,7 +147,7 @@ internal fun LandingScreen( } R.string.onboardingBubbleCreatingAnAccountIsEasy -> { Phrase.from(stringResource(item.stringId)) - .put(EMOJI_KEY, BACKHAND_INDEX_POINTING_DOWN_EMOJI) + .put(EMOJI_KEY, "\uD83D\uDC47") // this hardcoded emoji might be moved to NonTranslatableConstants eventually .format().toString() } else -> { 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 85fbeba78f..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 @@ -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/messagenotifications/MessageNotifications.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt index 7e9846a385..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 @@ -53,7 +53,12 @@ internal fun MessageNotificationsScreen( return } - if (state.showDialog) OnboardingBackPressAlertDialog(dismissDialog, quit = quit) + if (state.showingBackWarningDialogText != null) { + OnboardingBackPressAlertDialog(dismissDialog, + textId = state.showingBackWarningDialogText, + quit = quit + ) + } Column { Spacer(Modifier.weight(1f)) 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 a220e9d60f..0508e97946 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.onboarding.manager.CreateAccountManager @@ -55,14 +56,16 @@ 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() { @@ -75,7 +78,7 @@ internal class MessageNotificationsViewModel( 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/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 class Permissions { 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 class Permissions { 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 021b67facb..e1b5d3f03c 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,34 +26,44 @@ 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 addIcon(id: Int) { + ImageView(context).apply { + setImageDrawable(ResourcesCompat.getDrawable(context.resources, id, context.theme)) + layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT) + }.also(header::addView) + } + + 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) } } - 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) + // 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.notNow) { 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 b77acfd261..bc7a1c4191 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/permissions/SettingsDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/SettingsDialog.kt @@ -11,7 +11,7 @@ class SettingsDialog { context.showSessionDialog { title(R.string.permissionsRequired) text(message) - button(R.string.theContinue, R.string.AccessibilityId_theContinue) { + 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/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 org.thoughtcrime.securesms.permissions.Permissions; 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/HelpSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt index d3c68a814d..2ae5604c06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt @@ -98,10 +98,10 @@ class HelpSettingsFragment: CorrectedPreferenceFragment() { Permissions.with(this) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .maxSdkVersion(Build.VERSION_CODES.P) - .withPermanentDenialDialog(requireContext().getSubbedString(R.string.permissionsStorageSaveDenied, APP_NAME_KEY to getString(R.string.app_name))) + .withPermanentDenialDialog(requireContext().getSubbedString(R.string.permissionsStorageDeniedLegacy, APP_NAME_KEY to getString(R.string.app_name))) .onAnyDenied { val c = requireContext() - val txt = c.getSubbedString(R.string.permissionsStorageSaveDenied, APP_NAME_KEY to getString(R.string.app_name)) + 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 { 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 a57c0ad728..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.unknown)); - return true; - } - } - - protected void initializeListSummary(ListPreference pref) { - pref.setSummary(pref.getEntry()); - } -} 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 1f273a43bd..d8aed33e2c 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 @@ -15,14 +14,15 @@ import androidx.preference.Preference import dagger.hilt.android.AndroidEntryPoint 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.preferences.widgets.DropDownPreference +import java.util.Arrays import javax.inject.Inject @AndroidEntryPoint -class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() { +class NotificationsPreferenceFragment : CorrectedPreferenceFragment() { @Inject lateinit var prefs: TextSecurePreferences @@ -45,8 +45,16 @@ 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) @@ -72,28 +80,18 @@ class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() { true } - findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)!!.onPreferenceClickListener = - Preference.OnPreferenceClickListener { preference: Preference -> - val listPreference = preference as ListPreference - 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?) } @@ -112,54 +110,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.none) + 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.on - false -> R.string.off - }.let(context::getString) - } } 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 361f7b5fd5..a2170e2cf7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt @@ -25,7 +25,7 @@ import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.areNoti import org.thoughtcrime.securesms.util.IntentUtils @AndroidEntryPoint -class PrivacySettingsPreferenceFragment : ListSummaryPreferenceFragment() { +class PrivacySettingsPreferenceFragment : CorrectedPreferenceFragment() { @Inject lateinit var configFactory: ConfigFactory 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 fbf0affb8c..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 @@ -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) 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 bfba7377a7..51a634187a 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,136 +14,115 @@ 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.platform.testTag +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 java.io.File -import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch 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.NonTranslatableStringConstants.DEBUG_MENU -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.debugmenu.DebugActivity -import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.home.PathActivity import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.* import org.thoughtcrime.securesms.preferences.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.CircularProgressIndicator import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton import org.thoughtcrime.securesms.ui.components.PrimaryOutlineCopyButton import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.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 @AndroidEntryPoint 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( @@ -151,12 +131,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" } @@ -169,17 +151,31 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { // set the toolbar icon to a close icon supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_baseline_close_24) - } - override fun onStart() { - super.onStart() + // 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) val environment: String = if(BuildConfig.BUILD_TYPE == "release") "" else " - ${prefs.getEnvironment().label}" val versionDetails = " ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE} - $gitCommitFirstSixChars) $environment" @@ -190,6 +186,25 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { binding.composeView.setThemedContent { Buttons() } + + lifecycleScope.launch { + viewModel.showLoader.collect { + binding.loader.isVisible = it + } + } + + lifecycleScope.launch { + viewModel.refreshAvatar.collect { + binding.profilePictureView.recycle() + binding.profilePictureView.update() + } + } + } + + override fun onStart() { + super.onStart() + + binding.profilePictureView.update() } override fun finish() { @@ -197,17 +212,6 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { overridePendingTransition(R.anim.fade_scale_in, R.anim.slide_to_bottom) } - private fun getDisplayName(): String = - TextSecurePreferences.getProfileName(this) ?: truncateIdForDisplay(hexEncodedPublicKey) - - private fun setupProfilePictureView(view: ProfilePictureView) { - view.apply { - publicKey = hexEncodedPublicKey - displayName = getDisplayName() - update() - } - } - override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) val scrollBundle = SparseArray() @@ -291,7 +295,7 @@ 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 { @@ -311,89 +315,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 @@ -417,39 +338,16 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { return updateDisplayName(displayName) } - private fun showEditProfilePictureUI() { - showSessionDialog { - title(R.string.profileDisplayPictureSet) - view(R.layout.dialog_change_avatar) - - // Note: This is the only instance in a dialog where the "Save" button is not a `dangerButton` - button(R.string.save) { startAvatarSelection() } - - if (prefs.getProfileAvatarId() != 0) { - button(R.string.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 - } - } - private fun startAvatarSelection() { // Ask for an optional camera permission. Permissions.with(this) .request(Manifest.permission.CAMERA) .onAnyResult { - tempFile = avatarSelection.startAvatarSelection( false, true) + avatarSelection.startAvatarSelection( + includeClear = false, + attemptToIncludeCamera = true, + createTempFile = viewModel::createTempFile + ) } .execute() } @@ -523,7 +421,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { Column { // add the debug menu in non release builds if (BuildConfig.BUILD_TYPE != "release") { - LargeItemButton(DEBUG_MENU, R.drawable.ic_settings) { push() } + LargeItemButton("Debug Menu", R.drawable.ic_settings) { push() } Divider() } @@ -576,6 +474,135 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { } } } + + @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() + } + .testTag(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), + 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/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 14d4f75d99..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/SignalListPreference.java +++ /dev/null @@ -1,74 +0,0 @@ -package org.thoughtcrime.securesms.preferences.widgets; - -import android.content.Context; -import androidx.preference.ListPreference; -import androidx.preference.PreferenceViewHolder; -import android.util.AttributeSet; -import android.widget.TextView; -import network.loki.messenger.R; - -public class SignalListPreference extends ListPreference { - - private TextView rightSummaryTV; - private CharSequence summary; - private OnPreferenceClickListener clickListener; - private CharSequence summarySpecifiedInLayoutXML; - - 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() { - summarySpecifiedInLayoutXML = this.getSummary(); - if (summarySpecifiedInLayoutXML == null) { summarySpecifiedInLayoutXML = ""; } - setWidgetLayoutResource(R.layout.preference_right_summary_widget); - } - - @Override - public void onBindViewHolder(PreferenceViewHolder view) { - super.onBindViewHolder(view); - this.rightSummaryTV = (TextView)view.findViewById(R.id.right_summary); - setSummary(this.summary); - } - - @Override - public void setSummary(CharSequence incomingSummary) { - // Set the left "subtitle" summary such as "The information shown in notifications." etc. - super.setSummary(summarySpecifiedInLayoutXML); - - // Then set the right summary to be the incoming drop-down selected option - this.summary = incomingSummary; - if (this.rightSummaryTV != null) { - this.rightSummaryTV.setText(incomingSummary); - } - } - - @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/ReactionsDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsDialogFragment.java index f871be0010..d68d467f27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsDialogFragment.java @@ -103,7 +103,9 @@ public final class ReactionsDialogFragment extends BottomSheetDialogFragment imp ViewCompat.setOnApplyWindowInsetsListener(container, (v, insets) -> insets.consumeSystemWindowInsets()); - TabLayoutMediator mediator = new TabLayoutMediator(emojiTabs, recipientPagerView, (tab, position) -> { + TabLayoutMediator mediator = new TabLayoutMediator( + emojiTabs, recipientPagerView, true, false, + (tab, position) -> { tab.setCustomView(R.layout.reactions_pill_large); View customView = Objects.requireNonNull(tab.getCustomView()); @@ -120,17 +122,13 @@ public final class ReactionsDialogFragment extends BottomSheetDialogFragment imp @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) {} @@ -141,21 +139,6 @@ public final class ReactionsDialogFragment extends BottomSheetDialogFragment imp 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); } 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 ab304e60de..7cb7eb9dac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordActivity.kt @@ -33,7 +33,7 @@ class RecoveryPasswordActivity : BaseActionBarActivity() { private fun onHide() { showSessionDialog { title(R.string.recoveryPasswordHidePermanently) - htmlText(R.string.recoveryPasswordHidePermanentlyDescription1) + text(R.string.recoveryPasswordHidePermanentlyDescription1) dangerButton(R.string.theContinue, R.string.AccessibilityId_theContinue) { onHideConfirm() } cancelButton() } 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 93c60d32f4..f2443057d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -362,6 +362,10 @@ class DefaultConversationRepository @Inject constructor( isSyncMessage = recipient.isLocalNumber ).await() } + + threadDb.setHasSent(threadId, true) + // add a control message for our user + storage.insertMessageRequestResponseFromYou(threadId) } } 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 fed01640e4..c4770f2110 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt @@ -58,6 +58,7 @@ class DialogButtonModel( val contentDescription: GetString = text, val color: Color = Color.Unspecified, val dismissOnClick: Boolean = true, + val enabled: Boolean = true, val onClick: () -> Unit = {}, ) @@ -164,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() @@ -222,16 +224,24 @@ fun DialogButton( text: String, 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( 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 180ec6fd07..f011510a24 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -13,6 +13,8 @@ import androidx.compose.foundation.interaction.MutableInteractionSource 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer @@ -56,6 +58,7 @@ import androidx.compose.ui.graphics.SolidColor 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 @@ -75,6 +78,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 @@ -141,7 +145,7 @@ fun LargeItemButtonWithDrawable( onClick: () -> Unit ) { ItemButtonWithDrawable( - textId, icon, modifier.heightIn(min = LocalDimensions.current.minLargeItemButtonHeight), + textId, icon, modifier, LocalType.current.h8, colors, onClick ) } @@ -187,8 +191,13 @@ 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 ) } @@ -201,8 +210,13 @@ fun LargeItemButton( onClick: () -> Unit ) { ItemButton( - text, icon, modifier.heightIn(min = LocalDimensions.current.minLargeItemButtonHeight), - LocalType.current.h8, colors, onClick + text = text, + icon = icon, + modifier = modifier, + minHeight = LocalDimensions.current.minLargeItemButtonHeight, + textStyle = LocalType.current.h8, + colors = colors, + onClick = onClick ) } @@ -211,6 +225,7 @@ fun ItemButton( text: String, icon: Int, modifier: Modifier, + minHeight: Dp = LocalDimensions.current.minItemButtonHeight, textStyle: TextStyle = LocalType.current.xl, colors: ButtonColors = transparentButtonColors(), onClick: () -> Unit @@ -225,6 +240,7 @@ fun ItemButton( modifier = Modifier.align(Alignment.Center) ) }, + minHeight = minHeight, textStyle = textStyle, colors = colors, onClick = onClick @@ -239,6 +255,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 @@ -253,6 +270,7 @@ fun ItemButton( modifier = Modifier.align(Alignment.Center) ) }, + minHeight = minHeight, textStyle = textStyle, colors = colors, onClick = onClick @@ -269,20 +287,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() @@ -294,7 +315,6 @@ fun ItemButton( text, Modifier .fillMaxWidth() - .padding(vertical = LocalDimensions.current.xsSpacing) .align(Alignment.CenterVertically), style = textStyle ) @@ -398,22 +418,31 @@ fun Divider(modifier: Modifier = Modifier, startIndent: Dp = 0.dp) { ) } +//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 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 6f0bda8c96..22eb4bf860 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,14 @@ package org.thoughtcrime.securesms.ui import android.app.Activity import android.content.Context +import android.content.ContextWrapper import androidx.compose.runtime.Composable import androidx.compose.ui.platform.ComposeView 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 @@ -39,3 +44,17 @@ fun ComposeView.setThemedContent(content: @Composable () -> Unit) = setContent { 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") +} 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 b2b40bbc6f..ea996b59ce 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,8 @@ 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 com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState -import com.google.accompanist.permissions.shouldShowRationale +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import com.google.zxing.BinaryBitmap import com.google.zxing.ChecksumException import com.google.zxing.FormatException @@ -55,19 +57,24 @@ 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" -@OptIn(ExperimentalPermissionsApi::class) @Composable fun QRScannerScreen( errors: Flow, @@ -84,31 +91,14 @@ fun QRScannerScreen( ) { LocalSoftwareKeyboardController.current?.hide() - val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) + val context = LocalContext.current + val permission = Manifest.permission.CAMERA - if (cameraPermissionState.status.isGranted) { + var showCameraPermissionDialog by remember { mutableStateOf(false) } + + if (ContextCompat.checkSelfPermission(context, permission) + == PackageManager.PERMISSION_GRANTED) { ScanQrCode(errors, onScan) - } else if (cameraPermissionState.status.shouldShowRationale) { - Column( - modifier = Modifier - .align(Alignment.Center) - .padding(horizontal = 60.dp) - ) { - Text( - stringResource(R.string.cameraGrantAccessDenied).let { txt -> - val c = LocalContext.current - Phrase.from(txt).put(APP_NAME_KEY, c.getString(R.string.app_name)).format().toString() - }, - 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 @@ -129,11 +119,43 @@ fun QRScannerScreen( 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/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/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 a0528ad432..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodePlaceholderFragment.kt +++ /dev/null @@ -1,34 +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 com.squareup.phrase.Phrase -import network.loki.messenger.R -import network.loki.messenger.databinding.FragmentScanQrCodePlaceholderBinding -import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY - -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() } - - binding.needCameraPermissionsTV.text = Phrase.from(context, R.string.cameraGrantAccessQr) - .put(APP_NAME_KEY, getString(R.string.app_name)) - .format() - } -} - -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/webrtc/CallMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt index 376cb41792..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, sender) - 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/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_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-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-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-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-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/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_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_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_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/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/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/layout/activity_appearance_settings.xml b/app/src/main/res/layout/activity_appearance_settings.xml index 140e0be611..b58b3ca5c3 100644 --- a/app/src/main/res/layout/activity_appearance_settings.xml +++ b/app/src/main/res/layout/activity_appearance_settings.xml @@ -209,15 +209,44 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="vertical"> - + android:layout_height="wrap_content" > + + + + + + android:backgroundTint="?backgroundSecondary"> + app:rippleColor="@color/button_primary_ripple" + android:src="@drawable/ic_plus" /> diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index cd5e2e7cef..3bfc9c65b0 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -139,4 +139,9 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_change_avatar.xml b/app/src/main/res/layout/dialog_change_avatar.xml deleted file mode 100644 index f240f81bbc..0000000000 --- a/app/src/main/res/layout/dialog_change_avatar.xml +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml b/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml index c7c5727adb..15c31891a7 100644 --- a/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml @@ -20,15 +20,15 @@ diff --git a/app/src/main/res/layout/fragment_create_group.xml b/app/src/main/res/layout/fragment_create_group.xml index 52ff393639..54f6dc1949 100644 --- a/app/src/main/res/layout/fragment_create_group.xml +++ b/app/src/main/res/layout/fragment_create_group.xml @@ -124,7 +124,7 @@ android:id="@+id/emptyStateMessageTextView" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="@string/conversationsNone" + android:text="@string/contactNone" android:textColor="?android:textColorPrimary" android:textSize="@dimen/medium_font_size" app:layout_constraintBottom_toTopOf="@id/createNewPrivateChatButton" diff --git a/app/src/main/res/layout/fragment_scan_qr_code.xml b/app/src/main/res/layout/fragment_scan_qr_code.xml deleted file mode 100644 index ea8e1c5fca..0000000000 --- a/app/src/main/res/layout/fragment_scan_qr_code.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_scan_qr_code_placeholder.xml b/app/src/main/res/layout/fragment_scan_qr_code_placeholder.xml deleted file mode 100644 index f88b66800b..0000000000 --- a/app/src/main/res/layout/fragment_scan_qr_code_placeholder.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - -