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

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

View File

@ -38,7 +38,8 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/
pull: 'always', pull: 'always',
environment: { ANDROID_HOME: '/usr/lib/android-sdk' }, environment: { ANDROID_HOME: '/usr/lib/android-sdk' },
commands: [ commands: [
'apt-get install -y ninja-build', 'apt-get install -y ninja-build openjdk-17-jdk',
'update-java-alternatives -s java-1.17.0-openjdk-amd64',
'./gradlew testPlayDebugUnitTestCoverageReport' './gradlew testPlayDebugUnitTestCoverageReport'
], ],
} }
@ -78,7 +79,8 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/
pull: 'always', pull: 'always',
environment: { SSH_KEY: { from_secret: 'SSH_KEY' }, ANDROID_HOME: '/usr/lib/android-sdk' }, environment: { SSH_KEY: { from_secret: 'SSH_KEY' }, ANDROID_HOME: '/usr/lib/android-sdk' },
commands: [ commands: [
'apt-get install -y ninja-build', 'apt-get install -y ninja-build openjdk-17-jdk',
'update-java-alternatives -s java-1.17.0-openjdk-amd64',
'./gradlew assemblePlayDebug', './gradlew assemblePlayDebug',
'./scripts/drone-static-upload.sh' './scripts/drone-static-upload.sh'
], ],

View File

@ -13,8 +13,8 @@ configurations.forEach {
it.exclude module: "commons-logging" it.exclude module: "commons-logging"
} }
def canonicalVersionCode = 380 def canonicalVersionCode = 382
def canonicalVersionName = "1.19.2" def canonicalVersionName = "1.20.0"
def postFixSize = 10 def postFixSize = 10
def abiPostFix = ['armeabi-v7a' : 1, def abiPostFix = ['armeabi-v7a' : 1,
@ -310,7 +310,6 @@ dependencies {
implementation "nl.komponents.kovenant:kovenant:$kovenantVersion" implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion" implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion"
implementation "com.jakewharton.rxbinding3:rxbinding:3.1.0" 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.github.ybq:Android-SpinKit:1.4.0"
implementation "com.opencsv:opencsv:4.6" implementation "com.opencsv:opencsv:4.6"
testImplementation "junit:junit:$junitVersion" testImplementation "junit:junit:$junitVersion"
@ -366,7 +365,7 @@ dependencies {
debugImplementation "androidx.compose.ui:ui-test-manifest:$composeVersion" debugImplementation "androidx.compose.ui:ui-test-manifest:$composeVersion"
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha" 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 "com.google.accompanist:accompanist-drawablepainter:0.33.1-alpha"
implementation "androidx.camera:camera-camera2:1.3.2" implementation "androidx.camera:camera-camera2:1.3.2"

View File

@ -457,39 +457,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
} }
private void resubmitProfilePictureIfNeeded() { private void resubmitProfilePictureIfNeeded() {
// Files expire on the file server after a while, so we simply re-upload the user's profile picture ProfilePictureUtilities.INSTANCE.resubmitProfilePictureIfNeeded(this);
// 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.");
}
});
} }
private void loadEmojiSearchIndexIfNeeded() { private void loadEmojiSearchIndexIfNeeded() {

View File

@ -412,14 +412,9 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
Permissions.with(this) Permissions.with(this)
.request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) .request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
.maxSdkVersion(Build.VERSION_CODES.P) .maxSdkVersion(Build.VERSION_CODES.P)
.withPermanentDenialDialog(Phrase.from(getApplicationContext(), R.string.permissionsStorageSaveDenied) .withPermanentDenialDialog(getPermanentlyDeniedStorageText())
.put(APP_NAME_KEY, getString(R.string.app_name))
.format().toString())
.onAnyDenied(() -> { .onAnyDenied(() -> {
String txt = Phrase.from(getApplicationContext(), R.string.permissionsStorageSaveDenied) Toast.makeText(this, getPermanentlyDeniedStorageText(), Toast.LENGTH_LONG).show();
.put(APP_NAME_KEY, getString(R.string.app_name))
.format().toString();
Toast.makeText(this, txt, Toast.LENGTH_LONG).show();
}) })
.onAllGranted(() -> { .onAllGranted(() -> {
SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this); SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this);
@ -436,6 +431,12 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
}); });
} }
private String getPermanentlyDeniedStorageText(){
return Phrase.from(getApplicationContext(), R.string.permissionsStorageDeniedLegacy)
.put(APP_NAME_KEY, getString(R.string.app_name))
.format().toString();
}
private void sendMediaSavedNotificationIfNeeded() { private void sendMediaSavedNotificationIfNeeded() {
if (conversationRecipient.isGroupRecipient()) return; if (conversationRecipient.isGroupRecipient()) return;
DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset())); DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset()));

View File

@ -0,0 +1,21 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.squareup.phrase.Phrase
import network.loki.messenger.R
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.thoughtcrime.securesms.permissions.SettingsDialog
class MissingMicrophonePermissionDialog {
companion object {
@JvmStatic
fun show(context: Context) = SettingsDialog.show(
context,
Phrase.from(context, R.string.permissionsMicrophoneAccessRequired)
.put(APP_NAME_KEY, context.getString(R.string.app_name))
.format().toString()
)
}
}

View File

@ -22,7 +22,10 @@ fun showMuteDialog(
if (entry.stringRes == R.string.notificationsMute) { if (entry.stringRes == R.string.notificationsMute) {
context.getString(R.string.notificationsMute) context.getString(R.string.notificationsMute)
} else { } 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) context.getSubbedString(entry.stringRes, TIME_LARGE_KEY to largeTimeUnitString)
} }
}.toTypedArray()) { }.toTypedArray()) {
@ -33,16 +36,17 @@ fun showMuteDialog(
// less than the entire duration - so 1 hour becomes 59 minutes, 1 day becomes 23 hours etc. // 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 // 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. // 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) { private enum class Option(@StringRes val stringRes: Int, val duration: Long) {
ONE_HOUR(R.string.notificationsMuteFor, duration = TimeUnit.HOURS.toMillis(1)), ONE_HOUR(R.string.notificationsMuteFor, duration = TimeUnit.HOURS.toMillis(1)),
TWO_HOURS(R.string.notificationsMuteFor, duration = TimeUnit.HOURS.toMillis(2)), TWO_HOURS(R.string.notificationsMuteFor, duration = TimeUnit.HOURS.toMillis(2)),
ONE_DAY(R.string.notificationsMuteFor, duration = TimeUnit.DAYS.toMillis(1)), ONE_DAY(R.string.notificationsMuteFor, duration = TimeUnit.DAYS.toMillis(1)),
SEVEN_DAYS(R.string.notificationsMuteFor, duration = TimeUnit.DAYS.toMillis(7)), SEVEN_DAYS(R.string.notificationsMuteFor, duration = TimeUnit.DAYS.toMillis(7)),
FOREVER(R.string.notificationsMute, getTime = { Long.MAX_VALUE } ); FOREVER(R.string.notificationsMute, duration = Long.MAX_VALUE );
constructor(@StringRes stringRes: Int, duration: Long): this(stringRes, { duration } )
} }

View File

@ -17,6 +17,8 @@
package org.thoughtcrime.securesms; package org.thoughtcrime.securesms;
import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
@ -29,14 +31,21 @@ import android.provider.OpenableColumns;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.View; import android.view.View;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBar;
import androidx.appcompat.widget.Toolbar; import androidx.appcompat.widget.Toolbar;
import com.squareup.phrase.Phrase;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import network.loki.messenger.R; import network.loki.messenger.R;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.DistributionTypes; import org.session.libsession.utilities.DistributionTypes;
import org.session.libsession.utilities.ViewUtil; import org.session.libsession.utilities.ViewUtil;
@ -57,8 +66,7 @@ import org.thoughtcrime.securesms.util.MediaUtil;
* @author Jake McGinty * @author Jake McGinty
*/ */
public class ShareActivity extends PassphraseRequiredActionBarActivity public class ShareActivity extends PassphraseRequiredActionBarActivity
implements ContactSelectionListFragment.OnContactSelectedListener implements ContactSelectionListFragment.OnContactSelectedListener {
{
private static final String TAG = ShareActivity.class.getSimpleName(); private static final String TAG = ShareActivity.class.getSimpleName();
public static final String EXTRA_THREAD_ID = "thread_id"; public static final String EXTRA_THREAD_ID = "thread_id";
@ -84,7 +92,7 @@ public class ShareActivity extends PassphraseRequiredActionBarActivity
setContentView(R.layout.share_activity); setContentView(R.layout.share_activity);
// initializeToolbar(); initializeToolbar();
initializeResources(); initializeResources();
initializeSearch(); initializeSearch();
initializeMedia(); initializeMedia();
@ -126,13 +134,14 @@ public class ShareActivity extends PassphraseRequiredActionBarActivity
else super.onBackPressed(); else super.onBackPressed();
} }
/* private void initializeToolbar() { private void initializeToolbar() {
SearchToolbar toolbar = findViewById(R.id.search_toolbar); TextView tootlbarTitle = findViewById(R.id.title);
setSupportActionBar(toolbar); tootlbarTitle.setText(
ActionBar actionBar = getSupportActionBar(); Phrase.from(getApplicationContext(), R.string.shareToSession)
actionBar.setDisplayHomeAsUpEnabled(true); .put(APP_NAME_KEY, getString(R.string.app_name))
actionBar.setHomeButtonEnabled(true); .format().toString()
}*/ );
}
private void initializeResources() { private void initializeResources() {
progressWheel = findViewById(R.id.progress_wheel); progressWheel = findViewById(R.id.progress_wheel);

View File

@ -74,8 +74,9 @@ class AvatarSelection(
*/ */
fun startAvatarSelection( fun startAvatarSelection(
includeClear: Boolean, includeClear: Boolean,
attemptToIncludeCamera: Boolean attemptToIncludeCamera: Boolean,
): File? { createTempFile: ()->File?
) {
var captureFile: File? = null var captureFile: File? = null
val hasCameraPermission = ContextCompat val hasCameraPermission = ContextCompat
.checkSelfPermission( .checkSelfPermission(
@ -83,18 +84,11 @@ class AvatarSelection(
Manifest.permission.CAMERA Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED ) == PackageManager.PERMISSION_GRANTED
if (attemptToIncludeCamera && hasCameraPermission) { if (attemptToIncludeCamera && hasCameraPermission) {
try { captureFile = createTempFile()
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)
}
} }
val chooserIntent = createAvatarSelectionIntent(activity, captureFile, includeClear) val chooserIntent = createAvatarSelectionIntent(activity, captureFile, includeClear)
onPickImage.launch(chooserIntent) onPickImage.launch(chooserIntent)
return captureFile
} }
private fun createAvatarSelectionIntent( private fun createAvatarSelectionIntent(

View File

@ -89,9 +89,9 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
return super.onOptionsItemSelected(item) return super.onOptionsItemSelected(item)
} }
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
if (intent?.action == ACTION_ANSWER) { if (intent.action == ACTION_ANSWER) {
val answerIntent = WebRtcCallService.acceptCallIntent(this) val answerIntent = WebRtcCallService.acceptCallIntent(this)
answerIntent.flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT answerIntent.flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
ContextCompat.startForegroundService(this, answerIntent) ContextCompat.startForegroundService(this, answerIntent)

View File

@ -102,14 +102,13 @@ class ConversationActionBarView @JvmOverloads constructor(
if (config?.isEnabled == true) { if (config?.isEnabled == true) {
// Get the type of disappearing message and the abbreviated duration.. // Get the type of disappearing message and the abbreviated duration..
val dmTypeString = when (config.expiryMode) { val dmTypeString = when (config.expiryMode) {
is AfterRead -> context.getString(R.string.read) is AfterRead -> R.string.disappearingMessagesDisappearAfterReadState
else -> context.getString(R.string.send) else -> R.string.disappearingMessagesDisappearAfterSendState
} }
val durationAbbreviated = ExpirationUtil.getExpirationAbbreviatedDisplayValue(config.expiryMode.expirySeconds) val durationAbbreviated = ExpirationUtil.getExpirationAbbreviatedDisplayValue(config.expiryMode.expirySeconds)
// ..then substitute into the string.. // ..then substitute into the string..
val subtitleTxt = context.getSubbedString(R.string.disappearingMessagesDisappear, val subtitleTxt = context.getSubbedString(dmTypeString,
DISAPPEARING_MESSAGES_TYPE_KEY to dmTypeString,
TIME_KEY to durationAbbreviated TIME_KEY to durationAbbreviated
) )
@ -126,9 +125,7 @@ class ConversationActionBarView @JvmOverloads constructor(
settings += ConversationSetting( settings += ConversationSetting(
recipient.mutedUntil.takeUnless { it == Long.MAX_VALUE } recipient.mutedUntil.takeUnless { it == Long.MAX_VALUE }
?.let { ?.let {
val mutedDuration = (it - System.currentTimeMillis()).milliseconds context.getString(R.string.notificationsHeaderMute)
val durationString = LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit(context, mutedDuration)
context.getSubbedString(R.string.notificationsMuteFor, TIME_LARGE_KEY to durationString)
} }
?: context.getString(R.string.notificationsMuted), ?: context.getString(R.string.notificationsMuted),
ConversationSettingType.NOTIFICATION, ConversationSettingType.NOTIFICATION,

View File

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

View File

@ -32,14 +32,13 @@ data class State(
val nextType get() = when { val nextType get() = when {
expiryType == ExpiryType.AFTER_READ -> ExpiryType.AFTER_READ expiryType == ExpiryType.AFTER_READ -> ExpiryType.AFTER_READ
isNewConfigEnabled -> ExpiryType.AFTER_SEND else -> ExpiryType.AFTER_SEND
else -> ExpiryType.LEGACY
} }
val duration get() = expiryMode?.duration val duration get() = expiryMode?.duration
val expiryType get() = expiryMode?.type 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, R.string.off,
contentDescription = R.string.AccessibilityId_disappearingMessagesOff, contentDescription = R.string.AccessibilityId_disappearingMessagesOff,
), ),
LEGACY(
ExpiryMode::Legacy,
R.string.expiration_type_disappear_legacy,
contentDescription = R.string.AccessibilityId_disappearingMessagesLegacy
),
AFTER_READ( AFTER_READ(
ExpiryMode::AfterRead, ExpiryMode::AfterRead,
R.string.disappearingMessagesDisappearAfterRead, R.string.disappearingMessagesDisappearAfterRead,
@ -83,7 +77,6 @@ enum class ExpiryType(
} }
val ExpiryMode.type: ExpiryType get() = when(this) { val ExpiryMode.type: ExpiryType get() = when(this) {
is ExpiryMode.Legacy -> ExpiryType.LEGACY
is ExpiryMode.AfterSend -> ExpiryType.AFTER_SEND is ExpiryMode.AfterSend -> ExpiryType.AFTER_SEND
is ExpiryMode.AfterRead -> ExpiryType.AFTER_READ is ExpiryMode.AfterRead -> ExpiryType.AFTER_READ
else -> ExpiryType.NONE else -> ExpiryType.NONE

View File

@ -23,7 +23,6 @@ fun State.toUiState() = UiState(
private fun State.typeOptions(): List<ExpiryRadioOption>? = if (typeOptionsHidden) null else { private fun State.typeOptions(): List<ExpiryRadioOption>? = if (typeOptionsHidden) null else {
buildList { buildList {
add(offTypeOption()) add(offTypeOption())
if (!isNewConfigEnabled) add(legacyTypeOption())
if (!isGroup) add(afterReadTypeOption()) if (!isGroup) add(afterReadTypeOption())
add(afterSendTypeOption()) add(afterSendTypeOption())
} }
@ -48,7 +47,6 @@ private fun State.timeOptions(): List<ExpiryRadioOption>? {
} }
private fun State.offTypeOption() = typeOption(ExpiryType.NONE) 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.afterReadTypeOption() = newTypeOption(ExpiryType.AFTER_READ)
private fun State.afterSendTypeOption() = newTypeOption(ExpiryType.AFTER_SEND) private fun State.afterSendTypeOption() = newTypeOption(ExpiryType.AFTER_SEND)
private fun State.newTypeOption(type: ExpiryType) = typeOption(type, isNewConfigEnabled && isSelfAdmin) private fun State.newTypeOption(type: ExpiryType) = typeOption(type, isNewConfigEnabled && isSelfAdmin)

View File

@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.ui.Callbacks
import org.thoughtcrime.securesms.ui.NoOpCallbacks import org.thoughtcrime.securesms.ui.NoOpCallbacks
import org.thoughtcrime.securesms.ui.OptionsCard import org.thoughtcrime.securesms.ui.OptionsCard
import org.thoughtcrime.securesms.ui.RadioOption 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.components.SlimOutlineButton
import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.fadingEdges import org.thoughtcrime.securesms.ui.fadingEdges
@ -71,7 +72,8 @@ fun DisappearingMessages(
} }
} }
if (state.showSetButton) SlimOutlineButton( if (state.showSetButton){
PrimaryOutlineButton(
stringResource(R.string.set), stringResource(R.string.set),
modifier = Modifier modifier = Modifier
.contentDescription(R.string.AccessibilityId_setButton) .contentDescription(R.string.AccessibilityId_setButton)
@ -81,3 +83,4 @@ fun DisappearingMessages(
) )
} }
} }
}

View File

@ -27,21 +27,18 @@ fun PreviewStates(
} }
class StatePreviewParameterProvider : PreviewParameterProvider<State> { 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( private val newConfigValues get() = sequenceOf(
// new 1-1 // new 1-1
State(expiryMode = ExpiryMode.NONE), State(expiryMode = ExpiryMode.NONE),
State(expiryMode = ExpiryMode.Legacy(43200)),
State(expiryMode = ExpiryMode.AfterRead(300)), State(expiryMode = ExpiryMode.AfterRead(300)),
State(expiryMode = ExpiryMode.AfterSend(43200)), State(expiryMode = ExpiryMode.AfterSend(43200)),
// new group non-admin // new group non-admin
State(isGroup = true, isSelfAdmin = false), State(isGroup = true, isSelfAdmin = false),
State(isGroup = true, isSelfAdmin = false, expiryMode = ExpiryMode.Legacy(43200)),
State(isGroup = true, isSelfAdmin = false, expiryMode = ExpiryMode.AfterSend(43200)), State(isGroup = true, isSelfAdmin = false, expiryMode = ExpiryMode.AfterSend(43200)),
// new group admin // new group admin
State(isGroup = true), State(isGroup = true),
State(isGroup = true, expiryMode = ExpiryMode.Legacy(43200)),
State(isGroup = true, expiryMode = ExpiryMode.AfterSend(43200)), State(isGroup = true, expiryMode = ExpiryMode.AfterSend(43200)),
// new note-to-self // new note-to-self
State(isNoteToSelf = true), State(isNoteToSelf = true),

View File

@ -104,7 +104,6 @@ import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.hexEncodedPrivateKey import org.session.libsignal.utilities.hexEncodedPrivateKey
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.SessionDialogBuilder
import org.thoughtcrime.securesms.attachments.ScreenshotObserver import org.thoughtcrime.securesms.attachments.ScreenshotObserver
import org.thoughtcrime.securesms.audio.AudioRecorder import org.thoughtcrime.securesms.audio.AudioRecorder
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
@ -117,6 +116,8 @@ import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companio
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_DELETE import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_DELETE
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_REPLY 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_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.BlockedDialog
import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton
@ -1935,7 +1936,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} else { } else {
Permissions.with(this) Permissions.with(this)
.request(Manifest.permission.RECORD_AUDIO) .request(Manifest.permission.RECORD_AUDIO)
.withRationaleDialog(getString(R.string.permissionsMicrophoneAccessRequired), R.drawable.ic_baseline_mic_48)
.withPermanentDenialDialog(Phrase.from(applicationContext, R.string.permissionsMicrophoneAccessRequired) .withPermanentDenialDialog(Phrase.from(applicationContext, R.string.permissionsMicrophoneAccessRequired)
.put(APP_NAME_KEY, getString(R.string.app_name)) .put(APP_NAME_KEY, getString(R.string.app_name))
.format().toString()) .format().toString())
@ -2204,6 +2204,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
ON_REPLY -> reply(set) ON_REPLY -> reply(set)
ON_RESEND -> resendMessage(set) ON_RESEND -> resendMessage(set)
ON_DELETE -> deleteMessages(set) ON_DELETE -> deleteMessages(set)
ON_COPY -> copyMessages(set)
ON_SAVE -> {
if(message is MmsMessageRecord) saveAttachmentsIfPossible(setOf(message))
}
} }
} }
@ -2238,7 +2242,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
return result == PackageManager.PERMISSION_GRANTED return result == PackageManager.PERMISSION_GRANTED
} }
override fun saveAttachment(messages: Set<MessageRecord>) { override fun saveAttachmentsIfPossible(messages: Set<MessageRecord>) {
val message = messages.first() as MmsMessageRecord val message = messages.first() as MmsMessageRecord
// Note: The save option is only added to the menu in ConversationReactionOverlay.getMenuActionItems // Note: The save option is only added to the menu in ConversationReactionOverlay.getMenuActionItems
@ -2253,8 +2257,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// that we've warned the user just _once_ that any attachments they save can be accessed by other apps. // that we've warned the user just _once_ that any attachments they save can be accessed by other apps.
val haveWarned = TextSecurePreferences.getHaveWarnedUserAboutSavingAttachments(this) val haveWarned = TextSecurePreferences.getHaveWarnedUserAboutSavingAttachments(this)
if (haveWarned) { if (haveWarned) {
// On Android versions below 30 we require the WRITE_EXTERNAL_STORAGE permission to save attachments. // On Android versions below 29 we require the WRITE_EXTERNAL_STORAGE permission to save attachments.
if (Build.VERSION.SDK_INT < 30) { if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
// Save the attachment(s) then bail if we already have permission to do so // Save the attachment(s) then bail if we already have permission to do so
if (hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { if (hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
saveAttachments(message) saveAttachments(message)
@ -2275,7 +2279,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
Permissions.with(this) Permissions.with(this)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.maxSdkVersion(Build.VERSION_CODES.P) // P is 28 .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)) .put(APP_NAME_KEY, getString(R.string.app_name))
.format().toString()) .format().toString())
.onAnyDenied { .onAnyDenied {
@ -2285,7 +2289,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
showSessionDialog { showSessionDialog {
title(R.string.permissionsRequired) 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)) .put(APP_NAME_KEY, getString(R.string.app_name))
.format().toString() .format().toString()
text(txt) text(txt)
@ -2425,7 +2429,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
ConversationReactionOverlay.Action.REPLY -> reply(selectedItems) ConversationReactionOverlay.Action.REPLY -> reply(selectedItems)
ConversationReactionOverlay.Action.RESYNC -> resyncMessage(selectedItems) ConversationReactionOverlay.Action.RESYNC -> resyncMessage(selectedItems)
ConversationReactionOverlay.Action.RESEND -> resendMessage(selectedItems) ConversationReactionOverlay.Action.RESEND -> resendMessage(selectedItems)
ConversationReactionOverlay.Action.DOWNLOAD -> saveAttachment(selectedItems) ConversationReactionOverlay.Action.DOWNLOAD -> saveAttachmentsIfPossible(selectedItems)
ConversationReactionOverlay.Action.COPY_MESSAGE -> copyMessages(selectedItems) ConversationReactionOverlay.Action.COPY_MESSAGE -> copyMessages(selectedItems)
ConversationReactionOverlay.Action.VIEW_INFO -> showMessageDetail(selectedItems) ConversationReactionOverlay.Action.VIEW_INFO -> showMessageDetail(selectedItems)
ConversationReactionOverlay.Action.SELECT -> selectMessages(selectedItems) ConversationReactionOverlay.Action.SELECT -> selectMessages(selectedItems)

View File

@ -1,8 +1,10 @@
package org.thoughtcrime.securesms.conversation.v2 package org.thoughtcrime.securesms.conversation.v2
import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.database.Cursor import android.database.Cursor
import android.net.Uri
import android.util.SparseArray import android.util.SparseArray
import android.util.SparseBooleanArray import android.util.SparseBooleanArray
import android.view.MotionEvent 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.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import com.bumptech.glide.RequestManager 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.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.ui.getSubbedCharSequence import org.thoughtcrime.securesms.ui.getSubbedCharSequence
@ -121,7 +128,11 @@ class ConversationAdapter(
val senderId = message.individualRecipient.address.serialize() val senderId = message.individualRecipient.address.serialize()
val senderIdHash = senderId.hashCode() val senderIdHash = senderId.hashCode()
updateQueue.trySend(senderId) updateQueue.trySend(senderId)
if (contactCache[senderIdHash] == null && !contactLoadedCache.getOrDefault(senderIdHash, false)) { if (contactCache[senderIdHash] == null && !contactLoadedCache.getOrDefault(
senderIdHash,
false
)
) {
getSenderInfo(senderId)?.let { contact -> getSenderInfo(senderId)?.let { contact ->
contactCache[senderIdHash] = contact contactCache[senderIdHash] = contact
} }
@ -143,36 +154,27 @@ class ConversationAdapter(
) )
if (!message.isDeleted) { if (!message.isDeleted) {
visibleMessageView.onPress = { event -> onItemPress(message, viewHolder.adapterPosition, visibleMessageView, event) } visibleMessageView.onPress = { event ->
visibleMessageView.onSwipeToReply = { onItemSwipeToReply(message, viewHolder.adapterPosition) } onItemPress(
visibleMessageView.onLongPress = { onItemLongPress(message, viewHolder.adapterPosition, visibleMessageView) } message,
viewHolder.adapterPosition,
visibleMessageView,
event
)
}
visibleMessageView.onSwipeToReply =
{ onItemSwipeToReply(message, viewHolder.adapterPosition) }
visibleMessageView.onLongPress =
{ onItemLongPress(message, viewHolder.adapterPosition, visibleMessageView) }
} else { } else {
visibleMessageView.onPress = null visibleMessageView.onPress = null
visibleMessageView.onSwipeToReply = null visibleMessageView.onSwipeToReply = null
visibleMessageView.onLongPress = null visibleMessageView.onLongPress = null
} }
} }
is ControlMessageViewHolder -> { is ControlMessageViewHolder -> {
viewHolder.view.bind(message, messageBefore) viewHolder.view.bind(message, messageBefore)
if (message.isCallLog && message.isFirstMissedCall) {
viewHolder.view.setOnClickListener {
context.showSessionDialog {
val titleTxt = context.getSubbedString(R.string.callsMissedCallFrom, NAME_KEY to message.individualRecipient.name!!)
title(titleTxt)
val bodyTxt = context.getSubbedCharSequence(R.string.callsYouMissedCallPermissions, NAME_KEY to message.individualRecipient.name!!)
text(bodyTxt)
button(R.string.sessionSettings) {
Intent(context, PrivacySettingsActivity::class.java)
.let(context::startActivity)
}
cancelButton()
}
}
} else {
viewHolder.view.setOnClickListener(null)
}
} }
} }
} }

View File

@ -24,9 +24,6 @@ import androidx.core.view.doOnLayout
import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat
import com.squareup.phrase.Phrase import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job 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.repository.ConversationRepository
import org.thoughtcrime.securesms.util.AnimationCompleteListener import org.thoughtcrime.securesms.util.AnimationCompleteListener
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
import java.util.Locale
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@AndroidEntryPoint @AndroidEntryPoint
class ConversationReactionOverlay : FrameLayout { class ConversationReactionOverlay : FrameLayout {
@ -213,7 +213,7 @@ class ConversationReactionOverlay : FrameLayout {
endY = backgroundView.height + menuPadding + reactionBarTopPadding endY = backgroundView.height + menuPadding + reactionBarTopPadding
} }
} else { } else {
endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.height endY = overlayHeight - contextMenu.getMaxHeight() - 2*menuPadding - conversationItemSnapshot.height
reactionBarBackgroundY = endY - reactionBarHeight - menuPadding reactionBarBackgroundY = endY - reactionBarHeight - menuPadding
} }
endApparentTop = endY endApparentTop = endY
@ -538,7 +538,7 @@ class ConversationReactionOverlay : FrameLayout {
items += ActionItem(R.attr.menu_copy_icon, R.string.copy, { handleActionItemClicked(Action.COPY_MESSAGE) }) items += ActionItem(R.attr.menu_copy_icon, R.string.copy, { handleActionItemClicked(Action.COPY_MESSAGE) })
} }
// Copy Account ID // 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) }) items += ActionItem(R.attr.menu_copy_icon, R.string.accountIDCopy, { handleActionItemClicked(Action.COPY_ACCOUNT_ID) })
} }
// Delete message // Delete message
@ -572,7 +572,7 @@ class ConversationReactionOverlay : FrameLayout {
items += ActionItem(R.attr.menu_save_icon, items += ActionItem(R.attr.menu_save_icon,
R.string.save, R.string.save,
{ handleActionItemClicked(Action.DOWNLOAD) }, { handleActionItemClicked(Action.DOWNLOAD) },
R.string.AccessibilityId_save R.string.AccessibilityId_saveAttachment
) )
} }
} }

View File

@ -18,9 +18,11 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.HorizontalPager 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.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
@ -58,6 +61,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAt
import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.ui.Avatar import org.thoughtcrime.securesms.ui.Avatar
import org.thoughtcrime.securesms.ui.CarouselNextButton import org.thoughtcrime.securesms.ui.CarouselNextButton
import org.thoughtcrime.securesms.ui.CarouselPrevButton import org.thoughtcrime.securesms.ui.CarouselPrevButton
@ -95,6 +99,8 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
const val ON_REPLY = 1 const val ON_REPLY = 1
const val ON_RESEND = 2 const val ON_RESEND = 2
const val ON_DELETE = 3 const val ON_DELETE = 3
const val ON_COPY = 4
const val ON_SAVE = 5
} }
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
@ -121,11 +127,18 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
@Composable @Composable
private fun MessageDetailsScreen() { private fun MessageDetailsScreen() {
val state by viewModel.stateFlow.collectAsState() 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( MessageDetails(
state = state, state = state,
onReply = if (state.canReply) { { setResultAndFinish(ON_REPLY) } } else null, onReply = if (state.canReply) { { setResultAndFinish(ON_REPLY) } } else null,
onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } }, onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } },
onSave = if(canSave) { { setResultAndFinish(ON_SAVE) } } else null,
onDelete = { setResultAndFinish(ON_DELETE) }, onDelete = { setResultAndFinish(ON_DELETE) },
onCopy = { setResultAndFinish(ON_COPY) },
onClickImage = { viewModel.onClickImage(it) }, onClickImage = { viewModel.onClickImage(it) },
onAttachmentNeedsDownload = viewModel::onAttachmentNeedsDownload, onAttachmentNeedsDownload = viewModel::onAttachmentNeedsDownload,
) )
@ -146,7 +159,9 @@ fun MessageDetails(
state: MessageDetailsState, state: MessageDetailsState,
onReply: (() -> Unit)? = null, onReply: (() -> Unit)? = null,
onResend: (() -> Unit)? = null, onResend: (() -> Unit)? = null,
onSave: (() -> Unit)? = null,
onDelete: () -> Unit = {}, onDelete: () -> Unit = {},
onCopy: () -> Unit = {},
onClickImage: (Int) -> Unit = {}, onClickImage: (Int) -> Unit = {},
onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit = { _ -> } onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit = { _ -> }
) { ) {
@ -180,9 +195,11 @@ fun MessageDetails(
state.nonImageAttachmentFileDetails?.let { FileDetails(it) } state.nonImageAttachmentFileDetails?.let { FileDetails(it) }
CellMetadata(state) CellMetadata(state)
CellButtons( CellButtons(
onReply, onReply = onReply,
onResend, onResend = onResend,
onDelete, onSave = onSave,
onDelete = onDelete,
onCopy = onCopy
) )
} }
} }
@ -204,7 +221,15 @@ fun CellMetadata(
senderInfo?.let { senderInfo?.let {
TitledView(state.fromTitle) { TitledView(state.fromTitle) {
Row { 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) TitledMonospaceText(it)
} }
} }
@ -218,7 +243,9 @@ fun CellMetadata(
fun CellButtons( fun CellButtons(
onReply: (() -> Unit)? = null, onReply: (() -> Unit)? = null,
onResend: (() -> Unit)? = null, onResend: (() -> Unit)? = null,
onDelete: () -> Unit = {}, onSave: (() -> Unit)? = null,
onDelete: () -> Unit,
onCopy: () -> Unit
) { ) {
Cell(modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing)) { Cell(modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing)) {
Column { Column {
@ -230,6 +257,23 @@ fun CellButtons(
) )
Divider() 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 { onResend?.let {
LargeItemButton( LargeItemButton(
R.string.resend, R.string.resend,
@ -238,6 +282,7 @@ fun CellButtons(
) )
Divider() Divider()
} }
LargeItemButton( LargeItemButton(
R.string.delete, R.string.delete,
R.drawable.ic_delete, R.drawable.ic_delete,
@ -319,6 +364,21 @@ fun ExpandButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
} }
} }
@Preview
@Composable
fun PreviewMessageDetailsButtons(
@PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors
) {
PreviewTheme(colors) {
CellButtons(
onReply = {},
onResend = {},
onSave = {},
onDelete = {},
onCopy = {}
)
}
}
@Preview @Preview
@Composable @Composable

View File

@ -102,7 +102,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
R.id.menu_context_resync -> delegate?.resyncMessage(selectedItems) R.id.menu_context_resync -> delegate?.resyncMessage(selectedItems)
R.id.menu_context_resend -> delegate?.resendMessage(selectedItems) R.id.menu_context_resend -> delegate?.resendMessage(selectedItems)
R.id.menu_message_details -> delegate?.showMessageDetail(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) R.id.menu_context_reply -> delegate?.reply(selectedItems)
} }
return true return true
@ -126,7 +126,7 @@ interface ConversationActionModeCallbackDelegate {
fun resyncMessage(messages: Set<MessageRecord>) fun resyncMessage(messages: Set<MessageRecord>)
fun resendMessage(messages: Set<MessageRecord>) fun resendMessage(messages: Set<MessageRecord>)
fun showMessageDetail(messages: Set<MessageRecord>) fun showMessageDetail(messages: Set<MessageRecord>)
fun saveAttachment(messages: Set<MessageRecord>) fun saveAttachmentsIfPossible(messages: Set<MessageRecord>)
fun reply(messages: Set<MessageRecord>) fun reply(messages: Set<MessageRecord>)
fun destroyActionMode() fun destroyActionMode()
} }

View File

@ -1,9 +1,11 @@
package org.thoughtcrime.securesms.conversation.v2.menus package org.thoughtcrime.securesms.conversation.v2.menus
import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri
import android.os.AsyncTask import android.os.AsyncTask
import android.view.Menu import android.view.Menu
import android.view.MenuInflater 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.MessageSender
import org.session.libsession.messaging.sending_receiving.leave import org.session.libsession.messaging.sending_receiving.leave
import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID 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.StringSubstitutionConstants.GROUP_NAME_KEY
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient 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.guava.Optional
import org.session.libsignal.utilities.toHexString import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.MissingMicrophonePermissionDialog
import org.thoughtcrime.securesms.media.MediaOverviewActivity import org.thoughtcrime.securesms.media.MediaOverviewActivity
import org.thoughtcrime.securesms.ShortcutLauncherActivity import org.thoughtcrime.securesms.ShortcutLauncherActivity
import org.thoughtcrime.securesms.calls.WebRtcCallActivity 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.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity import org.thoughtcrime.securesms.groups.EditClosedGroupActivity
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.service.WebRtcCallService import org.thoughtcrime.securesms.service.WebRtcCallService
import org.thoughtcrime.securesms.showMuteDialog import org.thoughtcrime.securesms.showMuteDialog
@ -162,6 +168,7 @@ object ConversationMenuHelper {
private fun call(context: Context, thread: Recipient) { private fun call(context: Context, thread: Recipient) {
// if the user has not enabled voice/video calls
if (!TextSecurePreferences.isCallNotificationsEnabled(context)) { if (!TextSecurePreferences.isCallNotificationsEnabled(context)) {
context.showSessionDialog { context.showSessionDialog {
title(R.string.callsPermissionsRequired) title(R.string.callsPermissionsRequired)
@ -173,6 +180,12 @@ object ConversationMenuHelper {
} }
return 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) WebRtcCallService.createCall(context, thread)
.let(context::startService) .let(context::startService)
@ -273,13 +286,13 @@ object ConversationMenuHelper {
val accountID = TextSecurePreferences.getLocalNumber(context) val accountID = TextSecurePreferences.getLocalNumber(context)
val isCurrentUserAdmin = admins.any { it.toString() == accountID } val isCurrentUserAdmin = admins.any { it.toString() == accountID }
val message = if (isCurrentUserAdmin) { val message = if (isCurrentUserAdmin) {
Phrase.from(context, R.string.groupLeaveDescriptionAdmin) Phrase.from(context, R.string.groupDeleteDescription)
.put(GROUP_NAME_KEY, group.title) .put(GROUP_NAME_KEY, group.title)
.format().toString() .format()
} else { } else {
Phrase.from(context, R.string.groupLeaveDescription) Phrase.from(context, R.string.groupLeaveDescription)
.put(GROUP_NAME_KEY, group.title) .put(GROUP_NAME_KEY, group.title)
.format().toString() .format()
} }
fun onLeaveFailed() { fun onLeaveFailed() {
@ -292,7 +305,7 @@ object ConversationMenuHelper {
context.showSessionDialog { context.showSessionDialog {
title(R.string.groupLeave) title(R.string.groupLeave)
text(message) text(message)
button(R.string.yes) { dangerButton(R.string.leave) {
try { try {
val groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString() val groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString()
val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey) val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey)
@ -303,7 +316,7 @@ object ConversationMenuHelper {
onLeaveFailed() onLeaveFailed()
} }
} }
button(R.string.no) button(R.string.cancel)
} }
} }

View File

@ -1,7 +1,10 @@
package org.thoughtcrime.securesms.conversation.v2.messages package org.thoughtcrime.securesms.conversation.v2.messages
import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent
import android.util.AttributeSet import android.util.AttributeSet
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
@ -10,17 +13,26 @@ import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.squareup.phrase.Phrase import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewControlMessageBinding import network.loki.messenger.databinding.ViewControlMessageBinding
import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.ExpiryMode
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.getColorFromAttr
import org.thoughtcrime.securesms.MissingMicrophonePermissionDialog
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages
import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent 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 @AndroidEntryPoint
class ControlMessageView : LinearLayout { class ControlMessageView : LinearLayout {
@ -29,6 +41,12 @@ class ControlMessageView : LinearLayout {
private val binding = ViewControlMessageBinding.inflate(LayoutInflater.from(context), this, true) 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) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
@ -77,26 +95,81 @@ class ControlMessageView : LinearLayout {
} }
} }
message.isMessageRequestResponse -> { message.isMessageRequestResponse -> {
binding.textView.text = context.getString(R.string.messageRequestsAccepted) val msgRecipient = message.recipient.address.serialize()
binding.root.contentDescription = Phrase.from(context, R.string.messageRequestYouHaveAccepted) val me = TextSecurePreferences.getLocalNumber(context)
.put(NAME_KEY, message.individualRecipient.name) binding.textView.text = if(me == msgRecipient) { // you accepted the user's request
.format() 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 -> { message.isCallLog -> {
val drawable = when { val drawable = when {
message.isIncomingCall -> R.drawable.ic_incoming_call message.isIncomingCall -> R.drawable.ic_incoming_call
message.isOutgoingCall -> R.drawable.ic_outgoing_call message.isOutgoingCall -> R.drawable.ic_outgoing_call
message.isFirstMissedCall -> R.drawable.ic_info_outline_light
else -> R.drawable.ic_missed_call else -> R.drawable.ic_missed_call
} }
binding.textView.isVisible = false 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 binding.callTextView.text = messageBody
if (message.expireStarted > 0 && message.expiresIn > 0) { if (message.expireStarted > 0 && message.expiresIn > 0) {
binding.expirationTimerView.isVisible = true binding.expirationTimerView.isVisible = true
binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
} }
// remove clicks by default
setOnClickListener(null)
hideInfo()
// handle click behaviour depending on criteria
if (message.isMissedCall || message.isFirstMissedCall) {
when {
// if we're currently missing the audio/microphone permission,
// show a dedicated permission dialog
!Permissions.hasAll(context, Manifest.permission.RECORD_AUDIO) -> {
showInfo()
setOnClickListener {
MissingMicrophonePermissionDialog.show(context)
}
}
// when the call toggle is disabled in the privacy screen,
// show a dedicated privacy dialog
!TextSecurePreferences.isCallNotificationsEnabled(context) -> {
showInfo()
setOnClickListener {
context.showSessionDialog {
val titleTxt = context.getSubbedString(
R.string.callsMissedCallFrom,
NAME_KEY to message.individualRecipient.name!!
)
title(titleTxt)
val bodyTxt = context.getSubbedCharSequence(
R.string.callsYouMissedCallPermissions,
NAME_KEY to message.individualRecipient.name!!
)
text(bodyTxt)
button(R.string.sessionSettings) {
Intent(context, PrivacySettingsActivity::class.java)
.let(context::startActivity)
}
cancelButton()
}
}
}
}
}
} }
} }
@ -104,6 +177,24 @@ class ControlMessageView : LinearLayout {
binding.callView.isVisible = message.isCallLog 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() { fun recycle() {
} }

View File

@ -106,7 +106,16 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
attachments.audioSlide != null -> { attachments.audioSlide != null -> {
binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_microphone) binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_microphone)
binding.quoteViewAttachmentPreviewImageView.isVisible = true 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 -> { attachments.documentSlide != null -> {
binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_document_large_light) binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_document_large_light)

View File

@ -61,6 +61,8 @@ import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.home.UserDetailsBottomSheet import org.thoughtcrime.securesms.home.UserDetailsBottomSheet
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager 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.DateUtils
import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.disableClipping
import org.thoughtcrime.securesms.util.toDp import org.thoughtcrime.securesms.util.toDp
@ -391,9 +393,9 @@ class VisibleMessageView : FrameLayout {
context.getColor(R.color.accent_orange), context.getColor(R.color.accent_orange),
R.string.messageStatusFailedToSync R.string.messageStatusFailedToSync
) )
message.isPending -> message.isPending -> {
// Non-mms messages display 'Sending'.. // Non-mms messages (or quote messages, which happen to be mms for some reason) display 'Sending'..
if (!message.isMms) { if (!message.isMms || (message as? MmsMessageRecord)?.quote != null) {
MessageStatusInfo( MessageStatusInfo(
R.drawable.ic_delivery_status_sending, R.drawable.ic_delivery_status_sending,
context.getColorFromAttr(R.attr.message_status_color), context.getColorFromAttr(R.attr.message_status_color),
@ -404,9 +406,10 @@ class VisibleMessageView : FrameLayout {
MessageStatusInfo( MessageStatusInfo(
R.drawable.ic_delivery_status_sending, R.drawable.ic_delivery_status_sending,
context.getColorFromAttr(R.attr.message_status_color), context.getColorFromAttr(R.attr.message_status_color),
R.string.messageStatusUploading R.string.uploading
) )
} }
}
message.isSyncing || message.isResyncing -> message.isSyncing || message.isResyncing ->
MessageStatusInfo( MessageStatusInfo(
R.drawable.ic_delivery_status_sending, R.drawable.ic_delivery_status_sending,

View File

@ -245,51 +245,58 @@ public class AttachmentManager {
public static void selectDocument(Activity activity, int requestCode) { public static void selectDocument(Activity activity, int requestCode) {
Permissions.PermissionsBuilder builder = Permissions.with(activity); Permissions.PermissionsBuilder builder = Permissions.with(activity);
Context c = activity.getApplicationContext();
// The READ_EXTERNAL_STORAGE permission is deprecated (and will AUTO-FAIL if requested!) on // 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. // 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
builder = builder.request(Manifest.permission.READ_MEDIA_VIDEO) builder = builder.request(Manifest.permission.READ_MEDIA_VIDEO)
.request(Manifest.permission.READ_MEDIA_IMAGES) .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 { } 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(); 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.
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.
.execute(); .execute();
} }
public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body) { public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body) {
Context c = activity.getApplicationContext(); 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); Permissions.PermissionsBuilder builder = Permissions.with(activity);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
builder = builder.request(Manifest.permission.READ_MEDIA_VIDEO) 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 { } 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) builder.onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode))
.withRationaleDialog(needStoragePermissionTxt, R.drawable.ic_baseline_photo_library_24)
.onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode))
.execute(); .execute();
} }
@ -313,18 +320,13 @@ public class AttachmentManager {
public void capturePhoto(Activity activity, int requestCode, Recipient recipient) { public void capturePhoto(Activity activity, int requestCode, Recipient recipient) {
String cameraPermissionDeniedTxt = Phrase.from(context, R.string.cameraGrantAccessDenied) String cameraPermissionDeniedTxt = Phrase.from(context, R.string.permissionsCameraDenied)
.put(APP_NAME_KEY, context.getString(R.string.app_name))
.format().toString();
String requireCameraPermissionTxt = Phrase.from(context, R.string.cameraGrantAccessDescription)
.put(APP_NAME_KEY, context.getString(R.string.app_name)) .put(APP_NAME_KEY, context.getString(R.string.app_name))
.format().toString(); .format().toString();
Permissions.with(activity) Permissions.with(activity)
.request(Manifest.permission.CAMERA) .request(Manifest.permission.CAMERA)
.withPermanentDenialDialog(cameraPermissionDeniedTxt) .withPermanentDenialDialog(cameraPermissionDeniedTxt)
.withRationaleDialog(requireCameraPermissionTxt, R.drawable.ic_baseline_photo_camera_24)
.onAllGranted(() -> { .onAllGranted(() -> {
Intent captureIntent = MediaSendActivity.buildCameraIntent(activity, recipient); Intent captureIntent = MediaSendActivity.buildCameraIntent(activity, recipient);
if (captureIntent.resolveActivity(activity.getPackageManager()) != null) { if (captureIntent.resolveActivity(activity.getPackageManager()) != null) {

View File

@ -234,7 +234,8 @@ public interface MmsSmsColumns {
public static boolean isCallLog(long type) { public static boolean isCallLog(long type) {
long baseType = type & BASE_TYPE_MASK; 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) { public static boolean isExpirationTimerUpdate(long type) {

View File

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

View File

@ -17,15 +17,11 @@
package org.thoughtcrime.securesms.database.model; package org.thoughtcrime.securesms.database.model;
import android.content.Context; import android.content.Context;
import android.text.SpannableString;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import org.session.libsession.utilities.recipients.Recipient; 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.MmsSmsColumns;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase;
/** /**
@ -68,7 +64,7 @@ public abstract class DisplayRecord {
public @NonNull String getBody() { public @NonNull String getBody() {
return body == null ? "" : body; return body == null ? "" : body;
} }
public abstract SpannableString getDisplayBody(@NonNull Context context); public abstract CharSequence getDisplayBody(@NonNull Context context);
public Recipient getRecipient() { return recipient; } public Recipient getRecipient() { return recipient; }
public long getDateSent() { return dateSent; } public long getDateSent() { return dateSent; }
public long getDateReceived() { return dateReceived; } public long getDateReceived() { return dateReceived; }

View File

@ -17,7 +17,6 @@
package org.thoughtcrime.securesms.database.model; package org.thoughtcrime.securesms.database.model;
import android.content.Context; import android.content.Context;
import android.text.SpannableString;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; 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.IdentityKeyMismatch;
import org.session.libsession.utilities.NetworkFailure; import org.session.libsession.utilities.NetworkFailure;
import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.recipients.Recipient;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase.Status; import org.thoughtcrime.securesms.database.SmsDatabase.Status;
import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.mms.SlideDeck;
import java.util.List; import java.util.List;
import network.loki.messenger.R;
/** /**
* Represents the message record model for MMS messages that contain * Represents the message record model for MMS messages that contain
* media (ie: they've been downloaded). * media (ie: they've been downloaded).
@ -76,7 +72,7 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
} }
@Override @Override
public SpannableString getDisplayBody(@NonNull Context context) { public CharSequence getDisplayBody(@NonNull Context context) {
return super.getDisplayBody(context); return super.getDisplayBody(context);
} }
} }

View File

@ -115,7 +115,7 @@ public abstract class MessageRecord extends DisplayRecord {
} }
@Override @Override
public SpannableString getDisplayBody(@NonNull Context context) { public CharSequence getDisplayBody(@NonNull Context context) {
if (isGroupUpdateMessage()) { if (isGroupUpdateMessage()) {
UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(getBody()); UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(getBody());
return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing())); return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing()));

View File

@ -18,14 +18,13 @@
package org.thoughtcrime.securesms.database.model; package org.thoughtcrime.securesms.database.model;
import android.content.Context; import android.content.Context;
import android.text.SpannableString;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import org.session.libsession.utilities.IdentityKeyMismatch; import org.session.libsession.utilities.IdentityKeyMismatch;
import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.recipients.Recipient;
import org.thoughtcrime.securesms.database.SmsDatabase;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import network.loki.messenger.R;
/** /**
* The message record model which represents standard SMS messages. * The message record model which represents standard SMS messages.
@ -56,7 +55,7 @@ public class SmsMessageRecord extends MessageRecord {
} }
@Override @Override
public SpannableString getDisplayBody(@NonNull Context context) { public CharSequence getDisplayBody(@NonNull Context context) {
return super.getDisplayBody(context); return super.getDisplayBody(context);
} }

View File

@ -18,7 +18,9 @@
package org.thoughtcrime.securesms.database.model; package org.thoughtcrime.securesms.database.model;
import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; 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.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.NAME_KEY;
import static org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY; import static org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY;
@ -32,10 +34,14 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import com.squareup.phrase.Phrase; import com.squareup.phrase.Phrase;
import org.session.libsession.utilities.ExpirationUtil; import org.session.libsession.utilities.ExpirationUtil;
import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.ui.UtilKt;
import kotlin.Pair;
import network.loki.messenger.R; import network.loki.messenger.R;
/** /**
@ -113,67 +119,68 @@ public class ThreadRecord extends DisplayRecord {
} }
@Override @Override
public SpannableString getDisplayBody(@NonNull Context context) { public CharSequence getDisplayBody(@NonNull Context context) {
if (isGroupUpdateMessage()) { if (isGroupUpdateMessage()) {
return emphasisAdded(context.getString(R.string.groupUpdated)); return context.getString(R.string.groupUpdated);
} else if (isOpenGroupInvitation()) { } else if (isOpenGroupInvitation()) {
return emphasisAdded(context.getString(R.string.communityInvitation)); return context.getString(R.string.communityInvitation);
} else if (MmsSmsColumns.Types.isLegacyType(type)) { } 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)) .put(APP_NAME_KEY, context.getString(R.string.app_name))
.format().toString(); .format().toString();
return emphasisAdded(txt);
} else if (MmsSmsColumns.Types.isDraftMessageType(type)) { } else if (MmsSmsColumns.Types.isDraftMessageType(type)) {
String draftText = context.getString(R.string.draft); String draftText = context.getString(R.string.draft);
return emphasisAdded(draftText + " " + getBody(), 0, draftText.length()); return draftText + " " + getBody();
} else if (SmsDatabase.Types.isOutgoingCall(type)) { } 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()) .put(NAME_KEY, getName())
.format().toString(); .format().toString();
return emphasisAdded(txt);
} else if (SmsDatabase.Types.isIncomingCall(type)) { } 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()) .put(NAME_KEY, getName())
.format().toString(); .format().toString();
return emphasisAdded(txt);
} else if (SmsDatabase.Types.isMissedCall(type)) { } 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()) .put(NAME_KEY, getName())
.format().toString(); .format().toString();
return emphasisAdded(txt);
} else if (SmsDatabase.Types.isExpirationTimerUpdate(type)) { } else if (SmsDatabase.Types.isExpirationTimerUpdate(type)) {
int seconds = (int) (getExpiresIn() / 1000); int seconds = (int) (getExpiresIn() / 1000);
if (seconds <= 0) { if (seconds <= 0) {
String txt = Phrase.from(context, R.string.disappearingMessagesTurnedOff) return Phrase.from(context, R.string.disappearingMessagesTurnedOff)
.put(NAME_KEY, getName()) .put(NAME_KEY, getName())
.format().toString(); .format().toString();
return emphasisAdded(txt);
} }
// Implied that disappearing messages is enabled.. // Implied that disappearing messages is enabled..
String time = ExpirationUtil.getExpirationDisplayValue(context, seconds); String time = ExpirationUtil.getExpirationDisplayValue(context, seconds);
String disappearAfterWhat = getDisappearingMsgExpiryTypeString(context); // Disappear after send or read? 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(NAME_KEY, getName())
.put(TIME_KEY, time) .put(TIME_KEY, time)
.put(DISAPPEARING_MESSAGES_TYPE_KEY, disappearAfterWhat) .put(DISAPPEARING_MESSAGES_TYPE_KEY, disappearAfterWhat)
.format().toString(); .format().toString();
return emphasisAdded(txt);
} else if (MmsSmsColumns.Types.isMediaSavedExtraction(type)) { } 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()) .put(NAME_KEY, getName())
.format().toString(); .format().toString();
return emphasisAdded(txt);
} else if (MmsSmsColumns.Types.isScreenshotExtraction(type)) { } 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()) .put(NAME_KEY, getName())
.format().toString(); .format().toString();
return emphasisAdded(txt);
} else if (MmsSmsColumns.Types.isMessageRequestResponse(type)) { } 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) { } else if (getCount() == 0) {
return new SpannableString(context.getString(R.string.messageEmpty)); return new SpannableString(context.getString(R.string.messageEmpty));
} else { } else {
@ -186,20 +193,37 @@ public class ThreadRecord extends DisplayRecord {
return new SpannableString(""); return new SpannableString("");
// Old behaviour was: return new SpannableString(emphasisAdded(context.getString(R.string.mediaMessage))); // Old behaviour was: return new SpannableString(emphasisAdded(context.getString(R.string.mediaMessage)));
} else { } 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) { return Phrase.from(context.getString(R.string.messageSnippetGroup))
SpannableString spannable = new SpannableString(sequence); .put(AUTHOR_KEY, prefix)
spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), .put(MESSAGE_SNIPPET_KEY, getBody())
start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); .format().toString();
return spannable; }
} }
public long getCount() { return count; } public long getCount() { return count; }

View File

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.debugmenu package org.thoughtcrime.securesms.debugmenu
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -19,6 +20,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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 androidx.compose.ui.tooling.preview.Preview
import network.loki.messenger.BuildConfig import network.loki.messenger.BuildConfig
import network.loki.messenger.R import network.loki.messenger.R
@ -102,13 +105,22 @@ fun DebugMenu(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
// Info pane // Info pane
DebugCell("App Info") { val clipboardManager = LocalClipboardManager.current
Text( val appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE} - ${
text = "Version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE} - ${
BuildConfig.GIT_HASH.take( BuildConfig.GIT_HASH.take(
6 6
) )
})", })"
DebugCell(
modifier = Modifier.clickable {
// clicking the cell copies the version number to the clipboard
clipboardManager.setText(AnnotatedString(appVersion))
},
title = "App Info"
) {
Text(
text = "Version: $appVersion",
style = LocalType.current.base style = LocalType.current.base
) )
} }

View File

@ -28,6 +28,7 @@ import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.ui.getSubbedString
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
@AndroidEntryPoint @AndroidEntryPoint
@ -37,6 +38,8 @@ class JoinCommunityFragment : Fragment() {
lateinit var delegate: StartConversationDelegate lateinit var delegate: StartConversationDelegate
var lastUrl: String? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
@ -66,34 +69,62 @@ class JoinCommunityFragment : Fragment() {
} }
fun joinCommunityIfPossible(url: String) { fun joinCommunityIfPossible(url: String) {
// 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 { val openGroup = try {
OpenGroupUrlParser.parseUrl(url) OpenGroupUrlParser.parseUrl(url)
} catch (e: OpenGroupUrlParser.Error) { } catch (e: OpenGroupUrlParser.Error) {
when (e) { when (e) {
is OpenGroupUrlParser.Error.MalformedURL, OpenGroupUrlParser.Error.NoRoom -> { is OpenGroupUrlParser.Error.MalformedURL, OpenGroupUrlParser.Error.NoRoom -> {
return Toast.makeText(activity, context?.resources?.getString(R.string.communityJoinError), Toast.LENGTH_SHORT).show() return@launch Toast.makeText(
activity,
context?.resources?.getString(R.string.communityJoinError),
Toast.LENGTH_SHORT
).show()
} }
is OpenGroupUrlParser.Error.InvalidPublicKey, OpenGroupUrlParser.Error.NoPublicKey -> { is OpenGroupUrlParser.Error.InvalidPublicKey, OpenGroupUrlParser.Error.NoPublicKey -> {
return Toast.makeText(activity, R.string.communityEnterUrlErrorInvalidDescription, Toast.LENGTH_SHORT).show() return@launch Toast.makeText(
activity,
R.string.communityEnterUrlErrorInvalidDescription,
Toast.LENGTH_SHORT
).show()
} }
} }
} }
showLoader() showLoader()
lifecycleScope.launch(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
val sanitizedServer = openGroup.server.removeSuffix("/") val sanitizedServer = openGroup.server.removeSuffix("/")
val openGroupID = "$sanitizedServer.${openGroup.room}" val openGroupID = "$sanitizedServer.${openGroup.room}"
OpenGroupManager.add(sanitizedServer, openGroup.room, openGroup.serverPublicKey, requireContext()) OpenGroupManager.add(
sanitizedServer,
openGroup.room,
openGroup.serverPublicKey,
requireContext()
)
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
storage.onOpenGroupAdded(sanitizedServer, openGroup.room) storage.onOpenGroupAdded(sanitizedServer, openGroup.room)
val threadID = GroupManager.getOpenGroupThreadID(openGroupID, requireContext()) val threadID =
GroupManager.getOpenGroupThreadID(openGroupID, requireContext())
val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray()) val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(
requireContext()
)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val recipient = Recipient.from(requireContext(), Address.fromSerialized(groupID), false) val recipient = Recipient.from(
requireContext(),
Address.fromSerialized(groupID),
false
)
openConversationActivity(requireContext(), threadID, recipient) openConversationActivity(requireContext(), threadID, recipient)
delegate.onDialogClosePressed() delegate.onDialogClosePressed()
} }
@ -101,10 +132,11 @@ class JoinCommunityFragment : Fragment() {
Log.e("Loki", "Couldn't join community.", e) Log.e("Loki", "Couldn't join community.", e)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
hideLoader() hideLoader()
val txt = Phrase.from(context, R.string.groupErrorJoin).put(GROUP_NAME_KEY, url).format().toString() val txt = context?.getSubbedString(R.string.groupErrorJoin,
GROUP_NAME_KEY to url)
Toast.makeText(activity, txt, Toast.LENGTH_SHORT).show() Toast.makeText(activity, txt, Toast.LENGTH_SHORT).show()
} }
return@launch }
} }
} }
} }

View File

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

View File

@ -103,7 +103,7 @@ class ConversationView : LinearLayout {
} }
binding.muteIndicatorImageView.setImageResource(drawableRes) binding.muteIndicatorImageView.setImageResource(drawableRes)
binding.snippetTextView.text = highlightMentions( binding.snippetTextView.text = highlightMentions(
text = thread.getSnippet(), text = thread.getDisplayBody(context),
formatOnly = true, // no styling here, only text formatting formatOnly = true, // no styling here, only text formatting
threadID = thread.threadId, threadID = thread.threadId,
context = context context = context
@ -139,16 +139,5 @@ class ConversationView : LinearLayout {
recipient.isLocalNumber -> context.getString(R.string.noteToSelf) recipient.isLocalNumber -> context.getString(R.string.noteToSelf)
else -> recipient.toShortString() // Internally uses the Contact API 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 // endregion
} }

View File

@ -18,7 +18,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.squareup.phrase.Phrase import com.squareup.phrase.Phrase
import network.loki.messenger.R 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.APP_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY
import org.thoughtcrime.securesms.ui.Divider import org.thoughtcrime.securesms.ui.Divider
@ -53,7 +52,7 @@ internal fun EmptyView(newAccount: Boolean) {
val c = LocalContext.current val c = LocalContext.current
Phrase.from(txt) Phrase.from(txt)
.put(APP_NAME_KEY, c.getString(R.string.app_name)) .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() .format().toString()
}, },
style = LocalType.current.base, style = LocalType.current.base,

View File

@ -388,6 +388,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
super.onDestroy() super.onDestroy()
EventBus.getDefault().unregister(this) 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 // endregion
// region Updating // region Updating
@ -547,6 +552,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} else { } else {
showMuteDialog(this) { until -> showMuteDialog(this) { until ->
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
Log.d("", "**** until: $until")
recipientDatabase.setMuted(thread.recipient, until) recipientDatabase.setMuted(thread.recipient, until)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
binding.recyclerView.adapter!!.notifyDataSetChanged() binding.recyclerView.adapter!!.notifyDataSetChanged()
@ -583,13 +589,15 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
val recipient = thread.recipient val recipient = thread.recipient
val title: String val title: String
val message: CharSequence val message: CharSequence
var positiveButtonId: Int = R.string.yes
var negativeButtonId: Int = R.string.no
if (recipient.isGroupRecipient) { if (recipient.isGroupRecipient) {
val group = groupDatabase.getGroup(recipient.address.toString()).orNull() val group = groupDatabase.getGroup(recipient.address.toString()).orNull()
// If you are an admin of this group you can delete it // If you are an admin of this group you can delete it
if (group != null && group.admins.map { it.toString() }.contains(textSecurePreferences.getLocalNumber())) { 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) message = Phrase.from(this.applicationContext, R.string.groupDeleteDescription)
.put(GROUP_NAME_KEY, group.title) .put(GROUP_NAME_KEY, group.title)
.format() .format()
@ -600,6 +608,9 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
.put(GROUP_NAME_KEY, group.title) .put(GROUP_NAME_KEY, group.title)
.format() .format()
} }
positiveButtonId = R.string.leave
negativeButtonId = R.string.cancel
} else { } else {
// If this is a 1-on-1 conversation // If this is a 1-on-1 conversation
if (recipient.name != null) { if (recipient.name != null) {
@ -610,15 +621,17 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} }
else { else {
// If not group-related and we don't have a recipient name then this must be our Note to Self conversation // 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) message = getString(R.string.clearMessagesNoteToSelfDescription)
positiveButtonId = R.string.clear
negativeButtonId = R.string.cancel
} }
} }
showSessionDialog { showSessionDialog {
title(title) title(title)
text(message) text(message)
button(R.string.yes) { dangerButton(positiveButtonId) {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
val context = this@HomeActivity val context = this@HomeActivity
// Cancel any outstanding jobs // Cancel any outstanding jobs
@ -650,7 +663,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show() Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show()
} }
} }
button(R.string.no) button(negativeButtonId)
} }
} }

View File

@ -114,7 +114,7 @@ class PathActivity : PassphraseRequiredActionBarActivity() {
val isGuardSnode = (OnionRequestAPI.guardSnodes.contains(snode)) val isGuardSnode = (OnionRequestAPI.guardSnodes.contains(snode))
getPathRow(snode, LineView.Location.Middle, index.toLong() * 1000 + 2000, dotAnimationRepeatInterval, isGuardSnode) 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 destinationRow = getPathRow(resources.getString(R.string.onionRoutingPathDestination), null, LineView.Location.Bottom, path.count().toLong() * 1000 + 2000, dotAnimationRepeatInterval)
val rows = listOf( youRow ) + pathRows + listOf( destinationRow ) val rows = listOf( youRow ) + pathRows + listOf( destinationRow )
for (row in rows) { for (row in rows) {

View File

@ -34,7 +34,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -69,7 +68,7 @@ fun MediaOverviewScreen(
} else { } else {
Toast.makeText( Toast.makeText(
context, context,
R.string.cameraGrantAccessDenied, R.string.permissionsCameraDenied,
Toast.LENGTH_LONG Toast.LENGTH_LONG
).show() ).show()
} }
@ -232,7 +231,7 @@ private fun SaveAttachmentWarningDialog(
title = context.getString(R.string.warning), title = context.getString(R.string.warning),
text = context.resources.getString(R.string.attachmentsWarning), text = context.resources.getString(R.string.attachmentsWarning),
buttons = listOf( 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) 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 private val MediaOverviewTab.titleResId: Int
get() = when (this) { get() = when (this) {
MediaOverviewTab.Media -> R.string.media MediaOverviewTab.Media -> R.string.media
MediaOverviewTab.Documents -> R.string.document MediaOverviewTab.Documents -> R.string.files
} }

View File

@ -362,7 +362,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
private void navigateToCamera() { private void navigateToCamera() {
Context c = getApplicationContext(); 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)) .put(APP_NAME_KEY, c.getString(R.string.app_name))
.format().toString(); .format().toString();
String requireCameraPermissionsTxt = Phrase.from(c, R.string.cameraGrantAccessDescription) String requireCameraPermissionsTxt = Phrase.from(c, R.string.cameraGrantAccessDescription)
@ -371,7 +371,6 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
Permissions.with(this) Permissions.with(this)
.request(Manifest.permission.CAMERA) .request(Manifest.permission.CAMERA)
.withRationaleDialog(requireCameraPermissionsTxt, R.drawable.ic_baseline_photo_camera_48)
.withPermanentDenialDialog(permanentDenialTxt) .withPermanentDenialDialog(permanentDenialTxt)
.onAllGranted(() -> { .onAllGranted(() -> {
Camera1Fragment fragment = getOrCreateCameraFragment(); Camera1Fragment fragment = getOrCreateCameraFragment();

View File

@ -49,14 +49,11 @@ abstract class Slide(@JvmField protected val context: Context, protected val att
// A missing file name is the legacy way to determine if an audio attachment is // A missing file name is the legacy way to determine if an audio attachment is
// a voice note vs. other arbitrary audio attachments. // a voice note vs. other arbitrary audio attachments.
if (attachment.isVoiceNote || attachment.fileName.isNullOrEmpty()) { if (attachment.isVoiceNote || attachment.fileName.isNullOrEmpty()) {
val baseString = context.getString(R.string.messageVoice) val voiceTxt = Phrase.from(context, R.string.messageVoiceSnippet)
val languageIsLTR = Util.usingLeftToRightLanguage(context) .put(EMOJI_KEY, "🎙")
val attachmentString = if (languageIsLTR) { .format().toString()
"🎙 $baseString"
} else { return Optional.fromNullable(voiceTxt)
"$baseString 🎙"
}
return Optional.fromNullable(attachmentString)
} }
} }
val txt = Phrase.from(context, R.string.attachmentsNotification) 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 { private fun emojiForMimeType(): String {
return if (MediaUtil.isGif(attachment)) { return when{
"🎡" MediaUtil.isGif(attachment) -> "🎡"
} else if (MediaUtil.isImage(attachment)) {
"📷" MediaUtil.isImage(attachment) -> "📷"
} else if (MediaUtil.isVideo(attachment)) {
"🎥" MediaUtil.isVideo(attachment) -> "🎥"
} else if (MediaUtil.isAudio(attachment)) {
"🎧" MediaUtil.isAudio(attachment) -> "🎧"
} else if (MediaUtil.isFile(attachment)) {
"📎" MediaUtil.isFile(attachment) -> "📎"
} else {
// We don't provide emojis for other mime-types such as VCARD // We don't provide emojis for other mime-types such as VCARD
"" else -> ""
} }
} }

View File

@ -17,11 +17,10 @@ import org.thoughtcrime.securesms.ui.theme.LocalColors
@Composable @Composable
fun OnboardingBackPressAlertDialog( fun OnboardingBackPressAlertDialog(
dismissDialog: () -> Unit, dismissDialog: () -> Unit,
@StringRes textId: Int = R.string.onboardingBackAccountCreation, @StringRes textId: Int,
quit: () -> Unit quit: () -> Unit
) { ) {
val c = LocalContext.current val c = LocalContext.current
val quitButtonText = c.getSubbedString(R.string.quit, APP_NAME_KEY to APP_NAME)
AlertDialog( AlertDialog(
onDismissRequest = dismissDialog, onDismissRequest = dismissDialog,
@ -31,7 +30,7 @@ fun OnboardingBackPressAlertDialog(
}, },
buttons = listOf( buttons = listOf(
DialogButtonModel( DialogButtonModel(
text = GetString(quitButtonText), text = GetString(stringResource(id = R.string.quitButton)),
color = LocalColors.current.danger, color = LocalColors.current.danger,
onClick = quit onClick = quit
), ),

View File

@ -37,8 +37,6 @@ import androidx.compose.ui.unit.dp
import com.squareup.phrase.Phrase import com.squareup.phrase.Phrase
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import network.loki.messenger.R 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.APP_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY
import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.AlertDialog
@ -139,7 +137,7 @@ internal fun LandingScreen(
R.string.onboardingBubbleWelcomeToSession -> { R.string.onboardingBubbleWelcomeToSession -> {
Phrase.from(stringResource(item.stringId)) Phrase.from(stringResource(item.stringId))
.put(APP_NAME_KEY, stringResource(R.string.app_name)) .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() .format().toString()
} }
R.string.onboardingBubbleSessionIsEngineered -> { R.string.onboardingBubbleSessionIsEngineered -> {
@ -149,7 +147,7 @@ internal fun LandingScreen(
} }
R.string.onboardingBubbleCreatingAnAccountIsEasy -> { R.string.onboardingBubbleCreatingAnAccountIsEasy -> {
Phrase.from(stringResource(item.stringId)) 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() .format().toString()
} }
else -> { else -> {

View File

@ -13,6 +13,7 @@ import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.onboarding.manager.LoadAccountManager import org.thoughtcrime.securesms.onboarding.manager.LoadAccountManager
import org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsActivity import org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsActivity
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.ui.setComposeContent import org.thoughtcrime.securesms.ui.setComposeContent
import org.thoughtcrime.securesms.util.start import org.thoughtcrime.securesms.util.start
@ -45,4 +46,9 @@ class LoadAccountActivity : BaseActionBarActivity() {
LoadAccountScreen(state, viewModel.qrErrors, viewModel::onChange, viewModel::onContinue, viewModel::onScanQrCode) LoadAccountScreen(state, viewModel.qrErrors, viewModel::onChange, viewModel::onContinue, viewModel::onScanQrCode)
} }
} }
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
}
} }

View File

@ -53,7 +53,12 @@ internal fun MessageNotificationsScreen(
return return
} }
if (state.showDialog) OnboardingBackPressAlertDialog(dismissDialog, quit = quit) if (state.showingBackWarningDialogText != null) {
OnboardingBackPressAlertDialog(dismissDialog,
textId = state.showingBackWarningDialogText,
quit = quit
)
}
Column { Column {
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))

View File

@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.notifications.PushRegistry import org.thoughtcrime.securesms.notifications.PushRegistry
@ -58,14 +59,16 @@ internal class MessageNotificationsViewModel(
fun onBackPressed(): Boolean = when (state) { fun onBackPressed(): Boolean = when (state) {
is State.CreateAccount -> false is State.CreateAccount -> false
is State.LoadAccount -> { is State.LoadAccount -> {
_uiStates.update { it.copy(showDialog = true) } _uiStates.update { it.copy(showingBackWarningDialogText = R.string.onboardingBackLoadAccount) }
true true
} }
} }
fun dismissDialog() { fun dismissDialog() {
_uiStates.update { it.copy(showDialog = false) } _uiStates.update {
it.copy(showingBackWarningDialogText = null)
}
} }
fun quit() { fun quit() {
@ -78,7 +81,7 @@ internal class MessageNotificationsViewModel(
data class UiState( data class UiState(
val pushEnabled: Boolean = true, val pushEnabled: Boolean = true,
val showDialog: Boolean = false, val showingBackWarningDialogText: Int? = null,
val clearData: Boolean = false val clearData: Boolean = false
) { ) {
val pushDisabled get() = !pushEnabled val pushDisabled get() = !pushEnabled

View File

@ -73,7 +73,7 @@ public class Permissions {
return this; 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.rationalDialogHeader = headers;
this.rationaleDialogMessage = message; this.rationaleDialogMessage = message;
return this; return this;
@ -143,7 +143,7 @@ public class Permissions {
if (!isInTargetSDKRange || permissionObject.hasAll(requestedPermissions)) { if (!isInTargetSDKRange || permissionObject.hasAll(requestedPermissions)) {
executePreGrantedPermissionsRequest(request); executePreGrantedPermissionsRequest(request);
} else if (rationaleDialogMessage != null && rationalDialogHeader != null) { } else if (rationaleDialogMessage != null) {
executePermissionsRequestWithRationale(request); executePermissionsRequestWithRationale(request);
} else { } else {
executePermissionsRequest(request); executePermissionsRequest(request);

View File

@ -4,6 +4,7 @@ import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.util.TypedValue import android.util.TypedValue
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
@ -25,10 +26,13 @@ object RationaleDialog {
onNegative: Runnable, onNegative: Runnable,
@DrawableRes vararg drawables: Int @DrawableRes vararg drawables: Int
): AlertDialog { ): AlertDialog {
val view = LayoutInflater.from(context).inflate(R.layout.permissions_rationale_dialog, null) var customView: View? = null
if (!drawables.isEmpty()) {
customView = LayoutInflater.from(context).inflate(R.layout.permissions_rationale_dialog, null)
.apply { clipToOutline = true } .apply { clipToOutline = true }
val header = view.findViewById<ViewGroup>(R.id.header_container) val header = customView.findViewById<ViewGroup>(R.id.header_container)
view.findViewById<TextView>(R.id.message).text = message
customView.findViewById<TextView>(R.id.message).text = message
fun addIcon(id: Int) { fun addIcon(id: Int) {
ImageView(context).apply { ImageView(context).apply {
@ -50,9 +54,16 @@ object RationaleDialog {
drawables.firstOrNull()?.let(::addIcon) drawables.firstOrNull()?.let(::addIcon)
drawables.drop(1).forEach { addPlus(); addIcon(it) } drawables.drop(1).forEach { addPlus(); addIcon(it) }
}
return context.showSessionDialog { 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.theContinue) { onPositive.run() }
button(R.string.notNow) { onNegative.run() } button(R.string.notNow) { onNegative.run() }
} }

View File

@ -11,7 +11,7 @@ class SettingsDialog {
context.showSessionDialog { context.showSessionDialog {
title(R.string.permissionsRequired) title(R.string.permissionsRequired)
text(message) text(message)
button(R.string.theContinue, R.string.AccessibilityId_theContinue) { button(R.string.sessionSettings, R.string.AccessibilityId_sessionSettings) {
context.startActivity(Permissions.getApplicationSettingsIntent(context)) context.startActivity(Permissions.getApplicationSettingsIntent(context))
} }
cancelButton() cancelButton()

View File

@ -9,7 +9,7 @@ import org.thoughtcrime.securesms.permissions.Permissions;
import network.loki.messenger.R; import network.loki.messenger.R;
public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment { public class ChatsPreferenceFragment extends CorrectedPreferenceFragment {
private static final String TAG = ChatsPreferenceFragment.class.getSimpleName(); private static final String TAG = ChatsPreferenceFragment.class.getSimpleName();
@Override @Override

View File

@ -98,10 +98,10 @@ class HelpSettingsFragment: CorrectedPreferenceFragment() {
Permissions.with(this) Permissions.with(this)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.maxSdkVersion(Build.VERSION_CODES.P) .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 { .onAnyDenied {
val c = requireContext() 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() Toast.makeText(c, txt, Toast.LENGTH_LONG).show()
} }
.onAllGranted { .onAllGranted {

View File

@ -1,29 +0,0 @@
package org.thoughtcrime.securesms.preferences;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import java.util.Arrays;
import network.loki.messenger.R;
public abstract class ListSummaryPreferenceFragment extends CorrectedPreferenceFragment {
protected class ListSummaryListener implements Preference.OnPreferenceChangeListener {
@Override
public boolean onPreferenceChange(Preference preference, Object value) {
ListPreference listPref = (ListPreference) preference;
int entryIndex = Arrays.asList(listPref.getEntryValues()).indexOf(value);
listPref.setSummary(entryIndex >= 0 && entryIndex < listPref.getEntries().length
? listPref.getEntries()[entryIndex]
: getString(R.string.unknown));
return true;
}
}
protected void initializeListSummary(ListPreference pref) {
pref.setSummary(pref.getEntry());
}
}

View File

@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.preferences
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Context
import android.content.Intent import android.content.Intent
import android.media.RingtoneManager import android.media.RingtoneManager
import android.net.Uri import android.net.Uri
@ -11,7 +10,6 @@ import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.text.TextUtils import android.text.TextUtils
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.preference.ListPreference
import androidx.preference.Preference import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -19,17 +17,19 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotificationsEnabled
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.components.SwitchPreferenceCompat import org.thoughtcrime.securesms.components.SwitchPreferenceCompat
import org.thoughtcrime.securesms.notifications.NotificationChannels import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.PushRegistry import org.thoughtcrime.securesms.notifications.PushRegistry
import org.thoughtcrime.securesms.preferences.widgets.DropDownPreference
import java.util.Arrays
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() { class NotificationsPreferenceFragment : CorrectedPreferenceFragment() {
@Inject @Inject
lateinit var pushRegistry: PushRegistry lateinit var pushRegistry: PushRegistry
@Inject @Inject
lateinit var prefs: TextSecurePreferences lateinit var prefs: TextSecurePreferences
@ -64,8 +64,16 @@ class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() {
NotificationChannels.getMessageVibrate(requireContext()) NotificationChannels.getMessageVibrate(requireContext())
) )
findPreference<Preference>(TextSecurePreferences.RINGTONE_PREF)!!.onPreferenceChangeListener = RingtoneSummaryListener() findPreference<DropDownPreference>(TextSecurePreferences.RINGTONE_PREF)?.apply {
findPreference<Preference>(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)!!.onPreferenceChangeListener = NotificationPrivacyListener() setOnViewReady { updateRingtonePref() }
onPreferenceChangeListener = RingtoneSummaryListener()
}
findPreference<DropDownPreference>(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)?.apply {
setOnViewReady { setDropDownLabel(entry) }
onPreferenceChangeListener = NotificationPrivacyListener()
}
findPreference<Preference>(TextSecurePreferences.VIBRATE_PREF)!!.onPreferenceChangeListener = findPreference<Preference>(TextSecurePreferences.VIBRATE_PREF)!!.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _: Preference?, newValue: Any -> Preference.OnPreferenceChangeListener { _: Preference?, newValue: Any ->
NotificationChannels.updateMessageVibrate(requireContext(), newValue as Boolean) NotificationChannels.updateMessageVibrate(requireContext(), newValue as Boolean)
@ -91,28 +99,18 @@ class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() {
true 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 = findPreference<Preference>(TextSecurePreferences.NOTIFICATION_PRIORITY_PREF)!!.onPreferenceClickListener =
Preference.OnPreferenceClickListener { Preference.OnPreferenceClickListener {
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
intent.putExtra( intent.putExtra(
Settings.EXTRA_CHANNEL_ID, NotificationChannels.getMessagesChannel(requireContext()) Settings.EXTRA_CHANNEL_ID,
NotificationChannels.getMessagesChannel(requireContext())
) )
intent.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName) intent.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName)
startActivity(intent) startActivity(intent)
true true
} }
initializeRingtoneSummary(findPreference(TextSecurePreferences.RINGTONE_PREF))
initializeMessageVibrateSummary(findPreference<Preference>(TextSecurePreferences.VIBRATE_PREF) as SwitchPreferenceCompat?) initializeMessageVibrateSummary(findPreference<Preference>(TextSecurePreferences.VIBRATE_PREF) as SwitchPreferenceCompat?)
} }
@ -131,54 +129,63 @@ class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() {
NotificationChannels.updateMessageRingtone(requireContext(), uri) NotificationChannels.updateMessageRingtone(requireContext(), uri)
prefs.setNotificationRingtone(uri.toString()) prefs.setNotificationRingtone(uri.toString())
} }
initializeRingtoneSummary(findPreference(TextSecurePreferences.RINGTONE_PREF)) updateRingtonePref()
} }
} }
private inner class RingtoneSummaryListener : Preference.OnPreferenceChangeListener { private inner class RingtoneSummaryListener : Preference.OnPreferenceChangeListener {
override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean { override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean {
val pref = preference as? DropDownPreference ?: return false
val value = newValue as? Uri val value = newValue as? Uri
if (value == null || TextUtils.isEmpty(value.toString())) { if (value == null || TextUtils.isEmpty(value.toString())) {
preference.setSummary(R.string.none) pref.setDropDownLabel(context?.getString(R.string.none))
} else { } else {
RingtoneManager.getRingtone(activity, value) RingtoneManager.getRingtone(activity, value)
?.getTitle(activity) ?.getTitle(activity)
?.let { preference.summary = it } ?.let { pref.setDropDownLabel(it) }
} }
return true return true
} }
} }
private fun initializeRingtoneSummary(pref: Preference?) { private fun updateRingtonePref() {
val listener = pref!!.onPreferenceChangeListener as RingtoneSummaryListener? val pref = findPreference<Preference>(TextSecurePreferences.RINGTONE_PREF)
val listener: RingtoneSummaryListener =
(pref?.onPreferenceChangeListener) as? RingtoneSummaryListener
?: return
val uri = prefs.getNotificationRingtone() val uri = prefs.getNotificationRingtone()
listener!!.onPreferenceChange(pref, uri) listener.onPreferenceChange(pref, uri)
} }
private fun initializeMessageVibrateSummary(pref: SwitchPreferenceCompat?) { private fun initializeMessageVibrateSummary(pref: SwitchPreferenceCompat?) {
pref!!.isChecked = prefs.isNotificationVibrateEnabled() pref!!.isChecked = prefs.isNotificationVibrateEnabled()
} }
private inner class NotificationPrivacyListener : ListSummaryListener() { private inner class NotificationPrivacyListener : Preference.OnPreferenceChangeListener {
@SuppressLint("StaticFieldLeak") @SuppressLint("StaticFieldLeak")
override fun onPreferenceChange(preference: Preference, value: Any): Boolean { 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?>() { object : AsyncTask<Void?, Void?, Void?>() {
override fun doInBackground(vararg params: Void?): Void? { override fun doInBackground(vararg params: Void?): Void? {
ApplicationContext.getInstance(activity).messageNotifier.updateNotification(activity!!) ApplicationContext.getInstance(activity).messageNotifier.updateNotification(
activity!!
)
return null return null
} }
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR)
return super.onPreferenceChange(preference, value) return true
} }
} }
companion object {
@Suppress("unused")
private val TAG = NotificationsPreferenceFragment::class.java.simpleName
fun getSummary(context: Context): CharSequence = when (isNotificationsEnabled(context)) {
true -> R.string.on
false -> R.string.off
}.let(context::getString)
}
} }

View File

@ -25,7 +25,7 @@ import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.areNoti
import org.thoughtcrime.securesms.util.IntentUtils import org.thoughtcrime.securesms.util.IntentUtils
@AndroidEntryPoint @AndroidEntryPoint
class PrivacySettingsPreferenceFragment : ListSummaryPreferenceFragment() { class PrivacySettingsPreferenceFragment : CorrectedPreferenceFragment() {
@Inject lateinit var configFactory: ConfigFactory @Inject lateinit var configFactory: ConfigFactory

View File

@ -25,6 +25,7 @@ import org.session.libsignal.utilities.PublicKeyValidation
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.threadDatabase 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.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.components.QRScannerScreen import org.thoughtcrime.securesms.ui.components.QRScannerScreen
@ -68,6 +69,11 @@ class QRCodeActivity : PassphraseRequiredActionBarActivity() {
finish() 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) @OptIn(ExperimentalFoundationApi::class)

View File

@ -6,6 +6,7 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
@ -13,136 +14,115 @@ import android.util.SparseArray
import android.view.ActionMode import android.view.ActionMode
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.animation.Crossfade 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.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.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.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.canhub.cropper.CropImage
import com.canhub.cropper.CropImageContract import com.canhub.cropper.CropImageContract
import com.squareup.phrase.Phrase import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.BuildConfig import network.loki.messenger.BuildConfig
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivitySettingsBinding 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.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.SSKEnvironment.ProfileManagerProtocol
import org.session.libsession.utilities.StringSubstitutionConstants.VERSION_KEY import org.session.libsession.utilities.StringSubstitutionConstants.VERSION_KEY
import org.session.libsession.utilities.TextSecurePreferences 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.Log
import org.session.libsignal.utilities.Util.SECURE_RANDOM
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.avatar.AvatarSelection import org.thoughtcrime.securesms.avatar.AvatarSelection
import org.thoughtcrime.securesms.components.ProfilePictureView
import org.thoughtcrime.securesms.debugmenu.DebugActivity import org.thoughtcrime.securesms.debugmenu.DebugActivity
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.home.PathActivity import org.thoughtcrime.securesms.home.PathActivity
import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity
import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.*
import org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity import org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity
import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints
import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity 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.Cell
import org.thoughtcrime.securesms.ui.DialogButtonModel
import org.thoughtcrime.securesms.ui.Divider import org.thoughtcrime.securesms.ui.Divider
import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.LargeItemButton import org.thoughtcrime.securesms.ui.LargeItemButton
import org.thoughtcrime.securesms.ui.LargeItemButtonWithDrawable 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.PrimaryOutlineButton
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineCopyButton import org.thoughtcrime.securesms.ui.components.PrimaryOutlineCopyButton
import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.setThemedContent 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.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.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.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.NetworkUtils import org.thoughtcrime.securesms.util.NetworkUtils
import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.show import java.io.File
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class SettingsActivity : PassphraseRequiredActionBarActivity() { class SettingsActivity : PassphraseRequiredActionBarActivity() {
private val TAG = "SettingsActivity" private val TAG = "SettingsActivity"
@Inject
lateinit var configFactory: ConfigFactory
@Inject @Inject
lateinit var prefs: TextSecurePreferences lateinit var prefs: TextSecurePreferences
private val viewModel: SettingsViewModel by viewModels()
private lateinit var binding: ActivitySettingsBinding private lateinit var binding: ActivitySettingsBinding
private var displayNameEditActionMode: ActionMode? = null private var displayNameEditActionMode: ActionMode? = null
set(value) { field = value; handleDisplayNameEditActionModeChanged() } set(value) { field = value; handleDisplayNameEditActionModeChanged() }
private var tempFile: File? = null
private val hexEncodedPublicKey: String get() = TextSecurePreferences.getLocalNumber(this)!!
private val onAvatarCropped = registerForActivityResult(CropImageContract()) { result -> private val onAvatarCropped = registerForActivityResult(CropImageContract()) { result ->
when { viewModel.onAvatarPicked(result)
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")
}
}
} }
private val onPickImage = registerForActivityResult( private val onPickImage = registerForActivityResult(
@ -151,12 +131,14 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val outputFile = Uri.fromFile(File(cacheDir, "cropped")) 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) cropImage(inputFile, outputFile)
} }
private val avatarSelection = AvatarSelection(this, onAvatarCropped, onPickImage) private val avatarSelection = AvatarSelection(this, onAvatarCropped, onPickImage)
private var showAvatarDialog: Boolean by mutableStateOf(false)
companion object { companion object {
private const val SCROLL_STATE = "SCROLL_STATE" private const val SCROLL_STATE = "SCROLL_STATE"
} }
@ -169,17 +151,31 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
// set the toolbar icon to a close icon // set the toolbar icon to a close icon
supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_baseline_close_24) supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_baseline_close_24)
// set the compose dialog content
binding.avatarDialog.setThemedContent {
if(showAvatarDialog){
AvatarDialogContainer(
saveAvatar = viewModel::saveAvatar,
removeAvatar = viewModel::removeAvatar,
startAvatarSelection = ::startAvatarSelection
)
}
} }
override fun onStart() {
super.onStart()
binding.run { binding.run {
setupProfilePictureView(profilePictureView) profilePictureView.apply {
profilePictureView.setOnClickListener { showEditProfilePictureUI() } publicKey = viewModel.hexEncodedPublicKey
displayName = viewModel.getDisplayName()
update()
}
profilePictureView.setOnClickListener {
binding.avatarDialog.isVisible = true
showAvatarDialog = true
}
ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) } ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) }
btnGroupNameDisplay.text = getDisplayName() btnGroupNameDisplay.text = viewModel.getDisplayName()
publicKeyTextView.text = hexEncodedPublicKey publicKeyTextView.text = viewModel.hexEncodedPublicKey
val gitCommitFirstSixChars = BuildConfig.GIT_HASH.take(6) val gitCommitFirstSixChars = BuildConfig.GIT_HASH.take(6)
val environment: String = if(BuildConfig.BUILD_TYPE == "release") "" else " - ${prefs.getEnvironment().label}" val environment: String = if(BuildConfig.BUILD_TYPE == "release") "" else " - ${prefs.getEnvironment().label}"
val versionDetails = " ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE} - $gitCommitFirstSixChars) $environment" val versionDetails = " ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE} - $gitCommitFirstSixChars) $environment"
@ -190,6 +186,25 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
binding.composeView.setThemedContent { binding.composeView.setThemedContent {
Buttons() 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() { override fun finish() {
@ -197,17 +212,6 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
overridePendingTransition(R.anim.fade_scale_in, R.anim.slide_to_bottom) 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) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
val scrollBundle = SparseArray<Parcelable>() val scrollBundle = SparseArray<Parcelable>()
@ -291,7 +295,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
} else { } else {
// if we have a network connection then attempt to update the display name // if we have a network connection then attempt to update the display name
TextSecurePreferences.setProfileName(this, displayName) TextSecurePreferences.setProfileName(this, displayName)
val user = configFactory.user val user = viewModel.getUser()
if (user == null) { if (user == null) {
Log.w(TAG, "Cannot update display name - missing user details from configFactory.") Log.w(TAG, "Cannot update display name - missing user details from configFactory.")
} else { } else {
@ -311,89 +315,6 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
binding.loader.isVisible = false binding.loader.isVisible = false
return updateWasSuccessful 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 // endregion
// region Interaction // region Interaction
@ -417,39 +338,16 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
return updateDisplayName(displayName) 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() { private fun startAvatarSelection() {
// Ask for an optional camera permission. // Ask for an optional camera permission.
Permissions.with(this) Permissions.with(this)
.request(Manifest.permission.CAMERA) .request(Manifest.permission.CAMERA)
.onAnyResult { .onAnyResult {
tempFile = avatarSelection.startAvatarSelection( false, true) avatarSelection.startAvatarSelection(
includeClear = false,
attemptToIncludeCamera = true,
createTempFile = viewModel::createTempFile
)
} }
.execute() .execute()
} }
@ -523,7 +421,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
Column { Column {
// add the debug menu in non release builds // add the debug menu in non release builds
if (BuildConfig.BUILD_TYPE != "release") { if (BuildConfig.BUILD_TYPE != "release") {
LargeItemButton(DEBUG_MENU, R.drawable.ic_settings) { push<DebugActivity>() } LargeItemButton("Debug Menu", R.drawable.ic_settings) { push<DebugActivity>() }
Divider() 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() private fun Context.hasPaths(): Flow<Boolean> = LocalBroadcastManager.getInstance(this).hasPaths()

View File

@ -0,0 +1,241 @@
package org.thoughtcrime.securesms.preferences
import android.content.Context
import android.widget.Toast
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.canhub.cropper.CropImage
import com.canhub.cropper.CropImageView
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.UserPic
import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.ProfileKeyUtil
import org.session.libsession.utilities.ProfilePictureUtilities
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.truncateIdForDisplay
import org.session.libsignal.utilities.ExternalStorageUtil.getImageDir
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.NoExternalStorageException
import org.session.libsignal.utilities.Util.SECURE_RANDOM
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.TempAvatar
import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints
import org.thoughtcrime.securesms.util.BitmapDecodingException
import org.thoughtcrime.securesms.util.BitmapUtil
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.NetworkUtils
import java.io.File
import java.io.IOException
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val prefs: TextSecurePreferences,
private val configFactory: ConfigFactory
) : ViewModel() {
private val TAG = "SettingsViewModel"
private var tempFile: File? = null
val hexEncodedPublicKey: String get() = prefs.getLocalNumber() ?: ""
private val userAddress = Address.fromSerialized(hexEncodedPublicKey)
private val _avatarDialogState: MutableStateFlow<AvatarDialogState> = MutableStateFlow(
getDefaultAvatarDialogState()
)
val avatarDialogState: StateFlow<AvatarDialogState>
get() = _avatarDialogState
private val _showLoader: MutableStateFlow<Boolean> = MutableStateFlow(false)
val showLoader: StateFlow<Boolean>
get() = _showLoader
/**
* Refreshes the avatar on the main settings page
*/
private val _refreshAvatar: MutableSharedFlow<Unit> = MutableSharedFlow()
val refreshAvatar: SharedFlow<Unit>
get() = _refreshAvatar.asSharedFlow()
fun getDisplayName(): String =
prefs.getProfileName() ?: truncateIdForDisplay(hexEncodedPublicKey)
fun hasAvatar() = prefs.getProfileAvatarId() != 0
fun createTempFile(): File? {
try {
tempFile = File.createTempFile("avatar-capture", ".jpg", getImageDir(context))
} catch (e: IOException) {
Log.e("Cannot reserve a temporary avatar capture file.", e)
} catch (e: NoExternalStorageException) {
Log.e("Cannot reserve a temporary avatar capture file.", e)
}
return tempFile
}
fun getTempFile() = tempFile
fun getUser() = configFactory.user
fun onAvatarPicked(result: CropImageView.CropResult) {
when {
result.isSuccessful -> {
Log.i(TAG, result.getUriFilePath(context).toString())
viewModelScope.launch(Dispatchers.IO) {
try {
val profilePictureToBeUploaded =
BitmapUtil.createScaledBytes(
context,
result.getUriFilePath(context).toString(),
ProfileMediaConstraints()
).bitmap
// update dialog with temporary avatar (has not been saved/uploaded yet)
_avatarDialogState.value =
AvatarDialogState.TempAvatar(profilePictureToBeUploaded, hasAvatar())
} catch (e: BitmapDecodingException) {
Log.e(TAG, e)
}
}
}
result is CropImage.CancelledResult -> {
Log.i(TAG, "Cropping image was cancelled by the user")
}
else -> {
Log.e(TAG, "Cropping image failed")
}
}
}
fun onAvatarDialogDismissed() {
_avatarDialogState.value = getDefaultAvatarDialogState()
}
fun getDefaultAvatarDialogState() = if (hasAvatar()) AvatarDialogState.UserAvatar(userAddress)
else AvatarDialogState.NoAvatar
fun saveAvatar() {
val tempAvatar = (avatarDialogState.value as? TempAvatar)?.data
?: return Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show()
val haveNetworkConnection = NetworkUtils.haveValidNetworkConnection(context);
if (!haveNetworkConnection) {
Log.w(TAG, "Cannot update profile picture - no network connection.")
Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show()
return
}
val onFail: () -> Unit = {
Log.e(TAG, "Sync failed when uploading profile picture.")
Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show()
}
syncProfilePicture(tempAvatar, onFail)
}
fun removeAvatar() {
val haveNetworkConnection = NetworkUtils.haveValidNetworkConnection(context);
if (!haveNetworkConnection) {
Log.w(TAG, "Cannot remove profile picture - no network connection.")
Toast.makeText(context, R.string.profileDisplayPictureRemoveError, Toast.LENGTH_LONG).show()
return
}
val onFail: () -> Unit = {
Log.e(TAG, "Sync failed when removing profile picture.")
Toast.makeText(context, R.string.profileDisplayPictureRemoveError, Toast.LENGTH_LONG).show()
}
val emptyProfilePicture = ByteArray(0)
syncProfilePicture(emptyProfilePicture, onFail)
}
// Helper method used by updateProfilePicture and removeProfilePicture to sync it online
private fun syncProfilePicture(profilePicture: ByteArray, onFail: () -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
_showLoader.value = true
try {
// Grab the profile key and kick of the promise to update the profile picture
val encodedProfileKey = ProfileKeyUtil.generateEncodedProfileKey(context)
ProfilePictureUtilities.upload(profilePicture, encodedProfileKey, context)
// If the online portion of the update succeeded then update the local state
val userConfig = configFactory.user
AvatarHelper.setAvatar(
context,
Address.fromSerialized(TextSecurePreferences.getLocalNumber(context)!!),
profilePicture
)
// When removing the profile picture the supplied ByteArray is empty so we'll clear the local data
if (profilePicture.isEmpty()) {
MessagingModuleConfiguration.shared.storage.clearUserPic()
// update dialog state
_avatarDialogState.value = AvatarDialogState.NoAvatar
} else {
prefs.setProfileAvatarId(SECURE_RANDOM.nextInt())
ProfileKeyUtil.setEncodedProfileKey(context, encodedProfileKey)
// Attempt to grab the details we require to update the profile picture
val url = prefs.getProfilePictureURL()
val profileKey = ProfileKeyUtil.getProfileKey(context)
// If we have a URL and a profile key then set the user's profile picture
if (!url.isNullOrEmpty() && profileKey.isNotEmpty()) {
userConfig?.setPic(UserPic(url, profileKey))
}
// update dialog state
_avatarDialogState.value = AvatarDialogState.UserAvatar(userAddress)
}
if (userConfig != null && userConfig.needsDump()) {
configFactory.persist(userConfig, SnodeAPI.nowWithOffset)
}
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
} catch (e: Exception){ // If the sync failed then inform the user
Log.d(TAG, "Error syncing avatar: $e")
withContext(Dispatchers.Main) {
onFail()
}
}
// Finally update the main avatar
_refreshAvatar.emit(Unit)
// And remove the loader animation after we've waited for the attempt to succeed or fail
_showLoader.value = false
}
}
sealed class AvatarDialogState() {
object NoAvatar : AvatarDialogState()
data class UserAvatar(val address: Address) : AvatarDialogState()
data class TempAvatar(
val data: ByteArray,
val hasAvatar: Boolean // true if the user has an avatar set already but is in this temp state because they are trying out a new avatar
) : AvatarDialogState()
}
}

View File

@ -0,0 +1,70 @@
package org.thoughtcrime.securesms.preferences.widgets
import android.content.Context
import android.util.AttributeSet
import android.widget.TextView
import androidx.preference.ListPreference
import androidx.preference.PreferenceViewHolder
import network.loki.messenger.R
class DropDownPreference : ListPreference {
private var dropDownLabel: TextView? = null
private var clickListener: OnPreferenceClickListener? = null
private var onViewReady: (()->Unit)? = null
constructor(
context: Context?,
attrs: AttributeSet?,
defStyleAttr: Int,
defStyleRes: Int
) : super(
context!!, attrs, defStyleAttr, defStyleRes
) {
initialize()
}
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(
context!!, attrs, defStyleAttr
) {
initialize()
}
constructor(context: Context?, attrs: AttributeSet?) : super(
context!!, attrs
) {
initialize()
}
constructor(context: Context?) : super(context!!) {
initialize()
}
private fun initialize() {
widgetLayoutResource = R.layout.preference_drop_down
}
override fun onBindViewHolder(view: PreferenceViewHolder) {
super.onBindViewHolder(view)
this.dropDownLabel = view.findViewById(R.id.drop_down_label) as TextView
onViewReady?.invoke()
}
override fun setOnPreferenceClickListener(onPreferenceClickListener: OnPreferenceClickListener?) {
this.clickListener = onPreferenceClickListener
}
fun setOnViewReady(init: (()->Unit)){
this.onViewReady = init
}
override fun onClick() {
if (clickListener == null || !clickListener!!.onPreferenceClick(this)) {
super.onClick()
}
}
fun setDropDownLabel(label: CharSequence?){
dropDownLabel?.text = label
}
}

View File

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

View File

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

View File

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

View File

@ -1,125 +0,0 @@
package org.thoughtcrime.securesms.qr;
import android.content.res.Configuration;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.zxing.BinaryBitmap;
import com.google.zxing.ChecksumException;
import com.google.zxing.DecodeHintType;
import com.google.zxing.FormatException;
import com.google.zxing.NotFoundException;
import com.google.zxing.PlanarYUVLuminanceSource;
import com.google.zxing.Result;
import com.google.zxing.common.HybridBinarizer;
import com.google.zxing.qrcode.QRCodeReader;
import org.thoughtcrime.securesms.components.camera.CameraView;
import org.thoughtcrime.securesms.components.camera.CameraView.PreviewFrame;
import org.session.libsignal.utilities.Log;
import org.session.libsession.utilities.Util;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
public class ScanningThread extends Thread implements CameraView.PreviewCallback {
private static final String TAG = ScanningThread.class.getSimpleName();
private final QRCodeReader reader = new QRCodeReader();
private final AtomicReference<ScanListener> scanListener = new AtomicReference<>();
private final Map<DecodeHintType, String> hints = new HashMap<>();
private boolean scanning = true;
private PreviewFrame previewFrame;
public void setCharacterSet(String characterSet) {
hints.put(DecodeHintType.CHARACTER_SET, characterSet);
}
public void setScanListener(ScanListener scanListener) {
this.scanListener.set(scanListener);
}
@Override
public void onPreviewFrame(@NonNull PreviewFrame previewFrame) {
try {
synchronized (this) {
this.previewFrame = previewFrame;
this.notify();
}
} catch (RuntimeException e) {
Log.w(TAG, e);
}
}
@Override
public void run() {
while (true) {
PreviewFrame ourFrame;
synchronized (this) {
while (scanning && previewFrame == null) {
Util.wait(this, 0);
}
if (!scanning) return;
else ourFrame = previewFrame;
previewFrame = null;
}
String data = getScannedData(ourFrame.getData(), ourFrame.getWidth(), ourFrame.getHeight(), ourFrame.getOrientation());
ScanListener scanListener = this.scanListener.get();
if (data != null && scanListener != null) {
scanListener.onQrDataFound(data);
return;
}
}
}
public void stopScanning() {
synchronized (this) {
scanning = false;
notify();
}
}
private @Nullable String getScannedData(byte[] data, int width, int height, int orientation) {
try {
if (orientation == Configuration.ORIENTATION_PORTRAIT) {
byte[] rotatedData = new byte[data.length];
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
rotatedData[x * height + height - y - 1] = data[x + y * width];
}
}
int tmp = width;
width = height;
height = tmp;
data = rotatedData;
}
PlanarYUVLuminanceSource source = new PlanarYUVLuminanceSource(data, width, height,
0, 0, width, height,
false);
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
Result result = reader.decode(bitmap, hints);
if (result != null) return result.getText();
} catch (NullPointerException | ChecksumException | FormatException | IndexOutOfBoundsException e) {
Log.w(TAG, e);
} catch (NotFoundException e) {
// Thanks ZXing...
}
return null;
}
}

View File

@ -103,7 +103,9 @@ public final class ReactionsDialogFragment extends BottomSheetDialogFragment imp
ViewCompat.setOnApplyWindowInsetsListener(container, (v, insets) -> insets.consumeSystemWindowInsets()); 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); tab.setCustomView(R.layout.reactions_pill_large);
View customView = Objects.requireNonNull(tab.getCustomView()); View customView = Objects.requireNonNull(tab.getCustomView());
@ -120,17 +122,13 @@ public final class ReactionsDialogFragment extends BottomSheetDialogFragment imp
@Override @Override
public void onTabSelected(TabLayout.Tab tab) { public void onTabSelected(TabLayout.Tab tab) {
View customView = tab.getCustomView(); View customView = tab.getCustomView();
TextView text = customView.findViewById(R.id.reactions_pill_count);
customView.setBackground(ContextCompat.getDrawable(requireContext(), R.drawable.reaction_pill_background_selected)); customView.setBackground(ContextCompat.getDrawable(requireContext(), R.drawable.reaction_pill_background_selected));
text.setTextColor(ThemeUtil.getThemedColor(requireContext(), R.attr.reactionsPillSelectedTextColor));
} }
@Override @Override
public void onTabUnselected(TabLayout.Tab tab) { public void onTabUnselected(TabLayout.Tab tab) {
View customView = tab.getCustomView(); View customView = tab.getCustomView();
TextView text = customView.findViewById(R.id.reactions_pill_count);
customView.setBackground(ContextCompat.getDrawable(requireContext(), R.drawable.reaction_pill_dialog_background)); customView.setBackground(ContextCompat.getDrawable(requireContext(), R.drawable.reaction_pill_dialog_background));
text.setTextColor(ThemeUtil.getThemedColor(requireContext(), R.attr.reactionsPillNormalTextColor));
} }
@Override @Override
public void onTabReselected(TabLayout.Tab tab) {} public void onTabReselected(TabLayout.Tab tab) {}
@ -141,21 +139,6 @@ public final class ReactionsDialogFragment extends BottomSheetDialogFragment imp
private void setUpRecipientsRecyclerView() { private void setUpRecipientsRecyclerView() {
recipientsAdapter = new ReactionViewPagerAdapter(this); 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); recipientPagerView.setAdapter(recipientsAdapter);
} }

View File

@ -33,7 +33,7 @@ class RecoveryPasswordActivity : BaseActionBarActivity() {
private fun onHide() { private fun onHide() {
showSessionDialog { showSessionDialog {
title(R.string.recoveryPasswordHidePermanently) title(R.string.recoveryPasswordHidePermanently)
htmlText(R.string.recoveryPasswordHidePermanentlyDescription1) text(R.string.recoveryPasswordHidePermanentlyDescription1)
dangerButton(R.string.theContinue, R.string.AccessibilityId_theContinue) { onHideConfirm() } dangerButton(R.string.theContinue, R.string.AccessibilityId_theContinue) { onHideConfirm() }
cancelButton() cancelButton()
} }

View File

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

View File

@ -58,6 +58,7 @@ class DialogButtonModel(
val contentDescription: GetString = text, val contentDescription: GetString = text,
val color: Color = Color.Unspecified, val color: Color = Color.Unspecified,
val dismissOnClick: Boolean = true, val dismissOnClick: Boolean = true,
val enabled: Boolean = true,
val onClick: () -> Unit = {}, val onClick: () -> Unit = {},
) )
@ -164,7 +165,8 @@ fun AlertDialog(
.fillMaxHeight() .fillMaxHeight()
.contentDescription(it.contentDescription()) .contentDescription(it.contentDescription())
.weight(1f), .weight(1f),
color = it.color color = it.color,
enabled = it.enabled
) { ) {
it.onClick() it.onClick()
if (it.dismissOnClick) onDismissRequest() if (it.dismissOnClick) onDismissRequest()
@ -222,16 +224,24 @@ fun DialogButton(
text: String, text: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
color: Color = Color.Unspecified, color: Color = Color.Unspecified,
enabled: Boolean,
onClick: () -> Unit onClick: () -> Unit
) { ) {
TextButton( TextButton(
modifier = modifier, modifier = modifier,
shape = RectangleShape, shape = RectangleShape,
enabled = enabled,
onClick = onClick onClick = onClick
) { ) {
val textColor = if(enabled) {
color.takeOrElse { LocalColors.current.text }
} else {
LocalColors.current.disabled
}
Text( Text(
text, text,
color = color.takeOrElse { LocalColors.current.text }, color = textColor,
style = LocalType.current.large.bold(), style = LocalType.current.large.bold(),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.padding( modifier = Modifier.padding(

View File

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

View File

@ -2,9 +2,14 @@ package org.thoughtcrime.securesms.ui
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.ContextWrapper
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.Fragment 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 com.squareup.phrase.Phrase
import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme
@ -39,3 +44,17 @@ fun ComposeView.setThemedContent(content: @Composable () -> Unit) = setContent {
content() content()
} }
} }
@ExperimentalPermissionsApi
fun PermissionState.isPermanentlyDenied(): Boolean {
return !status.shouldShowRationale && !status.isGranted
}
fun Context.findActivity(): Activity {
var context = this
while (context is ContextWrapper) {
if (context is Activity) return context
context = context.baseContext
}
throw IllegalStateException("Permissions should be called in the context of an Activity")
}

View File

@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.ui.components
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.camera.core.CameraSelector 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar import androidx.compose.material3.Snackbar
@ -30,8 +31,11 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import com.google.accompanist.permissions.ExperimentalPermissionsApi import androidx.core.app.ActivityCompat
import com.google.accompanist.permissions.isGranted import androidx.core.content.ContextCompat
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
import com.google.zxing.BinaryBitmap import com.google.zxing.BinaryBitmap
import com.google.zxing.ChecksumException import com.google.zxing.ChecksumException
import com.google.zxing.FormatException import com.google.zxing.FormatException
@ -56,18 +58,23 @@ import com.google.zxing.Result
import com.google.zxing.common.HybridBinarizer import com.google.zxing.common.HybridBinarizer
import com.google.zxing.qrcode.QRCodeReader import com.google.zxing.qrcode.QRCodeReader
import com.squareup.phrase.Phrase import com.squareup.phrase.Phrase
import java.util.concurrent.Executors
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.session.libsignal.utilities.Log 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.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.LocalType
import java.util.concurrent.Executors
private const val TAG = "NewMessageFragment" private const val TAG = "NewMessageFragment"
@OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun QRScannerScreen( fun QRScannerScreen(
errors: Flow<String>, errors: Flow<String>,
@ -84,31 +91,14 @@ fun QRScannerScreen(
) { ) {
LocalSoftwareKeyboardController.current?.hide() 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) 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 { } else {
Column( Column(
modifier = Modifier modifier = Modifier
@ -129,11 +119,43 @@ fun QRScannerScreen(
PrimaryOutlineButton( PrimaryOutlineButton(
stringResource(R.string.cameraGrantAccess), stringResource(R.string.cameraGrantAccess),
modifier = Modifier.fillMaxWidth(), 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)) Spacer(modifier = Modifier.weight(1f))
} }
} }
// camera permission denied permanently dialog
if(showCameraPermissionDialog){
AlertDialog(
onDismissRequest = { showCameraPermissionDialog = false },
title = stringResource(R.string.permissionsRequired),
text = context.getSubbedString(R.string.permissionsCameraDenied,
APP_NAME_KEY to context.getString(R.string.app_name)),
buttons = listOf(
DialogButtonModel(
text = GetString(stringResource(id = R.string.sessionSettings)),
onClick = onClickSettings
),
DialogButtonModel(
GetString(stringResource(R.string.cancel))
)
)
)
}
} }
} }

View File

@ -17,6 +17,7 @@ data class Dimensions(
val dividerIndent: Dp = 60.dp, val dividerIndent: Dp = 60.dp,
val appBarHeight: Dp = 64.dp, val appBarHeight: Dp = 64.dp,
val minItemButtonHeight: Dp = 50.dp,
val minLargeItemButtonHeight: Dp = 60.dp, val minLargeItemButtonHeight: Dp = 60.dp,
val indicatorHeight: Dp = 4.dp, val indicatorHeight: Dp = 4.dp,

View File

@ -1,65 +0,0 @@
package org.thoughtcrime.securesms.util
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import network.loki.messenger.databinding.FragmentScanQrCodeBinding
import org.thoughtcrime.securesms.qr.ScanListener
import org.thoughtcrime.securesms.qr.ScanningThread
class ScanQRCodeFragment : Fragment() {
private lateinit var binding: FragmentScanQrCodeBinding
private val scanningThread = ScanningThread()
var scanListener: ScanListener? = null
set(value) { field = value; scanningThread.setScanListener(scanListener) }
var message: CharSequence = ""
override fun onCreateView(layoutInflater: LayoutInflater, viewGroup: ViewGroup?, bundle: Bundle?): View {
binding = FragmentScanQrCodeBinding.inflate(layoutInflater, viewGroup, false)
return binding.root
}
override fun onViewCreated(view: View, bundle: Bundle?) {
super.onViewCreated(view, bundle)
when (resources.configuration.orientation) {
Configuration.ORIENTATION_LANDSCAPE -> binding.overlayView.orientation = LinearLayout.HORIZONTAL
else -> binding.overlayView.orientation = LinearLayout.VERTICAL
}
binding.messageTextView.text = message
binding.messageTextView.isVisible = message.isNotEmpty()
}
override fun onResume() {
super.onResume()
binding.cameraView.onResume()
binding.cameraView.setPreviewCallback(scanningThread)
try {
scanningThread.start()
} catch (exception: Exception) {
// Do nothing
}
scanningThread.setScanListener(scanListener)
}
override fun onConfigurationChanged(newConfiguration: Configuration) {
super.onConfigurationChanged(newConfiguration)
binding.cameraView.onPause()
when (newConfiguration.orientation) {
Configuration.ORIENTATION_LANDSCAPE -> binding.overlayView.orientation = LinearLayout.HORIZONTAL
else -> binding.overlayView.orientation = LinearLayout.VERTICAL
}
binding.cameraView.onResume()
binding.cameraView.setPreviewCallback(scanningThread)
}
override fun onPause() {
super.onPause()
this.binding.cameraView.onPause()
this.scanningThread.stopScanning()
}
}

View File

@ -1,34 +0,0 @@
package org.thoughtcrime.securesms.util
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.squareup.phrase.Phrase
import network.loki.messenger.R
import network.loki.messenger.databinding.FragmentScanQrCodePlaceholderBinding
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
class ScanQRCodePlaceholderFragment: Fragment() {
private lateinit var binding: FragmentScanQrCodePlaceholderBinding
var delegate: ScanQRCodePlaceholderFragmentDelegate? = null
override fun onCreateView(layoutInflater: LayoutInflater, viewGroup: ViewGroup?, bundle: Bundle?): View {
binding = FragmentScanQrCodePlaceholderBinding.inflate(layoutInflater, viewGroup, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.grantCameraAccessButton.setOnClickListener { delegate?.requestCameraAccess() }
binding.needCameraPermissionsTV.text = Phrase.from(context, R.string.cameraGrantAccessQr)
.put(APP_NAME_KEY, getString(R.string.app_name))
.format()
}
}
interface ScanQRCodePlaceholderFragmentDelegate {
fun requestCameraAccess()
}

View File

@ -1,89 +1,27 @@
package org.thoughtcrime.securesms.util package org.thoughtcrime.securesms.util
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.tbruyelle.rxpermissions2.RxPermissions import kotlinx.coroutines.flow.emptyFlow
import network.loki.messenger.R import org.thoughtcrime.securesms.ui.components.QRScannerScreen
import org.thoughtcrime.securesms.qr.ScanListener import org.thoughtcrime.securesms.ui.createThemedComposeView
class ScanQRCodeWrapperFragment : Fragment(), ScanQRCodePlaceholderFragmentDelegate, ScanListener { class ScanQRCodeWrapperFragment : Fragment() {
companion object { companion object {
const val FRAGMENT_TAG = "ScanQRCodeWrapperFragment_FRAGMENT_TAG" const val FRAGMENT_TAG = "ScanQRCodeWrapperFragment_FRAGMENT_TAG"
} }
var delegate: ScanQRCodeWrapperFragmentDelegate? = null 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 onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
override fun setUserVisibleHint(isVisibleToUser: Boolean) { createThemedComposeView {
super.setUserVisibleHint(isVisibleToUser) QRScannerScreen(emptyFlow(), onScan = {
enabled = isVisibleToUser delegate?.handleQRCodeScanned(it)
} })
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)
}
} }
} }

View File

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.webrtc package org.thoughtcrime.securesms.webrtc
import android.Manifest
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.Intent 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.PRE_OFFER
import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.PROVISIONAL_ANSWER import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.PROVISIONAL_ANSWER
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.service.WebRtcCallService import org.thoughtcrime.securesms.service.WebRtcCallService
import org.thoughtcrime.securesms.util.CallNotificationBuilder import org.thoughtcrime.securesms.util.CallNotificationBuilder
import org.webrtc.IceCandidate import org.webrtc.IceCandidate
@ -59,18 +61,16 @@ class CallMessageProcessor(private val context: Context, private val textSecureP
Log.i("Loki", "Contact is approved?: $approvedContact") Log.i("Loki", "Contact is approved?: $approvedContact")
if (!approvedContact && storage.getUserPublicKey() != sender) continue 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") Log.d("Loki","Dropping call message if call notifications disabled")
if (nextMessage.type != PRE_OFFER) continue if (nextMessage.type != PRE_OFFER) continue
val sentTimestamp = nextMessage.sentTimestamp ?: 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 continue
} }
@ -92,15 +92,11 @@ 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() 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 (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) { private fun incomingHangup(callMessage: CallMessage) {
val callId = callMessage.callId ?: return val callId = callMessage.callId ?: return

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android"> <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"/> <item android:color="?prominentButtonColor"/>
</selector> </selector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 691 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,8 +5,8 @@
android:viewportHeight="20"> android:viewportHeight="20">
<path <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: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 <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: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> </vector>

View File

@ -5,8 +5,8 @@
android:viewportHeight="20"> android:viewportHeight="20">
<path <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: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 <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: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> </vector>

View File

@ -5,8 +5,8 @@
android:viewportHeight="20"> android:viewportHeight="20">
<path <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: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 <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: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> </vector>

View File

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

View File

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

View File

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

View File

@ -209,15 +209,44 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical"> android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="wrap_content"
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 <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:textSize="12sp"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textColor="?message_received_text_color" android:textColor="?message_received_text_color"
android:text="@string/appearancePreview1" android:text="@string/appearancePreview1"
android:gravity="center" android:gravity="center"
android:drawablePadding="12dp" android:layout_marginStart="@dimen/small_spacing"
app:drawableLeftCompat="@drawable/quote_accent_line" /> app:layout_constraintStart_toEndOf="@+id/quote_line"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/quote_sender"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView <TextView
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:text="@string/appearancePreview2" android:text="@string/appearancePreview2"

View File

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

View File

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

View File

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

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