Merge pull request #1662 from oxen-io/release/1.20.0

Release/1.20.0
This commit is contained in:
ThomasSession 2024-09-11 11:11:52 +10:00 committed by GitHub
commit af89d5fee1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
130 changed files with 2716 additions and 2596 deletions

View File

@ -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',
'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',
'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'
],

View File

@ -13,8 +13,8 @@ configurations.forEach {
it.exclude module: "commons-logging"
}
def canonicalVersionCode = 380
def canonicalVersionName = "1.19.2"
def canonicalVersionCode = 382
def canonicalVersionName = "1.20.0"
def postFixSize = 10
def abiPostFix = ['armeabi-v7a' : 1,
@ -310,7 +310,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"
@ -366,7 +365,7 @@ dependencies {
debugImplementation "androidx.compose.ui:ui-test-manifest:$composeVersion"
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha"
implementation "com.google.accompanist:accompanist-permissions:0.33.1-alpha"
implementation "com.google.accompanist:accompanist-permissions:0.36.0"
implementation "com.google.accompanist:accompanist-drawablepainter:0.33.1-alpha"
implementation "androidx.camera:camera-camera2:1.3.2"

View File

@ -457,39 +457,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() {

View File

@ -412,14 +412,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);
@ -436,6 +431,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()));

View File

@ -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()
)
}
}

View File

@ -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 );
}

View File

@ -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() {
SearchToolbar 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<Uri, Void, Uri> {
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<Uri, Void, Uri> {
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);
}
}
}
}

View File

@ -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(

View File

@ -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)

View File

@ -102,14 +102,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
)
@ -126,9 +125,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,

View File

@ -58,7 +58,7 @@ class DisappearingMessagesViewModel(
init {
viewModelScope.launch {
val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode?.maybeConvertToLegacy(isNewConfigEnabled) ?: ExpiryMode.NONE
val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode ?: ExpiryMode.NONE
val recipient = threadDb.getRecipientForThreadId(threadId)
val groupRecord = recipient?.takeIf { it.isClosedGroupRecipient }
?.run { groupDb.getGroup(address.toGroupString()).orNull() }
@ -80,7 +80,7 @@ class DisappearingMessagesViewModel(
override fun onSetClick() = viewModelScope.launch {
val state = _state.value
val mode = state.expiryMode?.coerceLegacyToAfterSend()
val mode = state.expiryMode
val address = state.address
if (address == null || mode == null) {
_event.send(Event.FAIL)
@ -92,8 +92,6 @@ class DisappearingMessagesViewModel(
_event.send(Event.SUCCESS)
}
private fun ExpiryMode.coerceLegacyToAfterSend() = takeUnless { it is ExpiryMode.Legacy } ?: ExpiryMode.AfterSend(expirySeconds)
@dagger.assisted.AssistedFactory
interface AssistedFactory {
fun create(threadId: Long): Factory
@ -125,5 +123,3 @@ class DisappearingMessagesViewModel(
) as T
}
}
private fun ExpiryMode.maybeConvertToLegacy(isNewConfigEnabled: Boolean): ExpiryMode = takeIf { isNewConfigEnabled } ?: ExpiryMode.Legacy(expirySeconds)

View File

@ -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

View File

@ -23,7 +23,6 @@ fun State.toUiState() = UiState(
private fun State.typeOptions(): List<ExpiryRadioOption>? = 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<ExpiryRadioOption>? {
}
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)

View File

@ -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
)
}
}
}

View File

@ -27,21 +27,18 @@ fun PreviewStates(
}
class StatePreviewParameterProvider : PreviewParameterProvider<State> {
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),

View File

@ -104,7 +104,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
@ -117,6 +116,8 @@ import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companio
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_DELETE
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_REPLY
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_RESEND
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_COPY
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_SAVE
import org.thoughtcrime.securesms.conversation.v2.dialogs.BlockedDialog
import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton
@ -1935,7 +1936,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())
@ -2204,6 +2204,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))
}
}
}
@ -2238,7 +2242,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
return result == PackageManager.PERMISSION_GRANTED
}
override fun saveAttachment(messages: Set<MessageRecord>) {
override fun saveAttachmentsIfPossible(messages: Set<MessageRecord>) {
val message = messages.first() as MmsMessageRecord
// Note: The save option is only added to the menu in ConversationReactionOverlay.getMenuActionItems
@ -2253,8 +2257,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)
@ -2275,7 +2279,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 {
@ -2285,7 +2289,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)
@ -2425,7 +2429,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)

View File

@ -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)
}
}
}
}

View File

@ -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
)
}
}

View File

@ -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
@ -58,6 +61,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
@ -95,6 +99,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) {
@ -121,11 +127,18 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
@Composable
private fun MessageDetailsScreen() {
val state by viewModel.stateFlow.collectAsState()
// can only save if the there is a media attachment which has finished downloading.
val canSave = state.mmsRecord?.containsMediaSlide() == true
&& state.mmsRecord?.isMediaPending == false
MessageDetails(
state = state,
onReply = if (state.canReply) { { setResultAndFinish(ON_REPLY) } } else null,
onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } },
onSave = if(canSave) { { setResultAndFinish(ON_SAVE) } } else null,
onDelete = { setResultAndFinish(ON_DELETE) },
onCopy = { setResultAndFinish(ON_COPY) },
onClickImage = { viewModel.onClickImage(it) },
onAttachmentNeedsDownload = viewModel::onAttachmentNeedsDownload,
)
@ -146,7 +159,9 @@ fun MessageDetails(
state: MessageDetailsState,
onReply: (() -> Unit)? = null,
onResend: (() -> Unit)? = null,
onSave: (() -> Unit)? = null,
onDelete: () -> Unit = {},
onCopy: () -> Unit = {},
onClickImage: (Int) -> Unit = {},
onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit = { _ -> }
) {
@ -180,9 +195,11 @@ fun MessageDetails(
state.nonImageAttachmentFileDetails?.let { FileDetails(it) }
CellMetadata(state)
CellButtons(
onReply,
onResend,
onDelete,
onReply = onReply,
onResend = onResend,
onSave = onSave,
onDelete = onDelete,
onCopy = onCopy
)
}
}
@ -204,7 +221,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)
}
}
@ -218,7 +243,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 {
@ -230,6 +257,23 @@ fun CellButtons(
)
Divider()
}
LargeItemButton(
R.string.copy,
R.drawable.ic_copy,
onClick = onCopy
)
Divider()
onSave?.let {
LargeItemButton(
R.string.save,
R.drawable.ic_baseline_save_24,
onClick = it
)
Divider()
}
onResend?.let {
LargeItemButton(
R.string.resend,
@ -238,6 +282,7 @@ fun CellButtons(
)
Divider()
}
LargeItemButton(
R.string.delete,
R.drawable.ic_delete,
@ -319,6 +364,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

View File

@ -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<MessageRecord>)
fun resendMessage(messages: Set<MessageRecord>)
fun showMessageDetail(messages: Set<MessageRecord>)
fun saveAttachment(messages: Set<MessageRecord>)
fun saveAttachmentsIfPossible(messages: Set<MessageRecord>)
fun reply(messages: Set<MessageRecord>)
fun destroyActionMode()
}

View File

@ -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.EditClosedGroupActivity
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.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)
}
}

View File

@ -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,17 +13,26 @@ 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
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.ExpirationConfiguration
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 {
@ -29,6 +41,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)
@ -77,26 +95,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()
}
}
}
}
}
}
}
@ -104,6 +177,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() {
}

View File

@ -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)

View File

@ -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
@ -391,9 +393,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),
@ -404,9 +406,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,

View File

@ -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) {

View File

@ -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) {

View File

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.database
import android.content.Context
import android.net.Uri
import network.loki.messenger.R
import java.security.MessageDigest
import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN
@ -1448,7 +1449,10 @@ open class Storage(
SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode)
}
override fun insertMessageRequestResponse(response: MessageRequestResponse) {
/**
* This will create a control message used to indicate that a contact has accepted our message request
*/
override fun insertMessageRequestResponseFromContact(response: MessageRequestResponse) {
val userPublicKey = getUserPublicKey()
val senderPublicKey = response.sender!!
val recipientPublicKey = response.recipient!!
@ -1542,6 +1546,34 @@ open class Storage(
}
}
/**
* This will create a control message used to indicate that you have accepted a message request
*/
override fun insertMessageRequestResponseFromYou(threadId: Long){
val userPublicKey = getUserPublicKey() ?: return
val mmsDb = DatabaseComponent.get(context).mmsDatabase()
val message = IncomingMediaMessage(
fromSerialized(userPublicKey),
SnodeAPI.nowWithOffset,
-1,
0,
0,
false,
false,
true,
false,
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent()
)
mmsDb.insertSecureDecryptedMessageInbox(message, threadId, runThreadUpdate = false)
}
override fun getRecipientApproved(address: Address): Boolean {
return DatabaseComponent.get(context).recipientDatabase().getApproved(address)
}

View File

@ -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; }

View File

@ -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);
}
}

View File

@ -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()));

View File

@ -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);
}

View File

@ -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;
@ -32,10 +34,14 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.squareup.phrase.Phrase;
import org.session.libsession.utilities.ExpirationUtil;
import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.ui.UtilKt;
import kotlin.Pair;
import network.loki.messenger.R;
/**
@ -113,67 +119,68 @@ public class ThreadRecord extends DisplayRecord {
}
@Override
public SpannableString getDisplayBody(@NonNull Context context) {
public CharSequence getDisplayBody(@NonNull Context context) {
if (isGroupUpdateMessage()) {
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 {
@ -186,20 +193,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; }

View File

@ -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(

View File

@ -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
}
}
}

View File

@ -8,6 +8,7 @@ import android.view.ViewGroup
import androidx.core.view.isVisible
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
@ -82,7 +83,33 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
binding.muteNotificationsTextView.setOnClickListener(this)
binding.notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted
binding.notificationsTextView.setOnClickListener(this)
binding.deleteTextView.setOnClickListener(this)
// delete
binding.deleteTextView.apply {
setOnClickListener(this@ConversationOptionsBottomSheet)
// the text and content description will change depending on the type
when{
// groups and communities
recipient.isGroupRecipient -> {
text = context.getString(R.string.leave)
contentDescription = context.getString(R.string.AccessibilityId_leave)
}
// note to self
recipient.isLocalNumber -> {
text = context.getString(R.string.clear)
contentDescription = context.getString(R.string.AccessibilityId_clear)
}
// 1on1
else -> {
text = context.getString(R.string.delete)
contentDescription = context.getString(R.string.AccessibilityId_delete)
}
}
}
binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 || configFactory.convoVolatile?.getConversationUnread(thread) == true
binding.markAllAsReadTextView.setOnClickListener(this)
binding.pinTextView.isVisible = !thread.isPinned

View File

@ -103,7 +103,7 @@ class ConversationView : LinearLayout {
}
binding.muteIndicatorImageView.setImageResource(drawableRes)
binding.snippetTextView.text = highlightMentions(
text = thread.getSnippet(),
text = thread.getDisplayBody(context),
formatOnly = true, // no styling here, only text formatting
threadID = thread.threadId,
context = context
@ -139,16 +139,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
}

View File

@ -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,

View File

@ -388,6 +388,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
super.onDestroy()
EventBus.getDefault().unregister(this)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
}
// endregion
// region Updating
@ -547,6 +552,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} else {
showMuteDialog(this) { until ->
lifecycleScope.launch(Dispatchers.IO) {
Log.d("", "**** until: $until")
recipientDatabase.setMuted(thread.recipient, until)
withContext(Dispatchers.Main) {
binding.recyclerView.adapter!!.notifyDataSetChanged()
@ -583,13 +589,15 @@ 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()
// 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()
@ -600,6 +608,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) {
@ -610,15 +621,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
@ -650,7 +663,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show()
}
}
button(R.string.no)
button(negativeButtonId)
}
}

View File

@ -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) {

View File

@ -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
}

View File

@ -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();

View File

@ -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 -> ""
}
}

View File

@ -17,11 +17,10 @@ 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
val quitButtonText = c.getSubbedString(R.string.quit, APP_NAME_KEY to APP_NAME)
AlertDialog(
onDismissRequest = dismissDialog,
@ -31,7 +30,7 @@ fun OnboardingBackPressAlertDialog(
},
buttons = listOf(
DialogButtonModel(
text = GetString(quitButtonText),
text = GetString(stringResource(id = R.string.quitButton)),
color = LocalColors.current.danger,
onClick = quit
),

View File

@ -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 -> {

View File

@ -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<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
}
}

View File

@ -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))

View File

@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import network.loki.messenger.R
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.notifications.PushRegistry
@ -58,14 +59,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() {
@ -78,7 +81,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

View File

@ -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);

View File

@ -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<ViewGroup>(R.id.header_container)
view.findViewById<TextView>(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<ViewGroup>(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<TextView>(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() }
}

View File

@ -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()

View File

@ -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

View File

@ -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 {

View File

@ -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());
}
}

View File

@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.preferences
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
@ -11,7 +10,6 @@ import android.os.Bundle
import android.provider.Settings
import android.text.TextUtils
import androidx.lifecycle.lifecycleScope
import androidx.preference.ListPreference
import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
@ -19,17 +17,19 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotificationsEnabled
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.components.SwitchPreferenceCompat
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.PushRegistry
import org.thoughtcrime.securesms.preferences.widgets.DropDownPreference
import java.util.Arrays
import javax.inject.Inject
@AndroidEntryPoint
class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() {
class NotificationsPreferenceFragment : CorrectedPreferenceFragment() {
@Inject
lateinit var pushRegistry: PushRegistry
@Inject
lateinit var prefs: TextSecurePreferences
@ -41,22 +41,22 @@ class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() {
val fcmPreference: SwitchPreferenceCompat = findPreference(fcmKey)!!
fcmPreference.isChecked = prefs.isPushEnabled()
fcmPreference.setOnPreferenceChangeListener { _: Preference, newValue: Any ->
prefs.setPushEnabled(newValue as Boolean)
val job = pushRegistry.refresh(true)
prefs.setPushEnabled(newValue as Boolean)
val job = pushRegistry.refresh(true)
fcmPreference.isEnabled = false
fcmPreference.isEnabled = false
lifecycleScope.launch(Dispatchers.IO) {
job.join()
lifecycleScope.launch(Dispatchers.IO) {
job.join()
withContext(Dispatchers.Main) {
fcmPreference.isEnabled = true
}
withContext(Dispatchers.Main) {
fcmPreference.isEnabled = true
}
true
}
true
}
prefs.setNotificationRingtone(
NotificationChannels.getMessageRingtone(requireContext()).toString()
)
@ -64,8 +64,16 @@ class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() {
NotificationChannels.getMessageVibrate(requireContext())
)
findPreference<Preference>(TextSecurePreferences.RINGTONE_PREF)!!.onPreferenceChangeListener = RingtoneSummaryListener()
findPreference<Preference>(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)!!.onPreferenceChangeListener = NotificationPrivacyListener()
findPreference<DropDownPreference>(TextSecurePreferences.RINGTONE_PREF)?.apply {
setOnViewReady { updateRingtonePref() }
onPreferenceChangeListener = RingtoneSummaryListener()
}
findPreference<DropDownPreference>(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)?.apply {
setOnViewReady { setDropDownLabel(entry) }
onPreferenceChangeListener = NotificationPrivacyListener()
}
findPreference<Preference>(TextSecurePreferences.VIBRATE_PREF)!!.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _: Preference?, newValue: Any ->
NotificationChannels.updateMessageVibrate(requireContext(), newValue as Boolean)
@ -91,28 +99,18 @@ class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() {
true
}
findPreference<Preference>(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<Preference>(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF) as ListPreference?)
findPreference<Preference>(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<Preference>(TextSecurePreferences.VIBRATE_PREF) as SwitchPreferenceCompat?)
}
@ -131,54 +129,63 @@ class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() {
NotificationChannels.updateMessageRingtone(requireContext(), uri)
prefs.setNotificationRingtone(uri.toString())
}
initializeRingtoneSummary(findPreference(TextSecurePreferences.RINGTONE_PREF))
updateRingtonePref()
}
}
private inner class RingtoneSummaryListener : Preference.OnPreferenceChangeListener {
override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean {
val pref = preference as? DropDownPreference ?: return false
val value = newValue as? Uri
if (value == null || TextUtils.isEmpty(value.toString())) {
preference.setSummary(R.string.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<Preference>(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<Void?, Void?, Void?>() {
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)
}
}

View File

@ -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

View File

@ -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<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
}
}
@OptIn(ExperimentalFoundationApi::class)

View File

@ -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<Parcelable>()
@ -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<ProfilePictureView>(R.id.profile_picture_view)
?.also(::setupProfilePictureView)
val pictureIcon = findViewById<View>(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<DebugActivity>() }
LargeItemButton("Debug Menu", R.drawable.ic_settings) { push<DebugActivity>() }
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<Boolean> = LocalBroadcastManager.getInstance(this).hasPaths()

View File

@ -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<AvatarDialogState> = MutableStateFlow(
getDefaultAvatarDialogState()
)
val avatarDialogState: StateFlow<AvatarDialogState>
get() = _avatarDialogState
private val _showLoader: MutableStateFlow<Boolean> = MutableStateFlow(false)
val showLoader: StateFlow<Boolean>
get() = _showLoader
/**
* Refreshes the avatar on the main settings page
*/
private val _refreshAvatar: MutableSharedFlow<Unit> = MutableSharedFlow()
val refreshAvatar: SharedFlow<Unit>
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()
}
}

View File

@ -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
}
}

View File

@ -1,70 +0,0 @@
package org.thoughtcrime.securesms.preferences.widgets;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.TextView;
import androidx.preference.ListPreference;
import androidx.preference.PreferenceViewHolder;
import network.loki.messenger.R;
public class SignalListPreference extends ListPreference {
private TextView rightSummary;
private CharSequence summary;
private OnPreferenceClickListener clickListener;
public SignalListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
initialize();
}
public SignalListPreference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initialize();
}
public SignalListPreference(Context context, AttributeSet attrs) {
super(context, attrs);
initialize();
}
public SignalListPreference(Context context) {
super(context);
initialize();
}
private void initialize() {
setWidgetLayoutResource(R.layout.preference_right_summary_widget);
}
@Override
public void onBindViewHolder(PreferenceViewHolder view) {
super.onBindViewHolder(view);
this.rightSummary = (TextView)view.findViewById(R.id.right_summary);
setSummary(this.summary);
}
@Override
public void setSummary(CharSequence summary) {
super.setSummary(null);
this.summary = summary;
if (this.rightSummary != null) {
this.rightSummary.setText(summary);
}
}
@Override
public void setOnPreferenceClickListener(OnPreferenceClickListener onPreferenceClickListener) {
this.clickListener = onPreferenceClickListener;
}
@Override
protected void onClick() {
if (clickListener == null || !clickListener.onPreferenceClick(this)) {
super.onClick();
}
}
}

View File

@ -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);
}
}
}

View File

@ -1,5 +0,0 @@
package org.thoughtcrime.securesms.qr;
public interface ScanListener {
public void onQrDataFound(String data);
}

View File

@ -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> scanListener = new AtomicReference<>();
private final Map<DecodeHintType, String> 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;
}
}

View File

@ -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);
}

View File

@ -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()
}

View File

@ -333,6 +333,8 @@ class DefaultConversationRepository @Inject constructor(
MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = recipient.isLocalNumber)
.success {
threadDb.setHasSent(threadId, true)
// add a control message for our user
storage.insertMessageRequestResponseFromYou(threadId)
continuation.resume(Result.success(Unit))
}.fail { error ->
continuation.resume(Result.failure(error))

View File

@ -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(

View File

@ -11,8 +11,12 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
@ -45,6 +49,7 @@ import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -60,6 +65,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import network.loki.messenger.R
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.components.ProfilePictureView
import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.OptionsCardData
@ -126,7 +132,7 @@ fun LargeItemButtonWithDrawable(
onClick: () -> Unit
) {
ItemButtonWithDrawable(
textId, icon, modifier.heightIn(min = LocalDimensions.current.minLargeItemButtonHeight),
textId, icon, modifier,
LocalType.current.h8, colors, onClick
)
}
@ -167,8 +173,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
)
}
@ -181,8 +192,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
)
}
@ -191,6 +207,7 @@ fun ItemButton(
text: String,
icon: Int,
modifier: Modifier,
minHeight: Dp = LocalDimensions.current.minItemButtonHeight,
textStyle: TextStyle = LocalType.current.xl,
colors: ButtonColors = transparentButtonColors(),
onClick: () -> Unit
@ -205,6 +222,7 @@ fun ItemButton(
modifier = Modifier.align(Alignment.Center)
)
},
minHeight = minHeight,
textStyle = textStyle,
colors = colors,
onClick = onClick
@ -219,6 +237,7 @@ fun ItemButton(
@StringRes textId: Int,
@DrawableRes icon: Int,
modifier: Modifier = Modifier,
minHeight: Dp = LocalDimensions.current.minItemButtonHeight,
textStyle: TextStyle = LocalType.current.xl,
colors: ButtonColors = transparentButtonColors(),
onClick: () -> Unit
@ -233,6 +252,7 @@ fun ItemButton(
modifier = Modifier.align(Alignment.Center)
)
},
minHeight = minHeight,
textStyle = textStyle,
colors = colors,
onClick = onClick
@ -249,20 +269,23 @@ fun ItemButton(
text: String,
icon: @Composable BoxScope.() -> Unit,
modifier: Modifier = Modifier,
minHeight: Dp = LocalDimensions.current.minLargeItemButtonHeight,
textStyle: TextStyle = LocalType.current.xl,
colors: ButtonColors = transparentButtonColors(),
onClick: () -> Unit
) {
TextButton(
modifier = modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth()
.height(IntrinsicSize.Min)
.heightIn(min = minHeight)
.padding(horizontal = LocalDimensions.current.xsSpacing),
colors = colors,
onClick = onClick,
shape = RectangleShape,
) {
Box(
modifier = Modifier
.width(50.dp)
.wrapContentHeight()
modifier = Modifier.fillMaxHeight()
.aspectRatio(1f)
.align(Alignment.CenterVertically)
) {
icon()
@ -274,7 +297,6 @@ fun ItemButton(
text,
Modifier
.fillMaxWidth()
.padding(vertical = LocalDimensions.current.xsSpacing)
.align(Alignment.CenterVertically),
style = textStyle
)
@ -371,28 +393,38 @@ fun Modifier.fadingEdges(
@Composable
fun Divider(modifier: Modifier = Modifier, startIndent: Dp = 0.dp) {
HorizontalDivider(
modifier = modifier.padding(horizontal = LocalDimensions.current.smallSpacing)
modifier = modifier
.padding(horizontal = LocalDimensions.current.smallSpacing)
.padding(start = startIndent),
color = LocalColors.current.borders,
)
}
//TODO This component should be fully rebuilt in Compose at some point ~~
@Composable
fun RowScope.Avatar(recipient: Recipient) {
Box(
modifier = Modifier
.width(60.dp)
.align(Alignment.CenterVertically)
) {
AndroidView(
factory = {
ProfilePictureView(it).apply { update(recipient) }
},
modifier = Modifier
.width(46.dp)
.height(46.dp)
)
}
fun Avatar(
recipient: Recipient,
modifier: Modifier = Modifier
) {
AndroidView(
factory = {
ProfilePictureView(it).apply { update(recipient) }
},
modifier = modifier
)
}
@Composable
fun Avatar(
userAddress: Address,
modifier: Modifier = Modifier
) {
AndroidView(
factory = {
ProfilePictureView(it).apply { update(userAddress) }
},
modifier = modifier
)
}
@Composable
@ -471,4 +503,4 @@ fun LoadingArcOr(loading: Boolean, content: @Composable () -> Unit) {
AnimatedVisibility(!loading) {
content()
}
}
}

View File

@ -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")
}

View File

@ -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
@ -56,18 +58,23 @@ import com.google.zxing.Result
import com.google.zxing.common.HybridBinarizer
import com.google.zxing.qrcode.QRCodeReader
import com.squareup.phrase.Phrase
import java.util.concurrent.Executors
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<String>,
@ -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))
)
)
)
}
}
}

View File

@ -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,

View File

@ -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()
}
}

View File

@ -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()
}

View File

@ -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)
})
}
}

View File

@ -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) {

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="false" android:color="@color/gray50"/>
<item android:state_enabled="false" android:color="?android:textColorTertiary"/>
<item android:color="?prominentButtonColor"/>
</selector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 691 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 B

View File

@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM15,5l6,6v10c0,1.1 -0.9,2 -2,2L7.99,23C6.89,23 6,22.1 6,21l0.01,-14c0,-1.1 0.89,-2 1.99,-2h7zM14,12h5.5L14,6.5L14,12z"/>
</vector>

View File

@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z"/>
</vector>

View File

@ -1,5 +0,0 @@
<vector android:height="48dp" android:tint="?attr/colorControlNormal"
android:viewportHeight="24" android:viewportWidth="24"
android:width="48dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z"/>
</vector>

View File

@ -1,6 +0,0 @@
<vector android:height="48dp" android:tint="?attr/colorControlNormal"
android:viewportHeight="24" android:viewportWidth="24"
android:width="48dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,12m-3.2,0a3.2,3.2 0,1 1,6.4 0a3.2,3.2 0,1 1,-6.4 0"/>
<path android:fillColor="@android:color/white" android:pathData="M9,2L7.17,4L4,4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,6c0,-1.1 -0.9,-2 -2,-2h-3.17L15,2L9,2zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5z"/>
</vector>

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="50"
android:viewportHeight="50">
<path
android:pathData="M14.295,11.247H18.146V6.687C18.146,4.876 19.089,3.851 20.999,3.851H29.237V13.259C29.237,15.691 30.518,16.956 32.935,16.956H41.606V33.231C41.606,35.059 40.648,36.068 38.738,36.068H35.057V39.918H39.074C43.272,39.918 45.458,37.697 45.458,33.471V17.892C45.458,15.308 44.92,13.658 43.368,12.067L33.565,2.093C32.096,0.587 30.328,0 28.065,0H20.679C16.481,0 14.295,2.218 14.295,6.448V11.247ZM32.452,12.773V5.46L40.604,13.741H33.404C32.73,13.741 32.452,13.447 32.452,12.773Z"
android:fillColor="#000000"/>
<path
android:pathData="M4.571,43.552C4.571,47.798 6.744,50 10.955,50H29.353C33.563,50 35.737,47.779 35.737,43.552V28.424C35.737,25.791 35.403,24.559 33.756,22.88L23.103,12.062C21.52,10.448 20.172,10.082 17.805,10.082H10.955C6.76,10.082 4.571,12.283 4.571,16.529V43.552ZM8.422,43.313V16.753C8.422,14.957 9.365,13.932 11.278,13.932H17.318V24.693C17.318,27.509 18.711,28.882 21.491,28.882H31.882V43.313C31.882,45.14 30.923,46.149 29.03,46.149H11.262C9.365,46.149 8.422,45.14 8.422,43.313ZM21.872,25.486C21.061,25.486 20.715,25.143 20.715,24.328V14.688L31.347,25.486H21.872Z"
android:fillColor="#000000"/>
</vector>

View File

@ -5,8 +5,8 @@
android:viewportHeight="20">
<path
android:pathData="M14.414,7l3.293,-3.293a1,1 0,0 0,-1.414 -1.414L13,5.586V4a1,1 0,1 0,-2 0v4.003a0.996,0.996 0,0 0,0.617 0.921A0.997,0.997 0,0 0,12 9h4a1,1 0,1 0,0 -2h-1.586z"
android:fillColor="#000000"/>
android:fillColor="?message_received_text_color"/>
<path
android:pathData="M2,3a1,1 0,0 1,1 -1h2.153a1,1 0,0 1,0.986 0.836l0.74,4.435a1,1 0,0 1,-0.54 1.06l-1.548,0.773a11.037,11.037 0,0 0,6.105 6.105l0.774,-1.548a1,1 0,0 1,1.059 -0.54l4.435,0.74a1,1 0,0 1,0.836 0.986V17a1,1 0,0 1,-1 1h-2C7.82,18 2,12.18 2,5V3z"
android:fillColor="#000000"/>
android:fillColor="?message_received_text_color"/>
</vector>

View File

@ -5,8 +5,8 @@
android:viewportHeight="20">
<path
android:pathData="M2,3a1,1 0,0 1,1 -1h2.153a1,1 0,0 1,0.986 0.836l0.74,4.435a1,1 0,0 1,-0.54 1.06l-1.548,0.773a11.037,11.037 0,0 0,6.105 6.105l0.774,-1.548a1,1 0,0 1,1.059 -0.54l4.435,0.74a1,1 0,0 1,0.836 0.986V17a1,1 0,0 1,-1 1h-2C7.82,18 2,12.18 2,5V3z"
android:fillColor="#000000"/>
android:fillColor="?danger"/>
<path
android:pathData="M16.707,3.293a1,1 0,0 1,0 1.414L15.414,6l1.293,1.293a1,1 0,0 1,-1.414 1.414L14,7.414l-1.293,1.293a1,1 0,1 1,-1.414 -1.414L12.586,6l-1.293,-1.293a1,1 0,0 1,1.414 -1.414L14,4.586l1.293,-1.293a1,1 0,0 1,1.414 0z"
android:fillColor="#000000"/>
android:fillColor="?danger"/>
</vector>

View File

@ -5,8 +5,8 @@
android:viewportHeight="20">
<path
android:pathData="M17.924,2.617a0.997,0.997 0,0 0,-0.215 -0.322l-0.004,-0.004A0.997,0.997 0,0 0,17 2h-4a1,1 0,1 0,0 2h1.586l-3.293,3.293a1,1 0,0 0,1.414 1.414L16,5.414V7a1,1 0,1 0,2 0V3a0.997,0.997 0,0 0,-0.076 -0.383z"
android:fillColor="#000000"/>
android:fillColor="?message_received_text_color"/>
<path
android:pathData="M2,3a1,1 0,0 1,1 -1h2.153a1,1 0,0 1,0.986 0.836l0.74,4.435a1,1 0,0 1,-0.54 1.06l-1.548,0.773a11.037,11.037 0,0 0,6.105 6.105l0.774,-1.548a1,1 0,0 1,1.059 -0.54l4.435,0.74a1,1 0,0 1,0.836 0.986V17a1,1 0,0 1,-1 1h-2C7.82,18 2,12.18 2,5V3z"
android:fillColor="#000000"/>
android:fillColor="?message_received_text_color"/>
</vector>

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval" />

View File

@ -1,13 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="?prominentButtonColor">
<item>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="false">
<shape android:shape="rectangle">
<solid android:color="?colorPrimary"/>
<solid android:color="@android:color/transparent"/>
<corners android:radius="@dimen/medium_button_corner_radius" />
<stroke
android:color="?prominentButtonColor"
android:color="?android:textColorTertiary"
android:width="@dimen/border_thickness" />
</shape>
</item>
</ripple>
<item>
<ripple
android:color="?prominentButtonColor">
<item>
<shape android:shape="rectangle">
<solid android:color="@android:color/transparent"/>
<corners android:radius="@dimen/medium_button_corner_radius" />
<stroke
android:color="?prominentButtonColor"
android:width="@dimen/border_thickness" />
</shape>
</item>
</ripple>
</item>
</selector>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="rectangle"
xmlns:android="http://schemas.android.com/apk/res/android">
<size android:height="22dp" android:width="3dp"/>
<solid android:color="?colorAccent"/>
</shape>

View File

@ -209,15 +209,44 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:textSize="12sp"
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?message_received_text_color"
android:text="@string/appearancePreview1"
android:gravity="center"
android:drawablePadding="12dp"
app:drawableLeftCompat="@drawable/quote_accent_line" />
android:layout_height="wrap_content" >
<View
android:id="@+id/quote_line"
android:layout_width="3dp"
android:layout_height="0dp"
android:background="?colorAccent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<TextView
android:id="@+id/quote_sender"
android:textSize="12sp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?message_received_text_color"
android:text="@string/you"
android:textStyle="bold"
android:gravity="center"
android:layout_marginStart="@dimen/small_spacing"
app:layout_constraintStart_toEndOf="@+id/quote_line"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/quote_msg"/>
<TextView
android:id="@+id/quote_msg"
android:textSize="12sp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?message_received_text_color"
android:text="@string/appearancePreview1"
android:gravity="center"
android:layout_marginStart="@dimen/small_spacing"
app:layout_constraintStart_toEndOf="@+id/quote_line"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/quote_sender"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:layout_marginTop="4dp"
android:text="@string/appearancePreview2"

View File

@ -199,7 +199,7 @@
android:layout_centerHorizontal="true"
android:layout_alignParentTop="true"
android:background="@drawable/rounded_rectangle"
android:backgroundTint="?conversation_unread_count_indicator_background">
android:backgroundTint="?backgroundSecondary">
<TextView
android:id="@+id/unreadCountTextView"

View File

@ -171,10 +171,9 @@
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:background="@drawable/new_conversation_button_background"
android:layout_marginBottom="@dimen/new_conversation_button_bottom_offset"
android:src="@drawable/ic_plus"
app:tint="@color/white" />
app:rippleColor="@color/button_primary_ripple"
android:src="@drawable/ic_plus" />
</RelativeLayout>

View File

@ -139,4 +139,9 @@
</FrameLayout>
<androidx.compose.ui.platform.ComposeView
android:id="@+id/avatarDialog"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</RelativeLayout>

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