Merge remote-tracking branch 'origin/dev' into closed_groups
# Conflicts: # .drone.jsonnet # app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt # app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt # app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt # app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java # app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt # app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt # app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt # app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/SignalListPreference.java # app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt # app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt # app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt # libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt # libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt
@ -38,7 +38,8 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/
|
||||
pull: 'always',
|
||||
environment: { ANDROID_HOME: '/usr/lib/android-sdk' },
|
||||
commands: [
|
||||
'apt-get install -y ninja-build openjdk-17-jdk-headless',
|
||||
'apt-get install -y ninja-build openjdk-17-jdk',
|
||||
'update-java-alternatives -s java-1.17.0-openjdk-amd64',
|
||||
'./gradlew testPlayDebugUnitTestCoverageReport'
|
||||
],
|
||||
}
|
||||
@ -78,7 +79,8 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/
|
||||
pull: 'always',
|
||||
environment: { SSH_KEY: { from_secret: 'SSH_KEY' }, ANDROID_HOME: '/usr/lib/android-sdk' },
|
||||
commands: [
|
||||
'apt-get install -y ninja-build openjdk-17-jdk-headless',
|
||||
'apt-get install -y ninja-build openjdk-17-jdk',
|
||||
'update-java-alternatives -s java-1.17.0-openjdk-amd64',
|
||||
'./gradlew assemblePlayDebug',
|
||||
'./scripts/drone-static-upload.sh'
|
||||
],
|
||||
|
@ -15,8 +15,8 @@ configurations.configureEach {
|
||||
exclude module: "commons-logging"
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 380
|
||||
def canonicalVersionName = "1.19.2"
|
||||
def canonicalVersionCode = 382
|
||||
def canonicalVersionName = "1.20.0"
|
||||
|
||||
def postFixSize = 10
|
||||
def abiPostFix = ['armeabi-v7a' : 1,
|
||||
@ -314,7 +314,6 @@ dependencies {
|
||||
implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
|
||||
implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion"
|
||||
implementation "com.jakewharton.rxbinding3:rxbinding:3.1.0"
|
||||
implementation "com.github.tbruyelle:rxpermissions:0.10.2"
|
||||
implementation "com.github.ybq:Android-SpinKit:1.4.0"
|
||||
implementation "com.opencsv:opencsv:4.6"
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
@ -376,7 +375,7 @@ dependencies {
|
||||
implementation "androidx.navigation:navigation-compose:$navVersion"
|
||||
|
||||
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha"
|
||||
implementation "com.google.accompanist:accompanist-permissions:0.33.1-alpha"
|
||||
implementation "com.google.accompanist:accompanist-permissions:0.36.0"
|
||||
implementation "com.google.accompanist:accompanist-drawablepainter:0.33.1-alpha"
|
||||
|
||||
implementation "androidx.camera:camera-camera2:1.3.2"
|
||||
|
@ -483,39 +483,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
}
|
||||
|
||||
private void resubmitProfilePictureIfNeeded() {
|
||||
// Files expire on the file server after a while, so we simply re-upload the user's profile picture
|
||||
// at a certain interval to ensure it's always available.
|
||||
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
|
||||
if (userPublicKey == null) return;
|
||||
long now = new Date().getTime();
|
||||
long lastProfilePictureUpload = TextSecurePreferences.getLastProfilePictureUpload(this);
|
||||
if (now - lastProfilePictureUpload <= 14 * 24 * 60 * 60 * 1000) return;
|
||||
ThreadUtils.queue(() -> {
|
||||
// Don't generate a new profile key here; we do that when the user changes their profile picture
|
||||
Log.d("Loki-Avatar", "Uploading Avatar Started");
|
||||
String encodedProfileKey = TextSecurePreferences.getProfileKey(ApplicationContext.this);
|
||||
try {
|
||||
// Read the file into a byte array
|
||||
InputStream inputStream = AvatarHelper.getInputStreamFor(ApplicationContext.this, Address.fromSerialized(userPublicKey));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
int count;
|
||||
byte[] buffer = new byte[1024];
|
||||
while ((count = inputStream.read(buffer, 0, buffer.length)) != -1) {
|
||||
baos.write(buffer, 0, count);
|
||||
}
|
||||
baos.flush();
|
||||
byte[] profilePicture = baos.toByteArray();
|
||||
// Re-upload it
|
||||
ProfilePictureUtilities.INSTANCE.upload(profilePicture, encodedProfileKey, ApplicationContext.this).success(unit -> {
|
||||
// Update the last profile picture upload date
|
||||
TextSecurePreferences.setLastProfilePictureUpload(ApplicationContext.this, new Date().getTime());
|
||||
Log.d("Loki-Avatar", "Uploading Avatar Finished");
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Log.e("Loki-Avatar", "Uploading avatar failed.");
|
||||
}
|
||||
});
|
||||
ProfilePictureUtilities.INSTANCE.resubmitProfilePictureIfNeeded(this);
|
||||
}
|
||||
|
||||
private void loadEmojiSearchIndexIfNeeded() {
|
||||
|
@ -413,14 +413,9 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
|
||||
Permissions.with(this)
|
||||
.request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
.maxSdkVersion(Build.VERSION_CODES.P)
|
||||
.withPermanentDenialDialog(Phrase.from(getApplicationContext(), R.string.permissionsStorageSaveDenied)
|
||||
.put(APP_NAME_KEY, getString(R.string.app_name))
|
||||
.format().toString())
|
||||
.withPermanentDenialDialog(getPermanentlyDeniedStorageText())
|
||||
.onAnyDenied(() -> {
|
||||
String txt = Phrase.from(getApplicationContext(), R.string.permissionsStorageSaveDenied)
|
||||
.put(APP_NAME_KEY, getString(R.string.app_name))
|
||||
.format().toString();
|
||||
Toast.makeText(this, txt, Toast.LENGTH_LONG).show();
|
||||
Toast.makeText(this, getPermanentlyDeniedStorageText(), Toast.LENGTH_LONG).show();
|
||||
})
|
||||
.onAllGranted(() -> {
|
||||
SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this);
|
||||
@ -437,6 +432,12 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
|
||||
});
|
||||
}
|
||||
|
||||
private String getPermanentlyDeniedStorageText(){
|
||||
return Phrase.from(getApplicationContext(), R.string.permissionsStorageDeniedLegacy)
|
||||
.put(APP_NAME_KEY, getString(R.string.app_name))
|
||||
.format().toString();
|
||||
}
|
||||
|
||||
private void sendMediaSavedNotificationIfNeeded() {
|
||||
if (conversationRecipient.isGroupRecipient()) return;
|
||||
DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset()));
|
||||
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
@ -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 );
|
||||
}
|
@ -17,6 +17,8 @@
|
||||
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
@ -29,14 +31,21 @@ import android.provider.OpenableColumns;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
|
||||
import com.squareup.phrase.Phrase;
|
||||
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.session.libsession.utilities.DistributionTypes;
|
||||
import org.session.libsession.utilities.ViewUtil;
|
||||
@ -57,261 +66,261 @@ import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
* @author Jake McGinty
|
||||
*/
|
||||
public class ShareActivity extends PassphraseRequiredActionBarActivity
|
||||
implements ContactSelectionListFragment.OnContactSelectedListener
|
||||
{
|
||||
private static final String TAG = ShareActivity.class.getSimpleName();
|
||||
implements ContactSelectionListFragment.OnContactSelectedListener {
|
||||
private static final String TAG = ShareActivity.class.getSimpleName();
|
||||
|
||||
public static final String EXTRA_THREAD_ID = "thread_id";
|
||||
public static final String EXTRA_ADDRESS_MARSHALLED = "address_marshalled";
|
||||
public static final String EXTRA_DISTRIBUTION_TYPE = "distribution_type";
|
||||
public static final String EXTRA_THREAD_ID = "thread_id";
|
||||
public static final String EXTRA_ADDRESS_MARSHALLED = "address_marshalled";
|
||||
public static final String EXTRA_DISTRIBUTION_TYPE = "distribution_type";
|
||||
|
||||
private ContactSelectionListFragment contactsFragment;
|
||||
private SearchToolbar searchToolbar;
|
||||
private ImageView searchAction;
|
||||
private View progressWheel;
|
||||
private Uri resolvedExtra;
|
||||
private CharSequence resolvedPlaintext;
|
||||
private String mimeType;
|
||||
private boolean isPassingAlongMedia;
|
||||
private ContactSelectionListFragment contactsFragment;
|
||||
private SearchToolbar searchToolbar;
|
||||
private ImageView searchAction;
|
||||
private View progressWheel;
|
||||
private Uri resolvedExtra;
|
||||
private CharSequence resolvedPlaintext;
|
||||
private String mimeType;
|
||||
private boolean isPassingAlongMedia;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle icicle, boolean ready) {
|
||||
if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) {
|
||||
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, DisplayMode.FLAG_ALL);
|
||||
}
|
||||
|
||||
getIntent().putExtra(ContactSelectionListFragment.REFRESHABLE, false);
|
||||
|
||||
setContentView(R.layout.share_activity);
|
||||
|
||||
initializeToolbar();
|
||||
initializeResources();
|
||||
initializeSearch();
|
||||
initializeMedia();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
Log.i(TAG, "onNewIntent()");
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
initializeMedia();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
if (!isPassingAlongMedia && resolvedExtra != null) {
|
||||
BlobProvider.getInstance().delete(this, resolvedExtra);
|
||||
|
||||
if (!isFinishing()) {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case android.R.id.home:
|
||||
onBackPressed();
|
||||
return true;
|
||||
}
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (searchToolbar.isVisible()) searchToolbar.collapse();
|
||||
else super.onBackPressed();
|
||||
}
|
||||
|
||||
private void initializeToolbar() {
|
||||
Toolbar toolbar = findViewById(R.id.search_toolbar);
|
||||
setSupportActionBar(toolbar);
|
||||
ActionBar actionBar = getSupportActionBar();
|
||||
actionBar.setDisplayHomeAsUpEnabled(true);
|
||||
actionBar.setHomeButtonEnabled(true);
|
||||
}
|
||||
|
||||
private void initializeResources() {
|
||||
progressWheel = findViewById(R.id.progress_wheel);
|
||||
searchToolbar = findViewById(R.id.search_toolbar);
|
||||
searchAction = findViewById(R.id.search_action);
|
||||
contactsFragment = (ContactSelectionListFragment) getSupportFragmentManager().findFragmentById(R.id.contact_selection_list_fragment);
|
||||
contactsFragment.setOnContactSelectedListener(this);
|
||||
}
|
||||
|
||||
private void initializeSearch() {
|
||||
searchAction.setOnClickListener(v -> searchToolbar.display(searchAction.getX() + (searchAction.getWidth() / 2),
|
||||
searchAction.getY() + (searchAction.getHeight() / 2)));
|
||||
|
||||
searchToolbar.setListener(new SearchToolbar.SearchListener() {
|
||||
@Override
|
||||
public void onSearchTextChange(String text) {
|
||||
if (contactsFragment != null) {
|
||||
contactsFragment.setQueryFilter(text);
|
||||
@Override
|
||||
protected void onCreate(Bundle icicle, boolean ready) {
|
||||
if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) {
|
||||
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, DisplayMode.FLAG_ALL);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSearchClosed() {
|
||||
if (contactsFragment != null) {
|
||||
contactsFragment.resetQueryFilter();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
getIntent().putExtra(ContactSelectionListFragment.REFRESHABLE, false);
|
||||
|
||||
private void initializeMedia() {
|
||||
final Context context = this;
|
||||
isPassingAlongMedia = false;
|
||||
setContentView(R.layout.share_activity);
|
||||
|
||||
Uri streamExtra = getIntent().getParcelableExtra(Intent.EXTRA_STREAM);
|
||||
CharSequence charSequenceExtra = getIntent().getCharSequenceExtra(Intent.EXTRA_TEXT);
|
||||
mimeType = getMimeType(streamExtra);
|
||||
|
||||
if (streamExtra != null && PartAuthority.isLocalUri(streamExtra)) {
|
||||
isPassingAlongMedia = true;
|
||||
resolvedExtra = streamExtra;
|
||||
handleResolvedMedia(getIntent(), false);
|
||||
} else if (charSequenceExtra != null && mimeType != null && mimeType.startsWith("text/")) {
|
||||
resolvedPlaintext = charSequenceExtra;
|
||||
handleResolvedMedia(getIntent(), false);
|
||||
} else {
|
||||
contactsFragment.getView().setVisibility(View.GONE);
|
||||
progressWheel.setVisibility(View.VISIBLE);
|
||||
new ResolveMediaTask(context).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, streamExtra);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleResolvedMedia(Intent intent, boolean animate) {
|
||||
long threadId = intent.getLongExtra(EXTRA_THREAD_ID, -1);
|
||||
int distributionType = intent.getIntExtra(EXTRA_DISTRIBUTION_TYPE, -1);
|
||||
Address address = null;
|
||||
|
||||
if (intent.hasExtra(EXTRA_ADDRESS_MARSHALLED)) {
|
||||
Parcel parcel = Parcel.obtain();
|
||||
byte[] marshalled = intent.getByteArrayExtra(EXTRA_ADDRESS_MARSHALLED);
|
||||
parcel.unmarshall(marshalled, 0, marshalled.length);
|
||||
parcel.setDataPosition(0);
|
||||
address = parcel.readParcelable(getClassLoader());
|
||||
parcel.recycle();
|
||||
}
|
||||
|
||||
boolean hasResolvedDestination = threadId != -1 && address != null && distributionType != -1;
|
||||
|
||||
if (!hasResolvedDestination && animate) {
|
||||
ViewUtil.fadeIn(contactsFragment.getView(), 300);
|
||||
ViewUtil.fadeOut(progressWheel, 300);
|
||||
} else if (!hasResolvedDestination) {
|
||||
contactsFragment.getView().setVisibility(View.VISIBLE);
|
||||
progressWheel.setVisibility(View.GONE);
|
||||
} else {
|
||||
createConversation(threadId, address, distributionType);
|
||||
}
|
||||
}
|
||||
|
||||
private void createConversation(long threadId, Address address, int distributionType) {
|
||||
final Intent intent = getBaseShareIntent(ConversationActivityV2.class);
|
||||
intent.putExtra(ConversationActivityV2.ADDRESS, address);
|
||||
intent.putExtra(ConversationActivityV2.THREAD_ID, threadId);
|
||||
|
||||
isPassingAlongMedia = true;
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private Intent getBaseShareIntent(final @NonNull Class<?> target) {
|
||||
final Intent intent = new Intent(this, target);
|
||||
|
||||
if (resolvedExtra != null) {
|
||||
intent.setDataAndType(resolvedExtra, mimeType);
|
||||
} else if (resolvedPlaintext != null) {
|
||||
intent.putExtra(Intent.EXTRA_TEXT, resolvedPlaintext);
|
||||
intent.setType("text/plain");
|
||||
}
|
||||
|
||||
return intent;
|
||||
}
|
||||
|
||||
private String getMimeType(@Nullable Uri uri) {
|
||||
if (uri != null) {
|
||||
final String mimeType = MediaUtil.getMimeType(getApplicationContext(), uri);
|
||||
if (mimeType != null) return mimeType;
|
||||
}
|
||||
return MediaUtil.getCorrectedMimeType(getIntent().getType());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContactSelected(String number) {
|
||||
Recipient recipient = Recipient.from(this, Address.fromExternal(this, number), true);
|
||||
long existingThread = DatabaseComponent.get(this).threadDatabase().getThreadIdIfExistsFor(recipient);
|
||||
createConversation(existingThread, recipient.getAddress(), DistributionTypes.DEFAULT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(String number) {
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private class ResolveMediaTask extends AsyncTask<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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(
|
||||
|
@ -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)
|
||||
|
@ -2,13 +2,11 @@ package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewAnimationUtils;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.Nullable;
|
||||
@ -19,7 +17,7 @@ import org.thoughtcrime.securesms.util.AnimationCompleteListener;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
public class SearchToolbar extends LinearLayout {
|
||||
public class SearchToolbar extends Toolbar {
|
||||
|
||||
private float x, y;
|
||||
private MenuItem searchItem;
|
||||
@ -41,15 +39,10 @@ public class SearchToolbar extends LinearLayout {
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
inflate(getContext(), R.layout.search_toolbar, this);
|
||||
setOrientation(VERTICAL);
|
||||
setNavigationIcon(getContext().getResources().getDrawable(R.drawable.ic_baseline_clear_24));
|
||||
inflateMenu(R.menu.conversation_list_search);
|
||||
|
||||
Toolbar toolbar = findViewById(R.id.search_toolbar);
|
||||
|
||||
toolbar.setNavigationIcon(getContext().getResources().getDrawable(R.drawable.ic_baseline_clear_24));
|
||||
toolbar.inflateMenu(R.menu.conversation_list_search);
|
||||
|
||||
this.searchItem = toolbar.getMenu().findItem(R.id.action_filter_search);
|
||||
this.searchItem = getMenu().findItem(R.id.action_filter_search);
|
||||
SearchView searchView = (SearchView) searchItem.getActionView();
|
||||
EditText searchText = searchView.findViewById(androidx.appcompat.R.id.search_src_text);
|
||||
|
||||
@ -82,7 +75,7 @@ public class SearchToolbar extends LinearLayout {
|
||||
}
|
||||
});
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> hide());
|
||||
setNavigationOnClickListener(v -> hide());
|
||||
}
|
||||
|
||||
@MainThread
|
||||
|
@ -107,14 +107,13 @@ class ConversationActionBarView @JvmOverloads constructor(
|
||||
if (config?.isEnabled == true) {
|
||||
// Get the type of disappearing message and the abbreviated duration..
|
||||
val dmTypeString = when (config.expiryMode) {
|
||||
is AfterRead -> context.getString(R.string.read)
|
||||
else -> context.getString(R.string.send)
|
||||
is AfterRead -> R.string.disappearingMessagesDisappearAfterReadState
|
||||
else -> R.string.disappearingMessagesDisappearAfterSendState
|
||||
}
|
||||
val durationAbbreviated = ExpirationUtil.getExpirationAbbreviatedDisplayValue(config.expiryMode.expirySeconds)
|
||||
|
||||
// ..then substitute into the string..
|
||||
val subtitleTxt = context.getSubbedString(R.string.disappearingMessagesDisappear,
|
||||
DISAPPEARING_MESSAGES_TYPE_KEY to dmTypeString,
|
||||
val subtitleTxt = context.getSubbedString(dmTypeString,
|
||||
TIME_KEY to durationAbbreviated
|
||||
)
|
||||
|
||||
@ -131,9 +130,7 @@ class ConversationActionBarView @JvmOverloads constructor(
|
||||
settings += ConversationSetting(
|
||||
recipient.mutedUntil.takeUnless { it == Long.MAX_VALUE }
|
||||
?.let {
|
||||
val mutedDuration = (it - System.currentTimeMillis()).milliseconds
|
||||
val durationString = LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit(context, mutedDuration)
|
||||
context.getSubbedString(R.string.notificationsMuteFor, TIME_LARGE_KEY to durationString)
|
||||
context.getString(R.string.notificationsHeaderMute)
|
||||
}
|
||||
?: context.getString(R.string.notificationsMuted),
|
||||
ConversationSettingType.NOTIFICATION,
|
||||
|
@ -58,9 +58,9 @@ class DisappearingMessagesViewModel(
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode?.maybeConvertToLegacy(isNewConfigEnabled) ?: ExpiryMode.NONE
|
||||
val recipient = threadDb.getRecipientForThreadId(threadId) ?: return@launch
|
||||
val groupRecord = recipient.takeIf { it.isLegacyClosedGroupRecipient }
|
||||
val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode ?: ExpiryMode.NONE
|
||||
val recipient = threadDb.getRecipientForThreadId(threadId)?: return@launch
|
||||
val groupRecord = recipient.takeIf { it.isLegacyClosedGroupRecipient || it.isClosedGroupV2Recipient }
|
||||
?.run { groupDb.getGroup(address.toGroupString()).orNull() }
|
||||
|
||||
val isAdmin = when {
|
||||
@ -92,7 +92,7 @@ class DisappearingMessagesViewModel(
|
||||
|
||||
override fun onSetClick() = viewModelScope.launch {
|
||||
val state = _state.value
|
||||
val mode = state.expiryMode?.coerceLegacyToAfterSend()
|
||||
val mode = state.expiryMode
|
||||
val address = state.address
|
||||
if (address == null || mode == null) {
|
||||
_event.send(Event.FAIL)
|
||||
@ -104,8 +104,6 @@ class DisappearingMessagesViewModel(
|
||||
_event.send(Event.SUCCESS)
|
||||
}
|
||||
|
||||
private fun ExpiryMode.coerceLegacyToAfterSend() = takeUnless { it is ExpiryMode.Legacy } ?: ExpiryMode.AfterSend(expirySeconds)
|
||||
|
||||
@dagger.assisted.AssistedFactory
|
||||
interface AssistedFactory {
|
||||
fun create(threadId: Long): Factory
|
||||
@ -137,5 +135,3 @@ class DisappearingMessagesViewModel(
|
||||
) as T
|
||||
}
|
||||
}
|
||||
|
||||
private fun ExpiryMode.maybeConvertToLegacy(isNewConfigEnabled: Boolean): ExpiryMode = takeIf { isNewConfigEnabled } ?: ExpiryMode.Legacy(expirySeconds)
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -113,7 +113,6 @@ import org.session.libsignal.utilities.guava.Optional
|
||||
import org.session.libsignal.utilities.hexEncodedPrivateKey
|
||||
import org.thoughtcrime.securesms.ApplicationContext
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
||||
import org.thoughtcrime.securesms.SessionDialogBuilder
|
||||
import org.thoughtcrime.securesms.attachments.ScreenshotObserver
|
||||
import org.thoughtcrime.securesms.audio.AudioRecorder
|
||||
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
|
||||
@ -128,6 +127,8 @@ import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companio
|
||||
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_DELETE
|
||||
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_REPLY
|
||||
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_RESEND
|
||||
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_COPY
|
||||
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_SAVE
|
||||
import org.thoughtcrime.securesms.conversation.v2.dialogs.BlockedDialog
|
||||
import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog
|
||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton
|
||||
@ -1990,7 +1991,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
} else {
|
||||
Permissions.with(this)
|
||||
.request(Manifest.permission.RECORD_AUDIO)
|
||||
.withRationaleDialog(getString(R.string.permissionsMicrophoneAccessRequired), R.drawable.ic_baseline_mic_48)
|
||||
.withPermanentDenialDialog(Phrase.from(applicationContext, R.string.permissionsMicrophoneAccessRequired)
|
||||
.put(APP_NAME_KEY, getString(R.string.app_name))
|
||||
.format().toString())
|
||||
@ -2259,6 +2259,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
ON_REPLY -> reply(set)
|
||||
ON_RESEND -> resendMessage(set)
|
||||
ON_DELETE -> deleteMessages(set)
|
||||
ON_COPY -> copyMessages(set)
|
||||
ON_SAVE -> {
|
||||
if(message is MmsMessageRecord) saveAttachmentsIfPossible(setOf(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2293,7 +2297,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
return result == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override fun saveAttachment(messages: Set<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
|
||||
@ -2308,8 +2312,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
// that we've warned the user just _once_ that any attachments they save can be accessed by other apps.
|
||||
val haveWarned = TextSecurePreferences.getHaveWarnedUserAboutSavingAttachments(this)
|
||||
if (haveWarned) {
|
||||
// On Android versions below 30 we require the WRITE_EXTERNAL_STORAGE permission to save attachments.
|
||||
if (Build.VERSION.SDK_INT < 30) {
|
||||
// On Android versions below 29 we require the WRITE_EXTERNAL_STORAGE permission to save attachments.
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
|
||||
// Save the attachment(s) then bail if we already have permission to do so
|
||||
if (hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
|
||||
saveAttachments(message)
|
||||
@ -2330,7 +2334,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
Permissions.with(this)
|
||||
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
.maxSdkVersion(Build.VERSION_CODES.P) // P is 28
|
||||
.withPermanentDenialDialog(Phrase.from(applicationContext, R.string.permissionsStorageSaveDenied)
|
||||
.withPermanentDenialDialog(Phrase.from(applicationContext, R.string.permissionsStorageDeniedLegacy)
|
||||
.put(APP_NAME_KEY, getString(R.string.app_name))
|
||||
.format().toString())
|
||||
.onAnyDenied {
|
||||
@ -2340,7 +2344,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
showSessionDialog {
|
||||
title(R.string.permissionsRequired)
|
||||
|
||||
val txt = Phrase.from(applicationContext, R.string.permissionsStorageSaveDenied)
|
||||
val txt = Phrase.from(applicationContext, R.string.permissionsStorageDeniedLegacy)
|
||||
.put(APP_NAME_KEY, getString(R.string.app_name))
|
||||
.format().toString()
|
||||
text(txt)
|
||||
@ -2480,7 +2484,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
ConversationReactionOverlay.Action.REPLY -> reply(selectedItems)
|
||||
ConversationReactionOverlay.Action.RESYNC -> resyncMessage(selectedItems)
|
||||
ConversationReactionOverlay.Action.RESEND -> resendMessage(selectedItems)
|
||||
ConversationReactionOverlay.Action.DOWNLOAD -> saveAttachment(selectedItems)
|
||||
ConversationReactionOverlay.Action.DOWNLOAD -> saveAttachmentsIfPossible(selectedItems)
|
||||
ConversationReactionOverlay.Action.COPY_MESSAGE -> copyMessages(selectedItems)
|
||||
ConversationReactionOverlay.Action.VIEW_INFO -> showMessageDetail(selectedItems)
|
||||
ConversationReactionOverlay.Action.SELECT -> selectMessages(selectedItems)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -18,9 +18,11 @@ import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
@ -46,6 +48,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
|
||||
@ -59,6 +62,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAt
|
||||
import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
||||
import org.thoughtcrime.securesms.database.Storage
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
|
||||
import org.thoughtcrime.securesms.ui.Avatar
|
||||
import org.thoughtcrime.securesms.ui.CarouselNextButton
|
||||
import org.thoughtcrime.securesms.ui.CarouselPrevButton
|
||||
@ -96,6 +100,8 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
|
||||
const val ON_REPLY = 1
|
||||
const val ON_RESEND = 2
|
||||
const val ON_DELETE = 3
|
||||
const val ON_COPY = 4
|
||||
const val ON_SAVE = 5
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
@ -122,11 +128,18 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
|
||||
@Composable
|
||||
private fun MessageDetailsScreen() {
|
||||
val state by viewModel.stateFlow.collectAsState()
|
||||
|
||||
// can only save if the there is a media attachment which has finished downloading.
|
||||
val canSave = state.mmsRecord?.containsMediaSlide() == true
|
||||
&& state.mmsRecord?.isMediaPending == false
|
||||
|
||||
MessageDetails(
|
||||
state = state,
|
||||
onReply = if (state.canReply) { { setResultAndFinish(ON_REPLY) } } else null,
|
||||
onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } },
|
||||
onSave = if(canSave) { { setResultAndFinish(ON_SAVE) } } else null,
|
||||
onDelete = { setResultAndFinish(ON_DELETE) },
|
||||
onCopy = { setResultAndFinish(ON_COPY) },
|
||||
onClickImage = { viewModel.onClickImage(it) },
|
||||
onAttachmentNeedsDownload = viewModel::onAttachmentNeedsDownload,
|
||||
)
|
||||
@ -147,7 +160,9 @@ fun MessageDetails(
|
||||
state: MessageDetailsState,
|
||||
onReply: (() -> Unit)? = null,
|
||||
onResend: (() -> Unit)? = null,
|
||||
onSave: (() -> Unit)? = null,
|
||||
onDelete: () -> Unit = {},
|
||||
onCopy: () -> Unit = {},
|
||||
onClickImage: (Int) -> Unit = {},
|
||||
onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit = { _ -> }
|
||||
) {
|
||||
@ -181,9 +196,11 @@ fun MessageDetails(
|
||||
state.nonImageAttachmentFileDetails?.let { FileDetails(it) }
|
||||
CellMetadata(state)
|
||||
CellButtons(
|
||||
onReply,
|
||||
onResend,
|
||||
onDelete,
|
||||
onReply = onReply,
|
||||
onResend = onResend,
|
||||
onSave = onSave,
|
||||
onDelete = onDelete,
|
||||
onCopy = onCopy
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -205,7 +222,15 @@ fun CellMetadata(
|
||||
senderInfo?.let {
|
||||
TitledView(state.fromTitle) {
|
||||
Row {
|
||||
sender?.let { Avatar(it) }
|
||||
sender?.let {
|
||||
Avatar(
|
||||
recipient = it,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.size(46.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(LocalDimensions.current.smallSpacing))
|
||||
}
|
||||
TitledMonospaceText(it)
|
||||
}
|
||||
}
|
||||
@ -219,7 +244,9 @@ fun CellMetadata(
|
||||
fun CellButtons(
|
||||
onReply: (() -> Unit)? = null,
|
||||
onResend: (() -> Unit)? = null,
|
||||
onDelete: () -> Unit = {},
|
||||
onSave: (() -> Unit)? = null,
|
||||
onDelete: () -> Unit,
|
||||
onCopy: () -> Unit
|
||||
) {
|
||||
Cell(modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing)) {
|
||||
Column {
|
||||
@ -231,6 +258,23 @@ fun CellButtons(
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
|
||||
LargeItemButton(
|
||||
R.string.copy,
|
||||
R.drawable.ic_copy,
|
||||
onClick = onCopy
|
||||
)
|
||||
Divider()
|
||||
|
||||
onSave?.let {
|
||||
LargeItemButton(
|
||||
R.string.save,
|
||||
R.drawable.ic_baseline_save_24,
|
||||
onClick = it
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
|
||||
onResend?.let {
|
||||
LargeItemButton(
|
||||
R.string.resend,
|
||||
@ -239,6 +283,7 @@ fun CellButtons(
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
|
||||
LargeItemButton(
|
||||
R.string.delete,
|
||||
R.drawable.ic_delete,
|
||||
@ -320,6 +365,21 @@ fun ExpandButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewMessageDetailsButtons(
|
||||
@PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors
|
||||
) {
|
||||
PreviewTheme(colors) {
|
||||
CellButtons(
|
||||
onReply = {},
|
||||
onResend = {},
|
||||
onSave = {},
|
||||
onDelete = {},
|
||||
onCopy = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
|
@ -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()
|
||||
}
|
@ -1,9 +1,11 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.menus
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.AsyncTask
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
@ -22,11 +24,14 @@ import network.loki.messenger.R
|
||||
import org.session.libsession.messaging.sending_receiving.MessageSender
|
||||
import org.session.libsession.messaging.sending_receiving.leave
|
||||
import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID
|
||||
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
|
||||
import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.session.libsignal.utilities.guava.Optional
|
||||
import org.session.libsignal.utilities.toHexString
|
||||
import org.thoughtcrime.securesms.MissingMicrophonePermissionDialog
|
||||
import org.thoughtcrime.securesms.media.MediaOverviewActivity
|
||||
import org.thoughtcrime.securesms.ShortcutLauncherActivity
|
||||
import org.thoughtcrime.securesms.calls.WebRtcCallActivity
|
||||
@ -36,6 +41,7 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity
|
||||
import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity.Companion.groupIDKey
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
|
||||
import org.thoughtcrime.securesms.service.WebRtcCallService
|
||||
import org.thoughtcrime.securesms.showMuteDialog
|
||||
@ -162,6 +168,7 @@ object ConversationMenuHelper {
|
||||
|
||||
private fun call(context: Context, thread: Recipient) {
|
||||
|
||||
// if the user has not enabled voice/video calls
|
||||
if (!TextSecurePreferences.isCallNotificationsEnabled(context)) {
|
||||
context.showSessionDialog {
|
||||
title(R.string.callsPermissionsRequired)
|
||||
@ -173,6 +180,12 @@ object ConversationMenuHelper {
|
||||
}
|
||||
return
|
||||
}
|
||||
// or if the user has not granted audio/microphone permissions
|
||||
else if (!Permissions.hasAll(context, Manifest.permission.RECORD_AUDIO)) {
|
||||
Log.d("Loki", "Attempted to make a call without audio permissions")
|
||||
MissingMicrophonePermissionDialog.show(context)
|
||||
return
|
||||
}
|
||||
|
||||
WebRtcCallService.createCall(context, thread)
|
||||
.let(context::startService)
|
||||
@ -273,13 +286,13 @@ object ConversationMenuHelper {
|
||||
val accountID = TextSecurePreferences.getLocalNumber(context)
|
||||
val isCurrentUserAdmin = admins.any { it.toString() == accountID }
|
||||
val message = if (isCurrentUserAdmin) {
|
||||
Phrase.from(context, R.string.groupLeaveDescriptionAdmin)
|
||||
Phrase.from(context, R.string.groupDeleteDescription)
|
||||
.put(GROUP_NAME_KEY, group.title)
|
||||
.format().toString()
|
||||
.format()
|
||||
} else {
|
||||
Phrase.from(context, R.string.groupLeaveDescription)
|
||||
.put(GROUP_NAME_KEY, group.title)
|
||||
.format().toString()
|
||||
.format()
|
||||
}
|
||||
|
||||
fun onLeaveFailed() {
|
||||
@ -292,7 +305,7 @@ object ConversationMenuHelper {
|
||||
context.showSessionDialog {
|
||||
title(R.string.groupLeave)
|
||||
text(message)
|
||||
button(R.string.yes) {
|
||||
dangerButton(R.string.leave) {
|
||||
try {
|
||||
val groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString()
|
||||
val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey)
|
||||
@ -303,7 +316,7 @@ object ConversationMenuHelper {
|
||||
onLeaveFailed()
|
||||
}
|
||||
}
|
||||
button(R.string.no)
|
||||
button(R.string.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,10 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.messages
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.LinearLayout
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
@ -10,7 +13,6 @@ import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.squareup.phrase.Phrase
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ViewControlMessageBinding
|
||||
import network.loki.messenger.libsession_util.util.ExpiryMode
|
||||
@ -19,10 +21,20 @@ import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||
import org.session.libsession.messaging.utilities.UpdateMessageData
|
||||
import org.session.libsession.utilities.getColorFromAttr
|
||||
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.getColorFromAttr
|
||||
import org.thoughtcrime.securesms.MissingMicrophonePermissionDialog
|
||||
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages
|
||||
import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
|
||||
import org.thoughtcrime.securesms.showSessionDialog
|
||||
import org.thoughtcrime.securesms.ui.getSubbedCharSequence
|
||||
import org.thoughtcrime.securesms.ui.getSubbedString
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ControlMessageView : LinearLayout {
|
||||
@ -31,6 +43,12 @@ class ControlMessageView : LinearLayout {
|
||||
|
||||
private val binding = ViewControlMessageBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
|
||||
private val infoDrawable by lazy {
|
||||
val d = ResourcesCompat.getDrawable(resources, R.drawable.ic_info_outline_white_24dp, context.theme)
|
||||
d?.setTint(context.getColorFromAttr(R.attr.message_received_text_color))
|
||||
d
|
||||
}
|
||||
|
||||
constructor(context: Context) : super(context)
|
||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
|
||||
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
|
||||
@ -80,26 +98,81 @@ class ControlMessageView : LinearLayout {
|
||||
}
|
||||
}
|
||||
message.isMessageRequestResponse -> {
|
||||
binding.textView.text = context.getString(R.string.messageRequestsAccepted)
|
||||
binding.root.contentDescription = Phrase.from(context, R.string.messageRequestYouHaveAccepted)
|
||||
.put(NAME_KEY, message.individualRecipient.name)
|
||||
.format()
|
||||
val msgRecipient = message.recipient.address.serialize()
|
||||
val me = TextSecurePreferences.getLocalNumber(context)
|
||||
binding.textView.text = if(me == msgRecipient) { // you accepted the user's request
|
||||
val threadRecipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(message.threadId)
|
||||
context.getSubbedCharSequence(
|
||||
R.string.messageRequestYouHaveAccepted,
|
||||
NAME_KEY to (threadRecipient?.name ?: "")
|
||||
)
|
||||
} else { // they accepted your request
|
||||
context.getString(R.string.messageRequestsAccepted)
|
||||
}
|
||||
|
||||
binding.root.contentDescription = context.getString(R.string.AccessibilityId_message_request_config_message)
|
||||
}
|
||||
message.isCallLog -> {
|
||||
val drawable = when {
|
||||
message.isIncomingCall -> R.drawable.ic_incoming_call
|
||||
message.isOutgoingCall -> R.drawable.ic_outgoing_call
|
||||
message.isFirstMissedCall -> R.drawable.ic_info_outline_light
|
||||
else -> R.drawable.ic_missed_call
|
||||
}
|
||||
binding.textView.isVisible = false
|
||||
binding.callTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(ResourcesCompat.getDrawable(resources, drawable, context.theme), null, null, null)
|
||||
binding.callTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
ResourcesCompat.getDrawable(resources, drawable, context.theme),
|
||||
null, null, null)
|
||||
binding.callTextView.text = messageBody
|
||||
|
||||
if (message.expireStarted > 0 && message.expiresIn > 0) {
|
||||
binding.expirationTimerView.isVisible = true
|
||||
binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
|
||||
}
|
||||
|
||||
// remove clicks by default
|
||||
setOnClickListener(null)
|
||||
hideInfo()
|
||||
|
||||
// handle click behaviour depending on criteria
|
||||
if (message.isMissedCall || message.isFirstMissedCall) {
|
||||
when {
|
||||
// if we're currently missing the audio/microphone permission,
|
||||
// show a dedicated permission dialog
|
||||
!Permissions.hasAll(context, Manifest.permission.RECORD_AUDIO) -> {
|
||||
showInfo()
|
||||
setOnClickListener {
|
||||
MissingMicrophonePermissionDialog.show(context)
|
||||
}
|
||||
}
|
||||
|
||||
// when the call toggle is disabled in the privacy screen,
|
||||
// show a dedicated privacy dialog
|
||||
!TextSecurePreferences.isCallNotificationsEnabled(context) -> {
|
||||
showInfo()
|
||||
setOnClickListener {
|
||||
context.showSessionDialog {
|
||||
val titleTxt = context.getSubbedString(
|
||||
R.string.callsMissedCallFrom,
|
||||
NAME_KEY to message.individualRecipient.name!!
|
||||
)
|
||||
title(titleTxt)
|
||||
|
||||
val bodyTxt = context.getSubbedCharSequence(
|
||||
R.string.callsYouMissedCallPermissions,
|
||||
NAME_KEY to message.individualRecipient.name!!
|
||||
)
|
||||
text(bodyTxt)
|
||||
|
||||
button(R.string.sessionSettings) {
|
||||
Intent(context, PrivacySettingsActivity::class.java)
|
||||
.let(context::startActivity)
|
||||
}
|
||||
cancelButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
message.isGroupUpdateMessage -> {
|
||||
val updateMessageData: UpdateMessageData? = UpdateMessageData.fromJSON(message.body)
|
||||
@ -113,6 +186,24 @@ class ControlMessageView : LinearLayout {
|
||||
binding.callView.isVisible = message.isCallLog
|
||||
}
|
||||
|
||||
fun showInfo(){
|
||||
binding.callTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
binding.callTextView.compoundDrawablesRelative.first(),
|
||||
null,
|
||||
infoDrawable,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
fun hideInfo(){
|
||||
binding.callTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
binding.callTextView.compoundDrawablesRelative.first(),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
fun recycle() {
|
||||
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -61,6 +61,8 @@ import org.thoughtcrime.securesms.groups.OpenGroupManager
|
||||
import org.thoughtcrime.securesms.home.UserDetailsBottomSheet
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.RequestManager
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.disableClipping
|
||||
import org.thoughtcrime.securesms.util.toDp
|
||||
@ -390,9 +392,9 @@ class VisibleMessageView : FrameLayout {
|
||||
context.getColor(R.color.accent_orange),
|
||||
R.string.messageStatusFailedToSync
|
||||
)
|
||||
message.isPending ->
|
||||
// Non-mms messages display 'Sending'..
|
||||
if (!message.isMms) {
|
||||
message.isPending -> {
|
||||
// Non-mms messages (or quote messages, which happen to be mms for some reason) display 'Sending'..
|
||||
if (!message.isMms || (message as? MmsMessageRecord)?.quote != null) {
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_sending,
|
||||
context.getColorFromAttr(R.attr.message_status_color),
|
||||
@ -403,9 +405,10 @@ class VisibleMessageView : FrameLayout {
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_sending,
|
||||
context.getColorFromAttr(R.attr.message_status_color),
|
||||
R.string.messageStatusUploading
|
||||
R.string.uploading
|
||||
)
|
||||
}
|
||||
}
|
||||
message.isSyncing || message.isResyncing ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_sending,
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -5,6 +5,7 @@ import android.net.Uri
|
||||
import com.google.protobuf.ByteString
|
||||
import com.goterl.lazysodium.utils.KeyPair
|
||||
import network.loki.messenger.libsession_util.Config
|
||||
import network.loki.messenger.R
|
||||
import java.security.MessageDigest
|
||||
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN
|
||||
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED
|
||||
@ -2568,7 +2569,10 @@ open class Storage(
|
||||
SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode)
|
||||
}
|
||||
|
||||
override fun insertMessageRequestResponse(response: MessageRequestResponse) {
|
||||
/**
|
||||
* This will create a control message used to indicate that a contact has accepted our message request
|
||||
*/
|
||||
override fun insertMessageRequestResponseFromContact(response: MessageRequestResponse) {
|
||||
val userPublicKey = getUserPublicKey()
|
||||
val senderPublicKey = response.sender!!
|
||||
val recipientPublicKey = response.recipient!!
|
||||
@ -2668,6 +2672,34 @@ open class Storage(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This will create a control message used to indicate that you have accepted a message request
|
||||
*/
|
||||
override fun insertMessageRequestResponseFromYou(threadId: Long){
|
||||
val userPublicKey = getUserPublicKey() ?: return
|
||||
|
||||
val mmsDb = DatabaseComponent.get(context).mmsDatabase()
|
||||
val message = IncomingMediaMessage(
|
||||
fromSerialized(userPublicKey),
|
||||
SnodeAPI.nowWithOffset,
|
||||
-1,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
Optional.absent()
|
||||
)
|
||||
mmsDb.insertSecureDecryptedMessageInbox(message, threadId, runThreadUpdate = false)
|
||||
}
|
||||
|
||||
override fun getRecipientApproved(address: Address): Boolean {
|
||||
return address.isClosedGroupV2 || DatabaseComponent.get(context).recipientDatabase().getApproved(address)
|
||||
}
|
||||
|
@ -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; }
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -115,7 +115,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
}
|
||||
|
||||
@Override
|
||||
public SpannableString getDisplayBody(@NonNull Context context) {
|
||||
public CharSequence getDisplayBody(@NonNull Context context) {
|
||||
if (isGroupUpdateMessage()) {
|
||||
UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(getBody());
|
||||
return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing(), true));
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -18,7 +18,9 @@
|
||||
package org.thoughtcrime.securesms.database.model;
|
||||
|
||||
import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY;
|
||||
import static org.session.libsession.utilities.StringSubstitutionConstants.AUTHOR_KEY;
|
||||
import static org.session.libsession.utilities.StringSubstitutionConstants.DISAPPEARING_MESSAGES_TYPE_KEY;
|
||||
import static org.session.libsession.utilities.StringSubstitutionConstants.MESSAGE_SNIPPET_KEY;
|
||||
import static org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY;
|
||||
import static org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY;
|
||||
|
||||
@ -35,10 +37,14 @@ import org.session.libsession.messaging.utilities.UpdateMessageBuilder;
|
||||
import org.session.libsession.messaging.utilities.UpdateMessageData;
|
||||
import com.squareup.phrase.Phrase;
|
||||
import org.session.libsession.utilities.ExpirationUtil;
|
||||
import org.session.libsession.utilities.TextSecurePreferences;
|
||||
import org.session.libsession.utilities.recipients.Recipient;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsColumns;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.ui.UtilKt;
|
||||
|
||||
import kotlin.Pair;
|
||||
import network.loki.messenger.R;
|
||||
|
||||
/**
|
||||
@ -118,79 +124,78 @@ public class ThreadRecord extends DisplayRecord {
|
||||
}
|
||||
|
||||
@Override
|
||||
public SpannableString getDisplayBody(@NonNull Context context) {
|
||||
public CharSequence getDisplayBody(@NonNull Context context) {
|
||||
if (isGroupUpdateMessage()) {
|
||||
String body = getBody();
|
||||
if (!body.isEmpty()) {
|
||||
UpdateMessageData updateMessageData = UpdateMessageData.fromJSON(body);
|
||||
if (updateMessageData != null) {
|
||||
return emphasisAdded(
|
||||
UpdateMessageBuilder.buildGroupUpdateMessage(context, updateMessageData, null, isOutgoing(), false)
|
||||
.toString()
|
||||
);
|
||||
return UpdateMessageBuilder.buildGroupUpdateMessage(context, updateMessageData, null, isOutgoing(), false)
|
||||
.toString();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return emphasisAdded(context.getString(R.string.groupUpdated));
|
||||
return context.getString(R.string.groupUpdated);
|
||||
} else if (isOpenGroupInvitation()) {
|
||||
return emphasisAdded(context.getString(R.string.communityInvitation));
|
||||
return context.getString(R.string.communityInvitation);
|
||||
} else if (MmsSmsColumns.Types.isLegacyType(type)) {
|
||||
String txt = Phrase.from(context, R.string.messageErrorOld)
|
||||
return Phrase.from(context, R.string.messageErrorOld)
|
||||
.put(APP_NAME_KEY, context.getString(R.string.app_name))
|
||||
.format().toString();
|
||||
return emphasisAdded(txt);
|
||||
} else if (MmsSmsColumns.Types.isDraftMessageType(type)) {
|
||||
String draftText = context.getString(R.string.draft);
|
||||
return emphasisAdded(draftText + " " + getBody(), 0, draftText.length());
|
||||
return draftText + " " + getBody();
|
||||
} else if (SmsDatabase.Types.isOutgoingCall(type)) {
|
||||
String txt = Phrase.from(context, R.string.callsYouCalled)
|
||||
return Phrase.from(context, R.string.callsYouCalled)
|
||||
.put(NAME_KEY, getName())
|
||||
.format().toString();
|
||||
return emphasisAdded(txt);
|
||||
} else if (SmsDatabase.Types.isIncomingCall(type)) {
|
||||
String txt = Phrase.from(context, R.string.callsCalledYou)
|
||||
return Phrase.from(context, R.string.callsCalledYou)
|
||||
.put(NAME_KEY, getName())
|
||||
.format().toString();
|
||||
return emphasisAdded(txt);
|
||||
} else if (SmsDatabase.Types.isMissedCall(type)) {
|
||||
String txt = Phrase.from(context, R.string.callsMissedCallFrom)
|
||||
return Phrase.from(context, R.string.callsMissedCallFrom)
|
||||
.put(NAME_KEY, getName())
|
||||
.format().toString();
|
||||
return emphasisAdded(txt);
|
||||
} else if (SmsDatabase.Types.isExpirationTimerUpdate(type)) {
|
||||
int seconds = (int) (getExpiresIn() / 1000);
|
||||
if (seconds <= 0) {
|
||||
String txt = Phrase.from(context, R.string.disappearingMessagesTurnedOff)
|
||||
return Phrase.from(context, R.string.disappearingMessagesTurnedOff)
|
||||
.put(NAME_KEY, getName())
|
||||
.format().toString();
|
||||
return emphasisAdded(txt);
|
||||
}
|
||||
|
||||
// Implied that disappearing messages is enabled..
|
||||
String time = ExpirationUtil.getExpirationDisplayValue(context, seconds);
|
||||
String disappearAfterWhat = getDisappearingMsgExpiryTypeString(context); // Disappear after send or read?
|
||||
String txt = Phrase.from(context, R.string.disappearingMessagesSet)
|
||||
return Phrase.from(context, R.string.disappearingMessagesSet)
|
||||
.put(NAME_KEY, getName())
|
||||
.put(TIME_KEY, time)
|
||||
.put(DISAPPEARING_MESSAGES_TYPE_KEY, disappearAfterWhat)
|
||||
.format().toString();
|
||||
return emphasisAdded(txt);
|
||||
|
||||
} else if (MmsSmsColumns.Types.isMediaSavedExtraction(type)) {
|
||||
String txt = Phrase.from(context, R.string.attachmentsMediaSaved)
|
||||
return Phrase.from(context, R.string.attachmentsMediaSaved)
|
||||
.put(NAME_KEY, getName())
|
||||
.format().toString();
|
||||
return emphasisAdded(txt);
|
||||
|
||||
} else if (MmsSmsColumns.Types.isScreenshotExtraction(type)) {
|
||||
String txt = Phrase.from(context, R.string.screenshotTaken)
|
||||
return Phrase.from(context, R.string.screenshotTaken)
|
||||
.put(NAME_KEY, getName())
|
||||
.format().toString();
|
||||
return emphasisAdded(txt);
|
||||
|
||||
} else if (MmsSmsColumns.Types.isMessageRequestResponse(type)) {
|
||||
return emphasisAdded(context.getString(R.string.messageRequestsAccepted));
|
||||
if (lastMessage.getRecipient().getAddress().serialize().equals(
|
||||
TextSecurePreferences.getLocalNumber(context))) {
|
||||
return UtilKt.getSubbedCharSequence(
|
||||
context,
|
||||
R.string.messageRequestYouHaveAccepted,
|
||||
new Pair<>(NAME_KEY, getName())
|
||||
);
|
||||
}
|
||||
|
||||
return context.getString(R.string.messageRequestsAccepted);
|
||||
} else if (getCount() == 0) {
|
||||
return new SpannableString(context.getString(R.string.messageEmpty));
|
||||
} else {
|
||||
@ -203,20 +208,37 @@ public class ThreadRecord extends DisplayRecord {
|
||||
return new SpannableString("");
|
||||
// Old behaviour was: return new SpannableString(emphasisAdded(context.getString(R.string.mediaMessage)));
|
||||
} else {
|
||||
return new SpannableString(getBody());
|
||||
return getNonControlMessageDisplayBody(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SpannableString emphasisAdded(String sequence) {
|
||||
return emphasisAdded(sequence, 0, sequence.length());
|
||||
}
|
||||
/**
|
||||
* Logic to get the body for non control messages
|
||||
*/
|
||||
public CharSequence getNonControlMessageDisplayBody(@NonNull Context context) {
|
||||
Recipient recipient = getRecipient();
|
||||
// The logic will differ depending on the type.
|
||||
// 1-1, note to self and control messages (we shouldn't have any in here, but leaving the
|
||||
// logic to be safe) do not need author details
|
||||
if (recipient.isLocalNumber() || recipient.is1on1() ||
|
||||
(lastMessage != null && lastMessage.isControlMessage())
|
||||
) {
|
||||
return getBody();
|
||||
} else { // for groups (new, legacy, communities) show either 'You' or the contact's name
|
||||
String prefix = "";
|
||||
if (lastMessage != null && lastMessage.isOutgoing()) {
|
||||
prefix = context.getString(R.string.you);
|
||||
}
|
||||
else if(lastMessage != null){
|
||||
prefix = lastMessage.getIndividualRecipient().toShortString();
|
||||
}
|
||||
|
||||
private SpannableString emphasisAdded(String sequence, int start, int end) {
|
||||
SpannableString spannable = new SpannableString(sequence);
|
||||
spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC),
|
||||
start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return spannable;
|
||||
return Phrase.from(context.getString(R.string.messageSnippetGroup))
|
||||
.put(AUTHOR_KEY, prefix)
|
||||
.put(MESSAGE_SNIPPET_KEY, getBody())
|
||||
.format().toString();
|
||||
}
|
||||
}
|
||||
|
||||
public long getCount() { return count; }
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding
|
||||
import org.session.libsession.utilities.GroupRecord
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
@ -88,10 +89,36 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
|
||||
binding.muteNotificationsTextView.setOnClickListener(this)
|
||||
binding.notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted
|
||||
binding.notificationsTextView.setOnClickListener(this)
|
||||
binding.deleteTextView.isVisible = recipient.isContactRecipient || (recipient.isGroupRecipient && !isCurrentUserInGroup)
|
||||
binding.deleteTextView.setOnClickListener(this)
|
||||
|
||||
// delete
|
||||
binding.deleteTextView.apply {
|
||||
isVisible = recipient.isContactRecipient || (recipient.isGroupRecipient && !isCurrentUserInGroup)
|
||||
setOnClickListener(this@ConversationOptionsBottomSheet)
|
||||
|
||||
// the text and content description will change depending on the type
|
||||
when{
|
||||
// groups and communities
|
||||
recipient.isGroupRecipient -> {
|
||||
text = context.getString(R.string.leave)
|
||||
contentDescription = context.getString(R.string.AccessibilityId_leave)
|
||||
}
|
||||
|
||||
// note to self
|
||||
recipient.isLocalNumber -> {
|
||||
text = context.getString(R.string.clear)
|
||||
contentDescription = context.getString(R.string.AccessibilityId_clear)
|
||||
}
|
||||
|
||||
// 1on1
|
||||
else -> {
|
||||
text = context.getString(R.string.delete)
|
||||
contentDescription = context.getString(R.string.AccessibilityId_delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.leaveTextView.isVisible = recipient.isGroupRecipient && isCurrentUserInGroup
|
||||
binding.leaveTextView.setOnClickListener(this)
|
||||
|
||||
binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 || configFactory.convoVolatile?.getConversationUnread(thread) == true
|
||||
binding.markAllAsReadTextView.setOnClickListener(this)
|
||||
binding.pinTextView.isVisible = !thread.isPinned
|
||||
|
@ -113,7 +113,7 @@ class ConversationView : LinearLayout {
|
||||
}
|
||||
binding.muteIndicatorImageView.setImageResource(drawableRes)
|
||||
binding.snippetTextView.text = highlightMentions(
|
||||
text = thread.getSnippet(),
|
||||
text = thread.getDisplayBody(context),
|
||||
formatOnly = true, // no styling here, only text formatting
|
||||
threadID = thread.threadId,
|
||||
context = context
|
||||
@ -149,16 +149,5 @@ class ConversationView : LinearLayout {
|
||||
recipient.isLocalNumber -> context.getString(R.string.noteToSelf)
|
||||
else -> recipient.toShortString() // Internally uses the Contact API
|
||||
}
|
||||
|
||||
private fun ThreadRecord.getSnippet(): CharSequence = listOfNotNull(
|
||||
getSnippetPrefix(),
|
||||
getDisplayBody(context)
|
||||
).joinToString(": ")
|
||||
|
||||
private fun ThreadRecord.getSnippetPrefix(): CharSequence? = when {
|
||||
recipient.isLocalNumber || lastMessage?.isControlMessage == true -> null
|
||||
lastMessage?.isOutgoing == true -> resources.getString(R.string.you)
|
||||
else -> lastMessage?.individualRecipient?.toShortString()
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -391,6 +391,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
|
||||
@ -552,6 +557,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
} else {
|
||||
showMuteDialog(this) { until ->
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
Log.d("", "**** until: $until")
|
||||
recipientDatabase.setMuted(thread.recipient, until)
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.recyclerView.adapter!!.notifyDataSetChanged()
|
||||
@ -588,6 +594,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
val recipient = thread.recipient
|
||||
val title: String
|
||||
val message: CharSequence
|
||||
var positiveButtonId: Int = R.string.yes
|
||||
var negativeButtonId: Int = R.string.no
|
||||
|
||||
if (recipient.isGroupRecipient) {
|
||||
val group = groupDatabase.getGroup(recipient.address.toString()).orNull()
|
||||
@ -595,7 +603,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
// If you are an admin of this group you can delete it
|
||||
if (group != null && group.admins.map { it.toString() }
|
||||
.contains(textSecurePreferences.getLocalNumber())) {
|
||||
title = getString(R.string.groupDelete)
|
||||
title = getString(R.string.groupLeave)
|
||||
message = Phrase.from(this.applicationContext, R.string.groupDeleteDescription)
|
||||
.put(GROUP_NAME_KEY, group.title)
|
||||
.format()
|
||||
@ -609,6 +617,9 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
.put(GROUP_NAME_KEY, group.title)
|
||||
.format()
|
||||
}
|
||||
|
||||
positiveButtonId = R.string.leave
|
||||
negativeButtonId = R.string.cancel
|
||||
} else {
|
||||
// If this is a 1-on-1 conversation
|
||||
if (recipient.name != null) {
|
||||
@ -619,15 +630,17 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
}
|
||||
else {
|
||||
// If not group-related and we don't have a recipient name then this must be our Note to Self conversation
|
||||
title = getString(R.string.noteToSelf)
|
||||
title = getString(R.string.clearMessages)
|
||||
message = getString(R.string.clearMessagesNoteToSelfDescription)
|
||||
positiveButtonId = R.string.clear
|
||||
negativeButtonId = R.string.cancel
|
||||
}
|
||||
}
|
||||
|
||||
showSessionDialog {
|
||||
title(title)
|
||||
text(message)
|
||||
button(R.string.yes) {
|
||||
dangerButton(positiveButtonId) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
val context = this@HomeActivity
|
||||
// Cancel any outstanding jobs
|
||||
@ -668,7 +681,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
button(R.string.no)
|
||||
button(negativeButtonId)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
}
|
@ -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();
|
||||
|
@ -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 -> ""
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -6,28 +6,31 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.squareup.phrase.Phrase
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.utilities.NonTranslatableStringConstants.APP_NAME
|
||||
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
|
||||
import org.thoughtcrime.securesms.ui.AlertDialog
|
||||
import org.thoughtcrime.securesms.ui.DialogButtonModel
|
||||
import org.thoughtcrime.securesms.ui.GetString
|
||||
import org.thoughtcrime.securesms.ui.getSubbedString
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalColors
|
||||
|
||||
@Composable
|
||||
fun OnboardingBackPressAlertDialog(
|
||||
dismissDialog: () -> Unit,
|
||||
@StringRes textId: Int = R.string.onboardingBackAccountCreation,
|
||||
@StringRes textId: Int,
|
||||
quit: () -> Unit
|
||||
) {
|
||||
val c = LocalContext.current
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = dismissDialog,
|
||||
title = stringResource(R.string.warning),
|
||||
text = stringResource(textId).let { txt ->
|
||||
val c = LocalContext.current
|
||||
Phrase.from(txt).put(APP_NAME_KEY, c.getString(R.string.app_name)).format().toString()
|
||||
},
|
||||
buttons = listOf(
|
||||
DialogButtonModel(
|
||||
GetString(stringResource(R.string.quit)),
|
||||
text = GetString(stringResource(id = R.string.quitButton)),
|
||||
color = LocalColors.current.danger,
|
||||
onClick = quit
|
||||
),
|
||||
|
@ -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 -> {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
|
@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.ApplicationContext
|
||||
import org.thoughtcrime.securesms.onboarding.manager.CreateAccountManager
|
||||
@ -55,14 +56,16 @@ internal class MessageNotificationsViewModel(
|
||||
fun onBackPressed(): Boolean = when (state) {
|
||||
is State.CreateAccount -> false
|
||||
is State.LoadAccount -> {
|
||||
_uiStates.update { it.copy(showDialog = true) }
|
||||
_uiStates.update { it.copy(showingBackWarningDialogText = R.string.onboardingBackLoadAccount) }
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissDialog() {
|
||||
_uiStates.update { it.copy(showDialog = false) }
|
||||
_uiStates.update {
|
||||
it.copy(showingBackWarningDialogText = null)
|
||||
}
|
||||
}
|
||||
|
||||
fun quit() {
|
||||
@ -75,7 +78,7 @@ internal class MessageNotificationsViewModel(
|
||||
|
||||
data class UiState(
|
||||
val pushEnabled: Boolean = true,
|
||||
val showDialog: Boolean = false,
|
||||
val showingBackWarningDialogText: Int? = null,
|
||||
val clearData: Boolean = false
|
||||
) {
|
||||
val pushDisabled get() = !pushEnabled
|
||||
|
@ -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);
|
||||
|
@ -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() }
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.preferences
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.RingtoneManager
|
||||
import android.net.Uri
|
||||
@ -15,14 +14,15 @@ import androidx.preference.Preference
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotificationsEnabled
|
||||
import org.thoughtcrime.securesms.ApplicationContext
|
||||
import org.thoughtcrime.securesms.components.SwitchPreferenceCompat
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.preferences.widgets.DropDownPreference
|
||||
import java.util.Arrays
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() {
|
||||
class NotificationsPreferenceFragment : CorrectedPreferenceFragment() {
|
||||
@Inject
|
||||
lateinit var prefs: TextSecurePreferences
|
||||
|
||||
@ -45,8 +45,16 @@ class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() {
|
||||
NotificationChannels.getMessageVibrate(requireContext())
|
||||
)
|
||||
|
||||
findPreference<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)
|
||||
@ -72,28 +80,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?)
|
||||
}
|
||||
|
||||
@ -112,54 +110,63 @@ class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() {
|
||||
NotificationChannels.updateMessageRingtone(requireContext(), uri)
|
||||
prefs.setNotificationRingtone(uri.toString())
|
||||
}
|
||||
initializeRingtoneSummary(findPreference(TextSecurePreferences.RINGTONE_PREF))
|
||||
updateRingtonePref()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class RingtoneSummaryListener : Preference.OnPreferenceChangeListener {
|
||||
override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean {
|
||||
val pref = preference as? DropDownPreference ?: return false
|
||||
val value = newValue as? Uri
|
||||
if (value == null || TextUtils.isEmpty(value.toString())) {
|
||||
preference.setSummary(R.string.none)
|
||||
pref.setDropDownLabel(context?.getString(R.string.none))
|
||||
} else {
|
||||
RingtoneManager.getRingtone(activity, value)
|
||||
?.getTitle(activity)
|
||||
?.let { preference.summary = it }
|
||||
?.let { pref.setDropDownLabel(it) }
|
||||
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeRingtoneSummary(pref: Preference?) {
|
||||
val listener = pref!!.onPreferenceChangeListener as RingtoneSummaryListener?
|
||||
private fun updateRingtonePref() {
|
||||
val pref = findPreference<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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -1,74 +0,0 @@
|
||||
package org.thoughtcrime.securesms.preferences.widgets;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.preference.ListPreference;
|
||||
import androidx.preference.PreferenceViewHolder;
|
||||
import android.util.AttributeSet;
|
||||
import android.widget.TextView;
|
||||
import network.loki.messenger.R;
|
||||
|
||||
public class SignalListPreference extends ListPreference {
|
||||
|
||||
private TextView rightSummaryTV;
|
||||
private CharSequence summary;
|
||||
private OnPreferenceClickListener clickListener;
|
||||
private CharSequence summarySpecifiedInLayoutXML;
|
||||
|
||||
public SignalListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||
super(context, attrs, defStyleAttr, defStyleRes);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public SignalListPreference(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public SignalListPreference(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public SignalListPreference(Context context) {
|
||||
super(context);
|
||||
initialize();
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
summarySpecifiedInLayoutXML = this.getSummary();
|
||||
if (summarySpecifiedInLayoutXML == null) { summarySpecifiedInLayoutXML = ""; }
|
||||
setWidgetLayoutResource(R.layout.preference_right_summary_widget);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(PreferenceViewHolder view) {
|
||||
super.onBindViewHolder(view);
|
||||
this.rightSummaryTV = (TextView)view.findViewById(R.id.right_summary);
|
||||
setSummary(this.summary);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSummary(CharSequence incomingSummary) {
|
||||
// Set the left "subtitle" summary such as "The information shown in notifications." etc.
|
||||
super.setSummary(summarySpecifiedInLayoutXML);
|
||||
|
||||
// Then set the right summary to be the incoming drop-down selected option
|
||||
this.summary = incomingSummary;
|
||||
if (this.rightSummaryTV != null) {
|
||||
this.rightSummaryTV.setText(incomingSummary);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOnPreferenceClickListener (OnPreferenceClickListener
|
||||
onPreferenceClickListener){
|
||||
this.clickListener = onPreferenceClickListener;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onClick () {
|
||||
if (clickListener == null || !clickListener.onPreferenceClick(this)) {
|
||||
super.onClick();
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
package org.thoughtcrime.securesms.qr;
|
||||
|
||||
public interface ScanListener {
|
||||
public void onQrDataFound(String data);
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -362,6 +362,10 @@ class DefaultConversationRepository @Inject constructor(
|
||||
isSyncMessage = recipient.isLocalNumber
|
||||
).await()
|
||||
}
|
||||
|
||||
threadDb.setHasSent(threadId, true)
|
||||
// add a control message for our user
|
||||
storage.insertMessageRequestResponseFromYou(threadId)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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(
|
||||
|
@ -13,6 +13,8 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@ -56,6 +58,7 @@ import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@ -75,6 +78,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.components.ProfilePictureView
|
||||
import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.OptionsCardData
|
||||
@ -141,7 +145,7 @@ fun LargeItemButtonWithDrawable(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
ItemButtonWithDrawable(
|
||||
textId, icon, modifier.heightIn(min = LocalDimensions.current.minLargeItemButtonHeight),
|
||||
textId, icon, modifier,
|
||||
LocalType.current.h8, colors, onClick
|
||||
)
|
||||
}
|
||||
@ -187,8 +191,13 @@ fun LargeItemButton(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
ItemButton(
|
||||
textId, icon, modifier.heightIn(min = LocalDimensions.current.minLargeItemButtonHeight),
|
||||
LocalType.current.h8, colors, onClick
|
||||
textId = textId,
|
||||
icon = icon,
|
||||
modifier = modifier,
|
||||
minHeight = LocalDimensions.current.minLargeItemButtonHeight,
|
||||
textStyle = LocalType.current.h8,
|
||||
colors = colors,
|
||||
onClick = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@ -201,8 +210,13 @@ fun LargeItemButton(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
ItemButton(
|
||||
text, icon, modifier.heightIn(min = LocalDimensions.current.minLargeItemButtonHeight),
|
||||
LocalType.current.h8, colors, onClick
|
||||
text = text,
|
||||
icon = icon,
|
||||
modifier = modifier,
|
||||
minHeight = LocalDimensions.current.minLargeItemButtonHeight,
|
||||
textStyle = LocalType.current.h8,
|
||||
colors = colors,
|
||||
onClick = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@ -211,6 +225,7 @@ fun ItemButton(
|
||||
text: String,
|
||||
icon: Int,
|
||||
modifier: Modifier,
|
||||
minHeight: Dp = LocalDimensions.current.minItemButtonHeight,
|
||||
textStyle: TextStyle = LocalType.current.xl,
|
||||
colors: ButtonColors = transparentButtonColors(),
|
||||
onClick: () -> Unit
|
||||
@ -225,6 +240,7 @@ fun ItemButton(
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
},
|
||||
minHeight = minHeight,
|
||||
textStyle = textStyle,
|
||||
colors = colors,
|
||||
onClick = onClick
|
||||
@ -239,6 +255,7 @@ fun ItemButton(
|
||||
@StringRes textId: Int,
|
||||
@DrawableRes icon: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
minHeight: Dp = LocalDimensions.current.minItemButtonHeight,
|
||||
textStyle: TextStyle = LocalType.current.xl,
|
||||
colors: ButtonColors = transparentButtonColors(),
|
||||
onClick: () -> Unit
|
||||
@ -253,6 +270,7 @@ fun ItemButton(
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
},
|
||||
minHeight = minHeight,
|
||||
textStyle = textStyle,
|
||||
colors = colors,
|
||||
onClick = onClick
|
||||
@ -269,20 +287,23 @@ fun ItemButton(
|
||||
text: String,
|
||||
icon: @Composable BoxScope.() -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
minHeight: Dp = LocalDimensions.current.minLargeItemButtonHeight,
|
||||
textStyle: TextStyle = LocalType.current.xl,
|
||||
colors: ButtonColors = transparentButtonColors(),
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
TextButton(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
modifier = modifier.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min)
|
||||
.heightIn(min = minHeight)
|
||||
.padding(horizontal = LocalDimensions.current.xsSpacing),
|
||||
colors = colors,
|
||||
onClick = onClick,
|
||||
shape = RectangleShape,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(50.dp)
|
||||
.wrapContentHeight()
|
||||
modifier = Modifier.fillMaxHeight()
|
||||
.aspectRatio(1f)
|
||||
.align(Alignment.CenterVertically)
|
||||
) {
|
||||
icon()
|
||||
@ -294,7 +315,6 @@ fun ItemButton(
|
||||
text,
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = LocalDimensions.current.xsSpacing)
|
||||
.align(Alignment.CenterVertically),
|
||||
style = textStyle
|
||||
)
|
||||
@ -398,22 +418,31 @@ fun Divider(modifier: Modifier = Modifier, startIndent: Dp = 0.dp) {
|
||||
)
|
||||
}
|
||||
|
||||
//TODO This component should be fully rebuilt in Compose at some point ~~
|
||||
@Composable
|
||||
fun RowScope.Avatar(recipient: Recipient) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(60.dp)
|
||||
.align(Alignment.CenterVertically)
|
||||
) {
|
||||
AndroidView(
|
||||
factory = {
|
||||
ProfilePictureView(it).apply { update(recipient) }
|
||||
},
|
||||
modifier = Modifier
|
||||
.width(46.dp)
|
||||
.height(46.dp)
|
||||
)
|
||||
}
|
||||
fun Avatar(
|
||||
recipient: Recipient,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AndroidView(
|
||||
factory = {
|
||||
ProfilePictureView(it).apply { update(recipient) }
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Avatar(
|
||||
userAddress: Address,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AndroidView(
|
||||
factory = {
|
||||
ProfilePictureView(it).apply { update(userAddress) }
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
@ -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")
|
||||
}
|
||||
|
@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.camera.core.CameraSelector
|
||||
@ -20,7 +22,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Snackbar
|
||||
@ -30,8 +31,11 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@ -43,10 +47,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.google.accompanist.permissions.shouldShowRationale
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.ChecksumException
|
||||
import com.google.zxing.FormatException
|
||||
@ -55,19 +57,24 @@ import com.google.zxing.PlanarYUVLuminanceSource
|
||||
import com.google.zxing.Result
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
import com.google.zxing.qrcode.QRCodeReader
|
||||
import java.util.concurrent.Executors
|
||||
import com.squareup.phrase.Phrase
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.ui.AlertDialog
|
||||
import org.thoughtcrime.securesms.ui.DialogButtonModel
|
||||
import org.thoughtcrime.securesms.ui.GetString
|
||||
import org.thoughtcrime.securesms.ui.findActivity
|
||||
import org.thoughtcrime.securesms.ui.getSubbedString
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
|
||||
import org.thoughtcrime.securesms.ui.theme.LocalType
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
private const val TAG = "NewMessageFragment"
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun QRScannerScreen(
|
||||
errors: Flow<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))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
Before Width: | Height: | Size: 233 B |
Before Width: | Height: | Size: 530 B |
Before Width: | Height: | Size: 152 B |
Before Width: | Height: | Size: 362 B |
Before Width: | Height: | Size: 230 B |
Before Width: | Height: | Size: 691 B |
Before Width: | Height: | Size: 354 B |
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 457 B |
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
12
app/src/main/res/drawable/ic_copy.xml
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval" />
|
@ -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>
|
||||
|
||||
|
@ -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>
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
|
||||
|