diff --git a/app/build.gradle b/app/build.gradle index 5d08c803c67..861b94a8434 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ configurations.all { exclude module: "commons-logging" } -def canonicalVersionCode = 376 -def canonicalVersionName = "1.18.6" +def canonicalVersionCode = 379 +def canonicalVersionName = "1.19.0" def postFixSize = 10 def abiPostFix = ['armeabi-v7a' : 1, @@ -263,7 +263,7 @@ dependencies { implementation 'androidx.fragment:fragment-ktx:1.5.3' implementation "androidx.core:core-ktx:$coreVersion" implementation "androidx.work:work-runtime-ktx:2.7.1" - playImplementation ("com.google.firebase:firebase-messaging:18.0.0") { + playImplementation ("com.google.firebase:firebase-messaging:24.0.0") { exclude group: 'com.google.firebase', module: 'firebase-core' exclude group: 'com.google.firebase', module: 'firebase-analytics' exclude group: 'com.google.firebase', module: 'firebase-measurement-connector' @@ -271,7 +271,7 @@ dependencies { if (project.hasProperty('huawei')) huaweiImplementation 'com.huawei.hms:push:6.7.0.300' implementation 'com.google.android.exoplayer:exoplayer-core:2.9.1' implementation 'com.google.android.exoplayer:exoplayer-ui:2.9.1' - implementation 'org.conscrypt:conscrypt-android:2.0.0' + implementation 'org.conscrypt:conscrypt-android:2.5.2' implementation 'org.signal:aesgcmprovider:0.0.3' implementation 'org.webrtc:google-webrtc:1.0.32006' implementation "me.leolin:ShortcutBadger:1.1.16" @@ -322,6 +322,7 @@ dependencies { implementation "com.google.protobuf:protobuf-java:$protobufVersion" implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" + implementation "com.squareup.phrase:phrase:$phraseVersion" implementation 'app.cash.copper:copper-flow:1.0.0' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" @@ -375,14 +376,24 @@ dependencies { implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5' - implementation 'androidx.compose.ui:ui:1.5.2' - implementation 'androidx.compose.ui:ui-tooling:1.5.2' + implementation "androidx.compose.ui:ui:$composeVersion" + implementation "androidx.compose.animation:animation:$composeVersion" + implementation "androidx.compose.ui:ui-tooling:$composeVersion" + implementation "androidx.compose.runtime:runtime-livedata:$composeVersion" + implementation "androidx.compose.foundation:foundation-layout:$composeVersion" + implementation "androidx.compose.material3:material3:1.2.1" + androidTestImplementation "androidx.compose.ui:ui-test-junit4-android:$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-pager-indicators:0.33.1-alpha" - implementation "androidx.compose.runtime:runtime-livedata:1.5.2" + implementation "com.google.accompanist:accompanist-permissions: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-lifecycle:1.3.2" + implementation "androidx.camera:camera-view:1.3.2" - implementation 'androidx.compose.foundation:foundation-layout:1.5.2' - implementation 'androidx.compose.material:material:1.5.2' + implementation "com.google.mlkit:barcode-scanning:17.2.0" } static def getLastCommitTimestamp() { diff --git a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt index a20a3a2a679..6fb3888ff2a 100644 --- a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt @@ -22,6 +22,8 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice import com.adevinta.android.barista.interaction.PermissionGranter import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable import org.hamcrest.Matcher @@ -49,9 +51,14 @@ class HomeActivityTests { private val activityMonitor = Instrumentation.ActivityMonitor(ConversationActivityV2::class.java.name, null, false) + private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + @Before fun setUp() { InstrumentationRegistry.getInstrumentation().addMonitor(activityMonitor) + } @After @@ -72,25 +79,34 @@ class HomeActivityTests { onView(isRoot()).perform(waitFor(500)) } + private fun objectFromDesc(id: Int) = device.findObject(By.desc(context.getString(id))) + private fun setupLoggedInState(hasViewedSeed: Boolean = false) { // landing activity - onView(withId(R.id.registerButton)).perform(ViewActions.click()) - // session ID - register activity - onView(withId(R.id.registerButton)).perform(ViewActions.click()) + objectFromDesc(R.string.onboardingAccountCreate).click() + // display name selection - onView(withId(R.id.displayNameEditText)).perform(ViewActions.typeText("test-user123")) - onView(withId(R.id.registerButton)).perform(ViewActions.click()) + objectFromDesc(R.string.displayNameEnter).click() + device.pressKeyCode(65) + device.pressKeyCode(66) + device.pressKeyCode(67) + + // Continue with display name + objectFromDesc(R.string.continue_2).click() + + // Continue with default push notification setting + objectFromDesc(R.string.continue_2).click() + // PN select if (hasViewedSeed) { // has viewed seed is set to false after register activity TextSecurePreferences.setHasViewedSeed(InstrumentationRegistry.getInstrumentation().targetContext, true) } - onView(withId(R.id.backgroundPollingOptionView)).perform(ViewActions.click()) - onView(withId(R.id.registerButton)).perform(ViewActions.click()) // allow notification permission PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS) } + private fun goToMyChat() { onView(withId(R.id.newConversationButton)).perform(ViewActions.click()) onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click()) @@ -111,8 +127,8 @@ class HomeActivityTests { @Test fun testLaunches_dismiss_seedView() { setupLoggedInState() - onView(allOf(withId(R.id.button), isDescendantOfA(withId(R.id.seedReminderView)))).perform(ViewActions.click()) - onView(withId(R.id.copyButton)).perform(ViewActions.click()) + objectFromDesc(R.string.continue_2).click() + objectFromDesc(R.string.copy).click() pressBack() onView(withId(R.id.seedReminderView)).check(matches(not(isDisplayed()))) } @@ -133,7 +149,7 @@ class HomeActivityTests { fun testChat_withSelf() { setupLoggedInState() goToMyChat() - TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true) + TextSecurePreferences.setLinkPreviewsEnabled(context, true) sendMessage("howdy") sendMessage("test") // tests url rewriter doesn't crash diff --git a/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt index 157085135ea..54470569e19 100644 --- a/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt @@ -39,7 +39,7 @@ class LibSessionTests { private fun randomSeedBytes() = (0 until 16).map { Random.nextInt(UByte.MAX_VALUE.toInt()).toByte() } private fun randomKeyPair() = KeyPairUtilities.generate(randomSeedBytes().toByteArray()) - private fun randomSessionId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey + private fun randomAccountId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey private var fakeHashI = 0 private val nextFakeHash: String @@ -102,7 +102,7 @@ class LibSessionTests { val storageSpy = spy(app.storage) app.storage = storageSpy - val newContactId = randomSessionId() + val newContactId = randomAccountId() val singleContact = Contact( id = newContactId, approved = true, @@ -123,7 +123,7 @@ class LibSessionTests { val storageSpy = spy(app.storage) app.storage = storageSpy - val randomRecipient = randomSessionId() + val randomRecipient = randomAccountId() val newContact = Contact( id = randomRecipient, approved = true, @@ -158,7 +158,7 @@ class LibSessionTests { app.storage = storageSpy // Initial state - val randomRecipient = randomSessionId() + val randomRecipient = randomAccountId() val currentContact = Contact( id = randomRecipient, approved = true, diff --git a/app/src/androidTest/java/network/loki/messenger/SodiumUtilitiesTest.kt b/app/src/androidTest/java/network/loki/messenger/SodiumUtilitiesTest.kt index af260a0bf02..028a76a33ff 100644 --- a/app/src/androidTest/java/network/loki/messenger/SodiumUtilitiesTest.kt +++ b/app/src/androidTest/java/network/loki/messenger/SodiumUtilitiesTest.kt @@ -136,29 +136,29 @@ class SodiumUtilitiesTest { } @Test - fun sessionIdSuccess() { - val result = SodiumUtilities.sessionId("05$publicKey", "15$blindedPublicKey", serverPublicKey) + fun accountIdSuccess() { + val result = SodiumUtilities.accountId("05$publicKey", "15$blindedPublicKey", serverPublicKey) assertTrue(result) } @Test - fun sessionIdFailureInvalidSessionId() { - val result = SodiumUtilities.sessionId("AB$publicKey", "15$blindedPublicKey", serverPublicKey) + fun accountIdFailureInvalidAccountId() { + val result = SodiumUtilities.accountId("AB$publicKey", "15$blindedPublicKey", serverPublicKey) assertFalse(result) } @Test - fun sessionIdFailureInvalidBlindedId() { - val result = SodiumUtilities.sessionId("05$publicKey", "AB$blindedPublicKey", serverPublicKey) + fun accountIdFailureInvalidBlindedId() { + val result = SodiumUtilities.accountId("05$publicKey", "AB$blindedPublicKey", serverPublicKey) assertFalse(result) } @Test - fun sessionIdFailureBlindingFactor() { - val result = SodiumUtilities.sessionId("05$publicKey", "15$blindedPublicKey", "Test") + fun accountIdFailureBlindingFactor() { + val result = SodiumUtilities.accountId("05$publicKey", "15$blindedPublicKey", "Test") assertFalse(result) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b564f10dfd7..d70c9e080ea 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -60,6 +60,7 @@ + @@ -99,25 +100,26 @@ android:value="false" /> { - lokiAPIDatabase.clearSnodePool(); - lokiAPIDatabase.clearOnionRequestPaths(); - TextSecurePreferences.setHasAppliedPatchSnodeVersion(this, true); - }); - } - messagingModuleConfiguration = new MessagingModuleConfiguration( this, storage, @@ -272,7 +263,7 @@ public void onStart(@NonNull LifecycleOwner owner) { // If the user account hasn't been created or onboarding wasn't finished then don't start // the pollers - if (TextSecurePreferences.getLocalNumber(this) == null || !TextSecurePreferences.hasSeenWelcomeScreen(this)) { + if (textSecurePreferences.getLocalNumber() == null) { return; } @@ -285,6 +276,9 @@ public void onStart(@NonNull LifecycleOwner owner) { OpenGroupManager.INSTANCE.startPolling(); }); + + // fetch last version data + versionDataFetcher.startTimedVersionCheck(); } @Override @@ -297,12 +291,14 @@ public void onStop(@NonNull LifecycleOwner owner) { poller.stopIfNeeded(); } ClosedGroupPollerV2.getShared().stopAll(); + versionDataFetcher.stopTimedVersionCheck(); } @Override public void onTerminate() { stopKovenant(); // Loki OpenGroupManager.INSTANCE.stopPolling(); + versionDataFetcher.stopTimedVersionCheck(); super.onTerminate(); } @@ -462,6 +458,13 @@ public void startPollingIfNeeded() { ClosedGroupPollerV2.getShared().start(); } + public void retrieveUserProfile() { + setUpPollingIfNeeded(); + if (poller != null) { + poller.retrieveUserProfile(); + } + } + private void resubmitProfilePictureIfNeeded() { // Files expire on the file server after a while, so we simply re-upload the user's profile picture // at a certain interval to ensure it's always available. @@ -512,23 +515,23 @@ private void loadEmojiSearchIndexIfNeeded() { }); } - public void clearAllData(boolean isMigratingToV2KeyPair) { - if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive()) { - firebaseInstanceIdJob.cancel(null); - } - String displayName = TextSecurePreferences.getProfileName(this); - boolean isUsingFCM = TextSecurePreferences.isPushEnabled(this); + // Method to clear the local data - returns true on success otherwise false + + /** + * Clear all local profile data and message history then restart the app after a brief delay. + * @return true on success, false otherwise. + */ + @SuppressLint("ApplySharedPref") + public boolean clearAllData() { TextSecurePreferences.clearAll(this); - if (isMigratingToV2KeyPair) { - TextSecurePreferences.setPushEnabled(this, isUsingFCM); - TextSecurePreferences.setProfileName(this, displayName); - } getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit(); if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) { Log.d("Loki", "Failed to delete database."); + return false; } configFactory.keyPairChanged(); Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200)); + return true; } public void restartApplication() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt index f294e387ffe..071da43311f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt @@ -18,7 +18,7 @@ fun showMuteDialog( private enum class Option(@StringRes val stringRes: Int, val getTime: () -> Long) { ONE_HOUR(R.string.arrays__mute_for_one_hour, duration = TimeUnit.HOURS.toMillis(1)), - TWO_HOURS(R.string.arrays__mute_for_two_hours, duration = TimeUnit.DAYS.toMillis(2)), + TWO_HOURS(R.string.arrays__mute_for_two_hours, duration = TimeUnit.HOURS.toMillis(2)), ONE_DAY(R.string.arrays__mute_for_one_day, duration = TimeUnit.DAYS.toMillis(1)), SEVEN_DAYS(R.string.arrays__mute_for_seven_days, duration = TimeUnit.DAYS.toMillis(7)), FOREVER(R.string.arrays__mute_forever, getTime = { Long.MAX_VALUE }); diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActionBarActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActionBarActivity.java index bf2aba63f34..dbe7c4a4330 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActionBarActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActionBarActivity.java @@ -15,7 +15,7 @@ import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.home.HomeActivity; -import org.thoughtcrime.securesms.onboarding.LandingActivity; +import org.thoughtcrime.securesms.onboarding.landing.LandingActivity; import org.thoughtcrime.securesms.service.KeyCachingService; import java.util.Locale; @@ -125,12 +125,12 @@ private Intent getIntentForState(int state) { } private int getApplicationState(boolean locked) { - if (locked) { + if (TextSecurePreferences.getLocalNumber(this) == null) { + return STATE_WELCOME_SCREEN; + } else if (locked) { return STATE_PROMPT_PASSPHRASE; } else if (DatabaseUpgradeActivity.isUpdate(this)) { return STATE_UPGRADE_DATABASE; - } else if (!TextSecurePreferences.hasSeenWelcomeScreen(this)) { - return STATE_WELCOME_SCREEN; } else { return STATE_NORMAL; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt index 598977392b0..71e04230f2a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt @@ -1,6 +1,8 @@ package org.thoughtcrime.securesms import android.content.Context +import android.content.Intent +import android.net.Uri import android.view.LayoutInflater import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT @@ -15,7 +17,7 @@ import androidx.annotation.LayoutRes import androidx.annotation.StringRes import androidx.annotation.StyleRes import androidx.appcompat.app.AlertDialog -import androidx.core.view.setMargins +import androidx.core.text.HtmlCompat import androidx.core.view.updateMargins import androidx.fragment.app.Fragment import network.loki.messenger.R @@ -80,6 +82,10 @@ class SessionDialogBuilder(val context: Context) { }.let(topView::addView) } + fun htmlText(@StringRes id: Int, @StyleRes style: Int = 0, modify: TextView.() -> Unit = {}) { + text(HtmlCompat.fromHtml(context.resources.getString(id), 0)) + } + fun view(view: View) = contentView.addView(view) fun view(@LayoutRes layout: Int): View = LayoutInflater.from(context).inflate(layout, contentView) @@ -108,14 +114,14 @@ class SessionDialogBuilder(val context: Context) { options, ) { dialog, it -> onSelect(it); dialog.dismiss() } - fun destructiveButton( + fun dangerButton( @StringRes text: Int, @StringRes contentDescription: Int = text, listener: () -> Unit = {} ) = button( text, contentDescription, - R.style.Widget_Session_Button_Dialog_DestructiveText, + R.style.Widget_Session_Button_Dialog_DangerText, ) { listener() } fun okButton(listener: (() -> Unit) = {}) = button(android.R.string.ok) { listener() } @@ -143,6 +149,20 @@ class SessionDialogBuilder(val context: Context) { fun Context.showSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog = SessionDialogBuilder(this).apply { build() }.show() +fun Context.showOpenUrlDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog = + SessionDialogBuilder(this).apply { + title(R.string.urlOpen) + text(R.string.urlOpenBrowser) + build() + }.show() + +fun Context.showOpenUrlDialog(url: String): AlertDialog = + showOpenUrlDialog { + okButton { openUrl(url) } + cancelButton() + } + +fun Context.openUrl(url: String) = Intent(Intent.ACTION_VIEW, Uri.parse(url)).let(::startActivity) fun Fragment.showSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog = SessionDialogBuilder(requireContext()).apply { build() }.show() diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java index fd265337f94..7b4bde9c4cb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java @@ -1,28 +1,22 @@ package org.thoughtcrime.securesms.audio; -import android.annotation.TargetApi; + import android.content.Context; import android.net.Uri; -import android.os.Build; import android.os.ParcelFileDescriptor; +import android.util.Pair; import androidx.annotation.NonNull; - +import java.io.IOException; +import java.util.concurrent.ExecutorService; import org.session.libsession.utilities.MediaTypes; -import org.session.libsignal.utilities.Log; -import android.util.Pair; - -import org.thoughtcrime.securesms.providers.BlobProvider; -import org.thoughtcrime.securesms.util.MediaUtil; - -import org.session.libsignal.utilities.ThreadUtils; import org.session.libsession.utilities.Util; import org.session.libsignal.utilities.ListenableFuture; +import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.SettableFuture; +import org.session.libsignal.utilities.ThreadUtils; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.util.MediaUtil; -import java.io.IOException; -import java.util.concurrent.ExecutorService; - -@TargetApi(Build.VERSION_CODES.JELLY_BEAN) public class AudioRecorder { private static final String TAG = AudioRecorder.class.getSimpleName(); @@ -34,11 +28,16 @@ public class AudioRecorder { private AudioCodec audioCodec; private Uri captureUri; + // Simple interface that allows us to provide a callback method to our `startRecording` method + public interface AudioMessageRecordingFinishedCallback { + void onAudioMessageRecordingFinished(); + } + public AudioRecorder(@NonNull Context context) { this.context = context; } - public void startRecording() { + public void startRecording(AudioMessageRecordingFinishedCallback callback) { Log.i(TAG, "startRecording()"); executor.execute(() -> { @@ -55,9 +54,11 @@ public void startRecording() { .forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0) .withMimeType(MediaTypes.AUDIO_AAC) .createForSingleSessionOnDisk(context, e -> Log.w(TAG, "Error during recording", e)); - audioCodec = new AudioCodec(); + audioCodec = new AudioCodec(); audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1])); + + callback.onAudioMessageRecordingFinished(); } catch (IOException e) { Log.w(TAG, e); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/OrientationManager.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/OrientationManager.kt new file mode 100644 index 00000000000..baae40bcb2e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/OrientationManager.kt @@ -0,0 +1,87 @@ +package org.thoughtcrime.securesms.calls + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.provider.Settings +import androidx.core.content.ContextCompat.getSystemService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity.SENSOR_SERVICE +import org.thoughtcrime.securesms.webrtc.Orientation +import kotlin.math.asin + +class OrientationManager(private val context: Context): SensorEventListener { + private var sensorManager: SensorManager? = null + private var rotationVectorSensor: Sensor? = null + + private val _orientation = MutableStateFlow(Orientation.UNKNOWN) + val orientation: StateFlow = _orientation + + fun startOrientationListener(){ + // create the sensor manager if it's still null + if(sensorManager == null) { + sensorManager = context.getSystemService(SENSOR_SERVICE) as SensorManager + } + + if(rotationVectorSensor == null) { + rotationVectorSensor = sensorManager!!.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) + } + + sensorManager?.registerListener(this, rotationVectorSensor, SensorManager.SENSOR_DELAY_UI) + } + + fun stopOrientationListener(){ + sensorManager?.unregisterListener(this) + } + + fun destroy(){ + stopOrientationListener() + sensorManager = null + rotationVectorSensor = null + _orientation.value = Orientation.UNKNOWN + } + + override fun onSensorChanged(event: SensorEvent) { + if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) { + // if auto-rotate is off, bail and send UNKNOWN + if (!isAutoRotateOn()) { + _orientation.value = Orientation.UNKNOWN + return + } + + // Get the quaternion from the rotation vector sensor + val quaternion = FloatArray(4) + SensorManager.getQuaternionFromVector(quaternion, event.values) + + // Calculate Euler angles from the quaternion + val pitch = asin(2.0 * (quaternion[0] * quaternion[2] - quaternion[3] * quaternion[1])) + + // Convert radians to degrees + val pitchDegrees = Math.toDegrees(pitch).toFloat() + + // Determine the device's orientation based on the pitch and roll values + val currentOrientation = when { + pitchDegrees > 45 -> Orientation.LANDSCAPE + pitchDegrees < -45 -> Orientation.REVERSED_LANDSCAPE + else -> Orientation.PORTRAIT + } + + if (currentOrientation != _orientation.value) { + _orientation.value = currentOrientation + } + } + } + + //Function to check if Android System Auto-rotate is on or off + private fun isAutoRotateOn(): Boolean { + return Settings.System.getInt( + context.contentResolver, + Settings.System.ACCELEROMETER_ROTATION, 0 + ) == 1 + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt index d46321863b3..ea4108ba9aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt @@ -5,11 +5,17 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.graphics.Outline +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorManager import android.media.AudioManager import android.os.Build import android.os.Bundle +import android.provider.Settings import android.view.MenuItem -import android.view.OrientationEventListener +import android.view.View +import android.view.ViewOutlineProvider import android.view.WindowManager import androidx.activity.viewModels import androidx.core.content.ContextCompat @@ -21,13 +27,14 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive -import android.provider.Settings import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ActivityWebrtcBinding import org.apache.commons.lang3.time.DurationFormatUtils import org.session.libsession.avatars.ProfileContactPhoto import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.truncateIdForDisplay import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.dependencies.DatabaseComponent @@ -43,8 +50,10 @@ import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_OUTGOING import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_PRE_INIT import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_RECONNECTING import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_RINGING +import org.thoughtcrime.securesms.webrtc.Orientation import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager.AudioDevice.EARPIECE import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager.AudioDevice.SPEAKER_PHONE +import kotlin.math.asin @AndroidEntryPoint class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { @@ -71,16 +80,13 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { } private var hangupReceiver: BroadcastReceiver? = null - private val rotationListener by lazy { - object : OrientationEventListener(this) { - override fun onOrientationChanged(orientation: Int) { - if ((orientation + 15) % 90 < 30) { - viewModel.deviceRotation = orientation -// updateControlsRotation(orientation.quadrantRotation() * -1) - } - } - } - } + /** + * We need to track the device's orientation so we can calculate whether or not to rotate the video streams + * This works a lot better than using `OrientationEventListener > onOrientationChanged' + * which gives us a rotation angle that doesn't take into account pitch vs roll, so tipping the device from front to back would + * trigger the video rotation logic, while we really only want it when the device is in portrait or landscape. + */ + private var orientationManager = OrientationManager(this) override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == android.R.id.home) { @@ -102,13 +108,6 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { super.onCreate(savedInstanceState, ready) - // Only enable auto-rotate if system auto-rotate is enabled - if (isAutoRotateOn()) { - rotationListener.enable() - } else { - rotationListener.disable() - } - binding = ActivityWebrtcBinding.inflate(layoutInflater) setContentView(binding.root) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { @@ -136,6 +135,10 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { supportActionBar?.setDisplayHomeAsUpEnabled(false) } + binding.floatingRendererContainer.setOnClickListener { + viewModel.swapVideos() + } + binding.microphoneButton.setOnClickListener { val audioEnabledIntent = WebRtcCallService.microphoneIntent(this, !viewModel.microphoneEnabled) @@ -174,7 +177,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { Permissions.with(this) .request(Manifest.permission.CAMERA) .onAllGranted { - val intent = WebRtcCallService.cameraEnabled(this, !viewModel.videoEnabled) + val intent = WebRtcCallService.cameraEnabled(this, !viewModel.videoState.value.userVideoEnabled) startService(intent) } .execute() @@ -191,14 +194,54 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { onBackPressed() } + lifecycleScope.launch { + orientationManager.orientation.collect { orientation -> + viewModel.deviceOrientation = orientation + updateControlsRotation() + } + } + + clipFloatingInsets() + + // set up the user avatar + TextSecurePreferences.getLocalNumber(this)?.let{ + val username = TextSecurePreferences.getProfileName(this) ?: truncateIdForDisplay(it) + binding.userAvatar.apply { + publicKey = it + displayName = username + update() + } + } + } + + /** + * Makes sure the floating video inset has clipped rounded corners, included with the video stream itself + */ + private fun clipFloatingInsets() { + // clip the video inset with rounded corners + val videoInsetProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + // all corners + outline.setRoundRect( + 0, 0, view.width, view.height, + resources.getDimensionPixelSize(R.dimen.video_inset_radius).toFloat() + ) + } + } + + binding.floatingRendererContainer.outlineProvider = videoInsetProvider + binding.floatingRendererContainer.clipToOutline = true + } + + override fun onResume() { + super.onResume() + orientationManager.startOrientationListener() + } - //Function to check if Android System Auto-rotate is on or off - private fun isAutoRotateOn(): Boolean { - return Settings.System.getInt( - contentResolver, - Settings.System.ACCELEROMETER_ROTATION, 0 - ) == 1 + override fun onPause() { + super.onPause() + orientationManager.stopOrientationListener() } override fun onDestroy() { @@ -206,7 +249,8 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { hangupReceiver?.let { receiver -> LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver) } - rotationListener.disable() + + orientationManager.destroy() } private fun answerCall() { @@ -214,15 +258,33 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { ContextCompat.startForegroundService(this, answerIntent) } - private fun updateControlsRotation(newRotation: Int) { + private fun updateControlsRotation() { with (binding) { - val rotation = newRotation.toFloat() - remoteRecipient.rotation = rotation - speakerPhoneButton.rotation = rotation - microphoneButton.rotation = rotation - enableCameraButton.rotation = rotation - switchCameraButton.rotation = rotation - endCallButton.rotation = rotation + val rotation = when(viewModel.deviceOrientation){ + Orientation.LANDSCAPE -> -90f + Orientation.REVERSED_LANDSCAPE -> 90f + else -> 0f + } + + userAvatar.animate().cancel() + userAvatar.animate().rotation(rotation).start() + contactAvatar.animate().cancel() + contactAvatar.animate().rotation(rotation).start() + + speakerPhoneButton.animate().cancel() + speakerPhoneButton.animate().rotation(rotation).start() + + microphoneButton.animate().cancel() + microphoneButton.animate().rotation(rotation).start() + + enableCameraButton.animate().cancel() + enableCameraButton.animate().rotation(rotation).start() + + switchCameraButton.animate().cancel() + switchCameraButton.animate().rotation(rotation).start() + + endCallButton.animate().cancel() + endCallButton.animate().rotation(rotation).start() } } @@ -280,44 +342,20 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { launch { viewModel.recipient.collect { latestRecipient -> + binding.contactAvatar.recycle() + if (latestRecipient.recipient != null) { - val publicKey = latestRecipient.recipient.address.serialize() - val displayName = getUserDisplayName(publicKey) - supportActionBar?.title = displayName - val signalProfilePicture = latestRecipient.recipient.contactPhoto - val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject - val sizeInPX = - resources.getDimensionPixelSize(R.dimen.extra_large_profile_picture_size) - binding.remoteRecipientName.text = displayName - if (signalProfilePicture != null && avatar != "0" && avatar != "") { - glide.clear(binding.remoteRecipient) - glide.load(signalProfilePicture) - .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) - .circleCrop() - .error( - AvatarPlaceholderGenerator.generate( - this@WebRtcCallActivity, - sizeInPX, - publicKey, - displayName - ) - ) - .into(binding.remoteRecipient) - } else { - glide.clear(binding.remoteRecipient) - glide.load( - AvatarPlaceholderGenerator.generate( - this@WebRtcCallActivity, - sizeInPX, - publicKey, - displayName - ) - ) - .diskCacheStrategy(DiskCacheStrategy.ALL).circleCrop() - .into(binding.remoteRecipient) + val contactPublicKey = latestRecipient.recipient.address.serialize() + val contactDisplayName = getUserDisplayName(contactPublicKey) + supportActionBar?.title = contactDisplayName + binding.remoteRecipientName.text = contactDisplayName + + // sort out the contact's avatar + binding.contactAvatar.apply { + publicKey = contactPublicKey + displayName = contactDisplayName + update() } - } else { - glide.clear(binding.remoteRecipient) } } } @@ -346,49 +384,75 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { } } + // handle video state launch { - viewModel.localVideoEnabledState.collect { isEnabled -> - binding.localRenderer.removeAllViews() - if (isEnabled) { - viewModel.localRenderer?.let { surfaceView -> - surfaceView.setZOrderOnTop(true) - - // Mirror the video preview of the person making the call to prevent disorienting them - surfaceView.setMirror(true) - - binding.localRenderer.addView(surfaceView) + viewModel.videoState.collect { state -> + binding.floatingRenderer.removeAllViews() + binding.fullscreenRenderer.removeAllViews() + + // handle fullscreen video window + if(state.showFullscreenVideo()){ + viewModel.fullscreenRenderer?.let { surfaceView -> + binding.fullscreenRenderer.addView(surfaceView) + binding.fullscreenRenderer.isVisible = true + hideAvatar() } + } else { + binding.fullscreenRenderer.isVisible = false + showAvatar(state.swapped) } - binding.localRenderer.isVisible = isEnabled - binding.enableCameraButton.isSelected = isEnabled - } - } - launch { - viewModel.remoteVideoEnabledState.collect { isEnabled -> - binding.remoteRenderer.removeAllViews() - if (isEnabled) { - viewModel.remoteRenderer?.let { surfaceView -> - binding.remoteRenderer.addView(surfaceView) + // handle floating video window + if(state.showFloatingVideo()){ + viewModel.floatingRenderer?.let { surfaceView -> + binding.floatingRenderer.addView(surfaceView) + binding.floatingRenderer.isVisible = true + binding.swapViewIcon.bringToFront() } + } else { + binding.floatingRenderer.isVisible = false } - binding.remoteRenderer.isVisible = isEnabled - binding.remoteRecipient.isVisible = !isEnabled + + // the floating video inset (empty or not) should be shown + // the moment we have either of the video streams + val showFloatingContainer = state.userVideoEnabled || state.remoteVideoEnabled + binding.floatingRendererContainer.isVisible = showFloatingContainer + binding.swapViewIcon.isVisible = showFloatingContainer + + // make sure to default to the contact's avatar if the floating container is not visible + if (!showFloatingContainer) showAvatar(false) + + // handle buttons + binding.enableCameraButton.isSelected = state.userVideoEnabled } } } } + /** + * Shows the avatar image. + * If @showUserAvatar is true, the user's avatar is shown, otherwise the contact's avatar is shown. + */ + private fun showAvatar(showUserAvatar: Boolean) { + binding.userAvatar.isVisible = showUserAvatar + binding.contactAvatar.isVisible = !showUserAvatar + } + + private fun hideAvatar() { + binding.userAvatar.isVisible = false + binding.contactAvatar.isVisible = false + } + private fun getUserDisplayName(publicKey: String): String { val contact = - DatabaseComponent.get(this).sessionContactDatabase().getContactWithSessionID(publicKey) + DatabaseComponent.get(this).sessionContactDatabase().getContactWithAccountID(publicKey) return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey } override fun onStop() { super.onStop() uiJob?.cancel() - binding.remoteRenderer.removeAllViews() - binding.localRenderer.removeAllViews() + binding.fullscreenRenderer.removeAllViews() + binding.floatingRenderer.removeAllViews() } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt index 52e2d52ab11..6d59bbfc92f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt @@ -15,8 +15,10 @@ import org.session.libsession.avatars.ProfileContactPhoto import org.session.libsession.avatars.ResourceContactPhoto import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.Address +import org.session.libsession.utilities.AppTextSecurePreferences import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideRequests @@ -24,13 +26,16 @@ import org.thoughtcrime.securesms.mms.GlideRequests class ProfilePictureView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : RelativeLayout(context, attrs) { + private val TAG = "ProfilePictureView" + private val binding = ViewProfilePictureBinding.inflate(LayoutInflater.from(context), this) private val glide: GlideRequests = GlideApp.with(this) + private val prefs = AppTextSecurePreferences(context) + private val userPublicKey = prefs.getLocalNumber() var publicKey: String? = null var displayName: String? = null var additionalPublicKey: String? = null var additionalDisplayName: String? = null - var isLarge = false private val profilePicturesCache = mutableMapOf() private val unknownRecipientDrawable by lazy { ResourceContactPhoto(R.drawable.ic_profile_default) @@ -38,25 +43,28 @@ class ProfilePictureView @JvmOverloads constructor( private val unknownOpenGroupDrawable by lazy { ResourceContactPhoto(R.drawable.ic_notification) .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) } - // endregion - constructor(context: Context, sender: Recipient): this(context) { update(sender) } - // region Updating fun update(recipient: Recipient) { - fun getUserDisplayName(publicKey: String): String { - val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey) - return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey - } + recipient.run { update(address, isClosedGroupRecipient, isOpenGroupInboxRecipient) } + } + + fun update( + address: Address, + isClosedGroupRecipient: Boolean = false, + isOpenGroupInboxRecipient: Boolean = false + ) { + fun getUserDisplayName(publicKey: String): String = prefs.takeIf { userPublicKey == publicKey }?.getProfileName() + ?: DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)?.displayName(Contact.ContactContext.REGULAR) + ?: publicKey - if (recipient.isClosedGroupRecipient) { + if (isClosedGroupRecipient) { val members = DatabaseComponent.get(context).groupDatabase() - .getGroupMemberAddresses(recipient.address.toGroupString(), true) - .sorted() - .take(2) - .toMutableList() + .getGroupMemberAddresses(address.toGroupString(), true) + .sorted() + .take(2) if (members.size <= 1) { publicKey = "" displayName = "" @@ -70,13 +78,13 @@ class ProfilePictureView @JvmOverloads constructor( additionalPublicKey = apk additionalDisplayName = getUserDisplayName(apk) } - } else if(recipient.isOpenGroupInboxRecipient) { - val publicKey = GroupUtil.getDecodedOpenGroupInboxSessionId(recipient.address.serialize()) + } else if(isOpenGroupInboxRecipient) { + val publicKey = GroupUtil.getDecodedOpenGroupInboxAccountId(address.serialize()) this.publicKey = publicKey displayName = getUserDisplayName(publicKey) additionalPublicKey = null } else { - val publicKey = recipient.address.toString() + val publicKey = address.serialize() this.publicKey = publicKey displayName = getUserDisplayName(publicKey) additionalPublicKey = null @@ -85,31 +93,27 @@ class ProfilePictureView @JvmOverloads constructor( } fun update() { - val publicKey = publicKey ?: return + val publicKey = publicKey ?: return Log.w(TAG, "Could not find public key to update profile picture") val additionalPublicKey = additionalPublicKey + // if we have a multi avatar setup if (additionalPublicKey != null) { setProfilePictureIfNeeded(binding.doubleModeImageView1, publicKey, displayName) setProfilePictureIfNeeded(binding.doubleModeImageView2, additionalPublicKey, additionalDisplayName) binding.doubleModeImageViewContainer.visibility = View.VISIBLE - } else { + + // clear single image + glide.clear(binding.singleModeImageView) + binding.singleModeImageView.visibility = View.INVISIBLE + } else { // single image mode + setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName) + binding.singleModeImageView.visibility = View.VISIBLE + + // clear multi image glide.clear(binding.doubleModeImageView1) glide.clear(binding.doubleModeImageView2) binding.doubleModeImageViewContainer.visibility = View.INVISIBLE } - if (additionalPublicKey == null && !isLarge) { - setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName) - binding.singleModeImageView.visibility = View.VISIBLE - } else { - glide.clear(binding.singleModeImageView) - binding.singleModeImageView.visibility = View.INVISIBLE - } - if (additionalPublicKey == null && isLarge) { - setProfilePictureIfNeeded(binding.largeSingleModeImageView, publicKey, displayName) - binding.largeSingleModeImageView.visibility = View.VISIBLE - } else { - glide.clear(binding.largeSingleModeImageView) - binding.largeSingleModeImageView.visibility = View.INVISIBLE - } + } private fun setProfilePictureIfNeeded(imageView: ImageView, publicKey: String, displayName: String?) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/CompositeEmojiPageModel.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/CompositeEmojiPageModel.java deleted file mode 100644 index cc36ab31c63..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/CompositeEmojiPageModel.java +++ /dev/null @@ -1,64 +0,0 @@ -package org.thoughtcrime.securesms.components.emoji; - -import android.net.Uri; - -import androidx.annotation.AttrRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.thoughtcrime.securesms.conversation.v2.Util; - -import java.util.LinkedList; -import java.util.List; - -public class CompositeEmojiPageModel implements EmojiPageModel { - @AttrRes private final int iconAttr; - @NonNull private final List models; - - public CompositeEmojiPageModel(@AttrRes int iconAttr, @NonNull List models) { - this.iconAttr = iconAttr; - this.models = models; - } - - @Override - public String getKey() { - return Util.hasItems(models) ? models.get(0).getKey() : ""; - } - - public int getIconAttr() { - return iconAttr; - } - - @Override - public @NonNull List getEmoji() { - List emojis = new LinkedList<>(); - for (EmojiPageModel model : models) { - emojis.addAll(model.getEmoji()); - } - return emojis; - } - - @Override - public @NonNull List getDisplayEmoji() { - List emojis = new LinkedList<>(); - for (EmojiPageModel model : models) { - emojis.addAll(model.getDisplayEmoji()); - } - return emojis; - } - - @Override - public boolean hasSpriteMap() { - return false; - } - - @Override - public @Nullable Uri getSpriteUri() { - return null; - } - - @Override - public boolean isDynamic() { - return false; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/CompositeEmojiPageModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/CompositeEmojiPageModel.kt new file mode 100644 index 00000000000..37c30b59742 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/CompositeEmojiPageModel.kt @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.components.emoji + +import android.net.Uri +import androidx.annotation.AttrRes +import java.util.LinkedList + +class CompositeEmojiPageModel( + @field:AttrRes @param:AttrRes private val iconAttr: Int, + private val models: List +) : EmojiPageModel { + + override fun getKey(): String { + return if (models.isEmpty()) "" else models[0].key + } + + override fun getIconAttr(): Int { return iconAttr } + + override fun getEmoji(): List { + val emojis: MutableList = LinkedList() + for (model in models) { + emojis.addAll(model.emoji) + } + return emojis + } + + override fun getDisplayEmoji(): List { + val emojis: MutableList = LinkedList() + for (model in models) { + emojis.addAll(model.displayEmoji) + } + return emojis + } + + override fun hasSpriteMap(): Boolean { return false } + + override fun getSpriteUri(): Uri? { return null } + + override fun isDynamic(): Boolean { return false } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt index 700534fad18..af32b7f50f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt @@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.components.menu import android.content.Context import androidx.annotation.AttrRes -import androidx.annotation.ColorRes +import androidx.annotation.ColorInt /** * Represents an action to be rendered @@ -13,5 +13,5 @@ data class ActionItem( val action: Runnable, val contentDescription: Int? = null, val subtitle: ((Context) -> CharSequence?)? = null, - @ColorRes val color: Int? = null, + @ColorInt val color: Int? = null, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt index 69dec0cdd69..7b92e505c6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt @@ -78,7 +78,7 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) { override fun bind(model: DisplayItem) { val item = model.item - val color = item.color?.let { ContextCompat.getColor(context, it) } + val color = item.color if (item.iconRes > 0) { val typedValue = TypedValue() diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt index b46243eeb0e..f9fd5287070 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt @@ -49,7 +49,7 @@ class UserView : LinearLayout { val isLocalUser = user.isLocalNumber fun getUserDisplayName(publicKey: String): String { if (isLocalUser) return context.getString(R.string.MessageRecord_you) - val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey) + val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey) return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey } val address = user.address.serialize() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt index d336c967cee..38da11ae24f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt @@ -14,7 +14,6 @@ import org.session.libsession.utilities.ExpirationUtil import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.getExpirationTypeDisplayValue -import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities @@ -57,7 +56,7 @@ class DisappearingMessages @Inject constructor( context.getExpirationTypeDisplayValue(message.isNotDisappearAfterRead) ) }) - destructiveButton( + dangerButton( text = if (message.expiresIn == 0L) R.string.dialog_disappearing_messages_follow_setting_confirm else R.string.dialog_disappearing_messages_follow_setting_set, contentDescription = if (message.expiresIn == 0L) R.string.AccessibilityId_confirm else R.string.AccessibilityId_set_button ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt index 16e74cdde9a..f66512d79f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt @@ -18,7 +18,7 @@ import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.Disappear import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.UiState import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.ThreadDatabase -import org.thoughtcrime.securesms.ui.AppTheme +import org.thoughtcrime.securesms.ui.setThemedContent import javax.inject.Inject @AndroidEntryPoint @@ -45,7 +45,7 @@ class DisappearingMessagesActivity: PassphraseRequiredActionBarActivity() { setUpToolbar() - binding.container.setContent { DisappearingMessagesScreen() } + binding.container.setThemedContent { DisappearingMessagesScreen() } lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -87,8 +87,6 @@ class DisappearingMessagesActivity: PassphraseRequiredActionBarActivity() { @Composable fun DisappearingMessagesScreen() { val uiState by viewModel.uiState.collectAsState(UiState()) - AppTheme { - DisappearingMessages(uiState, callbacks = viewModel) - } + DisappearingMessages(uiState, callbacks = viewModel) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt index 6ddc28c688b..d78d33a2f9f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt @@ -13,8 +13,8 @@ import kotlin.time.Duration.Companion.seconds fun State.toUiState() = UiState( cards = listOfNotNull( - typeOptions()?.let { ExpiryOptionsCard(GetString(R.string.activity_disappearing_messages_delete_type), it) }, - timeOptions()?.let { ExpiryOptionsCard(GetString(R.string.activity_disappearing_messages_timer), it) } + typeOptions()?.let { ExpiryOptionsCardData(GetString(R.string.activity_disappearing_messages_delete_type), it) }, + timeOptions()?.let { ExpiryOptionsCardData(GetString(R.string.activity_disappearing_messages_timer), it) } ), showGroupFooter = isGroup && isNewConfigEnabled, showSetButton = isSelfAdmin diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt index 3fec60a0a3c..ae17e6a09ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt @@ -3,31 +3,32 @@ package org.thoughtcrime.securesms.conversation.disappearingmessages.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Text +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import network.loki.messenger.R import network.loki.messenger.libsession_util.util.ExpiryMode import org.thoughtcrime.securesms.ui.Callbacks -import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.NoOpCallbacks import org.thoughtcrime.securesms.ui.OptionsCard -import org.thoughtcrime.securesms.ui.OutlineButton import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.components.SlimOutlineButton import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.fadingEdges +import org.thoughtcrime.securesms.ui.theme.LocalType typealias ExpiryCallbacks = Callbacks typealias ExpiryRadioOption = RadioOption @@ -40,35 +41,42 @@ fun DisappearingMessages( ) { val scrollState = rememberScrollState() - Column(modifier = modifier.padding(horizontal = 32.dp)) { + Column(modifier = modifier.padding(horizontal = LocalDimensions.current.spacing)) { Box(modifier = Modifier.weight(1f)) { Column( modifier = Modifier - .padding(bottom = 20.dp) + .padding(vertical = LocalDimensions.current.spacing) .verticalScroll(scrollState) .fadingEdges(scrollState), - verticalArrangement = Arrangement.spacedBy(16.dp) ) { - state.cards.forEach { - OptionsCard(it, callbacks) + state.cards.forEachIndexed { index, option -> + OptionsCard(option, callbacks) + + // add spacing if not the last item + if(index != state.cards.lastIndex){ + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + } } - if (state.showGroupFooter) Text(text = stringResource(R.string.activity_disappearing_messages_group_footer), - style = TextStyle( - fontSize = 11.sp, - fontWeight = FontWeight(400), - color = Color(0xFFA1A2A1), - textAlign = TextAlign.Center), - modifier = Modifier.fillMaxWidth()) + if (state.showGroupFooter) Text( + text = stringResource(R.string.activity_disappearing_messages_group_footer), + style = LocalType.current.extraSmall, + fontWeight = FontWeight(400), + color = LocalColors.current.textSecondary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = LocalDimensions.current.xsSpacing) + ) } } - if (state.showSetButton) OutlineButton( - GetString(R.string.disappearing_messages_set_button_title), + if (state.showSetButton) SlimOutlineButton( + stringResource(R.string.disappearing_messages_set_button_title), modifier = Modifier - .contentDescription(GetString(R.string.AccessibilityId_set_button)) + .contentDescription(R.string.AccessibilityId_set_button) .align(Alignment.CenterHorizontally) - .padding(bottom = 20.dp), + .padding(bottom = LocalDimensions.current.spacing), onClick = callbacks::onSetClick ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt index c2524bf261a..d043cc314fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt @@ -7,19 +7,19 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp -import network.loki.messenger.R import network.loki.messenger.libsession_util.util.ExpiryMode import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType import org.thoughtcrime.securesms.conversation.disappearingmessages.State -import org.thoughtcrime.securesms.ui.PreviewTheme -import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider @Preview(widthDp = 450, heightDp = 700) @Composable fun PreviewStates( @PreviewParameter(StatePreviewParameterProvider::class) state: State ) { - PreviewTheme(R.style.Classic_Dark) { + PreviewTheme { DisappearingMessages( state.toUiState() ) @@ -51,9 +51,9 @@ class StatePreviewParameterProvider : PreviewParameterProvider { @Preview @Composable fun PreviewThemes( - @PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors ) { - PreviewTheme(themeResId) { + PreviewTheme(colors) { DisappearingMessages( State(expiryMode = ExpiryMode.AfterSend(43200)).toUiState(), modifier = Modifier.size(400.dp, 600.dp) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt index 40f917427c6..47159571f84 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt @@ -5,15 +5,15 @@ import network.loki.messenger.libsession_util.util.ExpiryMode import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.RadioOption -typealias ExpiryOptionsCard = OptionsCard +typealias ExpiryOptionsCardData = OptionsCardData data class UiState( - val cards: List = emptyList(), + val cards: List = emptyList(), val showGroupFooter: Boolean = false, val showSetButton: Boolean = true ) { constructor( - vararg cards: ExpiryOptionsCard, + vararg cards: ExpiryOptionsCardData, showGroupFooter: Boolean = false, showSetButton: Boolean = true, ): this( @@ -23,7 +23,7 @@ data class UiState( ) } -data class OptionsCard( +data class OptionsCardData( val title: GetString, val options: List> ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/paging/ConversationPager.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/paging/ConversationPager.kt deleted file mode 100644 index 827c3945460..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/paging/ConversationPager.kt +++ /dev/null @@ -1,129 +0,0 @@ -package org.thoughtcrime.securesms.conversation.paging - -import androidx.annotation.WorkerThread -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.PagingSource -import androidx.paging.PagingState -import androidx.recyclerview.widget.DiffUtil -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.session.libsession.messaging.contacts.Contact -import org.thoughtcrime.securesms.database.MmsSmsDatabase -import org.thoughtcrime.securesms.database.SessionContactDatabase -import org.thoughtcrime.securesms.database.model.MessageRecord - -private const val TIME_BUCKET = 600000L // bucket into 10 minute increments - -private fun config() = PagingConfig( - pageSize = 25, - maxSize = 100, - enablePlaceholders = false -) - -fun Long.bucketed(): Long = (TIME_BUCKET - this % TIME_BUCKET) + this - -fun conversationPager(threadId: Long, initialKey: PageLoad? = null, db: MmsSmsDatabase, contactDb: SessionContactDatabase) = Pager(config(), initialKey = initialKey) { - ConversationPagingSource(threadId, db, contactDb) -} - -class ConversationPagerDiffCallback: DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: MessageAndContact, newItem: MessageAndContact): Boolean = - oldItem.message.id == newItem.message.id && oldItem.message.isMms == newItem.message.isMms - - override fun areContentsTheSame(oldItem: MessageAndContact, newItem: MessageAndContact): Boolean = - oldItem == newItem -} - -data class MessageAndContact(val message: MessageRecord, - val contact: Contact?) - -data class PageLoad(val fromTime: Long, val toTime: Long? = null) - -class ConversationPagingSource( - private val threadId: Long, - private val messageDb: MmsSmsDatabase, - private val contactDb: SessionContactDatabase - ): PagingSource() { - - override fun getRefreshKey(state: PagingState): PageLoad? { - val anchorPosition = state.anchorPosition ?: return null - val anchorPage = state.closestPageToPosition(anchorPosition) ?: return null - val next = anchorPage.nextKey?.fromTime - val previous = anchorPage.prevKey?.fromTime ?: anchorPage.data.firstOrNull()?.message?.dateSent ?: return null - return PageLoad(previous, next) - } - - private val contactCache = mutableMapOf() - - @WorkerThread - private fun getContact(sessionId: String): Contact? { - contactCache[sessionId]?.let { contact -> - return contact - } ?: run { - contactDb.getContactWithSessionID(sessionId)?.let { contact -> - contactCache[sessionId] = contact - return contact - } - } - return null - } - - override suspend fun load(params: LoadParams): LoadResult { - val pageLoad = params.key ?: withContext(Dispatchers.IO) { - messageDb.getConversationSnippet(threadId).use { - val reader = messageDb.readerFor(it) - var record: MessageRecord? = null - if (reader != null) { - record = reader.next - while (record != null && record.isDeleted) { - record = reader.next - } - } - record?.dateSent?.let { fromTime -> - PageLoad(fromTime) - } - } - } ?: return LoadResult.Page(emptyList(), null, null) - - val result = withContext(Dispatchers.IO) { - val cursor = messageDb.getConversationPage( - threadId, - pageLoad.fromTime, - pageLoad.toTime ?: -1L, - params.loadSize - ) - val processedList = mutableListOf() - val reader = messageDb.readerFor(cursor) - while (reader.next != null && !invalid) { - reader.current?.let { item -> - val contact = getContact(item.individualRecipient.address.serialize()) - processedList += MessageAndContact(item, contact) - } - } - reader.close() - processedList.toMutableList() - } - - val hasNext = withContext(Dispatchers.IO) { - if (result.isEmpty()) return@withContext false - val lastTime = result.last().message.dateSent - messageDb.hasNextPage(threadId, lastTime) - } - - val nextCheckTime = if (hasNext) { - val lastSent = result.last().message.dateSent - if (lastSent == pageLoad.fromTime) null else lastSent - } else null - - val hasPrevious = withContext(Dispatchers.IO) { messageDb.hasPreviousPage(threadId, pageLoad.fromTime) } - val nextKey = if (!hasNext) null else nextCheckTime - val prevKey = if (!hasPrevious) null else messageDb.getPreviousPage(threadId, pageLoad.fromTime, params.loadSize) - - return LoadResult.Page( - data = result, // next check time is not null if drop is true - prevKey = prevKey?.let { PageLoad(it, pageLoad.fromTime) }, - nextKey = nextKey?.let { PageLoad(it) } - ) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt deleted file mode 100644 index df2bc1c3715..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt +++ /dev/null @@ -1,103 +0,0 @@ -package org.thoughtcrime.securesms.conversation.start - -import android.content.Context -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import network.loki.messenger.databinding.ContactSectionHeaderBinding -import network.loki.messenger.databinding.ViewContactBinding -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.mms.GlideRequests - -sealed class ContactListItem { - class Header(val name: String) : ContactListItem() - class Contact(val recipient: Recipient, val displayName: String) : ContactListItem() -} - -class ContactListAdapter( - private val context: Context, - private val glide: GlideRequests, - private val listener: (Recipient) -> Unit -) : RecyclerView.Adapter() { - var items = listOf() - set(value) { - field = value - notifyDataSetChanged() - } - - private object ViewType { - const val Contact = 0 - const val Header = 1 - } - - class ContactViewHolder(private val binding: ViewContactBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(contact: ContactListItem.Contact, glide: GlideRequests, listener: (Recipient) -> Unit) { - binding.profilePictureView.update(contact.recipient) - binding.nameTextView.text = contact.displayName - binding.root.setOnClickListener { listener(contact.recipient) } - - // TODO: When we implement deleting contacts (hide might be safest for now) then probably set a long-click listener here w/ something like: - /* - binding.root.setOnLongClickListener { - Log.w("[ACL]", "Long clicked on contact ${contact.recipient.name}") - binding.contentView.context.showSessionDialog { - title("Delete Contact") - text("Are you sure you want to delete this contact?") - button(R.string.delete) { - val contacts = configFactory.contacts ?: return - contacts.upsertContact(contact.recipient.address.serialize()) { priority = PRIORITY_HIDDEN } - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) - endActionMode() - } - cancelButton(::endActionMode) - } - true - } - */ - } - - fun unbind() { binding.profilePictureView.recycle() } - } - - class HeaderViewHolder( - private val binding: ContactSectionHeaderBinding - ) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: ContactListItem.Header) { - with(binding) { - label.text = item.name - } - } - } - - override fun getItemCount(): Int { return items.size } - - override fun onViewRecycled(holder: RecyclerView.ViewHolder) { - super.onViewRecycled(holder) - if (holder is ContactViewHolder) { holder.unbind() } - } - - override fun getItemViewType(position: Int): Int { - return when (items[position]) { - is ContactListItem.Header -> ViewType.Header - else -> ViewType.Contact - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return if (viewType == ViewType.Contact) { - ContactViewHolder(ViewContactBinding.inflate(LayoutInflater.from(context), parent, false)) - } else { - HeaderViewHolder(ContactSectionHeaderBinding.inflate(LayoutInflater.from(context), parent, false)) - } - } - - override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) { - val item = items[position] - if (viewHolder is ContactViewHolder) { - viewHolder.bind(item as ContactListItem.Contact, glide, listener) - } else if (viewHolder is HeaderViewHolder) { - viewHolder.bind(item as ContactListItem.Header) - } - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationDelegate.kt index 7e51e1de072..04b16b64eff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationDelegate.kt @@ -1,10 +1,21 @@ package org.thoughtcrime.securesms.conversation.start -interface NewConversationDelegate { +interface StartConversationDelegate { fun onNewMessageSelected() fun onCreateGroupSelected() fun onJoinCommunitySelected() fun onContactSelected(address: String) fun onDialogBackPressed() fun onDialogClosePressed() + fun onInviteFriend() +} + +object NullStartConversationDelegate: StartConversationDelegate { + override fun onNewMessageSelected() {} + override fun onCreateGroupSelected() {} + override fun onJoinCommunitySelected() {} + override fun onContactSelected(address: String) {} + override fun onDialogBackPressed() {} + override fun onDialogClosePressed() {} + override fun onInviteFriend() {} } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt index 7b666be56f2..8dffb1fd9b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt @@ -7,6 +7,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams import androidx.fragment.app.Fragment import androidx.fragment.app.commit import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -15,15 +16,22 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import org.session.libsession.utilities.Address +import org.session.libsession.utilities.modifyLayoutParams +import org.thoughtcrime.securesms.conversation.start.home.StartConversationHomeFragment +import org.thoughtcrime.securesms.conversation.start.invitefriend.InviteFriendFragment +import org.thoughtcrime.securesms.conversation.start.newmessage.NewMessageFragment import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.dms.NewMessageFragment import org.thoughtcrime.securesms.groups.CreateGroupFragment import org.thoughtcrime.securesms.groups.JoinCommunityFragment @AndroidEntryPoint -class NewConversationFragment : BottomSheetDialogFragment(), NewConversationDelegate { +class StartConversationFragment : BottomSheetDialogFragment(), StartConversationDelegate { - private val defaultPeekHeight: Int by lazy { (Resources.getSystem().displayMetrics.heightPixels * 0.94).toInt() } + companion object{ + const val PEEK_RATIO = 0.94f + } + + private val defaultPeekHeight: Int by lazy { (Resources.getSystem().displayMetrics.heightPixels * PEEK_RATIO).toInt() } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -35,38 +43,34 @@ class NewConversationFragment : BottomSheetDialogFragment(), NewConversationDele override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) replaceFragment( - fragment = NewConversationHomeFragment().apply { delegate = this@NewConversationFragment }, - fragmentKey = NewConversationHomeFragment::class.java.simpleName + fragment = StartConversationHomeFragment().also { it.delegate.value = this }, + fragmentKey = StartConversationHomeFragment::class.java.simpleName ) } - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val dialog = BottomSheetDialog(requireContext(), R.style.Theme_Session_BottomSheet) - dialog.setOnShowListener { - val bottomSheetDialog = it as BottomSheetDialog - val parentLayout = - bottomSheetDialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) - parentLayout?.let { it -> - val behaviour = BottomSheetBehavior.from(it) - val layoutParams = it.layoutParams - layoutParams.height = defaultPeekHeight - it.layoutParams = layoutParams - behaviour.state = BottomSheetBehavior.STATE_EXPANDED + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = + BottomSheetDialog(requireContext(), R.style.Theme_Session_BottomSheet).apply { + setOnShowListener { _ -> + findViewById(com.google.android.material.R.id.design_bottom_sheet)?.apply { + modifyLayoutParams { height = defaultPeekHeight } + }?.let { BottomSheetBehavior.from(it) }?.apply { + skipCollapsed = true + state = BottomSheetBehavior.STATE_EXPANDED + } } } - return dialog - } + override fun onNewMessageSelected() { - replaceFragment(NewMessageFragment().apply { delegate = this@NewConversationFragment }) + replaceFragment(NewMessageFragment().also { it.delegate = this }) } override fun onCreateGroupSelected() { - replaceFragment(CreateGroupFragment().apply { delegate = this@NewConversationFragment }) + replaceFragment(CreateGroupFragment().also { it.delegate = this }) } override fun onJoinCommunitySelected() { - replaceFragment(JoinCommunityFragment().apply { delegate = this@NewConversationFragment }) + replaceFragment(JoinCommunityFragment().also { it.delegate = this }) } override fun onContactSelected(address: String) { @@ -80,6 +84,10 @@ class NewConversationFragment : BottomSheetDialogFragment(), NewConversationDele childFragmentManager.popBackStack() } + override fun onInviteFriend() { + replaceFragment(InviteFriendFragment().also { it.delegate = this }) + } + override fun onDialogClosePressed() { dismiss() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt deleted file mode 100644 index 92f050f76ac..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt +++ /dev/null @@ -1,70 +0,0 @@ -package org.thoughtcrime.securesms.conversation.start - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.RecyclerView -import dagger.hilt.android.AndroidEntryPoint -import network.loki.messenger.R -import network.loki.messenger.databinding.FragmentNewConversationHomeBinding -import org.session.libsession.messaging.contacts.Contact -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.utilities.PublicKeyValidation -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.mms.GlideApp -import javax.inject.Inject - -@AndroidEntryPoint -class NewConversationHomeFragment : Fragment() { - - private lateinit var binding: FragmentNewConversationHomeBinding - private val viewModel: NewConversationHomeViewModel by viewModels() - - @Inject - lateinit var textSecurePreferences: TextSecurePreferences - - lateinit var delegate: NewConversationDelegate - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentNewConversationHomeBinding.inflate(inflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() } - binding.createPrivateChatButton.setOnClickListener { delegate.onNewMessageSelected() } - binding.createClosedGroupButton.setOnClickListener { delegate.onCreateGroupSelected() } - binding.joinCommunityButton.setOnClickListener { delegate.onJoinCommunitySelected() } - val adapter = ContactListAdapter(requireContext(), GlideApp.with(requireContext())) { - delegate.onContactSelected(it.address.serialize()) - } - val unknownSectionTitle = getString(R.string.new_conversation_unknown_contacts_section_title) - val recipients = viewModel.recipients.value?.filter { !it.isGroupRecipient && it.address.serialize() != textSecurePreferences.getLocalNumber()!! } ?: emptyList() - val contactGroups = recipients.map { - val sessionId = it.address.serialize() - val contact = DatabaseComponent.get(requireContext()).sessionContactDatabase().getContactWithSessionID(sessionId) - val displayName = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionId - ContactListItem.Contact(it, displayName) - }.sortedBy { it.displayName } - .groupBy { if (PublicKeyValidation.isValid(it.displayName)) unknownSectionTitle else it.displayName.firstOrNull()?.uppercase() ?: unknownSectionTitle } - .toMutableMap() - contactGroups.remove(unknownSectionTitle)?.let { contactGroups.put(unknownSectionTitle, it) } - adapter.items = contactGroups.flatMap { entry -> listOf(ContactListItem.Header(entry.key)) + entry.value } - binding.contactsRecyclerView.adapter = adapter - val divider = ContextCompat.getDrawable(requireActivity(), R.drawable.conversation_menu_divider)!!.let { - DividerItemDecoration(requireActivity(), RecyclerView.VERTICAL).apply { - setDrawable(it) - } - } - binding.contactsRecyclerView.addItemDecoration(divider) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeViewModel.kt deleted file mode 100644 index 47fc50598c1..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeViewModel.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.thoughtcrime.securesms.conversation.start - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.database.ThreadDatabase -import javax.inject.Inject - -@HiltViewModel -class NewConversationHomeViewModel @Inject constructor(private val threadDb: ThreadDatabase): ViewModel() { - - private val _recipients = MutableLiveData>() - val recipients: LiveData> = _recipients - - init { - viewModelScope.launch { - threadDb.approvedConversationList.use { openCursor -> - val reader = threadDb.readerFor(openCursor) - val threads = mutableListOf() - while (true) { - threads += reader.next?.recipient ?: break - } - withContext(Dispatchers.Main) { - _recipients.value = threads - } - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt new file mode 100644 index 00000000000..fcf13c9cf11 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt @@ -0,0 +1,114 @@ +package org.thoughtcrime.securesms.conversation.start.home + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import network.loki.messenger.R +import org.thoughtcrime.securesms.conversation.start.NullStartConversationDelegate +import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate +import org.thoughtcrime.securesms.ui.Divider +import org.thoughtcrime.securesms.ui.ItemButton +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.LocalColors +import org.thoughtcrime.securesms.ui.components.AppBar +import org.thoughtcrime.securesms.ui.components.QrImage +import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.theme.LocalType + +@Composable +internal fun StartConversationScreen( + accountId: String, + delegate: StartConversationDelegate +) { + Column(modifier = Modifier.background( + LocalColors.current.backgroundSecondary, + shape = MaterialTheme.shapes.small + )) { + AppBar(stringResource(R.string.dialog_start_conversation_title), onClose = delegate::onDialogClosePressed) + Surface( + modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()), + color = LocalColors.current.backgroundSecondary + ) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + ItemButton( + textId = R.string.messageNew, + icon = R.drawable.ic_message, + modifier = Modifier.contentDescription(R.string.AccessibilityId_new_direct_message), + onClick = delegate::onNewMessageSelected) + Divider(startIndent = LocalDimensions.current.dividerIndent) + ItemButton( + textId = R.string.activity_create_group_title, + icon = R.drawable.ic_group, + modifier = Modifier.contentDescription(R.string.AccessibilityId_create_group), + onClick = delegate::onCreateGroupSelected + ) + Divider(startIndent = LocalDimensions.current.dividerIndent) + ItemButton( + textId = R.string.dialog_join_community_title, + icon = R.drawable.ic_globe, + modifier = Modifier.contentDescription(R.string.AccessibilityId_join_community), + onClick = delegate::onJoinCommunitySelected + ) + Divider(startIndent = LocalDimensions.current.dividerIndent) + ItemButton( + textId = R.string.activity_settings_invite_button_title, + icon = R.drawable.ic_invite_friend, + Modifier.contentDescription(R.string.AccessibilityId_invite_friend_button), + onClick = delegate::onInviteFriend + ) + Column( + modifier = Modifier + .padding(horizontal = LocalDimensions.current.spacing) + .padding(top = LocalDimensions.current.spacing) + .padding(bottom = LocalDimensions.current.spacing) + ) { + Text(stringResource(R.string.accountIdYours), style = LocalType.current.xl) + Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing)) + Text( + text = stringResource(R.string.qrYoursDescription), + color = LocalColors.current.textSecondary, + style = LocalType.current.small + ) + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + QrImage( + string = accountId, + Modifier.contentDescription(R.string.AccessibilityId_qr_code), + icon = R.drawable.session + ) + } + } + } + } +} + +@Preview +@Composable +private fun PreviewStartConversationScreen( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + StartConversationScreen( + accountId = "059287129387123", + NullStartConversationDelegate + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversationFragment.kt new file mode 100644 index 00000000000..1934c5e5bb9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversationFragment.kt @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.conversation.start.home + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.fragment.app.Fragment +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.MutableStateFlow +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate +import org.thoughtcrime.securesms.conversation.start.NullStartConversationDelegate +import org.thoughtcrime.securesms.ui.createThemedComposeView +import javax.inject.Inject + +@AndroidEntryPoint +class StartConversationHomeFragment : Fragment() { + + @Inject + lateinit var textSecurePreferences: TextSecurePreferences + + var delegate = MutableStateFlow(NullStartConversationDelegate) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = createThemedComposeView { + StartConversationScreen( + accountId = TextSecurePreferences.getLocalNumber(requireContext())!!, + delegate = delegate.collectAsState().value + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt new file mode 100644 index 00000000000..54abf663037 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.conversation.start.invitefriend + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.components.AppBar +import org.thoughtcrime.securesms.ui.components.SlimOutlineButton +import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton +import org.thoughtcrime.securesms.ui.components.border +import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.theme.LocalType + +@Composable +internal fun InviteFriend( + accountId: String, + onBack: () -> Unit = {}, + onClose: () -> Unit = {}, + copyPublicKey: () -> Unit = {}, + sendInvitation: () -> Unit = {}, +) { + Column(modifier = Modifier.background( + LocalColors.current.backgroundSecondary, + shape = MaterialTheme.shapes.small + )) { + AppBar(stringResource(R.string.invite_a_friend), onBack = onBack, onClose = onClose) + Column( + modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing) + .padding(top = LocalDimensions.current.spacing), + ) { + Text( + accountId, + modifier = Modifier + .contentDescription(R.string.AccessibilityId_account_id) + .fillMaxWidth() + .border() + .padding(LocalDimensions.current.spacing), + textAlign = TextAlign.Center, + style = LocalType.current.base + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + Text( + stringResource(R.string.invite_your_friend_to_chat_with_you_on_session_by_sharing_your_account_id_with_them), + textAlign = TextAlign.Center, + style = LocalType.current.small, + color = LocalColors.current.textSecondary, + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing) + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + Row(horizontalArrangement = spacedBy(LocalDimensions.current.smallSpacing)) { + SlimOutlineButton( + stringResource(R.string.share), + modifier = Modifier + .weight(1f) + .contentDescription("Share button"), + onClick = sendInvitation + ) + + SlimOutlineCopyButton( + modifier = Modifier.weight(1f), + onClick = copyPublicKey + ) + } + } + } +} + +@Preview +@Composable +private fun PreviewInviteFriend() { + PreviewTheme { + InviteFriend("050000000") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriendFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriendFragment.kt new file mode 100644 index 00000000000..4239a7a0675 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriendFragment.kt @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.conversation.start.invitefriend + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.LocalContext +import androidx.fragment.app.Fragment +import dagger.hilt.android.AndroidEntryPoint +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate +import org.thoughtcrime.securesms.preferences.copyPublicKey +import org.thoughtcrime.securesms.preferences.sendInvitationToUseSession +import org.thoughtcrime.securesms.ui.createThemedComposeView + +@AndroidEntryPoint +class InviteFriendFragment : Fragment() { + lateinit var delegate: StartConversationDelegate + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View = createThemedComposeView { + InviteFriend( + TextSecurePreferences.getLocalNumber(LocalContext.current)!!, + onBack = { delegate.onDialogBackPressed() }, + onClose = { delegate.onDialogClosePressed() }, + copyPublicKey = requireContext()::copyPublicKey, + sendInvitation = requireContext()::sendInvitationToUseSession, + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/Callbacks.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/Callbacks.kt new file mode 100644 index 00000000000..02d39b13272 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/Callbacks.kt @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.conversation.start.newmessage + +internal interface Callbacks { + fun onChange(value: String) {} + fun onContinue() {} + fun onScanQrCode(value: String) {} +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt new file mode 100644 index 00000000000..f0a6e21b4c3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt @@ -0,0 +1,211 @@ +package org.thoughtcrime.securesms.conversation.start.newmessage + +import android.graphics.Rect +import android.os.Build +import android.view.ViewTreeObserver +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import network.loki.messenger.R +import org.thoughtcrime.securesms.conversation.start.StartConversationFragment.Companion.PEEK_RATIO +import org.thoughtcrime.securesms.ui.LoadingArcOr +import org.thoughtcrime.securesms.ui.components.AppBar +import org.thoughtcrime.securesms.ui.components.BorderlessButtonWithIcon +import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode +import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton +import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField +import org.thoughtcrime.securesms.ui.components.SessionTabRow +import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import kotlin.math.max + +private val TITLES = listOf(R.string.enter_account_id, R.string.qrScan) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun NewMessage( + state: State, + qrErrors: Flow = emptyFlow(), + callbacks: Callbacks = object: Callbacks {}, + onClose: () -> Unit = {}, + onBack: () -> Unit = {}, + onHelp: () -> Unit = {}, +) { + val pagerState = rememberPagerState { TITLES.size } + + Column(modifier = Modifier.background( + LocalColors.current.backgroundSecondary, + shape = MaterialTheme.shapes.small + )) { + AppBar(stringResource(R.string.messageNew), onClose = onClose, onBack = onBack) + SessionTabRow(pagerState, TITLES) + HorizontalPager(pagerState) { + when (TITLES[it]) { + R.string.enter_account_id -> EnterAccountId(state, callbacks, onHelp) + R.string.qrScan -> MaybeScanQrCode(qrErrors, onScan = callbacks::onScanQrCode) + } + } + } +} + +@Composable +private fun EnterAccountId( + state: State, + callbacks: Callbacks, + onHelp: () -> Unit = {} +) { + // the scaffold is required to provide the contentPadding. That contentPadding is needed + // to properly handle the ime padding. + Scaffold() { contentPadding -> + // we need this extra surface to handle nested scrolling properly, + // because this scrollable component is inside a bottomSheet dialog which is itself scrollable + Surface( + modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()), + color = LocalColors.current.backgroundSecondary + ) { + + var accountModifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + + // There is a known issue with the ime padding on android versions below 30 + /// So on these older versions we need to resort to some manual padding based on the visible height + // when the keyboard is up + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + val keyboardHeight by keyboardHeight() + accountModifier = accountModifier.padding(bottom = keyboardHeight) + } else { + accountModifier = accountModifier + .consumeWindowInsets(contentPadding) + .imePadding() + } + + Column( + modifier = accountModifier + ) { + Column( + modifier = Modifier.padding(vertical = LocalDimensions.current.spacing), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SessionOutlinedTextField( + text = state.newMessageIdOrOns, + modifier = Modifier + .padding(horizontal = LocalDimensions.current.spacing), + contentDescription = "Session id input box", + placeholder = stringResource(R.string.accountIdOrOnsEnter), + onChange = callbacks::onChange, + onContinue = callbacks::onContinue, + error = state.error?.string(), + isTextErrorColor = state.isTextErrorColor + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) + + BorderlessButtonWithIcon( + text = stringResource(R.string.messageNewDescription), + modifier = Modifier + .contentDescription(R.string.AccessibilityId_help_desk_link) + .padding(horizontal = LocalDimensions.current.mediumSpacing) + .fillMaxWidth(), + style = LocalType.current.small, + color = LocalColors.current.textSecondary, + iconRes = R.drawable.ic_circle_question_mark, + onClick = onHelp + ) + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + Spacer(Modifier.weight(2f)) + + PrimaryOutlineButton( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(horizontal = LocalDimensions.current.xlargeSpacing) + .padding(bottom = LocalDimensions.current.smallSpacing) + .fillMaxWidth() + .contentDescription(R.string.next), + enabled = state.isNextButtonEnabled, + onClick = callbacks::onContinue + ) { + LoadingArcOr(state.loading) { + Text(stringResource(R.string.next)) + } + } + } + } + } +} + +@Composable +fun keyboardHeight(): MutableState { + val view = LocalView.current + var keyboardHeight = remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + + DisposableEffect(view) { + val listener = ViewTreeObserver.OnGlobalLayoutListener { + val rect = Rect() + view.getWindowVisibleDisplayFrame(rect) + val screenHeight = view.rootView.height * PEEK_RATIO + val keypadHeightPx = max( screenHeight - rect.bottom, 0f) + + keyboardHeight.value = with(density) { keypadHeightPx.toDp() } + } + + view.viewTreeObserver.addOnGlobalLayoutListener(listener) + onDispose { + view.viewTreeObserver.removeOnGlobalLayoutListener(listener) + } + } + + return keyboardHeight +} + +@Preview +@Composable +private fun PreviewNewMessage( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + NewMessage(State("z")) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageFragment.kt new file mode 100644 index 00000000000..8c383fe1a93 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageFragment.kt @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.conversation.start.newmessage + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.openUrl +import org.thoughtcrime.securesms.ui.createThemedComposeView + +class NewMessageFragment : Fragment() { + private val viewModel: NewMessageViewModel by viewModels() + + lateinit var delegate: StartConversationDelegate + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + lifecycleScope.launch { + viewModel.success.collect { + createPrivateChat(it.publicKey) + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View = createThemedComposeView { + val uiState by viewModel.state.collectAsState(State()) + NewMessage( + uiState, + viewModel.qrErrors, + viewModel, + onClose = { delegate.onDialogClosePressed() }, + onBack = { delegate.onDialogBackPressed() }, + onHelp = { requireContext().openUrl("https://sessionapp.zendesk.com/hc/en-us/articles/4439132747033-How-do-Account-ID-usernames-work") } + ) + } + + private fun createPrivateChat(hexEncodedPublicKey: String) { + val recipient = Recipient.from(requireContext(), Address.fromSerialized(hexEncodedPublicKey), false) + Intent(requireContext(), ConversationActivityV2::class.java).apply { + putExtra(ConversationActivityV2.ADDRESS, recipient.address) + setDataAndType(requireActivity().intent.data, requireActivity().intent.type) + putExtra(ConversationActivityV2.THREAD_ID, DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient)) + }.let(requireContext()::startActivity) + delegate.onDialogClosePressed() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt new file mode 100644 index 00000000000..6ed8a08233d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.conversation.start.newmessage + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.snode.SnodeAPI +import org.session.libsignal.utilities.PublicKeyValidation +import org.session.libsignal.utilities.timeout +import org.thoughtcrime.securesms.ui.GetString +import java.util.concurrent.TimeoutException +import javax.inject.Inject + +@HiltViewModel +internal class NewMessageViewModel @Inject constructor( + private val application: Application +): AndroidViewModel(application), Callbacks { + + private val _state = MutableStateFlow(State()) + val state = _state.asStateFlow() + + private val _success = MutableSharedFlow() + val success get() = _success.asSharedFlow() + + private val _qrErrors = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val qrErrors = _qrErrors.asSharedFlow() + + private var loadOnsJob: Job? = null + + override fun onChange(value: String) { + loadOnsJob?.cancel() + loadOnsJob = null + + _state.update { it.copy(newMessageIdOrOns = value, isTextErrorColor = false, loading = false) } + } + + override fun onContinue() { + val idOrONS = state.value.newMessageIdOrOns.trim() + + if (PublicKeyValidation.isValid(idOrONS, isPrefixRequired = false)) { + onUnvalidatedPublicKey(publicKey = idOrONS) + } else { + resolveONS(ons = idOrONS) + } + } + + override fun onScanQrCode(value: String) { + if (PublicKeyValidation.isValid(value, isPrefixRequired = false) && PublicKeyValidation.hasValidPrefix(value)) { + onPublicKey(value) + } else { + _qrErrors.tryEmit(application.getString(R.string.this_qr_code_does_not_contain_an_account_id)) + } + } + + private fun resolveONS(ons: String) { + if (loadOnsJob?.isActive == true) return + + // This could be an ONS name + _state.update { it.copy(isTextErrorColor = false, error = null, loading = true) } + + loadOnsJob = viewModelScope.launch(Dispatchers.IO) { + try { + val publicKey = SnodeAPI.getAccountID(ons).timeout(30_000).get() + if (isActive) onPublicKey(publicKey) + } catch (e: Exception) { + if (isActive) onError(e) + } + } + } + + private fun onError(e: Exception) { + _state.update { it.copy(loading = false, isTextErrorColor = true, error = GetString(e) { it.toMessage() }) } + } + + private fun onPublicKey(publicKey: String) { + _state.update { it.copy(loading = false) } + viewModelScope.launch { _success.emit(Success(publicKey)) } + } + + private fun onUnvalidatedPublicKey(publicKey: String) { + if (PublicKeyValidation.hasValidPrefix(publicKey)) { + onPublicKey(publicKey) + } else { + _state.update { it.copy(isTextErrorColor = true, error = GetString(R.string.accountIdErrorInvalid), loading = false) } + } + } + + private fun Exception.toMessage() = when (this) { + is SnodeAPI.Error.Generic -> application.getString(R.string.onsErrorNotRecognized) + is TimeoutException -> application.getString(R.string.onsErrorUnableToSearch) + else -> application.getString(R.string.fragment_enter_public_key_error_message) + } +} + +internal data class State( + val newMessageIdOrOns: String = "", + val isTextErrorColor: Boolean = false, + val error: GetString? = null, + val loading: Boolean = false +) { + val isNextButtonEnabled: Boolean get() = newMessageIdOrOns.isNotBlank() +} + +internal data class Success(val publicKey: String) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index e7f413b6fc1..1e6eb2489ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -37,7 +37,6 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.core.text.set import androidx.core.text.toSpannable -import androidx.core.view.drawToBitmap import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment @@ -66,8 +65,6 @@ import network.loki.messenger.libsession_util.util.ExpiryMode import nl.komponents.kovenant.ui.successUi import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact -import org.session.libsession.messaging.jobs.AttachmentDownloadJob -import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.control.DataExtractionNotification @@ -80,7 +77,8 @@ import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel -import org.session.libsession.messaging.utilities.SessionId +import org.session.libsession.messaging.utilities.AccountId +import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized @@ -113,10 +111,12 @@ import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companio import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_RESEND import org.thoughtcrime.securesms.conversation.v2.dialogs.BlockedDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog -import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate +import org.thoughtcrime.securesms.conversation.v2.input_bar.VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS +import org.thoughtcrime.securesms.conversation.v2.input_bar.VoiceRecorderConstants.SHOW_HIDE_VOICE_UI_DURATION_MS +import org.thoughtcrime.securesms.conversation.v2.input_bar.VoiceRecorderState import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidateAdapter import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback @@ -169,8 +169,9 @@ import org.thoughtcrime.securesms.util.ActivityDispatcher import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.MediaUtil +import org.thoughtcrime.securesms.util.NetworkUtils import org.thoughtcrime.securesms.util.SaveAttachmentTask -import org.thoughtcrime.securesms.util.SimpleTextWatcher +import org.thoughtcrime.securesms.util.drawToBitmap import org.thoughtcrime.securesms.util.isScrolledToBottom import org.thoughtcrime.securesms.util.isScrolledToWithin30dpOfBottom import org.thoughtcrime.securesms.util.push @@ -201,7 +202,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback, ConversationMenuHelper.ConversationMenuListener { - private var binding: ActivityConversationV2Binding? = null + private lateinit var binding: ActivityConversationV2Binding @Inject lateinit var textSecurePreferences: TextSecurePreferences @Inject lateinit var threadDb: ThreadDatabase @@ -236,12 +237,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe intent.getParcelableExtra
(ADDRESS)?.let { it -> threadId = threadDb.getThreadIdIfExistsFor(it.serialize()) if (threadId == -1L) { - val sessionId = SessionId(it.serialize()) + val accountId = AccountId(it.serialize()) val openGroup = lokiThreadDb.getOpenGroupChat(intent.getLongExtra(FROM_GROUP_THREAD_ID, -1)) - val address = if (sessionId.prefix == IdPrefix.BLINDED && openGroup != null) { - storage.getOrCreateBlindedIdMapping(sessionId.hexString, openGroup.server, openGroup.publicKey).sessionId?.let { + val address = if (accountId.prefix == IdPrefix.BLINDED && openGroup != null) { + storage.getOrCreateBlindedIdMapping(accountId.hexString, openGroup.server, openGroup.publicKey).accountId?.let { fromSerialized(it) - } ?: GroupUtil.getEncodedOpenGroupInboxID(openGroup, sessionId) + } ?: GroupUtil.getEncodedOpenGroupInboxID(openGroup, accountId) } else { it } @@ -281,13 +282,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private var emojiPickerVisible = false private val isScrolledToBottom: Boolean - get() = binding?.conversationRecyclerView?.isScrolledToBottom ?: true + get() = binding.conversationRecyclerView.isScrolledToBottom private val isScrolledToWithin30dpOfBottom: Boolean - get() = binding?.conversationRecyclerView?.isScrolledToWithin30dpOfBottom ?: true + get() = binding.conversationRecyclerView.isScrolledToWithin30dpOfBottom private val layoutManager: LinearLayoutManager? - get() { return binding?.conversationRecyclerView?.layoutManager as LinearLayoutManager? } + get() { return binding.conversationRecyclerView.layoutManager as LinearLayoutManager? } private val seed by lazy { var hexEncodedSeed = IdentityKeyUtil.retrieve(this, IdentityKeyUtil.LOKI_SEED) @@ -299,7 +300,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val loadFileContents: (String) -> String = { fileName -> MnemonicUtilities.loadFileContents(appContext, fileName) } - MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english) + MnemonicCodec(loadFileContents).encode(hexEncodedSeed, MnemonicCodec.Language.Configuration.english) } // There is a bug when initially joining a community where all messages will immediately be marked @@ -341,7 +342,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // Register an AdapterDataObserver to scroll us to the bottom of the RecyclerView for if // we're already near the the bottom and the data changes. - adapter.registerAdapterDataObserver(ConversationAdapterDataObserver(binding?.conversationRecyclerView!!, adapter)) + adapter.registerAdapterDataObserver(ConversationAdapterDataObserver(binding.conversationRecyclerView, adapter)) adapter } @@ -364,6 +365,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private var currentLastVisibleRecyclerViewIndex: Int = RecyclerView.NO_POSITION private var recyclerScrollState: Int = RecyclerView.SCROLL_STATE_IDLE + // Lower limit for the length of voice messages - any lower and we inform the user rather than sending + private val MINIMUM_VOICE_MESSAGE_DURATION_MS = 1000L + // region Settings companion object { // Extras @@ -385,7 +389,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) binding = ActivityConversationV2Binding.inflate(layoutInflater) - setContentView(binding!!.root) + setContentView(binding.root) // messageIdToScroll messageToScrollTimestamp.set(intent.getLongExtra(SCROLL_MESSAGE_ID, -1)) @@ -403,12 +407,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe restoreDraftIfNeeded() setUpUiStateObserver() - binding!!.scrollToBottomButton.setOnClickListener { - val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener + binding.scrollToBottomButton.setOnClickListener { + val layoutManager = binding.conversationRecyclerView.layoutManager as LinearLayoutManager val targetPosition = if (reverseMessageList) 0 else adapter.itemCount if (layoutManager.isSmoothScrolling) { - binding?.conversationRecyclerView?.scrollToPosition(targetPosition) + binding.conversationRecyclerView.scrollToPosition(targetPosition) } else { // It looks like 'smoothScrollToPosition' will actually load all intermediate items in // order to do the scroll, this can be very slow if there are a lot of messages so @@ -418,11 +422,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // val position = if (reverseMessageList) layoutManager.findFirstVisibleItemPosition() else layoutManager.findLastVisibleItemPosition() // val targetBuffer = if (reverseMessageList) 10 else Math.max(0, (adapter.itemCount - 1) - 10) // if (position > targetBuffer) { -// binding?.conversationRecyclerView?.scrollToPosition(targetBuffer) +// binding.conversationRecyclerView?.scrollToPosition(targetBuffer) // } - binding?.conversationRecyclerView?.post { - binding?.conversationRecyclerView?.smoothScrollToPosition(targetPosition) + binding.conversationRecyclerView.post { + binding.conversationRecyclerView.smoothScrollToPosition(targetPosition) } } } @@ -430,7 +434,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe updateUnreadCountIndicator() updatePlaceholder() setUpBlockedBanner() - binding!!.searchBottomBar.setEventListener(this) + binding.searchBottomBar.setEventListener(this) updateSendAfterApprovalText() setUpMessageRequestsBar() @@ -461,7 +465,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe setUpOutdatedClientBanner() if (author != null && messageTimestamp >= 0 && targetPosition >= 0) { - binding?.conversationRecyclerView?.scrollToPosition(targetPosition) + binding.conversationRecyclerView.scrollToPosition(targetPosition) } else { scrollToFirstUnreadMessageIfNeeded(true) @@ -523,7 +527,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe screenshotObserver ) viewModel.run { - binding?.toolbarContent?.update(recipient ?: return, openGroup, expirationConfiguration) + binding.toolbarContent?.update(recipient ?: return, openGroup, expirationConfiguration) } } @@ -591,12 +595,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // called from onCreate private fun setUpRecyclerView() { - binding!!.conversationRecyclerView.adapter = adapter + binding.conversationRecyclerView.adapter = adapter val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, reverseMessageList) - binding!!.conversationRecyclerView.layoutManager = layoutManager + binding.conversationRecyclerView.layoutManager = layoutManager // Workaround for the fact that CursorRecyclerViewAdapter doesn't auto-update automatically (even though it says it will) LoaderManager.getInstance(this).restartLoader(0, null, this) - binding!!.conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + binding.conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { // The unreadCount check is to prevent us scrolling to the bottom when we first enter a conversation @@ -624,7 +628,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // If the current last visible message index is less than the previous one (i.e. we've // lost visibility of one or more messages due to showing the IME keyboard) AND we're // at the bottom of the message feed.. - val atBottomAndTrueLastNoLongerVisible = currentLastVisibleRecyclerViewIndex!! <= previousLastVisibleRecyclerViewIndex!! && !binding?.scrollToBottomButton?.isVisible!! + val atBottomAndTrueLastNoLongerVisible = currentLastVisibleRecyclerViewIndex <= previousLastVisibleRecyclerViewIndex && + !binding.scrollToBottomButton.isVisible // ..OR we're at the last message or have received a new message.. val atLastOrReceivedNewMessage = currentLastVisibleRecyclerViewIndex == (adapter.itemCount - 1) @@ -632,8 +637,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // ..then scroll the recycler view to the last message on resize. Note: We cannot just call // scroll/smoothScroll - we have to `post` it or nothing happens! if (atBottomAndTrueLastNoLongerVisible || atLastOrReceivedNewMessage) { - binding?.conversationRecyclerView?.post { - binding?.conversationRecyclerView?.smoothScrollToPosition(adapter.itemCount) + binding.conversationRecyclerView.post { + binding.conversationRecyclerView.smoothScrollToPosition(adapter.itemCount) } } @@ -643,14 +648,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // called from onCreate private fun setUpToolBar() { - val binding = binding ?: return setSupportActionBar(binding.toolbar) val actionBar = supportActionBar ?: return val recipient = viewModel.recipient ?: return actionBar.title = "" actionBar.setDisplayHomeAsUpEnabled(true) actionBar.setHomeButtonEnabled(true) - binding!!.toolbarContent.bind( + binding.toolbarContent.bind( this, viewModel.threadId, recipient, @@ -662,7 +666,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // called from onCreate private fun setUpInputBar() { - val binding = binding ?: return binding.inputBar.isGone = viewModel.hidesInputBar() binding.inputBar.delegate = this binding.inputBarRecordingView.delegate = this @@ -708,10 +711,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } else if (intent.hasExtra(Intent.EXTRA_TEXT)) { val dataTextExtra = intent.getCharSequenceExtra(Intent.EXTRA_TEXT) ?: "" - binding!!.inputBar.text = dataTextExtra.toString() + binding.inputBar.text = dataTextExtra.toString() } else { viewModel.getDraft()?.let { text -> - binding!!.inputBar.text = text + binding.inputBar.text = text } } } @@ -722,17 +725,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val recipients = if (state != null) state.typists else listOf() // FIXME: Also checking isScrolledToBottom is a quick fix for an issue where the // typing indicator overlays the recycler view when scrolled up - val viewContainer = binding?.typingIndicatorViewContainer ?: return@observe + val viewContainer = binding.typingIndicatorViewContainer viewContainer.isVisible = recipients.isNotEmpty() && isScrolledToBottom viewContainer.setTypists(recipients) } if (textSecurePreferences.isTypingIndicatorsEnabled()) { - binding!!.inputBar.addTextChangedListener(object : SimpleTextWatcher() { - - override fun onTextChanged(text: String?) { - ApplicationContext.getInstance(this@ConversationActivityV2).typingStatusSender.onTypingStarted(viewModel.threadId) - } - }) + binding.inputBar.addTextChangedListener { + ApplicationContext.getInstance(this).typingStatusSender.onTypingStarted(viewModel.threadId) + } } } @@ -747,7 +747,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun getLatestOpenGroupInfoIfNeeded() { val openGroup = viewModel.openGroup ?: return OpenGroupApi.getMemberCount(openGroup.room, openGroup.server) successUi { - binding?.toolbarContent?.updateSubtitle(viewModel.recipient!!, openGroup, viewModel.expirationConfiguration) + binding.toolbarContent.updateSubtitle(viewModel.recipient!!, openGroup, viewModel.expirationConfiguration) maybeUpdateToolbar(viewModel.recipient!!) } } @@ -755,11 +755,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // called from onCreate private fun setUpBlockedBanner() { val recipient = viewModel.recipient?.takeUnless { it.isGroupRecipient } ?: return - val sessionID = recipient.address.toString() - val name = sessionContactDb.getContactWithSessionID(sessionID)?.displayName(Contact.ContactContext.REGULAR) ?: sessionID - binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name) - binding?.blockedBanner?.isVisible = recipient.isBlocked - binding?.blockedBanner?.setOnClickListener { viewModel.unblock() } + val accountID = recipient.address.toString() + val name = sessionContactDb.getContactWithAccountID(accountID)?.displayName(Contact.ContactContext.REGULAR) ?: accountID + binding.blockedBannerTextView.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name) + binding.blockedBanner.isVisible = recipient.isBlocked + binding.blockedBanner.setOnClickListener { viewModel.unblock() } } private fun setUpOutdatedClientBanner() { @@ -768,9 +768,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val shouldShowLegacy = ExpirationConfiguration.isNewConfigEnabled && legacyRecipient != null - binding?.outdatedBanner?.isVisible = shouldShowLegacy + binding.outdatedBanner.isVisible = shouldShowLegacy if (shouldShowLegacy) { - binding?.outdatedBannerTextView?.text = + binding.outdatedBannerTextView.text = resources.getString(R.string.activity_conversation_outdated_client_banner_text, legacyRecipient!!.name) } } @@ -783,13 +783,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (previewState == null) return@observe when { previewState.isLoading -> { - binding?.inputBar?.draftLinkPreview() + binding.inputBar.draftLinkPreview() } previewState.linkPreview.isPresent -> { - binding?.inputBar?.updateLinkPreviewDraft(glide, previewState.linkPreview.get()) + binding.inputBar.updateLinkPreviewDraft(glide, previewState.linkPreview.get()) } else -> { - binding?.inputBar?.cancelLinkPreviewDraft() + binding.inputBar.cancelLinkPreviewDraft() } } } @@ -803,7 +803,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe viewModel.messageShown(it.id) } if (uiState.isMessageRequestAccepted == true) { - binding?.messageRequestBar?.visibility = View.GONE + binding.messageRequestBar.visibility = View.GONE } if (!uiState.conversationExists && !isFinishing) { // Conversation should be deleted now, just go back @@ -827,12 +827,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (lastSeenItemPosition <= 3) { return lastSeenItemPosition } - binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition) + binding.conversationRecyclerView.scrollToPosition(lastSeenItemPosition) return lastSeenItemPosition } private fun highlightViewAtPosition(position: Int) { - binding?.conversationRecyclerView?.post { + binding.conversationRecyclerView.post { (layoutManager?.findViewByPosition(position) as? VisibleMessageView)?.playHighlight() } } @@ -852,11 +852,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun onDestroy() { - viewModel.saveDraft(binding?.inputBar?.text?.trim() ?: "") + viewModel.saveDraft(binding.inputBar.text.trim()) cancelVoiceMessage() tearDownRecipientObserver() super.onDestroy() - binding = null } // endregion @@ -867,7 +866,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe runOnUiThread { val threadRecipient = viewModel.recipient ?: return@runOnUiThread if (threadRecipient.isContactRecipient) { - binding?.blockedBanner?.isVisible = threadRecipient.isBlocked + binding.blockedBanner.isVisible = threadRecipient.isBlocked } setUpMessageRequestsBar() invalidateOptionsMenu() @@ -879,29 +878,29 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun maybeUpdateToolbar(recipient: Recipient) { - binding?.toolbarContent?.update(recipient, viewModel.openGroup, viewModel.expirationConfiguration) + binding.toolbarContent.update(recipient, viewModel.openGroup, viewModel.expirationConfiguration) } private fun updateSendAfterApprovalText() { - binding?.textSendAfterApproval?.isVisible = viewModel.showSendAfterApprovalText + binding.textSendAfterApproval.isVisible = viewModel.showSendAfterApprovalText } private fun showOrHideInputIfNeeded() { - binding?.inputBar?.showInput = viewModel.recipient?.takeIf { it.isClosedGroupRecipient } + binding.inputBar.showInput = viewModel.recipient?.takeIf { it.isClosedGroupRecipient } ?.run { address.toGroupString().let(groupDb::getGroup).orNull()?.isActive == true } ?: true } private fun setUpMessageRequestsBar() { - binding?.inputBar?.showMediaControls = !isOutgoingMessageRequestThread() - binding?.messageRequestBar?.isVisible = isIncomingMessageRequestThread() - binding?.acceptMessageRequestButton?.setOnClickListener { + binding.inputBar.showMediaControls = !isOutgoingMessageRequestThread() + binding.messageRequestBar.isVisible = isIncomingMessageRequestThread() + binding.acceptMessageRequestButton.setOnClickListener { acceptMessageRequest() } - binding?.messageRequestBlock?.setOnClickListener { + binding.messageRequestBlock.setOnClickListener { block(deleteThread = true) } - binding?.declineMessageRequestButton?.setOnClickListener { + binding.declineMessageRequestButton.setOnClickListener { viewModel.declineMessageRequest() lifecycleScope.launch(Dispatchers.IO) { ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2) @@ -911,7 +910,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun acceptMessageRequest() { - binding?.messageRequestBar?.isVisible = false + binding.messageRequestBar.isVisible = false viewModel.acceptMessageRequest() lifecycleScope.launch(Dispatchers.IO) { @@ -930,7 +929,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } ?: false override fun inputBarEditTextContentChanged(newContent: CharSequence) { - val inputBarText = binding?.inputBar?.text ?: return // TODO check if we should be referencing newContent here instead + val inputBarText = binding.inputBar.text // TODO check if we should be referencing newContent here instead if (textSecurePreferences.isLinkPreviewsEnabled()) { linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0) } @@ -948,10 +947,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun toggleAttachmentOptions() { val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f val allButtonContainers = listOfNotNull( - binding?.cameraButtonContainer, - binding?.libraryButtonContainer, - binding?.documentButtonContainer, - binding?.gifButtonContainer + binding.cameraButtonContainer, + binding.libraryButtonContainer, + binding.documentButtonContainer, + binding.gifButtonContainer ) val isReversed = isShowingAttachmentOptions // Run the animation in reverse val count = allButtonContainers.size @@ -971,20 +970,20 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun showVoiceMessageUI() { - binding?.inputBarRecordingView?.show(lifecycleScope) - binding?.inputBar?.alpha = 0.0f + binding.inputBarRecordingView.show(lifecycleScope) + binding.inputBar.alpha = 0.0f val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) - animation.duration = 250L + animation.duration = SHOW_HIDE_VOICE_UI_DURATION_MS animation.addUpdateListener { animator -> - binding?.inputBar?.alpha = animator.animatedValue as Float + binding.inputBar.alpha = animator.animatedValue as Float } animation.start() } private fun expandVoiceMessageLockView() { - val lockView = binding?.inputBarRecordingView?.lockView ?: return + val lockView = binding.inputBarRecordingView.lockView val animation = ValueAnimator.ofObject(FloatEvaluator(), lockView.scaleX, 1.10f) - animation.duration = 250L + animation.duration = ANIMATE_LOCK_DURATION_MS animation.addUpdateListener { animator -> lockView.scaleX = animator.animatedValue as Float lockView.scaleY = animator.animatedValue as Float @@ -993,9 +992,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun collapseVoiceMessageLockView() { - val lockView = binding?.inputBarRecordingView?.lockView ?: return + val lockView = binding.inputBarRecordingView.lockView val animation = ValueAnimator.ofObject(FloatEvaluator(), lockView.scaleX, 1.0f) - animation.duration = 250L + animation.duration = ANIMATE_LOCK_DURATION_MS animation.addUpdateListener { animator -> lockView.scaleX = animator.animatedValue as Float lockView.scaleY = animator.animatedValue as Float @@ -1004,24 +1003,24 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun hideVoiceMessageUI() { - val chevronImageView = binding?.inputBarRecordingView?.chevronImageView ?: return - val slideToCancelTextView = binding?.inputBarRecordingView?.slideToCancelTextView ?: return + val chevronImageView = binding.inputBarRecordingView.chevronImageView + val slideToCancelTextView = binding.inputBarRecordingView.slideToCancelTextView listOf( chevronImageView, slideToCancelTextView ).forEach { view -> val animation = ValueAnimator.ofObject(FloatEvaluator(), view.translationX, 0.0f) - animation.duration = 250L + animation.duration = ANIMATE_LOCK_DURATION_MS animation.addUpdateListener { animator -> view.translationX = animator.animatedValue as Float } animation.start() } - binding?.inputBarRecordingView?.hide() + binding.inputBarRecordingView.hide() } override fun handleVoiceMessageUIHidden() { - val inputBar = binding?.inputBar ?: return + val inputBar = binding.inputBar inputBar.alpha = 1.0f val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f) - animation.duration = 250L + animation.duration = SHOW_HIDE_VOICE_UI_DURATION_MS animation.addUpdateListener { animator -> inputBar.alpha = animator.animatedValue as Float } @@ -1029,8 +1028,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun handleRecyclerViewScrolled() { - val binding = binding ?: return - // Note: The typing indicate is whether the other person / other people are typing - it has // nothing to do with the IME keyboard state. val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible @@ -1058,10 +1055,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun updatePlaceholder() { - val recipient = viewModel.recipient - ?: return Log.w("Loki", "recipient was null in placeholder update") + val recipient = viewModel.recipient ?: return Log.w("Loki", "recipient was null in placeholder update") val blindedRecipient = viewModel.blindedRecipient - val binding = binding ?: return val openGroup = viewModel.openGroup val (textResource, insertParam) = when { @@ -1091,11 +1086,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun showScrollToBottomButtonIfApplicable() { - binding?.scrollToBottomButton?.isVisible = !emojiPickerVisible && !isScrolledToBottom && adapter.itemCount > 0 + binding.scrollToBottomButton.isVisible = !emojiPickerVisible && !isScrolledToBottom && adapter.itemCount > 0 } private fun updateUnreadCountIndicator() { - val binding = binding ?: return val formattedUnreadCount = if (unreadCount < 10000) unreadCount.toString() else "9999+" binding.unreadCountTextView.text = formattedUnreadCount val textSize = if (unreadCount < 10000) 12.0f else 9.0f @@ -1124,7 +1118,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe showSessionDialog { title(R.string.RecipientPreferenceActivity_block_this_contact_question) text(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact) - destructiveButton(R.string.RecipientPreferenceActivity_block, R.string.AccessibilityId_block_confirm) { + dangerButton(R.string.RecipientPreferenceActivity_block, R.string.AccessibilityId_block_confirm) { viewModel.block() if (deleteThread) { viewModel.deleteThread() @@ -1135,8 +1129,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - override fun copySessionID(sessionId: String) { - val clip = ClipData.newPlainText("Session ID", sessionId) + override fun copyAccountID(accountId: String) { + val clip = ClipData.newPlainText("Account ID", accountId) val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager manager.setPrimaryClip(clip) Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() @@ -1145,7 +1139,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun copyOpenGroupUrl(thread: Recipient) { if (!thread.isCommunityRecipient) { return } - val threadId = threadDb.getThreadIdIfExistsFor(thread) ?: return + val threadId = threadDb.getThreadIdIfExistsFor(thread) val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return val clip = ClipData.newPlainText("Community URL", openGroup.joinURL) @@ -1167,7 +1161,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe showSessionDialog { title(R.string.ConversationActivity_unblock_this_contact_question) text(R.string.ConversationActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact) - destructiveButton( + dangerButton( R.string.ConversationActivity_unblock, R.string.AccessibilityId_block_confirm ) { viewModel.unblock() } @@ -1204,7 +1198,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun handleSwipeToReply(message: MessageRecord) { if (message.isOpenGroupInvitation) return val recipient = viewModel.recipient ?: return - binding?.inputBar?.draftQuote(recipient, message, glide) + binding.inputBar.draftQuote(recipient, message, glide) } // `position` is the adapter position; not the visual position @@ -1233,9 +1227,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe Log.e("Loki", "Failed to show emoji picker", e) return } - - val binding = binding ?: return - emojiPickerVisible = true ViewUtil.hideKeyboard(this, visibleMessageView) binding.reactionsShade.isVisible = true @@ -1288,36 +1279,48 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun sendEmojiReaction(emoji: String, originalMessage: MessageRecord) { // Create the message - val recipient = viewModel.recipient ?: return + val recipient = viewModel.recipient ?: return Log.w(TAG, "Could not locate recipient when sending emoji reaction") val reactionMessage = VisibleMessage() val emojiTimestamp = SnodeAPI.nowWithOffset reactionMessage.sentTimestamp = emojiTimestamp - val author = textSecurePreferences.getLocalNumber()!! - // Put the message in the database - val reaction = ReactionRecord( - messageId = originalMessage.id, - isMms = originalMessage.isMms, - author = author, - emoji = emoji, - count = 1, - dateSent = emojiTimestamp, - dateReceived = emojiTimestamp - ) - reactionDb.addReaction(MessageId(originalMessage.id, originalMessage.isMms), reaction, false) - val originalAuthor = if (originalMessage.isOutgoing) { - fromSerialized(viewModel.blindedPublicKey ?: textSecurePreferences.getLocalNumber()!!) - } else originalMessage.individualRecipient.address - // Send it - reactionMessage.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, true) - if (recipient.isCommunityRecipient) { - val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return - viewModel.openGroup?.let { - OpenGroupApi.addReaction(it.room, it.server, messageServerId, emoji) - } + val author = textSecurePreferences.getLocalNumber() + + if (author == null) { + Log.w(TAG, "Unable to locate local number when sending emoji reaction - aborting.") + return } else { - MessageSender.send(reactionMessage, recipient.address) + // Put the message in the database + val reaction = ReactionRecord( + messageId = originalMessage.id, + isMms = originalMessage.isMms, + author = author, + emoji = emoji, + count = 1, + dateSent = emojiTimestamp, + dateReceived = emojiTimestamp + ) + reactionDb.addReaction(MessageId(originalMessage.id, originalMessage.isMms), reaction, false) + + val originalAuthor = if (originalMessage.isOutgoing) { + fromSerialized(viewModel.blindedPublicKey ?: textSecurePreferences.getLocalNumber()!!) + } else originalMessage.individualRecipient.address + + // Send it + reactionMessage.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, true) + if (recipient.isCommunityRecipient) { + + val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: + return Log.w(TAG, "Failed to find message server ID when adding emoji reaction") + + viewModel.openGroup?.let { + OpenGroupApi.addReaction(it.room, it.server, messageServerId, emoji) + } + } else { + MessageSender.send(reactionMessage, recipient.address) + } + + LoaderManager.getInstance(this).restartLoader(0, null, this) } - LoaderManager.getInstance(this).restartLoader(0, null, this) } private fun sendEmojiRemoval(emoji: String, originalMessage: MessageRecord) { @@ -1325,23 +1328,32 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val message = VisibleMessage() val emojiTimestamp = SnodeAPI.nowWithOffset message.sentTimestamp = emojiTimestamp - val author = textSecurePreferences.getLocalNumber()!! - reactionDb.deleteReaction(emoji, MessageId(originalMessage.id, originalMessage.isMms), author, false) - - val originalAuthor = if (originalMessage.isOutgoing) { - fromSerialized(viewModel.blindedPublicKey ?: textSecurePreferences.getLocalNumber()!!) - } else originalMessage.individualRecipient.address - - message.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, false) - if (recipient.isCommunityRecipient) { - val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return - viewModel.openGroup?.let { - OpenGroupApi.deleteReaction(it.room, it.server, messageServerId, emoji) - } + val author = textSecurePreferences.getLocalNumber() + + if (author == null) { + Log.w(TAG, "Unable to locate local number when removing emoji reaction - aborting.") + return } else { - MessageSender.send(message, recipient.address) + reactionDb.deleteReaction(emoji, MessageId(originalMessage.id, originalMessage.isMms), author, false) + + val originalAuthor = if (originalMessage.isOutgoing) { + fromSerialized(viewModel.blindedPublicKey ?: textSecurePreferences.getLocalNumber()!!) + } else originalMessage.individualRecipient.address + + message.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, false) + if (recipient.isCommunityRecipient) { + + val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: + return Log.w(TAG, "Failed to find message server ID when removing emoji reaction") + + viewModel.openGroup?.let { + OpenGroupApi.deleteReaction(it.room, it.server, messageServerId, emoji) + } + } else { + MessageSender.send(message, recipient.address) + } + LoaderManager.getInstance(this).restartLoader(0, null, this) } - LoaderManager.getInstance(this).restartLoader(0, null, this) } override fun onCustomReactionSelected(messageRecord: MessageRecord, hasAddedCustomEmoji: Boolean) { @@ -1399,8 +1411,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun onMicrophoneButtonMove(event: MotionEvent) { val rawX = event.rawX - val chevronImageView = binding?.inputBarRecordingView?.chevronImageView ?: return - val slideToCancelTextView = binding?.inputBarRecordingView?.slideToCancelTextView ?: return + val chevronImageView = binding.inputBarRecordingView.chevronImageView + val slideToCancelTextView = binding.inputBarRecordingView.slideToCancelTextView if (rawX < screenWidth / 2) { val translationX = rawX - screenWidth / 2 val sign = -1.0f @@ -1434,16 +1446,54 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun onMicrophoneButtonUp(event: MotionEvent) { val x = event.rawX.roundToInt() val y = event.rawY.roundToInt() - if (isValidLockViewLocation(x, y)) { - binding?.inputBarRecordingView?.lock() - } else { - val recordButtonOverlay = binding?.inputBarRecordingView?.recordButtonOverlay ?: return - val location = IntArray(2) { 0 } - recordButtonOverlay.getLocationOnScreen(location) - val hitRect = Rect(location[0], location[1], location[0] + recordButtonOverlay.width, location[1] + recordButtonOverlay.height) - if (hitRect.contains(x, y)) { - sendVoiceMessage() - } else { + val inputBar = binding.inputBar + + // Lock voice recording on if the button is released over the lock area AND the + // voice recording has currently lasted for at least the time it takes to animate + // the lock area into position. Without this time check we can accidentally lock + // to recording audio on a quick tap as the lock area animates out from the record + // audio message button and the pointer-up event catches it mid-animation. + // + // Further, by limiting this to AnimateLockDurationMS rather than our minimum voice + // message length we get a fast, responsive UI that can lock 'straight away' - BUT + // we then have to artificially bump the voice message duration because if you press + // and slide to lock then release in one quick motion the pointer up event may be + // less than our minimum voice message duration - so we'll bump our recorded duration + // slightly to make sure we don't see the "Tap and hold to record..." toast when we + // finish recording the message. + if (isValidLockViewLocation(x, y) && inputBar.voiceMessageDurationMS >= ANIMATE_LOCK_DURATION_MS) { + binding.inputBarRecordingView.lock() + + // Artificially bump message duration on lock if required + if (inputBar.voiceMessageDurationMS < MINIMUM_VOICE_MESSAGE_DURATION_MS) { + inputBar.voiceMessageDurationMS = MINIMUM_VOICE_MESSAGE_DURATION_MS + } + + // If the user put the record audio button into the lock state then we are still recording audio + binding.inputBar.voiceRecorderState = VoiceRecorderState.Recording + } + else // If the user didn't attempt to lock voice recording on.. + { + // Regardless of where the button up event occurred we're now shutting down the recording (whether we send it or not) + binding.inputBar.voiceRecorderState = VoiceRecorderState.ShuttingDownAfterRecord + + val rba = binding.inputBarRecordingView?.recordButtonOverlay + if (rba != null) { + val location = IntArray(2) { 0 } + rba.getLocationOnScreen(location) + val hitRect = Rect(location[0], location[1], location[0] + rba.width, location[1] + rba.height) + + // If the up event occurred over the record button overlay we send the voice message.. + if (hitRect.contains(x, y)) { + sendVoiceMessage() + } else { + // ..otherwise if they've released off the button we'll cancel sending. + cancelVoiceMessage() + } + } + else + { + // Just to cover all our bases, if for whatever reason the record button overlay was null we'll also cancel recording cancelVoiceMessage() } } @@ -1452,7 +1502,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun isValidLockViewLocation(x: Int, y: Int): Boolean { // We can be anywhere above the lock view and a bit to the side of it (at most `lockViewHitMargin` // to the side) - val binding = binding ?: return false val lockViewLocation = IntArray(2) { 0 } binding.inputBarRecordingView.lockView.getLocationOnScreen(lockViewLocation) val hitRect = Rect(lockViewLocation[0] - lockViewHitMargin, 0, @@ -1460,10 +1509,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe return hitRect.contains(x, y) } - override fun scrollToMessageIfPossible(timestamp: Long) { val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return - binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition) + binding.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition) } override fun onReactionClicked(emoji: String, messageId: MessageId, userWasSender: Boolean) { @@ -1494,7 +1542,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (!textSecurePreferences.autoplayAudioMessages()) return if (indexInAdapter < 0 || indexInAdapter >= adapter.itemCount) { return } - val viewHolder = binding?.conversationRecyclerView?.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder ?: return + val viewHolder = binding.conversationRecyclerView.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder ?: return viewHolder.view.playVoiceMessage() } @@ -1504,7 +1552,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe BlockedDialog(recipient, this).show(supportFragmentManager, "Blocked Dialog") return } - val binding = binding ?: return val sentMessageInfo = if (binding.inputBar.linkPreview != null || binding.inputBar.quote != null) { sendAttachments(listOf(), getMessageBody(), binding.inputBar.quote, binding.inputBar.linkPreview) } else { @@ -1540,9 +1587,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val text = getMessageBody() val userPublicKey = textSecurePreferences.getLocalNumber() val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey) - if (text.contains(seed) && !isNoteToSelf && !hasPermissionToSendSeed) { - val dialog = SendSeedDialog { sendTextOnlyMessage(true) } - dialog.show(supportFragmentManager, "Send Seed Dialog") + if (seed in text && !isNoteToSelf && !hasPermissionToSendSeed) { + showSessionDialog { + title(R.string.dialog_send_seed_title) + text(R.string.dialog_send_seed_explanation) + button(R.string.dialog_send_seed_send_button_title) { sendTextOnlyMessage(true) } + cancelButton() + } + return null } // Create the message @@ -1551,13 +1603,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe message.text = text val expiresInMillis = viewModel.expirationConfiguration?.expiryMode?.expiryMillis ?: 0 val expireStartedAt = if (viewModel.expirationConfiguration?.expiryMode is ExpiryMode.AfterSend) { - message.sentTimestamp!! + message.sentTimestamp } else 0 - val outgoingTextMessage = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt) + val outgoingTextMessage = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt!!) // Clear the input bar - binding?.inputBar?.text = "" - binding?.inputBar?.cancelQuoteDraft() - binding?.inputBar?.cancelLinkPreviewDraft() + binding.inputBar.text = "" + binding.inputBar.cancelQuoteDraft() + binding.inputBar.cancelLinkPreviewDraft() // Put the message in the database message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true) // Send it @@ -1570,7 +1622,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun sendAttachments( attachments: List, body: String?, - quotedMessage: MessageRecord? = binding?.inputBar?.quote, + quotedMessage: MessageRecord? = binding.inputBar?.quote, linkPreview: LinkPreview? = null ): Pair? { val recipient = viewModel.recipient ?: return null @@ -1599,9 +1651,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } else 0 val outgoingTextMessage = OutgoingMediaMessage.from(message, recipient, attachments, localQuote, linkPreview, expiresInMs, expireStartedAtMs) // Clear the input bar - binding?.inputBar?.text = "" - binding?.inputBar?.cancelQuoteDraft() - binding?.inputBar?.cancelLinkPreviewDraft() + binding.inputBar.text = "" + binding.inputBar.cancelQuoteDraft() + binding.inputBar.cancelLinkPreviewDraft() // Reset the attachment manager attachmentManager.clear() // Reset attachments button if needed @@ -1640,7 +1692,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun pickFromLibrary() { val recipient = viewModel.recipient ?: return - binding?.inputBar?.text?.trim()?.let { text -> + binding.inputBar.text?.trim()?.let { text -> AttachmentManager.selectGallery(this, PICK_FROM_LIBRARY, recipient, text) } } @@ -1733,7 +1785,20 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (Permissions.hasAll(this, Manifest.permission.RECORD_AUDIO)) { showVoiceMessageUI() window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - audioRecorder.startRecording() + + // Allow the caller (us!) to define what should happen when the voice recording finishes. + // Specifically in this instance, if we just tap the record audio button then by the time + // we actually finish setting up and get here the recording has been cancelled and the voice + // recorder state is Idle! As such we'll only tick the recorder state over to Recording if + // we were still in the SettingUpToRecord state when we got here (i.e., the record voice + // message button is still held or is locked to keep recording audio without being held). + val callback: () -> Unit = { + if (binding.inputBar.voiceRecorderState == VoiceRecorderState.SettingUpToRecord) { + binding.inputBar.voiceRecorderState = VoiceRecorderState.Recording + } + } + audioRecorder.startRecording(callback) + stopAudioHandler.postDelayed(stopVoiceMessageRecordingTask, 300000) // Limit voice messages to 5 minute each } else { Permissions.with(this) @@ -1744,11 +1809,61 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } + private fun informUserIfNetworkOrSessionNodePathIsInvalid() { + + // Check that we have a valid network network connection & inform the user if not + val connectedToInternet = NetworkUtils.haveValidNetworkConnection(applicationContext) + if (!connectedToInternet) + { + // TODO: Adjust to display error to user with official localised string when SES-2319 is addressed + Log.e(TAG, "Cannot sent voice message - no network connection.") + } + + // Check that we have a suite of Session Nodes to route through. + // Note: We can have the entry node plus the 2 Session Nodes and the data _still_ might not + // send due to any node flakiness - but without doing some manner of test-ping through + // there's no way to test our client -> destination connectivity (unless we abuse the typing + // indicators?) + val paths = OnionRequestAPI.paths + if (paths.isNullOrEmpty() || paths.count() != 2) { + // TODO: Adjust to display error to user with official localised string when SES-2319 is addressed + Log.e(TAG, "Cannot send voice message - bad Session Node path.") + } + } + override fun sendVoiceMessage() { + // When the record voice message button is released we always need to reset the UI and cancel + // any further recording operation.. hideVoiceMessageUI() window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) val future = audioRecorder.stopRecording() stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask) + + // ..but we'll bail without sending the voice message & inform the user that they need to press and HOLD + // the record voice message button if their message was less than 1 second long. + val inputBar = binding.inputBar + val voiceMessageDurationMS = inputBar.voiceMessageDurationMS + + // Now tear-down is complete we can move back into the idle state ready to record another voice message. + // CAREFUL: This state must be set BEFORE we show any warning toast about short messages because it early + // exits before transmitting the audio! + inputBar.voiceRecorderState = VoiceRecorderState.Idle + + // Voice message too short? Warn with toast instead of sending. + // Note: The 0L check prevents the warning toast being shown when leaving the conversation activity. + if (voiceMessageDurationMS != 0L && voiceMessageDurationMS < MINIMUM_VOICE_MESSAGE_DURATION_MS) { + Toast.makeText(this@ConversationActivityV2, R.string.messageVoiceErrorShort, Toast.LENGTH_SHORT).show() + inputBar.voiceMessageDurationMS = 0L + return + } + + informUserIfNetworkOrSessionNodePathIsInvalid() + // Note: We could return here if there was a network or node path issue, but instead we'll try + // our best to send the voice message even if it might fail - because in that case it'll get put + // into the draft database and can be retried when we regain network connectivity and a working + // node path. + + // Attempt to send it the voice message future.addListener(object : ListenableFuture.Listener> { override fun onSuccess(result: Pair) { @@ -1765,10 +1880,23 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun cancelVoiceMessage() { + val inputBar = binding.inputBar + hideVoiceMessageUI() window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) audioRecorder.stopRecording() stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask) + + // Note: The 0L check prevents the warning toast being shown when leaving the conversation activity + val voiceMessageDuration = inputBar.voiceMessageDurationMS + if (voiceMessageDuration != 0L && voiceMessageDuration < MINIMUM_VOICE_MESSAGE_DURATION_MS) { + Toast.makeText(applicationContext, applicationContext.getString(R.string.messageVoiceErrorShort), Toast.LENGTH_SHORT).show() + inputBar.voiceMessageDurationMS = 0L + } + + // When tear-down is complete (via cancelling) we can move back into the idle state ready to record + // another voice message. + inputBar.voiceRecorderState = VoiceRecorderState.Idle } override fun selectMessages(messages: Set) { @@ -1916,9 +2044,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe endActionMode() } - override fun copySessionID(messages: Set) { - val sessionID = messages.first().individualRecipient.address.toString() - val clip = ClipData.newPlainText("Session ID", sessionID) + override fun copyAccountID(messages: Set) { + val accountID = messages.first().individualRecipient.address.toString() + val clip = ClipData.newPlainText("Account ID", accountID) val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager manager.setPrimaryClip(clip) Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() @@ -2002,7 +2130,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun reply(messages: Set) { val recipient = viewModel.recipient ?: return - messages.firstOrNull()?.let { binding?.inputBar?.draftQuote(recipient, it, glide) } + messages.firstOrNull()?.let { binding.inputBar.draftQuote(recipient, it, glide) } endActionMode() } @@ -2049,28 +2177,28 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe searchViewModel.onMissingResult() } } } - binding?.searchBottomBar?.setData(result.position, result.getResults().size) + binding.searchBottomBar.setData(result.position, result.getResults().size) }) } fun onSearchOpened() { searchViewModel.onSearchOpened() - binding?.searchBottomBar?.visibility = View.VISIBLE - binding?.searchBottomBar?.setData(0, 0) - binding?.inputBar?.visibility = View.INVISIBLE + binding.searchBottomBar.visibility = View.VISIBLE + binding.searchBottomBar.setData(0, 0) + binding.inputBar.visibility = View.INVISIBLE } fun onSearchClosed() { searchViewModel.onSearchClosed() - binding?.searchBottomBar?.visibility = View.GONE - binding?.inputBar?.visibility = View.VISIBLE + binding.searchBottomBar.visibility = View.GONE + binding.inputBar.visibility = View.VISIBLE adapter.onSearchQueryUpdated(null) invalidateOptionsMenu() } fun onSearchQueryUpdated(query: String) { searchViewModel.onQueryUpdated(query, viewModel.threadId) - binding?.searchBottomBar?.showLoading() + binding.searchBottomBar.showLoading() adapter.onSearchQueryUpdated(query) } @@ -2090,7 +2218,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun moveToMessagePosition(position: Int, highlight: Boolean, onMessageNotFound: Runnable?) { if (position >= 0) { - binding?.conversationRecyclerView?.scrollToPosition(position) + binding.conversationRecyclerView.scrollToPosition(position) if (highlight) { runOnUiThread { @@ -2118,7 +2246,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe ConversationReactionOverlay.Action.DELETE -> deleteMessages(selectedItems) ConversationReactionOverlay.Action.BAN_AND_DELETE_ALL -> banAndDeleteAll(selectedItems) ConversationReactionOverlay.Action.BAN_USER -> banUser(selectedItems) - ConversationReactionOverlay.Action.COPY_SESSION_ID -> copySessionID(selectedItems) + ConversationReactionOverlay.Action.COPY_ACCOUNT_ID -> copyAccountID(selectedItems) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt index 340336e53b1..40a089d4f62 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt @@ -70,7 +70,7 @@ class ConversationAdapter( @WorkerThread private fun getSenderInfo(sender: String): Contact? { - return contactDB.getContactWithSessionID(sender) + return contactDB.getContactWithAccountID(sender) } sealed class ViewType(val rawValue: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index af754a300a8..9f2046334b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -539,13 +539,14 @@ class ConversationReactionOverlay : FrameLayout { if (!containsControlMessage && hasText) { items += ActionItem(R.attr.menu_copy_icon, R.string.copy, { handleActionItemClicked(Action.COPY_MESSAGE) }) } - // Copy Session ID + // Copy Account ID if (recipient.isGroupRecipient && !recipient.isCommunityRecipient && message.recipient.address.toString() != userPublicKey) { - items += ActionItem(R.attr.menu_copy_icon, R.string.activity_conversation_menu_copy_session_id, { handleActionItemClicked(Action.COPY_SESSION_ID) }) + items += ActionItem(R.attr.menu_copy_icon, R.string.activity_conversation_menu_copy_account_id, { handleActionItemClicked(Action.COPY_ACCOUNT_ID) }) } // Delete message if (userCanDeleteSelectedItems(context, message, openGroup, userPublicKey, blindedPublicKey)) { - items += ActionItem(R.attr.menu_trash_icon, R.string.delete, { handleActionItemClicked(Action.DELETE) }, R.string.AccessibilityId_delete_message, message.subtitle, R.color.destructive) + items += ActionItem(R.attr.menu_trash_icon, R.string.delete, { handleActionItemClicked(Action.DELETE) }, + R.string.AccessibilityId_delete_message, message.subtitle, ThemeUtil.getThemedColor(context, R.attr.danger)) } // Ban user if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) { @@ -689,7 +690,7 @@ class ConversationReactionOverlay : FrameLayout { RESYNC, DOWNLOAD, COPY_MESSAGE, - COPY_SESSION_ID, + COPY_ACCOUNT_ID, VIEW_INFO, SELECT, DELETE, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index b29f9e1b49c..cbae0e757f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -18,7 +18,7 @@ import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment -import org.session.libsession.messaging.utilities.SessionId +import org.session.libsession.messaging.utilities.AccountId import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient @@ -77,7 +77,7 @@ class ConversationViewModel( val blindedPublicKey: String? get() = if (openGroup == null || edKeyPair == null || !serverCapabilities.contains(OpenGroupApi.Capability.BLIND.name.lowercase())) null else { SodiumUtilities.blindedKeyPair(openGroup!!.publicKey, edKeyPair)?.publicKey?.asBytes - ?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString + ?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString } val isMessageRequestThread : Boolean diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt index b6212b8542a..ca5b1cec110 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt @@ -26,7 +26,7 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen val contact by lazy { val senderId = recipient.address.serialize() // this dialog won't show for open group contacts - contactDatabase.getContactWithSessionID(senderId) + contactDatabase.getContactWithAccountID(senderId) ?.displayName(Contact.ContactContext.REGULAR) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt index 925968f95bf..9514552d280 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt @@ -27,10 +27,9 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Icon -import androidx.compose.material.LocalTextStyle -import androidx.compose.material.Surface -import androidx.compose.material.Text +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -38,15 +37,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.lifecycleScope import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi @@ -59,7 +54,6 @@ import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAt import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.database.Storage -import org.thoughtcrime.securesms.ui.AppTheme import org.thoughtcrime.securesms.ui.Avatar import org.thoughtcrime.securesms.ui.CarouselNextButton import org.thoughtcrime.securesms.ui.CarouselPrevButton @@ -69,13 +63,19 @@ import org.thoughtcrime.securesms.ui.CellWithPaddingAndMargin import org.thoughtcrime.securesms.ui.Divider import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.HorizontalPagerIndicator -import org.thoughtcrime.securesms.ui.ItemButton -import org.thoughtcrime.securesms.ui.PreviewTheme -import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider +import org.thoughtcrime.securesms.ui.LargeItemButton +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.TitledText -import org.thoughtcrime.securesms.ui.blackAlpha40 -import org.thoughtcrime.securesms.ui.colorDestructive -import org.thoughtcrime.securesms.ui.destructiveButtonColors +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.blackAlpha40 +import org.thoughtcrime.securesms.ui.theme.dangerButtonColors +import org.thoughtcrime.securesms.ui.setComposeContent +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.bold +import org.thoughtcrime.securesms.ui.theme.monospace import javax.inject.Inject @AndroidEntryPoint @@ -102,9 +102,7 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() { viewModel.timestamp = intent.getLongExtra(MESSAGE_TIMESTAMP, -1L) - ComposeView(this) - .apply { setContent { MessageDetailsScreen() } } - .let(::setContentView) + setComposeContent { MessageDetailsScreen() } lifecycleScope.launch { viewModel.eventFlow.collect { @@ -121,16 +119,14 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() { @Composable private fun MessageDetailsScreen() { val state by viewModel.stateFlow.collectAsState() - AppTheme { - MessageDetails( - state = state, - onReply = if (state.canReply) { { setResultAndFinish(ON_REPLY) } } else null, - onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } }, - onDelete = { setResultAndFinish(ON_DELETE) }, - onClickImage = { viewModel.onClickImage(it) }, - onAttachmentNeedsDownload = viewModel::onAttachmentNeedsDownload, - ) - } + MessageDetails( + state = state, + onReply = if (state.canReply) { { setResultAndFinish(ON_REPLY) } } else null, + onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } }, + onDelete = { setResultAndFinish(ON_DELETE) }, + onClickImage = { viewModel.onClickImage(it) }, + onAttachmentNeedsDownload = viewModel::onAttachmentNeedsDownload, + ) } private fun setResultAndFinish(code: Int) { @@ -155,12 +151,12 @@ fun MessageDetails( Column( modifier = Modifier .verticalScroll(rememberScrollState()) - .padding(vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + .padding(vertical = LocalDimensions.current.smallSpacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) ) { state.record?.let { message -> AndroidView( - modifier = Modifier.padding(horizontal = 32.dp), + modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing), factory = { ViewVisibleMessageContentBinding.inflate(LayoutInflater.from(it)).mainContainerConstraint.apply { bind( @@ -196,7 +192,7 @@ fun CellMetadata( state.apply { if (listOfNotNull(sent, received, error, senderInfo).isEmpty()) return CellWithPaddingAndMargin { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing)) { TitledText(sent) TitledText(received) TitledErrorText(error) @@ -222,25 +218,25 @@ fun CellButtons( Cell { Column { onReply?.let { - ItemButton( - stringResource(R.string.reply), + LargeItemButton( + R.string.reply, R.drawable.ic_message_details__reply, onClick = it ) Divider() } onResend?.let { - ItemButton( - stringResource(R.string.resend), + LargeItemButton( + R.string.resend, R.drawable.ic_message_details__refresh, onClick = it ) Divider() } - ItemButton( - stringResource(R.string.delete), + LargeItemButton( + R.string.delete, R.drawable.ic_message_details__trash, - colors = destructiveButtonColors(), + colors = dangerButtonColors(), onClick = onDelete ) } @@ -254,7 +250,7 @@ fun Carousel(attachments: List, onClick: (Int) -> Unit) { val pagerState = rememberPagerState { attachments.size } - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing)) { Row { CarouselPrevButton(pagerState) Box(modifier = Modifier.weight(1f)) { @@ -263,7 +259,7 @@ fun Carousel(attachments: List, onClick: (Int) -> Unit) { ExpandButton( modifier = Modifier .align(Alignment.BottomEnd) - .padding(8.dp) + .padding(LocalDimensions.current.xxsSpacing) ) { onClick(pagerState.currentPage) } } CarouselNextButton(pagerState) @@ -316,9 +312,9 @@ fun ExpandButton(modifier: Modifier = Modifier, onClick: () -> Unit) { @Preview @Composable fun PreviewMessageDetails( - @PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors ) { - PreviewTheme(themeResId) { + PreviewTheme(colors) { MessageDetails( state = MessageDetailsState( nonImageAttachmentFileDetails = listOf( @@ -341,10 +337,10 @@ fun PreviewMessageDetails( fun FileDetails(fileDetails: List) { if (fileDetails.isEmpty()) return - CellWithPaddingAndMargin(padding = 0.dp) { + Cell { FlowRow( - modifier = Modifier.padding(vertical = 24.dp, horizontal = 12.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.padding(horizontal = LocalDimensions.current.xsSpacing, vertical = LocalDimensions.current.spacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) ) { fileDetails.forEach { BoxWithConstraints { @@ -352,7 +348,7 @@ fun FileDetails(fileDetails: List) { it, modifier = Modifier .widthIn(min = maxWidth.div(2)) - .padding(horizontal = 12.dp) + .padding(horizontal = LocalDimensions.current.xsSpacing) .width(IntrinsicSize.Max) ) } @@ -365,7 +361,8 @@ fun FileDetails(fileDetails: List) { fun TitledErrorText(titledText: TitledText?) { TitledText( titledText, - valueStyle = LocalTextStyle.current.copy(color = colorDestructive) + style = LocalType.current.base, + color = LocalColors.current.danger ) } @@ -373,7 +370,7 @@ fun TitledErrorText(titledText: TitledText?) { fun TitledMonospaceText(titledText: TitledText?) { TitledText( titledText, - valueStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace) + style = LocalType.current.base.monospace() ) } @@ -381,24 +378,25 @@ fun TitledMonospaceText(titledText: TitledText?) { fun TitledText( titledText: TitledText?, modifier: Modifier = Modifier, - valueStyle: TextStyle = LocalTextStyle.current, + style: TextStyle = LocalType.current.base, + color: Color = Color.Unspecified ) { titledText?.apply { TitledView(title, modifier) { - Text(text, style = valueStyle, modifier = Modifier.fillMaxWidth()) + Text( + text, + style = style, + color = color, + modifier = Modifier.fillMaxWidth() + ) } } } @Composable fun TitledView(title: GetString, modifier: Modifier = Modifier, content: @Composable () -> Unit) { - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { - Title(title) + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxxsSpacing)) { + Text(title.string(), style = LocalType.current.base.bold()) content() } } - -@Composable -fun Title(title: GetString) { - Text(title.string(), fontWeight = FontWeight.Bold) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.java deleted file mode 100644 index e81f06d0eed..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.java +++ /dev/null @@ -1,381 +0,0 @@ -/* - * Copyright (C) 2011 Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.thoughtcrime.securesms.conversation.v2; - -import android.annotation.TargetApi; -import android.app.ActivityManager; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.graphics.Typeface; -import android.net.Uri; -import android.os.Build.VERSION; -import android.os.Build.VERSION_CODES; -import android.text.Spannable; -import android.text.SpannableString; -import android.text.TextUtils; -import android.text.style.StyleSpan; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.annimon.stream.Stream; -import com.google.android.mms.pdu_alt.CharacterSets; -import com.google.android.mms.pdu_alt.EncodedStringValue; - -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.components.ComposeText; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.security.SecureRandom; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; - -import network.loki.messenger.R; - -public class Util { - private static final String TAG = Log.tag(Util.class); - - private static final long BUILD_LIFESPAN = TimeUnit.DAYS.toMillis(90); - - public static List asList(T... elements) { - List result = new LinkedList<>(); - Collections.addAll(result, elements); - return result; - } - - public static String join(String[] list, String delimiter) { - return join(Arrays.asList(list), delimiter); - } - - public static String join(Collection list, String delimiter) { - StringBuilder result = new StringBuilder(); - int i = 0; - - for (T item : list) { - result.append(item); - - if (++i < list.size()) - result.append(delimiter); - } - - return result.toString(); - } - - public static String join(long[] list, String delimeter) { - List boxed = new ArrayList<>(list.length); - - for (int i = 0; i < list.length; i++) { - boxed.add(list[i]); - } - - return join(boxed, delimeter); - } - - @SafeVarargs - public static @NonNull List join(@NonNull List... lists) { - int totalSize = Stream.of(lists).reduce(0, (sum, list) -> sum + list.size()); - List joined = new ArrayList<>(totalSize); - - for (List list : lists) { - joined.addAll(list); - } - - return joined; - } - - public static String join(List list, String delimeter) { - StringBuilder sb = new StringBuilder(); - - for (int j = 0; j < list.size(); j++) { - if (j != 0) sb.append(delimeter); - sb.append(list.get(j)); - } - - return sb.toString(); - } - - public static String rightPad(String value, int length) { - if (value.length() >= length) { - return value; - } - - StringBuilder out = new StringBuilder(value); - while (out.length() < length) { - out.append(" "); - } - - return out.toString(); - } - - public static boolean isEmpty(EncodedStringValue[] value) { - return value == null || value.length == 0; - } - - public static boolean isEmpty(ComposeText value) { - return value == null || value.getText() == null || TextUtils.isEmpty(value.getTextTrimmed()); - } - - public static boolean isEmpty(Collection collection) { - return collection == null || collection.isEmpty(); - } - - public static boolean isEmpty(@Nullable CharSequence charSequence) { - return charSequence == null || charSequence.length() == 0; - } - - public static boolean hasItems(@Nullable Collection collection) { - return collection != null && !collection.isEmpty(); - } - - public static V getOrDefault(@NonNull Map map, K key, V defaultValue) { - return map.containsKey(key) ? map.get(key) : defaultValue; - } - - public static String getFirstNonEmpty(String... values) { - for (String value : values) { - if (!Util.isEmpty(value)) { - return value; - } - } - return ""; - } - - public static @NonNull String emptyIfNull(@Nullable String value) { - return value != null ? value : ""; - } - - public static @NonNull CharSequence emptyIfNull(@Nullable CharSequence value) { - return value != null ? value : ""; - } - - public static CharSequence getBoldedString(String value) { - SpannableString spanned = new SpannableString(value); - spanned.setSpan(new StyleSpan(Typeface.BOLD), 0, - spanned.length(), - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - - return spanned; - } - - public static @NonNull String toIsoString(byte[] bytes) { - try { - return new String(bytes, CharacterSets.MIMENAME_ISO_8859_1); - } catch (UnsupportedEncodingException e) { - throw new AssertionError("ISO_8859_1 must be supported!"); - } - } - - public static byte[] toIsoBytes(String isoString) { - try { - return isoString.getBytes(CharacterSets.MIMENAME_ISO_8859_1); - } catch (UnsupportedEncodingException e) { - throw new AssertionError("ISO_8859_1 must be supported!"); - } - } - - public static byte[] toUtf8Bytes(String utf8String) { - try { - return utf8String.getBytes(CharacterSets.MIMENAME_UTF_8); - } catch (UnsupportedEncodingException e) { - throw new AssertionError("UTF_8 must be supported!"); - } - } - - public static void wait(Object lock, long timeout) { - try { - lock.wait(timeout); - } catch (InterruptedException ie) { - throw new AssertionError(ie); - } - } - - public static List split(String source, String delimiter) { - List results = new LinkedList<>(); - - if (TextUtils.isEmpty(source)) { - return results; - } - - String[] elements = source.split(delimiter); - Collections.addAll(results, elements); - - return results; - } - - public static byte[][] split(byte[] input, int firstLength, int secondLength) { - byte[][] parts = new byte[2][]; - - parts[0] = new byte[firstLength]; - System.arraycopy(input, 0, parts[0], 0, firstLength); - - parts[1] = new byte[secondLength]; - System.arraycopy(input, firstLength, parts[1], 0, secondLength); - - return parts; - } - - public static byte[] combine(byte[]... elements) { - try { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - - for (byte[] element : elements) { - baos.write(element); - } - - return baos.toByteArray(); - } catch (IOException e) { - throw new AssertionError(e); - } - } - - public static byte[] trim(byte[] input, int length) { - byte[] result = new byte[length]; - System.arraycopy(input, 0, result, 0, result.length); - - return result; - } - - public static byte[] getSecretBytes(int size) { - return getSecretBytes(new SecureRandom(), size); - } - - public static byte[] getSecretBytes(@NonNull SecureRandom secureRandom, int size) { - byte[] secret = new byte[size]; - secureRandom.nextBytes(secret); - return secret; - } - - public static T getRandomElement(T[] elements) { - return elements[new SecureRandom().nextInt(elements.length)]; - } - - public static T getRandomElement(List elements) { - return elements.get(new SecureRandom().nextInt(elements.size())); - } - - public static boolean equals(@Nullable Object a, @Nullable Object b) { - return a == b || (a != null && a.equals(b)); - } - - public static int hashCode(@Nullable Object... objects) { - return Arrays.hashCode(objects); - } - - public static @Nullable Uri uri(@Nullable String uri) { - if (uri == null) return null; - else return Uri.parse(uri); - } - - @TargetApi(VERSION_CODES.KITKAT) - public static boolean isLowMemory(Context context) { - ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); - - return (VERSION.SDK_INT >= VERSION_CODES.KITKAT && activityManager.isLowRamDevice()) || - activityManager.getLargeMemoryClass() <= 64; - } - - public static int clamp(int value, int min, int max) { - return Math.min(Math.max(value, min), max); - } - - public static long clamp(long value, long min, long max) { - return Math.min(Math.max(value, min), max); - } - - public static float clamp(float value, float min, float max) { - return Math.min(Math.max(value, min), max); - } - - /** - * Returns half of the difference between the given length, and the length when scaled by the - * given scale. - */ - public static float halfOffsetFromScale(int length, float scale) { - float scaledLength = length * scale; - return (length - scaledLength) / 2; - } - - public static @Nullable String readTextFromClipboard(@NonNull Context context) { - { - ClipboardManager clipboardManager = (ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE); - - if (clipboardManager.hasPrimaryClip() && clipboardManager.getPrimaryClip().getItemCount() > 0) { - return clipboardManager.getPrimaryClip().getItemAt(0).getText().toString(); - } else { - return null; - } - } - } - - public static void writeTextToClipboard(@NonNull Context context, @NonNull String text) { - writeTextToClipboard(context, context.getString(R.string.app_name), text); - } - - public static void writeTextToClipboard(@NonNull Context context, @NonNull String label, @NonNull String text) { - ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clip = ClipData.newPlainText(label, text); - clipboard.setPrimaryClip(clip); - } - - public static int toIntExact(long value) { - if ((int)value != value) { - throw new ArithmeticException("integer overflow"); - } - return (int)value; - } - - public static boolean isEquals(@Nullable Long first, long second) { - return first != null && first == second; - } - - @SafeVarargs - public static List concatenatedList(Collection ... items) { - final List concat = new ArrayList<>(Stream.of(items).reduce(0, (sum, list) -> sum + list.size())); - - for (Collection list : items) { - concat.addAll(list); - } - - return concat; - } - - public static boolean isLong(String value) { - try { - Long.parseLong(value); - return true; - } catch (NumberFormatException e) { - return false; - } - } - - public static int parseInt(String integer, int defaultValue) { - try { - return Integer.parseInt(integer); - } catch (NumberFormatException e) { - return defaultValue; - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt new file mode 100644 index 00000000000..ff33a58e91b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt @@ -0,0 +1,384 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.conversation.v2 + +import android.annotation.TargetApi +import android.app.ActivityManager +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.graphics.Typeface +import android.net.Uri +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES +import android.text.Spannable +import android.text.SpannableString +import android.text.TextUtils +import android.text.style.StyleSpan +import android.view.View +import com.annimon.stream.Stream +import com.google.android.mms.pdu_alt.CharacterSets +import com.google.android.mms.pdu_alt.EncodedStringValue +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.UnsupportedEncodingException +import java.security.SecureRandom +import java.util.Arrays +import java.util.Collections +import java.util.concurrent.TimeUnit +import kotlin.math.max +import kotlin.math.min +import network.loki.messenger.R +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.components.ComposeText + +object Util { + private val TAG: String = Log.tag(Util::class.java) + + private val BUILD_LIFESPAN = TimeUnit.DAYS.toMillis(90) + + fun asList(vararg elements: T): List { + val result = mutableListOf() // LinkedList() + Collections.addAll(result, *elements) + return result + } + + fun join(list: Array, delimiter: String?): String { + return join(listOf(*list), delimiter) + } + + fun join(list: Collection, delimiter: String?): String { + val result = StringBuilder() + var i = 0 + + for (item in list) { + result.append(item) + if (++i < list.size) result.append(delimiter) + } + + return result.toString() + } + + fun join(list: LongArray, delimeter: String?): String { + val boxed: MutableList = ArrayList(list.size) + + for (i in list.indices) { + boxed.add(list[i]) + } + + return join(boxed, delimeter) + } + + @SafeVarargs + fun join(vararg lists: List): List { + val totalSize = Stream.of(*lists).reduce(0) { sum: Int, list: List -> sum + list.size } + val joined: MutableList = ArrayList(totalSize) + + for (list in lists) { + joined.addAll(list) + } + + return joined + } + + fun join(list: List, delimeter: String?): String { + val sb = StringBuilder() + + for (j in list.indices) { + if (j != 0) sb.append(delimeter) + sb.append(list[j]) + } + + return sb.toString() + } + + fun rightPad(value: String, length: Int): String { + if (value.length >= length) { + return value + } + + val out = StringBuilder(value) + while (out.length < length) { + out.append(" ") + } + + return out.toString() + } + + fun isEmpty(value: Array?): Boolean { + return value == null || value.size == 0 + } + + fun isEmpty(value: ComposeText?): Boolean { + return value == null || value.text == null || TextUtils.isEmpty(value.textTrimmed) + } + + fun isEmpty(collection: Collection<*>?): Boolean { + return collection == null || collection.isEmpty() + } + + fun isEmpty(charSequence: CharSequence?): Boolean { + return charSequence == null || charSequence.length == 0 + } + + fun hasItems(collection: Collection<*>?): Boolean { + return collection != null && !collection.isEmpty() + } + + fun getOrDefault(map: Map, key: K, defaultValue: V): V? { + return if (map.containsKey(key)) map[key] else defaultValue + } + + fun getFirstNonEmpty(vararg values: String?): String { + for (value in values) { + if (!value.isNullOrEmpty()) { return value } + } + return "" + } + + fun emptyIfNull(value: String?): String { + return value ?: "" + } + + fun emptyIfNull(value: CharSequence?): CharSequence { + return value ?: "" + } + + fun getBoldedString(value: String?): CharSequence { + val spanned = SpannableString(value) + spanned.setSpan( + StyleSpan(Typeface.BOLD), 0, + spanned.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + + return spanned + } + + fun toIsoString(bytes: ByteArray?): String { + try { + return String(bytes!!, charset(CharacterSets.MIMENAME_ISO_8859_1)) + } catch (e: UnsupportedEncodingException) { + throw AssertionError("ISO_8859_1 must be supported!") + } + } + + fun toIsoBytes(isoString: String): ByteArray { + try { + return isoString.toByteArray(charset(CharacterSets.MIMENAME_ISO_8859_1)) + } catch (e: UnsupportedEncodingException) { + throw AssertionError("ISO_8859_1 must be supported!") + } + } + + fun toUtf8Bytes(utf8String: String): ByteArray { + try { + return utf8String.toByteArray(charset(CharacterSets.MIMENAME_UTF_8)) + } catch (e: UnsupportedEncodingException) { + throw AssertionError("UTF_8 must be supported!") + } + } + + fun wait(lock: Any, timeout: Long) { + try { + (lock as Object).wait(timeout) + } catch (ie: InterruptedException) { + throw AssertionError(ie) + } + } + + fun split(source: String, delimiter: String): List { + val results = mutableListOf() + + if (TextUtils.isEmpty(source)) { + return results + } + + val elements = + source.split(delimiter.toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + Collections.addAll(results, *elements) + + return results + } + + fun split(input: ByteArray?, firstLength: Int, secondLength: Int): Array { + val parts = arrayOfNulls(2) + + parts[0] = ByteArray(firstLength) + System.arraycopy(input, 0, parts[0], 0, firstLength) + + parts[1] = ByteArray(secondLength) + System.arraycopy(input, firstLength, parts[1], 0, secondLength) + + return parts + } + + fun combine(vararg elements: ByteArray?): ByteArray { + try { + val baos = ByteArrayOutputStream() + + for (element in elements) { + baos.write(element) + } + + return baos.toByteArray() + } catch (e: IOException) { + throw AssertionError(e) + } + } + + fun trim(input: ByteArray?, length: Int): ByteArray { + val result = ByteArray(length) + System.arraycopy(input, 0, result, 0, result.size) + + return result + } + + fun getSecretBytes(size: Int): ByteArray { + return getSecretBytes(SecureRandom(), size) + } + + fun getSecretBytes(secureRandom: SecureRandom, size: Int): ByteArray { + val secret = ByteArray(size) + secureRandom.nextBytes(secret) + return secret + } + + fun getRandomElement(elements: Array): T { + return elements[SecureRandom().nextInt(elements.size)] + } + + fun getRandomElement(elements: List): T { + return elements[SecureRandom().nextInt(elements.size)] + } + + fun equals(a: Any?, b: Any?): Boolean { + return a === b || (a != null && a == b) + } + + fun hashCode(vararg objects: Any?): Int { + return objects.contentHashCode() + } + + fun uri(uri: String?): Uri? { + return if (uri == null) null + else Uri.parse(uri) + } + + @TargetApi(VERSION_CODES.KITKAT) + fun isLowMemory(context: Context): Boolean { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + + return (VERSION.SDK_INT >= VERSION_CODES.KITKAT && activityManager.isLowRamDevice) || + activityManager.largeMemoryClass <= 64 + } + + fun clamp(value: Int, min: Int, max: Int): Int { + return min(max(value.toDouble(), min.toDouble()), max.toDouble()).toInt() + } + + fun clamp(value: Long, min: Long, max: Long): Long { + return min(max(value.toDouble(), min.toDouble()), max.toDouble()).toLong() + } + + fun clamp(value: Float, min: Float, max: Float): Float { + return min(max(value.toDouble(), min.toDouble()), max.toDouble()).toFloat() + } + + /** + * Returns half of the difference between the given length, and the length when scaled by the + * given scale. + */ + fun halfOffsetFromScale(length: Int, scale: Float): Float { + val scaledLength = length * scale + return (length - scaledLength) / 2 + } + + fun readTextFromClipboard(context: Context): String? { + run { + val clipboardManager = + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + return if (clipboardManager.hasPrimaryClip() && clipboardManager.primaryClip!!.itemCount > 0) { + clipboardManager.primaryClip!!.getItemAt(0).text.toString() + } else { + null + } + } + } + + fun writeTextToClipboard(context: Context, text: String) { + writeTextToClipboard(context, context.getString(R.string.app_name), text) + } + + fun writeTextToClipboard(context: Context, label: String, text: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(label, text) + clipboard.setPrimaryClip(clip) + } + + fun toIntExact(value: Long): Int { + if (value.toInt().toLong() != value) { + throw ArithmeticException("integer overflow") + } + return value.toInt() + } + + fun isEquals(first: Long?, second: Long): Boolean { + return first != null && first == second + } + + @SafeVarargs + fun concatenatedList(vararg items: Collection): List { + val concat: MutableList = ArrayList( + Stream.of(*items).reduce(0) { sum: Int, list: Collection -> sum + list.size }) + + for (list in items) { + concat.addAll(list) + } + + return concat + } + + fun isLong(value: String): Boolean { + try { + value.toLong() + return true + } catch (e: NumberFormatException) { + return false + } + } + + fun parseInt(integer: String, defaultValue: Int): Int { + return try { + integer.toInt() + } catch (e: NumberFormatException) { + defaultValue + } + } + + // Method to determine if we're currently in a left-to-right or right-to-left language like Arabic + fun usingRightToLeftLanguage(context: Context): Boolean { + val config = context.resources.configuration + return config.layoutDirection == View.LAYOUT_DIRECTION_RTL + } + + // Method to determine if we're currently in a left-to-right or right-to-left language like Arabic + fun usingLeftToRightLanguage(context: Context): Boolean { + val config = context.resources.configuration + return config.layoutDirection == View.LAYOUT_DIRECTION_LTR + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt index c0ff1cbb1d4..46feefb6088 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt @@ -20,9 +20,9 @@ class BlockedDialog(private val recipient: Recipient, private val context: Conte override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { val contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase() - val sessionID = recipient.address.toString() - val contact = contactDB.getContactWithSessionID(sessionID) - val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID + val accountID = recipient.address.toString() + val contact = contactDB.getContactWithAccountID(accountID) + val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: accountID val explanation = resources.getString(R.string.dialog_blocked_explanation, name) val spannable = SpannableStringBuilder(explanation) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt index 5edd63f100e..1af1d669cf2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt @@ -26,9 +26,9 @@ class DownloadDialog(private val recipient: Recipient) : DialogFragment() { @Inject lateinit var contactDB: SessionContactDatabase override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { - val sessionID = recipient.address.toString() - val contact = contactDB.getContactWithSessionID(sessionID) - val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID + val accountID = recipient.address.toString() + val contact = contactDB.getContactWithAccountID(accountID) + val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: accountID title(resources.getString(R.string.dialog_download_title, name)) val explanation = resources.getString(R.string.dialog_download_explanation, name) @@ -42,8 +42,8 @@ class DownloadDialog(private val recipient: Recipient) : DialogFragment() { } private fun trust() { - val sessionID = recipient.address.toString() - val contact = contactDB.getContactWithSessionID(sessionID) ?: return + val accountID = recipient.address.toString() + val contact = contactDB.getContactWithAccountID(accountID) ?: return val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient) contactDB.setContactIsTrusted(contact, true, threadID) JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/SendSeedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/SendSeedDialog.kt deleted file mode 100644 index 6abb0814d62..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/SendSeedDialog.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.dialogs - -import android.app.Dialog -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import network.loki.messenger.R -import org.thoughtcrime.securesms.createSessionDialog - -/** Shown if the user is about to send their recovery phrase to someone. */ -class SendSeedDialog(private val proceed: (() -> Unit)? = null) : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { - title(R.string.dialog_send_seed_title) - text(R.string.dialog_send_seed_explanation) - button(R.string.dialog_send_seed_send_button_title) { send() } - cancelButton() - } - - private fun send() { - proceed?.invoke() - dismiss() - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index 23041493677..8e38c7d38e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -1,16 +1,16 @@ package org.thoughtcrime.securesms.conversation.v2.input_bar +import android.annotation.SuppressLint import android.content.Context -import android.content.res.Resources import android.graphics.PointF import android.net.Uri import android.text.Editable import android.text.InputType -import android.text.TextWatcher import android.util.AttributeSet import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent +import android.view.View import android.view.inputmethod.EditorInfo import android.widget.RelativeLayout import android.widget.TextView @@ -28,20 +28,36 @@ import org.thoughtcrime.securesms.conversation.v2.messages.QuoteViewDelegate import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.util.addTextChangedListener import org.thoughtcrime.securesms.util.contains -import org.thoughtcrime.securesms.util.toDp -import org.thoughtcrime.securesms.util.toPx -class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, LinkPreviewDraftViewDelegate, +// Enums to keep track of the state of our voice recording mechanism as the user can +// manipulate the UI faster than we can setup & teardown. +enum class VoiceRecorderState { + Idle, + SettingUpToRecord, + Recording, + ShuttingDownAfterRecord +} + +@SuppressLint("ClickableViewAccessibility") +class InputBar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : RelativeLayout( + context, + attrs, + defStyleAttr +), InputBarEditTextDelegate, + QuoteViewDelegate, + LinkPreviewDraftViewDelegate, TextView.OnEditorActionListener { - private lateinit var binding: ViewInputBarBinding - private val screenWidth = Resources.getSystem().displayMetrics.widthPixels - private val vMargin by lazy { toDp(4, resources) } - private val minHeight by lazy { toPx(56, resources) } + + private var binding: ViewInputBarBinding = ViewInputBarBinding.inflate(LayoutInflater.from(context), this, true) private var linkPreviewDraftView: LinkPreviewDraftView? = null private var quoteView: QuoteView? = null var delegate: InputBarDelegate? = null - var additionalContentHeight = 0 var quote: MessageRecord? = null var linkPreview: LinkPreview? = null var showInput: Boolean = true @@ -54,34 +70,75 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li } var text: String - get() { return binding.inputBarEditText.text?.toString() ?: "" } + get() = binding.inputBarEditText.text?.toString() ?: "" set(value) { binding.inputBarEditText.setText(value) } - val attachmentButtonsContainerHeight: Int - get() = binding.attachmentsButtonContainer.height + // Keep track of when the user pressed the record voice message button, the duration that + // they held record, and the current audio recording mechanism state. + private var voiceMessageStartMS = 0L + var voiceMessageDurationMS = 0L + var voiceRecorderState = VoiceRecorderState.Idle - private val attachmentsButton by lazy { InputBarButton(context, R.drawable.ic_plus_24).apply { contentDescription = context.getString(R.string.AccessibilityId_attachments_button)} } - private val microphoneButton by lazy { InputBarButton(context, R.drawable.ic_microphone).apply { contentDescription = context.getString(R.string.AccessibilityId_microphone_button)} } - private val sendButton by lazy { InputBarButton(context, R.drawable.ic_arrow_up, true).apply { contentDescription = context.getString(R.string.AccessibilityId_send_message_button)} } + private val attachmentsButton = InputBarButton(context, R.drawable.ic_plus_24).apply { contentDescription = context.getString(R.string.AccessibilityId_attachments_button)} + val microphoneButton = InputBarButton(context, R.drawable.ic_microphone).apply { contentDescription = context.getString(R.string.AccessibilityId_microphone_button)} + private val sendButton = InputBarButton(context, R.drawable.ic_arrow_up, true).apply { contentDescription = context.getString(R.string.AccessibilityId_send_message_button)} - // region Lifecycle - constructor(context: Context) : super(context) { initialize() } - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } - - private fun initialize() { - binding = ViewInputBarBinding.inflate(LayoutInflater.from(context), this, true) + init { // Attachments button binding.attachmentsButtonContainer.addView(attachmentsButton) attachmentsButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) attachmentsButton.onPress = { toggleAttachmentOptions() } + // Microphone button binding.microphoneOrSendButtonContainer.addView(microphoneButton) microphoneButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) - microphoneButton.onLongPress = { startRecordingVoiceMessage() } + microphoneButton.onMove = { delegate?.onMicrophoneButtonMove(it) } microphoneButton.onCancel = { delegate?.onMicrophoneButtonCancel(it) } - microphoneButton.onUp = { delegate?.onMicrophoneButtonUp(it) } + + // Use a separate 'raw' OnTouchListener to record the microphone button down/up timestamps because + // they don't get delayed by any multi-threading or delegates which throw off the timestamp accuracy. + // For example: If we bind something to `microphoneButton.onPress` and also log something in + // `microphoneButton.onUp` and tap the button then the logged output order is onUp and THEN onPress! + microphoneButton.setOnTouchListener(object : OnTouchListener { + override fun onTouch(v: View, event: MotionEvent): Boolean { + if (!microphoneButton.snIsEnabled) return true + + // We only handle single finger touch events so just consume the event and bail if there are more + if (event.pointerCount > 1) return true + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + // Only start spinning up the voice recorder if we're not already recording, setting up, or tearing down + if (voiceRecorderState == VoiceRecorderState.Idle) { + // Take note of when we start recording so we can figure out how long the record button was held for + voiceMessageStartMS = System.currentTimeMillis() + + // We are now setting up to record, and when we actually start recording then + // AudioRecorder.startRecording will move us into the Recording state. + voiceRecorderState = VoiceRecorderState.SettingUpToRecord + startRecordingVoiceMessage() + } + } + MotionEvent.ACTION_UP -> { + // Work out how long the record audio button was held for + voiceMessageDurationMS = System.currentTimeMillis() - voiceMessageStartMS; + + // Regardless of our current recording state we'll always call the onMicrophoneButtonUp method + // and let the logic in that take the appropriate action as we cannot guarantee that letting + // go of the record button should always stop recording audio because the user may have moved + // the button into the 'locked' state so they don't have to keep it held down to record a voice + // message. + // Also: We need to tear down the voice recorder if it has been recording and is now stopping. + delegate?.onMicrophoneButtonUp(event) + } + } + + // Return false to propagate the event rather than consuming it + return false + } + }) + // Send button binding.microphoneOrSendButtonContainer.addView(sendButton) sendButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) @@ -91,16 +148,16 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li delegate?.sendMessage() } } + // Edit text binding.inputBarEditText.setOnEditorActionListener(this) if (TextSecurePreferences.isEnterSendsEnabled(context)) { binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_SEND - binding.inputBarEditText.inputType = - InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + binding.inputBarEditText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES } else { binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_NONE binding.inputBarEditText.inputType = - binding.inputBarEditText.inputType or + binding.inputBarEditText.inputType InputType.TYPE_TEXT_FLAG_CAP_SENTENCES } val incognitoFlag = if (TextSecurePreferences.isIncognitoKeyboardEnabled(context)) 16777216 else 0 @@ -117,29 +174,19 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li return false } - // endregion - - // region Updating override fun inputBarEditTextContentChanged(text: CharSequence) { microphoneButton.isVisible = text.trim().isEmpty() sendButton.isVisible = microphoneButton.isGone delegate?.inputBarEditTextContentChanged(text) } - override fun inputBarEditTextHeightChanged(newValue: Int) { - } + override fun inputBarEditTextHeightChanged(newValue: Int) { } - override fun commitInputContent(contentUri: Uri) { - delegate?.commitInputContent(contentUri) - } + override fun commitInputContent(contentUri: Uri) { delegate?.commitInputContent(contentUri) } - private fun toggleAttachmentOptions() { - delegate?.toggleAttachmentOptions() - } + private fun toggleAttachmentOptions() { delegate?.toggleAttachmentOptions() } - private fun startRecordingVoiceMessage() { - delegate?.startRecordingVoiceMessage() - } + private fun startRecordingVoiceMessage() { delegate?.startRecordingVoiceMessage() } fun draftQuote(thread: Recipient, message: MessageRecord, glide: GlideRequests) { quoteView?.let(binding.inputBarAdditionalContentContainer::removeView) @@ -221,18 +268,16 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li setOf(attachmentsButton, microphoneButton).forEach { it.snIsEnabled = showMediaControls } } - fun addTextChangedListener(textWatcher: TextWatcher) { - binding.inputBarEditText.addTextChangedListener(textWatcher) + fun addTextChangedListener(listener: (String) -> Unit) { + binding.inputBarEditText.addTextChangedListener(listener) } fun setInputBarEditableFactory(factory: Editable.Factory) { binding.inputBarEditText.setEditableFactory(factory) } - // endregion } interface InputBarDelegate { - fun inputBarEditTextContentChanged(newContent: CharSequence) fun toggleAttachmentOptions() fun showVoiceMessageUI() @@ -242,4 +287,4 @@ interface InputBarDelegate { fun onMicrophoneButtonUp(event: MotionEvent) fun sendMessage() fun commitInputContent(contentUri: Uri) -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt index 6d7281dc474..24b48ecdf7c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt @@ -25,6 +25,14 @@ import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.toPx import java.util.Date +// Constants for animation durations in milliseconds +object VoiceRecorderConstants { + const val ANIMATE_LOCK_DURATION_MS = 250L + const val DOT_ANIMATION_DURATION_MS = 500L + const val DOT_PULSE_ANIMATION_DURATION_MS = 1000L + const val SHOW_HIDE_VOICE_UI_DURATION_MS = 250L +} + class InputBarRecordingView : RelativeLayout { private lateinit var binding: ViewInputBarRecordingBinding private var startTimestamp = 0L @@ -79,7 +87,7 @@ class InputBarRecordingView : RelativeLayout { fun hide() { alpha = 1.0f val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) - animation.duration = 250L + animation.duration = VoiceRecorderConstants.SHOW_HIDE_VOICE_UI_DURATION_MS animation.addUpdateListener { animator -> alpha = animator.animatedValue as Float if (animator.animatedFraction == 1.0f) { @@ -113,7 +121,7 @@ class InputBarRecordingView : RelativeLayout { private fun animateDotView() { val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) dotViewAnimation = animation - animation.duration = 500L + animation.duration = VoiceRecorderConstants.DOT_ANIMATION_DURATION_MS animation.addUpdateListener { animator -> binding.dotView.alpha = animator.animatedValue as Float } @@ -128,7 +136,7 @@ class InputBarRecordingView : RelativeLayout { binding.pulseView.animateSizeChange(collapsedSize, expandedSize, 1000) val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.5, 0.0f) pulseAnimation = animation - animation.duration = 1000L + animation.duration = VoiceRecorderConstants.DOT_PULSE_ANIMATION_DURATION_MS animation.addUpdateListener { animator -> binding.pulseView.alpha = animator.animatedValue as Float if (animator.animatedFraction == 1.0f && isVisible) { pulse() } @@ -143,7 +151,7 @@ class InputBarRecordingView : RelativeLayout { layoutParams.bottomMargin = startMarginBottom binding.lockView.layoutParams = layoutParams val animation = ValueAnimator.ofObject(IntEvaluator(), startMarginBottom, endMarginBottom) - animation.duration = 250L + animation.duration = VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS animation.addUpdateListener { animator -> layoutParams.bottomMargin = animator.animatedValue as Int binding.lockView.layoutParams = layoutParams @@ -153,21 +161,25 @@ class InputBarRecordingView : RelativeLayout { fun lock() { val fadeOutAnimation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) - fadeOutAnimation.duration = 250L + fadeOutAnimation.duration = VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS fadeOutAnimation.addUpdateListener { animator -> binding.inputBarMiddleContentContainer.alpha = animator.animatedValue as Float binding.lockView.alpha = animator.animatedValue as Float } fadeOutAnimation.start() val fadeInAnimation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f) - fadeInAnimation.duration = 250L + fadeInAnimation.duration = VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS fadeInAnimation.addUpdateListener { animator -> binding.inputBarCancelButton.alpha = animator.animatedValue as Float } fadeInAnimation.start() binding.recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_arrow_up, context.theme)) - binding.recordButtonOverlay.setOnClickListener { delegate?.sendVoiceMessage() } binding.inputBarCancelButton.setOnClickListener { delegate?.cancelVoiceMessage() } + + // When the user has locked the voice recorder button on then THIS is where the next click + // is registered to actually send the voice message - it does NOT hit the microphone button + // onTouch listener again. + binding.recordButtonOverlay.setOnClickListener { delegate?.sendVoiceMessage() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt index fbb4d2231ff..d4068a3e6c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt @@ -117,9 +117,9 @@ class MentionViewModel( contactDatabase.getContacts(memberIDs).map { contact -> Member( - publicKey = contact.sessionID, + publicKey = contact.accountID, name = contact.displayName(contactContext).orEmpty(), - isModerator = contact.sessionID in moderatorIDs, + isModerator = contact.accountID in moderatorIDs, ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt index 21398c71aad..c7862ca22ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt @@ -6,7 +6,7 @@ import android.view.Menu import android.view.MenuItem import network.loki.messenger.R import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.utilities.SessionId +import org.session.libsession.messaging.utilities.AccountId import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.IdPrefix @@ -39,7 +39,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! val edKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!! val blindedPublicKey = openGroup?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, edKeyPair)?.publicKey?.asBytes } - ?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString + ?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString fun userCanDeleteSelectedItems(): Boolean { val allSentByCurrentUser = selectedItems.all { it.isOutgoing } val allReceivedByCurrentUser = selectedItems.all { !it.isOutgoing } @@ -63,7 +63,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p menu.findItem(R.id.menu_context_ban_and_delete_all).isVisible = userCanBanSelectedUsers() // Copy message text menu.findItem(R.id.menu_context_copy).isVisible = !containsControlMessage && hasText - // Copy Session ID + // Copy Account ID menu.findItem(R.id.menu_context_copy_public_key).isVisible = (thread.isGroupRecipient && !thread.isCommunityRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey) // Message detail @@ -91,7 +91,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p R.id.menu_context_ban_user -> delegate?.banUser(selectedItems) R.id.menu_context_ban_and_delete_all -> delegate?.banAndDeleteAll(selectedItems) R.id.menu_context_copy -> delegate?.copyMessages(selectedItems) - R.id.menu_context_copy_public_key -> delegate?.copySessionID(selectedItems) + R.id.menu_context_copy_public_key -> delegate?.copyAccountID(selectedItems) R.id.menu_context_resync -> delegate?.resyncMessage(selectedItems) R.id.menu_context_resend -> delegate?.resendMessage(selectedItems) R.id.menu_message_details -> delegate?.showMessageDetail(selectedItems) @@ -115,7 +115,7 @@ interface ConversationActionModeCallbackDelegate { fun banUser(messages: Set) fun banAndDeleteAll(messages: Set) fun copyMessages(messages: Set) - fun copySessionID(messages: Set) + fun copyAccountID(messages: Set) fun resyncMessage(messages: Set) fun resendMessage(messages: Set) fun showMessageDetail(messages: Set) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index 11069937a09..177becd497f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -57,9 +57,9 @@ object ConversationMenuHelper { if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) { inflater.inflate(R.menu.menu_conversation_expiration, menu) } - // One-on-one chat menu allows copying the session id + // One-on-one chat menu allows copying the account id if (thread.isContactRecipient) { - inflater.inflate(R.menu.menu_conversation_copy_session_id, menu) + inflater.inflate(R.menu.menu_conversation_copy_account_id, menu) } // One-on-one chat menu (options that should only be present for one-on-one chats) if (thread.isContactRecipient) { @@ -135,7 +135,7 @@ object ConversationMenuHelper { R.id.menu_unblock -> { unblock(context, thread) } R.id.menu_block -> { block(context, thread, deleteThread = false) } R.id.menu_block_delete -> { blockAndDelete(context, thread) } - R.id.menu_copy_session_id -> { copySessionID(context, thread) } + R.id.menu_copy_account_id -> { copyAccountID(context, thread) } R.id.menu_copy_open_group_url -> { copyOpenGroupUrl(context, thread) } R.id.menu_edit_group -> { editClosedGroup(context, thread) } R.id.menu_leave_group -> { leaveClosedGroup(context, thread) } @@ -246,10 +246,10 @@ object ConversationMenuHelper { listener.block(deleteThread = true) } - private fun copySessionID(context: Context, thread: Recipient) { + private fun copyAccountID(context: Context, thread: Recipient) { if (!thread.isContactRecipient) { return } val listener = context as? ConversationMenuListener ?: return - listener.copySessionID(thread.address.toString()) + listener.copyAccountID(thread.address.toString()) } private fun copyOpenGroupUrl(context: Context, thread: Recipient) { @@ -271,8 +271,8 @@ object ConversationMenuHelper { val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull() val admins = group.admins - val sessionID = TextSecurePreferences.getLocalNumber(context) - val isCurrentUserAdmin = admins.any { it.toString() == sessionID } + val accountID = TextSecurePreferences.getLocalNumber(context) + val isCurrentUserAdmin = admins.any { it.toString() == accountID } val message = if (isCurrentUserAdmin) { "Because you are the creator of this group it will be deleted for everyone. This cannot be undone." } else { @@ -325,7 +325,7 @@ object ConversationMenuHelper { interface ConversationMenuListener { fun block(deleteThread: Boolean = false) fun unblock() - fun copySessionID(sessionId: String) + fun copyAccountID(accountId: String) fun copyOpenGroupUrl(thread: Recipient) fun showDisappearingMessages(thread: Recipient) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt index c4a29fea542..77565244a07 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt @@ -70,7 +70,7 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? isOutgoingMessage: Boolean, isOpenGroupInvitation: Boolean, threadID: Long, isOriginalMissing: Boolean, glide: GlideRequests) { // Author - val author = contactDb.getContactWithSessionID(authorPublicKey) + val author = contactDb.getContactWithAccountID(authorPublicKey) val localNumber = TextSecurePreferences.getLocalNumber(context) val quoteIsLocalUser = localNumber != null && authorPublicKey == localNumber diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index 83c6904dec2..b320e72e265 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -22,6 +22,7 @@ import androidx.core.view.isVisible import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageContentBinding import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment @@ -289,7 +290,7 @@ class VisibleMessageContentView : ConstraintLayout { // replace URLSpans with ModalURLSpans body.getSpans(0, body.length).toList().forEach { urlSpan -> - val updatedUrl = urlSpan.url.let { HttpUrl.parse(it).toString() } + val updatedUrl = urlSpan.url.let { it.toHttpUrlOrNull().toString() } val replacementSpan = ModalURLSpan(updatedUrl) { url -> val activity = context as AppCompatActivity ModalUrlBottomSheet(url).show(activity.supportFragmentManager, "Open URL Dialog") diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 9470d17de58..b2e3bba81b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -36,6 +36,7 @@ import org.session.libsession.messaging.contacts.Contact.ContactContext import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.utilities.Address +import org.session.libsession.utilities.ThemeUtil.getThemedColor import org.session.libsession.utilities.ViewUtil import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.modifyLayoutParams @@ -143,7 +144,7 @@ class VisibleMessageView : FrameLayout { glide: GlideRequests = GlideApp.with(this), searchQuery: String? = null, contact: Contact? = null, - senderSessionID: String, + senderAccountID: String, lastSeen: Long, delegate: VisibleMessageViewDelegate? = null, onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit, @@ -178,30 +179,30 @@ class VisibleMessageView : FrameLayout { if (isGroupThread && !message.isOutgoing) { if (isEndOfMessageCluster) { - binding.profilePictureView.publicKey = senderSessionID + binding.profilePictureView.publicKey = senderAccountID binding.profilePictureView.update(message.individualRecipient) binding.profilePictureView.setOnClickListener { if (thread.isCommunityRecipient) { val openGroup = lokiThreadDb.getOpenGroupChat(threadID) - if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED && openGroup?.canWrite == true) { + if (IdPrefix.fromValue(senderAccountID) == IdPrefix.BLINDED && openGroup?.canWrite == true) { // TODO: support v2 soon val intent = Intent(context, ConversationActivityV2::class.java) intent.putExtra(ConversationActivityV2.FROM_GROUP_THREAD_ID, threadID) - intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(senderSessionID)) + intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(senderAccountID)) context.startActivity(intent) } } else { - maybeShowUserDetails(senderSessionID, threadID) + maybeShowUserDetails(senderAccountID, threadID) } } if (thread.isCommunityRecipient) { val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return var standardPublicKey = "" var blindedPublicKey: String? = null - if (IdPrefix.fromValue(senderSessionID)?.isBlinded() == true) { - blindedPublicKey = senderSessionID + if (IdPrefix.fromValue(senderAccountID)?.isBlinded() == true) { + blindedPublicKey = senderAccountID } else { - standardPublicKey = senderSessionID + standardPublicKey = senderAccountID } val isModerator = OpenGroupManager.isUserModerator(context, openGroup.groupId, standardPublicKey, blindedPublicKey) binding.moderatorIconImageView.isVisible = !message.isOutgoing && isModerator @@ -211,7 +212,7 @@ class VisibleMessageView : FrameLayout { binding.senderNameTextView.isVisible = !message.isOutgoing && (isStartOfMessageCluster && (isGroupThread || snIsSelected)) val contactContext = if (thread.isCommunityRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR - binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderSessionID + binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderAccountID // Unread marker val shouldShowUnreadMarker = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing @@ -382,7 +383,7 @@ class VisibleMessageView : FrameLayout { private fun getMessageStatusInfo(message: MessageRecord): MessageStatusInfo = when { message.isFailed -> MessageStatusInfo(R.drawable.ic_delivery_status_failed, - resources.getColor(R.color.destructive, context.theme), + getThemedColor(context, R.attr.danger), R.string.delivery_status_failed ) message.isSyncFailed -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt index 3a4bfd6816b..4d3e48bc5b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt @@ -1,15 +1,12 @@ package org.thoughtcrime.securesms.conversation.v2.utilities import android.content.Context -import android.graphics.Color import android.graphics.Typeface import android.text.Spannable import android.text.SpannableString -import android.text.style.BackgroundColorSpan import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan import android.util.Range -import androidx.core.content.res.ResourcesCompat import network.loki.messenger.R import nl.komponents.kovenant.combine.Tuple2 import org.session.libsession.messaging.contacts.Contact @@ -22,7 +19,6 @@ import org.session.libsession.utilities.truncateIdForDisplay import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.RoundedBackgroundSpan import org.thoughtcrime.securesms.util.getAccentColor -import org.thoughtcrime.securesms.util.toPx import java.util.regex.Pattern object MentionUtilities { @@ -66,7 +62,7 @@ object MentionUtilities { val userDisplayName: String? = if (isYou) { context.getString(R.string.MessageRecord_you) } else { - val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey) + val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey) @Suppress("NAME_SHADOWING") val context = if (openGroup != null) Contact.ContactContext.OPEN_GROUP else Contact.ContactContext.REGULAR contact?.displayName(context) ?: truncateIdForDisplay(publicKey) } @@ -161,7 +157,7 @@ object MentionUtilities { } private fun isYou(mentionedPublicKey: String, userPublicKey: String, openGroup: OpenGroup?): Boolean { - val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.sessionId(userPublicKey, mentionedPublicKey, it.publicKey) } ?: false + val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.accountId(userPublicKey, mentionedPublicKey, it.publicKey) } ?: false return mentionedPublicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/BlindedIdMappingDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/BlindedIdMappingDatabase.kt index 950c1c6bcf3..a5919d4394b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/BlindedIdMappingDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/BlindedIdMappingDatabase.kt @@ -31,7 +31,7 @@ class BlindedIdMappingDatabase(context: Context, helper: SQLCipherOpenHelper) : private fun readBlindedIdMapping(cursor: Cursor): BlindedIdMapping { return BlindedIdMapping( blindedId = cursor.getString(cursor.getColumnIndexOrThrow(BLINDED_PK)), - sessionId = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(SESSION_PK)), + accountId = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(SESSION_PK)), serverUrl = cursor.getString(cursor.getColumnIndexOrThrow(SERVER_URL)), serverId = cursor.getString(cursor.getColumnIndexOrThrow(SERVER_PK)), ) @@ -58,7 +58,7 @@ class BlindedIdMappingDatabase(context: Context, helper: SQLCipherOpenHelper) : try { val values = ContentValues().apply { put(BLINDED_PK, blindedIdMapping.blindedId) - put(SERVER_PK, blindedIdMapping.sessionId) + put(SERVER_PK, blindedIdMapping.accountId) put(SERVER_URL, blindedIdMapping.serverUrl) put(SERVER_PK, blindedIdMapping.serverId) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index 23a1af7cebe..9d3a6c9c184 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -1242,73 +1242,50 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } private fun getNotificationMmsMessageRecord(cursor: Cursor): NotificationMmsMessageRecord { - val id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)) - val dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT)) - val dateReceived = cursor.getLong( - cursor.getColumnIndexOrThrow( - NORMALIZED_DATE_RECEIVED - ) - ) - val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)) - val mailbox = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX)) - val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)) - val addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(ADDRESS_DEVICE_ID)) - val recipient = getRecipientFor(address) - val contentLocation = cursor.getString(cursor.getColumnIndexOrThrow(CONTENT_LOCATION)) - val transactionId = cursor.getString(cursor.getColumnIndexOrThrow(TRANSACTION_ID)) - val messageSize = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_SIZE)) - val expiry = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRY)) - val status = cursor.getInt(cursor.getColumnIndexOrThrow(STATUS)) - val deliveryReceiptCount = cursor.getInt( - cursor.getColumnIndexOrThrow( - DELIVERY_RECEIPT_COUNT - ) - ) - val readReceiptCount = if (isReadReceiptsEnabled(context)) cursor.getInt(cursor.getColumnIndexOrThrow(READ_RECEIPT_COUNT)) else 0 - val hasMention = (cursor.getInt(cursor.getColumnIndexOrThrow(HAS_MENTION)) == 1) - val contentLocationBytes: ByteArray? = contentLocation?.takeUnless { it.isEmpty() }?.let(::toIsoBytes) - val transactionIdBytes: ByteArray? = transactionId?.takeUnless { it.isEmpty() }?.let(::toIsoBytes) - val slideDeck = SlideDeck(context, MmsNotificationAttachment(status, messageSize)) + // Note: Additional details such as ADDRESS_DEVICE_ID, CONTENT_LOCATION, and TRANSACTION_ID are available if required. + val id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)) + val dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT)) + val dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_RECEIVED)) + val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)) + val mailbox = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX)) + val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)) + val recipient = getRecipientFor(address) + val messageSize = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_SIZE)) + val expiry = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRY)) + val status = cursor.getInt(cursor.getColumnIndexOrThrow(STATUS)) + val deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(DELIVERY_RECEIPT_COUNT)) + val readReceiptCount = if (isReadReceiptsEnabled(context)) cursor.getInt(cursor.getColumnIndexOrThrow(READ_RECEIPT_COUNT)) else 0 + val hasMention = (cursor.getInt(cursor.getColumnIndexOrThrow(HAS_MENTION)) == 1) + val slideDeck = SlideDeck(context, MmsNotificationAttachment(status, messageSize)) + return NotificationMmsMessageRecord( id, recipient, recipient, dateSent, dateReceived, deliveryReceiptCount, threadId, - contentLocationBytes, messageSize, expiry, status, - transactionIdBytes, mailbox, slideDeck, + messageSize, expiry, status, mailbox, slideDeck, readReceiptCount, hasMention ) } private fun getMediaMmsMessageRecord(cursor: Cursor, getQuote: Boolean): MediaMmsMessageRecord { - val id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)) - val dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT)) - val dateReceived = cursor.getLong( - cursor.getColumnIndexOrThrow( - NORMALIZED_DATE_RECEIVED - ) - ) - val box = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX)) - val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)) - val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)) - val addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(ADDRESS_DEVICE_ID)) - val deliveryReceiptCount = cursor.getInt( - cursor.getColumnIndexOrThrow( - DELIVERY_RECEIPT_COUNT - ) - ) - var readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(READ_RECEIPT_COUNT)) - val body = cursor.getString(cursor.getColumnIndexOrThrow(BODY)) - val partCount = cursor.getInt(cursor.getColumnIndexOrThrow(PART_COUNT)) - val mismatchDocument = cursor.getString( - cursor.getColumnIndexOrThrow( - MISMATCHED_IDENTITIES - ) - ) - val networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(NETWORK_FAILURE)) - val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID)) - val expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN)) - val expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRE_STARTED)) - val unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED)) == 1 - val hasMention = cursor.getInt(cursor.getColumnIndexOrThrow(HAS_MENTION)) == 1 + val id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)) + val dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT)) + val dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_RECEIVED)) + val box = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX)) + val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)) + val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)) + val addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(ADDRESS_DEVICE_ID)) + val deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(DELIVERY_RECEIPT_COUNT)) + var readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(READ_RECEIPT_COUNT)) + val body = cursor.getString(cursor.getColumnIndexOrThrow(BODY)) + val partCount = cursor.getInt(cursor.getColumnIndexOrThrow(PART_COUNT)) + val mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(MISMATCHED_IDENTITIES)) + val networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(NETWORK_FAILURE)) + val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID)) + val expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN)) + val expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRE_STARTED)) + val unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED)) == 1 + val hasMention = cursor.getInt(cursor.getColumnIndexOrThrow(HAS_MENTION)) == 1 + if (!isReadReceiptsEnabled(context)) { readReceiptCount = 0 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index 8dbef320172..dcd7778c9a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -55,7 +55,7 @@ public class RecipientDatabase extends Database { private static final String SYSTEM_PHONE_LABEL = "system_phone_label"; private static final String SYSTEM_CONTACT_URI = "system_contact_uri"; private static final String SIGNAL_PROFILE_NAME = "signal_profile_name"; - private static final String SIGNAL_PROFILE_AVATAR = "signal_profile_avatar"; + private static final String SESSION_PROFILE_AVATAR = "signal_profile_avatar"; private static final String PROFILE_SHARING = "profile_sharing_approval"; private static final String CALL_RINGTONE = "call_ringtone"; private static final String CALL_VIBRATE = "call_vibrate"; @@ -69,7 +69,7 @@ public class RecipientDatabase extends Database { private static final String[] RECIPIENT_PROJECTION = new String[] { BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED, PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI, - SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL, + SIGNAL_PROFILE_NAME, SESSION_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL, UNIDENTIFIED_ACCESS_MODE, FORCE_SMS_SELECTION, NOTIFY_TYPE, DISAPPEARING_STATE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS }; @@ -97,7 +97,7 @@ public class RecipientDatabase extends Database { SYSTEM_CONTACT_URI + " TEXT DEFAULT NULL, " + PROFILE_KEY + " TEXT DEFAULT NULL, " + SIGNAL_PROFILE_NAME + " TEXT DEFAULT NULL, " + - SIGNAL_PROFILE_AVATAR + " TEXT DEFAULT NULL, " + + SESSION_PROFILE_AVATAR + " TEXT DEFAULT NULL, " + PROFILE_SHARING + " INTEGER DEFAULT 0, " + CALL_RINGTONE + " TEXT DEFAULT NULL, " + CALL_VIBRATE + " INTEGER DEFAULT " + Recipient.VibrateState.DEFAULT.getId() + ", " + @@ -204,7 +204,7 @@ Optional getRecipientSettings(@NonNull Cursor cursor) { String systemPhoneLabel = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHONE_LABEL)); String systemContactUri = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_CONTACT_URI)); String signalProfileName = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_NAME)); - String signalProfileAvatar = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_AVATAR)); + String signalProfileAvatar = cursor.getString(cursor.getColumnIndexOrThrow(SESSION_PROFILE_AVATAR)); boolean profileSharing = cursor.getInt(cursor.getColumnIndexOrThrow(PROFILE_SHARING)) == 1; String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL)); int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE)); @@ -361,7 +361,7 @@ public void setProfileKey(@NonNull Recipient recipient, @Nullable byte[] profile public void setProfileAvatar(@NonNull Recipient recipient, @Nullable String profileAvatar) { ContentValues contentValues = new ContentValues(1); - contentValues.put(SIGNAL_PROFILE_AVATAR, profileAvatar); + contentValues.put(SESSION_PROFILE_AVATAR, profileAvatar); updateOrInsert(recipient.getAddress(), contentValues); recipient.resolve().setProfileAvatar(profileAvatar); notifyRecipientListeners(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt index 778af6c01cc..27b3e73397c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt @@ -6,7 +6,7 @@ import android.database.Cursor import androidx.core.database.getStringOrNull import org.json.JSONArray import org.session.libsession.messaging.contacts.Contact -import org.session.libsession.messaging.utilities.SessionId +import org.session.libsession.messaging.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.IdPrefix import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper @@ -15,7 +15,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da companion object { private const val sessionContactTable = "session_contact_database" - const val sessionID = "session_id" + const val accountID = "session_id" const val name = "name" const val nickname = "nickname" const val profilePictureURL = "profile_picture_url" @@ -25,7 +25,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da const val isTrusted = "is_trusted" @JvmStatic val createSessionContactTableCommand = "CREATE TABLE $sessionContactTable " + - "($sessionID STRING PRIMARY KEY, " + + "($accountID STRING PRIMARY KEY, " + "$name TEXT DEFAULT NULL, " + "$nickname TEXT DEFAULT NULL, " + "$profilePictureURL TEXT DEFAULT NULL, " + @@ -35,19 +35,19 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da "$isTrusted INTEGER DEFAULT 0);" } - fun getContactWithSessionID(sessionID: String): Contact? { + fun getContactWithAccountID(accountID: String): Contact? { val database = databaseHelper.readableDatabase - return database.get(sessionContactTable, "${Companion.sessionID} = ?", arrayOf( sessionID )) { cursor -> + return database.get(sessionContactTable, "${Companion.accountID} = ?", arrayOf( accountID )) { cursor -> contactFromCursor(cursor) } } - fun getContacts(sessionIDs: Collection): List { + fun getContacts(accountIDs: Collection): List { val database = databaseHelper.readableDatabase return database.getAll( sessionContactTable, - "$sessionID IN (SELECT value FROM json_each(?))", - arrayOf(JSONArray(sessionIDs).toString()) + "$accountID IN (SELECT value FROM json_each(?))", + arrayOf(JSONArray(accountIDs).toString()) ) { cursor -> contactFromCursor(cursor) } } @@ -56,8 +56,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da return database.getAll(sessionContactTable, null, null) { cursor -> contactFromCursor(cursor) }.filter { contact -> - val sessionId = SessionId(contact.sessionID) - sessionId.prefix == IdPrefix.STANDARD + contact.accountID.let(::AccountId).prefix == IdPrefix.STANDARD }.toSet() } @@ -65,7 +64,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da val database = databaseHelper.writableDatabase val contentValues = ContentValues(1) contentValues.put(Companion.isTrusted, if (isTrusted) 1 else 0) - database.update(sessionContactTable, contentValues, "$sessionID = ?", arrayOf( contact.sessionID )) + database.update(sessionContactTable, contentValues, "$accountID = ?", arrayOf( contact.accountID )) if (threadID >= 0) { notifyConversationListeners(threadID) } @@ -75,7 +74,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da fun setContact(contact: Contact) { val database = databaseHelper.writableDatabase val contentValues = ContentValues(8) - contentValues.put(sessionID, contact.sessionID) + contentValues.put(accountID, contact.accountID) contentValues.put(name, contact.name) contentValues.put(nickname, contact.nickname) contentValues.put(profilePictureURL, contact.profilePictureURL) @@ -85,13 +84,13 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da } contentValues.put(threadID, contact.threadID) contentValues.put(isTrusted, if (contact.isTrusted) 1 else 0) - database.insertOrUpdate(sessionContactTable, contentValues, "$sessionID = ?", arrayOf( contact.sessionID )) + database.insertOrUpdate(sessionContactTable, contentValues, "$accountID = ?", arrayOf( contact.accountID )) notifyConversationListListeners() } fun contactFromCursor(cursor: Cursor): Contact { - val sessionID = cursor.getString(cursor.getColumnIndexOrThrow(sessionID)) - val contact = Contact(sessionID) + val accountID = cursor.getString(cursor.getColumnIndexOrThrow(accountID)) + val contact = Contact(accountID) contact.name = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(name)) contact.nickname = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(nickname)) contact.profilePictureURL = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureURL)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index dd0544b420b..3115275773c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -2,14 +2,17 @@ package org.thoughtcrime.securesms.database import android.content.Context import android.net.Uri +import java.security.MessageDigest 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_PINNED +import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.Contacts import network.loki.messenger.libsession_util.ConversationVolatileConfig import network.loki.messenger.libsession_util.UserGroupsConfig import network.loki.messenger.libsession_util.UserProfile import network.loki.messenger.libsession_util.util.BaseCommunityInfo +import network.loki.messenger.libsession_util.util.Contact as LibSessionContact import network.loki.messenger.libsession_util.util.Conversation import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.GroupInfo @@ -55,7 +58,7 @@ import org.session.libsession.messaging.sending_receiving.link_preview.LinkPrevi import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2 import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel -import org.session.libsession.messaging.utilities.SessionId +import org.session.libsession.messaging.utilities.AccountId import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.snode.OnionRequestAPI @@ -66,6 +69,7 @@ import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.ProfileKeyUtil import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol.Companion.NAME_PADDED_LENGTH import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient.DisappearingState @@ -91,8 +95,6 @@ import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.SessionMetaProtocol -import java.security.MessageDigest -import network.loki.messenger.libsession_util.util.Contact as LibSessionContact private const val TAG = "Storage" @@ -110,12 +112,12 @@ open class Storage( if (address.isGroup) { val groups = configFactory.userGroups ?: return if (address.isClosedGroup) { - val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize()) + val accountId = GroupUtil.doubleDecodeGroupId(address.serialize()) val closedGroup = getGroup(address.toGroupString()) if (closedGroup != null && closedGroup.isActive) { - val legacyGroup = groups.getOrConstructLegacyGroupInfo(sessionId) + val legacyGroup = groups.getOrConstructLegacyGroupInfo(accountId) groups.set(legacyGroup) - val newVolatileParams = volatile.getOrConstructLegacyGroup(sessionId).copy( + val newVolatileParams = volatile.getOrConstructLegacyGroup(accountId).copy( lastRead = SnodeAPI.nowWithOffset, ) volatile.set(newVolatileParams) @@ -126,16 +128,16 @@ open class Storage( } } else if (address.isContact) { // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config - if (SessionId(address.serialize()).prefix != IdPrefix.STANDARD) return + if (AccountId(address.serialize()).prefix != IdPrefix.STANDARD) return // don't update our own address into the contacts DB if (getUserPublicKey() != address.serialize()) { val contacts = configFactory.contacts ?: return contacts.upsertContact(address.serialize()) { - priority = ConfigBase.PRIORITY_VISIBLE + priority = PRIORITY_VISIBLE } } else { val userProfile = configFactory.user ?: return - userProfile.setNtsPriority(ConfigBase.PRIORITY_VISIBLE) + userProfile.setNtsPriority(PRIORITY_VISIBLE) DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true) } val newVolatileParams = volatile.getOrConstructOneToOne(address.serialize()) @@ -148,16 +150,16 @@ open class Storage( if (address.isGroup) { val groups = configFactory.userGroups ?: return if (address.isClosedGroup) { - val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize()) - volatile.eraseLegacyClosedGroup(sessionId) - groups.eraseLegacyGroup(sessionId) + val accountId = GroupUtil.doubleDecodeGroupId(address.serialize()) + volatile.eraseLegacyClosedGroup(accountId) + groups.eraseLegacyGroup(accountId) } else if (address.isCommunity) { // these should be removed in the group leave / handling new configs Log.w("Loki", "Thread delete called for open group address, expecting to be handled elsewhere") } } else { // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config - if (SessionId(address.serialize()).prefix != IdPrefix.STANDARD) return + if (AccountId(address.serialize()).prefix != IdPrefix.STANDARD) return volatile.eraseOneToOne(address.serialize()) if (getUserPublicKey() != address.serialize()) { val contacts = configFactory.contacts ?: return @@ -264,10 +266,8 @@ open class Storage( } // otherwise recipient is one to one recipient.isContactRecipient -> { - // don't process non-standard session IDs though - val sessionId = SessionId(recipient.address.serialize()) - if (sessionId.prefix != IdPrefix.STANDARD) return - + // don't process non-standard account IDs though + if (AccountId(recipient.address.serialize()).prefix != IdPrefix.STANDARD) return config.getOrConstructOneToOne(recipient.address.serialize()) } else -> throw NullPointerException("Weren't expecting to have a convo with address ${recipient.address.serialize()}") @@ -298,8 +298,8 @@ open class Storage( var messageID: Long? = null val senderAddress = fromSerialized(message.sender!!) val isUserSender = (message.sender!! == getUserPublicKey()) - val isUserBlindedSender = message.threadID?.takeIf { it >= 0 }?.let { getOpenGroup(it)?.publicKey } - ?.let { SodiumUtilities.sessionId(getUserPublicKey()!!, message.sender!!, it) } ?: false + val isUserBlindedSender = message.threadID?.takeIf { it >= 0 }?.let(::getOpenGroup)?.publicKey + ?.let { SodiumUtilities.accountId(getUserPublicKey()!!, message.sender!!, it) } ?: false val group: Optional = when { openGroupID != null -> Optional.of(SignalServiceGroup(openGroupID.toByteArray(), SignalServiceGroup.GroupType.PUBLIC_CHAT)) groupPublicKey != null -> { @@ -471,22 +471,26 @@ open class Storage( val userPublicKey = getUserPublicKey() ?: return // would love to get rid of recipient and context from this val recipient = Recipient.from(context, fromSerialized(userPublicKey), false) - // update name + + // Update profile name val name = userProfile.getName() ?: return val userPic = userProfile.getPic() val profileManager = SSKEnvironment.shared.profileManager - if (name.isNotEmpty()) { - TextSecurePreferences.setProfileName(context, name) - profileManager.setName(context, recipient, name) + + name.takeUnless { it.isEmpty() }?.truncate(NAME_PADDED_LENGTH)?.let { + TextSecurePreferences.setProfileName(context, it) + profileManager.setName(context, recipient, it) + if (it != name) userProfile.setName(it) } - // update pfp + // Update profile picture if (userPic == UserPic.DEFAULT) { clearUserPic() } else if (userPic.key.isNotEmpty() && userPic.url.isNotEmpty() && TextSecurePreferences.getProfilePictureURL(context) != userPic.url) { setUserProfilePicture(userPic.url, userPic.key) } + if (userProfile.getNtsPriority() == PRIORITY_HIDDEN) { // delete nts thread if needed val ourThread = getThreadId(recipient) ?: return @@ -514,12 +518,13 @@ open class Storage( addLibSessionContacts(extracted, messageTimestamp) } - override fun clearUserPic() { - val userPublicKey = getUserPublicKey() ?: return + override fun clearUserPic() { + val userPublicKey = getUserPublicKey() ?: return Log.w(TAG, "No user public key when trying to clear user pic") val recipientDatabase = DatabaseComponent.get(context).recipientDatabase() - // would love to get rid of recipient and context from this + val recipient = Recipient.from(context, fromSerialized(userPublicKey), false) - // clear picture if userPic is null + + // Clear details related to the user's profile picture TextSecurePreferences.setProfileKey(context, null) ProfileKeyUtil.setEncodedProfileKey(context, null) recipientDatabase.setProfileAvatar(recipient, null) @@ -528,14 +533,13 @@ open class Storage( Recipient.removeCached(fromSerialized(userPublicKey)) configFactory.user?.setPic(UserPic.DEFAULT) - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) } private fun updateConvoVolatile(convos: ConversationVolatileConfig, messageTimestamp: Long) { val extracted = convos.all() for (conversation in extracted) { val threadId = when (conversation) { - is Conversation.OneToOne -> getThreadIdFor(conversation.sessionId, null, null, createThread = false) + is Conversation.OneToOne -> getThreadIdFor(conversation.accountId, null, null, createThread = false) is Conversation.LegacyGroup -> getThreadIdFor("", conversation.groupId,null, createThread = false) is Conversation.Community -> getThreadIdFor("",null, "${conversation.baseCommunityInfo.baseUrl.removeSuffix("/")}.${conversation.baseCommunityInfo.room}", createThread = false) } @@ -566,7 +570,7 @@ open class Storage( val existingJoinUrls = existingCommunities.values.map { it.joinURL } val existingClosedGroups = getAllGroups(includeInactive = true).filter { it.isClosedGroup } - val lgcIds = lgc.map { it.sessionId } + val lgcIds = lgc.map { it.accountId } val toDeleteClosedGroups = existingClosedGroups.filter { group -> GroupUtil.doubleDecodeGroupId(group.encodedId) !in lgcIds } @@ -600,8 +604,8 @@ open class Storage( } for (group in lgc) { - val groupId = GroupUtil.doubleEncodeGroupID(group.sessionId) - val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.sessionId } + val groupId = GroupUtil.doubleEncodeGroupID(group.accountId) + val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.accountId } val existingThread = existingGroup?.let { getThreadId(existingGroup.encodedId) } if (existingGroup != null) { if (group.priority == PRIORITY_HIDDEN && existingThread != null) { @@ -620,19 +624,19 @@ open class Storage( createGroup(groupId, title, admins + members, null, null, admins, formationTimestamp) setProfileSharing(Address.fromSerialized(groupId), true) // Add the group to the user's set of public keys to poll for - addClosedGroupPublicKey(group.sessionId) + addClosedGroupPublicKey(group.accountId) // Store the encryption key pair val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey)) - addClosedGroupEncryptionKeyPair(keyPair, group.sessionId, SnodeAPI.nowWithOffset) + addClosedGroupEncryptionKeyPair(keyPair, group.accountId, SnodeAPI.nowWithOffset) // Notify the PN server - PushRegistryV1.subscribeGroup(group.sessionId, publicKey = localUserPublicKey) + PushRegistryV1.subscribeGroup(group.accountId, publicKey = localUserPublicKey) // Notify the user val threadID = getOrCreateThreadIdFor(Address.fromSerialized(groupId)) threadDb.setDate(threadID, formationTimestamp) insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, title, members.map { it.serialize() }, admins.map { it.serialize() }, threadID, formationTimestamp) // Don't create config group here, it's from a config update // Start polling - ClosedGroupPollerV2.shared.startPolling(group.sessionId) + ClosedGroupPollerV2.shared.startPolling(group.accountId) } getThreadId(Address.fromSerialized(groupId))?.let { setExpirationConfiguration( @@ -933,10 +937,10 @@ open class Storage( groupVolatileConfig.lastRead = formationTimestamp volatiles.set(groupVolatileConfig) val groupInfo = GroupInfo.LegacyGroupInfo( - sessionId = groupPublicKey, + accountId = groupPublicKey, name = name, members = members, - priority = ConfigBase.PRIORITY_VISIBLE, + priority = PRIORITY_VISIBLE, encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte encSecKey = encryptionKeyPair.privateKey.serialize(), disappearingTimer = expirationTimer.toLong(), @@ -970,7 +974,7 @@ open class Storage( members = membersMap, encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte encSecKey = latestKeyPair.privateKey.serialize(), - priority = if (isPinned(threadID)) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, + priority = if (isPinned(threadID)) PRIORITY_PINNED else PRIORITY_VISIBLE, disappearingTimer = getExpirationConfiguration(threadID)?.expiryMode?.expirySeconds ?: 0L, joinedAt = (existingGroup.formationTimestamp / 1000L) ) @@ -1175,8 +1179,8 @@ open class Storage( return threadId ?: -1 } - override fun getContactWithSessionID(sessionID: String): Contact? { - return DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(sessionID) + override fun getContactWithAccountID(accountID: String): Contact? { + return DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(accountID) } override fun getAllContacts(): Set { @@ -1185,7 +1189,7 @@ open class Storage( override fun setContact(contact: Contact) { DatabaseComponent.get(context).sessionContactDatabase().setContact(contact) - val address = fromSerialized(contact.sessionID) + val address = fromSerialized(contact.accountID) if (!getRecipientApproved(address)) return val recipientHash = SSKEnvironment.shared.profileManager.contactUpdatedInternal(contact) val recipient = Recipient.from(context, address, false) @@ -1203,8 +1207,8 @@ open class Storage( override fun addLibSessionContacts(contacts: List, timestamp: Long) { val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase() val moreContacts = contacts.filter { contact -> - val id = SessionId(contact.id) - id.prefix?.isBlinded() == false || mappingDb.getBlindedIdMapping(contact.id).none { it.sessionId != null } + val id = AccountId(contact.id) + id.prefix?.isBlinded() == false || mappingDb.getBlindedIdMapping(contact.id).none { it.accountId != null } } val profileManager = SSKEnvironment.shared.profileManager moreContacts.forEach { contact -> @@ -1256,8 +1260,8 @@ open class Storage( val threadDatabase = DatabaseComponent.get(context).threadDatabase() val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase() val moreContacts = contacts.filter { contact -> - val id = SessionId(contact.publicKey) - id.prefix != IdPrefix.BLINDED || mappingDb.getBlindedIdMapping(contact.publicKey).none { it.sessionId != null } + val id = AccountId(contact.publicKey) + id.prefix != IdPrefix.BLINDED || mappingDb.getBlindedIdMapping(contact.publicKey).none { it.accountId != null } } for (contact in moreContacts) { val address = fromSerialized(contact.publicKey) @@ -1324,25 +1328,25 @@ open class Storage( val threadRecipient = getRecipientForThread(threadID) ?: return if (threadRecipient.isLocalNumber) { val user = configFactory.user ?: return - user.setNtsPriority(if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE) + user.setNtsPriority(if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE) } else if (threadRecipient.isContactRecipient) { val contacts = configFactory.contacts ?: return contacts.upsertContact(threadRecipient.address.serialize()) { - priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE + priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE } } else if (threadRecipient.isGroupRecipient) { val groups = configFactory.userGroups ?: return if (threadRecipient.isClosedGroupRecipient) { - val sessionId = GroupUtil.doubleDecodeGroupId(threadRecipient.address.serialize()) - val newGroupInfo = groups.getOrConstructLegacyGroupInfo(sessionId).copy ( - priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE - ) - groups.set(newGroupInfo) + threadRecipient.address.serialize() + .let(GroupUtil::doubleDecodeGroupId) + .let(groups::getOrConstructLegacyGroupInfo) + .copy (priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE) + .let(groups::set) } else if (threadRecipient.isCommunityRecipient) { val openGroup = getOpenGroup(threadID) ?: return val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return val newGroupInfo = groups.getOrConstructCommunityInfo(baseUrl, room, Hex.toStringCondensed(pubKeyHex)).copy ( - priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE + priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE ) groups.set(newGroupInfo) } @@ -1491,14 +1495,8 @@ open class Storage( val address = recipient.address.serialize() val blindedId = when { recipient.isGroupRecipient -> null - recipient.isOpenGroupInboxRecipient -> { - GroupUtil.getDecodedOpenGroupInboxSessionId(address) - } - else -> { - if (SessionId(address).prefix == IdPrefix.BLINDED) { - address - } else null - } + recipient.isOpenGroupInboxRecipient -> GroupUtil.getDecodedOpenGroupInboxAccountId(address) + else -> address.takeIf { AccountId(it).prefix == IdPrefix.BLINDED } } ?: continue mappingDb.getBlindedIdMapping(blindedId).firstOrNull()?.let { mappings[address] = it @@ -1506,18 +1504,18 @@ open class Storage( } } for (mapping in mappings) { - if (!SodiumUtilities.sessionId(senderPublicKey, mapping.value.blindedId, mapping.value.serverId)) { + if (!SodiumUtilities.accountId(senderPublicKey, mapping.value.blindedId, mapping.value.serverId)) { continue } - mappingDb.addBlindedIdMapping(mapping.value.copy(sessionId = senderPublicKey)) + mappingDb.addBlindedIdMapping(mapping.value.copy(accountId = senderPublicKey)) val blindedThreadId = threadDB.getOrCreateThreadIdFor(Recipient.from(context, fromSerialized(mapping.key), false)) mmsDb.updateThreadId(blindedThreadId, threadId) smsDb.updateThreadId(blindedThreadId, threadId) threadDB.deleteConversation(blindedThreadId) } - recipientDb.setApproved(sender, true) - recipientDb.setApprovedMe(sender, true) + setRecipientApproved(sender, true) + setRecipientApprovedMe(sender, true) val message = IncomingMediaMessage( sender.address, response.sentTimestamp!!, @@ -1615,20 +1613,20 @@ open class Storage( ): BlindedIdMapping { val db = DatabaseComponent.get(context).blindedIdMappingDatabase() val mapping = db.getBlindedIdMapping(blindedId).firstOrNull() ?: BlindedIdMapping(blindedId, null, server, serverPublicKey) - if (mapping.sessionId != null) { + if (mapping.accountId != null) { return mapping } getAllContacts().forEach { contact -> - val sessionId = SessionId(contact.sessionID) - if (sessionId.prefix == IdPrefix.STANDARD && SodiumUtilities.sessionId(sessionId.hexString, blindedId, serverPublicKey)) { - val contactMapping = mapping.copy(sessionId = sessionId.hexString) + val accountId = AccountId(contact.accountID) + if (accountId.prefix == IdPrefix.STANDARD && SodiumUtilities.accountId(accountId.hexString, blindedId, serverPublicKey)) { + val contactMapping = mapping.copy(accountId = accountId.hexString) db.addBlindedIdMapping(contactMapping) return contactMapping } } db.getBlindedIdMappingsExceptFor(server).forEach { - if (SodiumUtilities.sessionId(it.sessionId!!, blindedId, serverPublicKey)) { - val otherMapping = mapping.copy(sessionId = it.sessionId) + if (SodiumUtilities.accountId(it.accountId!!, blindedId, serverPublicKey)) { + val otherMapping = mapping.copy(accountId = it.accountId) db.addBlindedIdMapping(otherMapping) return otherMapping } @@ -1744,7 +1742,7 @@ open class Storage( if (recipient.isClosedGroupRecipient) { val userGroups = configFactory.userGroups ?: return - val groupPublicKey = GroupUtil.addressToGroupSessionId(recipient.address) + val groupPublicKey = GroupUtil.addressToGroupAccountId(recipient.address) val groupInfo = userGroups.getLegacyGroupInfo(groupPublicKey) ?.copy(disappearingTimer = expiryMode.expirySeconds) ?: return userGroups.set(groupInfo) @@ -1804,4 +1802,12 @@ open class Storage( lokiDb.setLastLegacySenderAddress(recipientAddress, null) } } -} \ No newline at end of file +} + +/** + * Truncate a string to a specified number of bytes + * + * This could split multi-byte characters/emojis. + */ +private fun String.truncate(sizeInBytes: Int): String = + toByteArray().takeIf { it.size > sizeInBytes }?.take(sizeInBytes)?.toByteArray()?.let(::String) ?: this diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Util.kt new file mode 100644 index 00000000000..402ee6155f7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Util.kt @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.database + +import android.content.Context +import org.thoughtcrime.securesms.dependencies.DatabaseComponent + +fun Context.threadDatabase() = DatabaseComponent.get(this).threadDatabase() \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java deleted file mode 100644 index 9fb40478791..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright (C) 2012 Moxie Marlinspike - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.thoughtcrime.securesms.database.model; - -import static java.util.Collections.emptyList; - -import android.content.Context; -import android.text.SpannableString; - -import androidx.annotation.NonNull; - -import org.session.libsession.utilities.recipients.Recipient; -import org.thoughtcrime.securesms.database.MmsDatabase; -import org.thoughtcrime.securesms.database.SmsDatabase.Status; -import org.thoughtcrime.securesms.mms.SlideDeck; - -import network.loki.messenger.R; - -/** - * Represents the message record model for MMS messages that are - * notifications (ie: they're pointers to undownloaded media). - * - * @author Moxie Marlinspike - * - */ - -public class NotificationMmsMessageRecord extends MmsMessageRecord { - private final byte[] contentLocation; - private final long messageSize; - private final long expiry; - private final int status; - private final byte[] transactionId; - - public NotificationMmsMessageRecord(long id, Recipient conversationRecipient, - Recipient individualRecipient, - long dateSent, long dateReceived, int deliveryReceiptCount, - long threadId, byte[] contentLocation, long messageSize, - long expiry, int status, byte[] transactionId, long mailbox, - SlideDeck slideDeck, int readReceiptCount, boolean hasMention) - { - super(id, "", conversationRecipient, individualRecipient, - dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, - emptyList(), emptyList(), - 0, 0, slideDeck, readReceiptCount, null, emptyList(), emptyList(), false, emptyList(), hasMention); - - this.contentLocation = contentLocation; - this.messageSize = messageSize; - this.expiry = expiry; - this.status = status; - this.transactionId = transactionId; - } - - public byte[] getTransactionId() { - return transactionId; - } - public int getStatus() { - return this.status; - } - public byte[] getContentLocation() { - return contentLocation; - } - public long getMessageSize() { - return (messageSize + 1023) / 1024; - } - public long getExpiration() { - return expiry * 1000; - } - - @Override - public boolean isOutgoing() { - return false; - } - - @Override - public boolean isPending() { - return false; - } - - @Override - public boolean isMmsNotification() { - return true; - } - - @Override - public boolean isMediaPending() { - return true; - } - - @Override - public SpannableString getDisplayBody(@NonNull Context context) { - if (status == MmsDatabase.Status.DOWNLOAD_INITIALIZED) { - return emphasisAdded(context.getString(R.string.NotificationMmsMessageRecord_multimedia_message)); - } else if (status == MmsDatabase.Status.DOWNLOAD_CONNECTING) { - return emphasisAdded(context.getString(R.string.NotificationMmsMessageRecord_downloading_mms_message)); - } else { - return emphasisAdded(context.getString(R.string.NotificationMmsMessageRecord_error_downloading_mms_message)); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.kt new file mode 100644 index 00000000000..eb742cd6b1d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2012 Moxie Marlinspike + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.database.model + +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.database.SmsDatabase +import org.thoughtcrime.securesms.mms.SlideDeck + +/** + * Represents the message record model for MMS messages that are + * notifications (ie: they're pointers to undownloaded media). + * + * @author Moxie Marlinspike + */ +class NotificationMmsMessageRecord( + id: Long, conversationRecipient: Recipient?, + individualRecipient: Recipient?, + dateSent: Long, + dateReceived: Long, + deliveryReceiptCount: Int, + threadId: Long, + private val messageSize: Long, + private val expiry: Long, + val status: Int, + mailbox: Long, + slideDeck: SlideDeck?, + readReceiptCount: Int, + hasMention: Boolean +) : MmsMessageRecord( + id, "", conversationRecipient, individualRecipient, + dateSent, dateReceived, threadId, SmsDatabase.Status.STATUS_NONE, deliveryReceiptCount, mailbox, + emptyList(), emptyList(), + 0, 0, slideDeck!!, readReceiptCount, null, emptyList(), emptyList(), false, emptyList(), hasMention +) { + fun getMessageSize(): Long { + return (messageSize + 1023) / 1024 + } + + val expiration: Long + get() = expiry * 1000 + + override fun isOutgoing(): Boolean { + return false + } + + override fun isPending(): Boolean { + return false + } + + override fun isMmsNotification(): Boolean { + return true + } + + override fun isMediaPending(): Boolean { + return true + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/EnterPublicKeyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/EnterPublicKeyFragment.kt deleted file mode 100644 index c3afbf5b771..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/dms/EnterPublicKeyFragment.kt +++ /dev/null @@ -1,101 +0,0 @@ -package org.thoughtcrime.securesms.dms - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.text.InputType -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.EditorInfo -import android.widget.Toast -import androidx.core.view.isVisible -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.Fragment -import network.loki.messenger.R -import network.loki.messenger.databinding.FragmentEnterPublicKeyBinding -import org.session.libsession.utilities.TextSecurePreferences -import org.thoughtcrime.securesms.util.QRCodeUtilities -import org.thoughtcrime.securesms.util.hideKeyboard -import org.thoughtcrime.securesms.util.toPx - -class EnterPublicKeyFragment : Fragment() { - private lateinit var binding: FragmentEnterPublicKeyBinding - - var delegate: EnterPublicKeyDelegate? = null - - private val hexEncodedPublicKey: String - get() { - return TextSecurePreferences.getLocalNumber(requireContext())!! - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = FragmentEnterPublicKeyBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - with(binding) { - publicKeyEditText.imeOptions = EditorInfo.IME_ACTION_DONE or 16777216 // Always use incognito keyboard - publicKeyEditText.setRawInputType(InputType.TYPE_CLASS_TEXT) - publicKeyEditText.setOnEditorActionListener { v, actionID, _ -> - if (actionID == EditorInfo.IME_ACTION_DONE) { - v.hideKeyboard() - handlePublicKeyEntered() - true - } else { - false - } - } - publicKeyEditText.addTextChangedListener { text -> createPrivateChatButton.isVisible = !text.isNullOrBlank() } - publicKeyEditText.setOnFocusChangeListener { _, hasFocus -> optionalContentContainer.isVisible = !hasFocus } - mainContainer.setOnTouchListener { _, _ -> - binding.optionalContentContainer.isVisible = true - publicKeyEditText.clearFocus() - publicKeyEditText.hideKeyboard() - true - } - val size = toPx(228, resources) - val qrCode = QRCodeUtilities.encode(hexEncodedPublicKey, size, isInverted = false, hasTransparentBackground = false) - qrCodeImageView.setImageBitmap(qrCode) - publicKeyTextView.text = hexEncodedPublicKey - publicKeyTextView.setOnCreateContextMenuListener { contextMenu, view, _ -> - contextMenu.add(0, view.id, 0, R.string.copy).setOnMenuItemClickListener { - copyPublicKey() - true - } - } - copyButton.setOnClickListener { copyPublicKey() } - shareButton.setOnClickListener { sharePublicKey() } - createPrivateChatButton.setOnClickListener { handlePublicKeyEntered(); publicKeyEditText.hideKeyboard() } - } - } - - private fun copyPublicKey() { - val clipboard = requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Session ID", hexEncodedPublicKey) - clipboard.setPrimaryClip(clip) - Toast.makeText(requireContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() - } - - private fun sharePublicKey() { - val intent = Intent() - intent.action = Intent.ACTION_SEND - intent.putExtra(Intent.EXTRA_TEXT, hexEncodedPublicKey) - intent.type = "text/plain" - startActivity(intent) - } - - private fun handlePublicKeyEntered() { - val hexEncodedPublicKey = binding.publicKeyEditText.text?.trim()?.toString() - if (hexEncodedPublicKey.isNullOrEmpty()) return - delegate?.handlePublicKeyEntered(hexEncodedPublicKey) - } -} - -fun interface EnterPublicKeyDelegate { - fun handlePublicKeyEntered(publicKey: String) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt deleted file mode 100644 index 74e2cac4c89..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt +++ /dev/null @@ -1,107 +0,0 @@ -package org.thoughtcrime.securesms.dms - -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import com.google.android.material.tabs.TabLayoutMediator -import dagger.hilt.android.AndroidEntryPoint -import network.loki.messenger.R -import network.loki.messenger.databinding.FragmentNewMessageBinding -import nl.komponents.kovenant.ui.failUi -import nl.komponents.kovenant.ui.successUi -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.recipients.Recipient -import org.session.libsignal.utilities.PublicKeyValidation -import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate -import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.dependencies.DatabaseComponent - -@AndroidEntryPoint -class NewMessageFragment : Fragment() { - - private lateinit var binding: FragmentNewMessageBinding - - lateinit var delegate: NewConversationDelegate - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentNewMessageBinding.inflate(inflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.backButton.setOnClickListener { delegate.onDialogBackPressed() } - binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() } - val onsOrPkDelegate = { onsNameOrPublicKey: String -> createPrivateChatIfPossible(onsNameOrPublicKey)} - val adapter = NewMessageFragmentAdapter( - parentFragment = this, - enterPublicKeyDelegate = onsOrPkDelegate, - scanPublicKeyDelegate = onsOrPkDelegate - ) - binding.viewPager.adapter = adapter - val mediator = TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, pos -> - tab.text = when (pos) { - 0 -> getString(R.string.activity_create_private_chat_enter_session_id_tab_title) - 1 -> getString(R.string.activity_create_private_chat_scan_qr_code_tab_title) - else -> throw IllegalStateException() - } - } - mediator.attach() - } - - private fun createPrivateChatIfPossible(onsNameOrPublicKey: String) { - if (PublicKeyValidation.isValid(onsNameOrPublicKey)) { - createPrivateChat(onsNameOrPublicKey) - } else { - // This could be an ONS name - showLoader() - SnodeAPI.getSessionID(onsNameOrPublicKey).successUi { hexEncodedPublicKey -> - hideLoader() - createPrivateChat(hexEncodedPublicKey) - }.failUi { exception -> - hideLoader() - var message = getString(R.string.fragment_enter_public_key_error_message) - exception.localizedMessage?.let { - message = it - } - Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() - } - } - } - - private fun createPrivateChat(hexEncodedPublicKey: String) { - val recipient = Recipient.from(requireContext(), Address.fromSerialized(hexEncodedPublicKey), false) - val intent = Intent(requireContext(), ConversationActivityV2::class.java) - intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) - intent.setDataAndType(requireActivity().intent.data, requireActivity().intent.type) - val existingThread = DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient) - intent.putExtra(ConversationActivityV2.THREAD_ID, existingThread) - requireContext().startActivity(intent) - delegate.onDialogClosePressed() - } - - private fun showLoader() { - binding.loader.visibility = View.VISIBLE - binding.loader.animate().setDuration(150).alpha(1.0f).start() - } - - private fun hideLoader() { - binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { - - override fun onAnimationEnd(animation: Animator) { - super.onAnimationEnd(animation) - binding.loader.visibility = View.GONE - } - }) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragmentAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragmentAdapter.kt deleted file mode 100644 index 3a07bcb518c..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragmentAdapter.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.thoughtcrime.securesms.dms - -import androidx.fragment.app.Fragment -import androidx.viewpager2.adapter.FragmentStateAdapter -import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment -import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate - -class NewMessageFragmentAdapter( - private val parentFragment: Fragment, - private val enterPublicKeyDelegate: EnterPublicKeyDelegate, - private val scanPublicKeyDelegate: ScanQRCodeWrapperFragmentDelegate -) : FragmentStateAdapter(parentFragment) { - - override fun getItemCount(): Int = 2 - - override fun createFragment(position: Int): Fragment { - return when (position) { - 0 -> EnterPublicKeyFragment().apply { delegate = enterPublicKeyDelegate } - 1 -> ScanQRCodeWrapperFragment().apply { delegate = scanPublicKeyDelegate } - else -> throw IllegalStateException() - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt index 75c7681b1de..7bfea9aab05 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt @@ -25,7 +25,7 @@ import org.session.libsession.utilities.Device import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.contacts.SelectContactsAdapter -import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate +import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView @@ -43,7 +43,7 @@ class CreateGroupFragment : Fragment() { private lateinit var binding: FragmentCreateGroupBinding private val viewModel: CreateGroupViewModel by viewModels() - lateinit var delegate: NewConversationDelegate + lateinit var delegate: StartConversationDelegate override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt index ae59c3833e8..964e1e17701 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt @@ -24,7 +24,7 @@ import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.OpenGroupUrlParser import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate +import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities @@ -33,7 +33,7 @@ class JoinCommunityFragment : Fragment() { private lateinit var binding: FragmentJoinCommunityBinding - lateinit var delegate: NewConversationDelegate + lateinit var delegate: StartConversationDelegate override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt index 01e1c514ff8..8bb7a39d4af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.groups import android.content.Context import androidx.annotation.WorkerThread import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.open_groups.GroupMemberRole import org.session.libsession.messaging.open_groups.OpenGroup @@ -143,9 +144,9 @@ object OpenGroupManager { @WorkerThread fun addOpenGroup(urlAsString: String, context: Context): OpenGroupApi.RoomInfo? { - val url = HttpUrl.parse(urlAsString) ?: return null + val url = urlAsString.toHttpUrlOrNull() ?: return null val server = OpenGroup.getServer(urlAsString) - val room = url.pathSegments().firstOrNull() ?: return null + val room = url.pathSegments.firstOrNull() ?: return null val publicKey = url.queryParameter("public_key") ?: return null return add(server.toString().removeSuffix("/"), room, publicKey, context).second // assume migrated from calling function diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index 36ea3a4371e..68aea844170 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.res.Resources import android.graphics.Typeface import android.graphics.drawable.ColorDrawable -import android.text.SpannableString import android.text.TextUtils import android.util.AttributeSet import android.util.TypedValue @@ -16,13 +15,13 @@ import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ViewConversationBinding +import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_NONE import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.getAccentColor import org.thoughtcrime.securesms.util.getConversationUnread @@ -50,7 +49,7 @@ class ConversationView : LinearLayout { // endregion // region Updating - fun bind(thread: ThreadRecord, isTyping: Boolean, glide: GlideRequests) { + fun bind(thread: ThreadRecord, isTyping: Boolean) { this.thread = thread if (thread.isPinned) { binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds( @@ -69,7 +68,7 @@ class ConversationView : LinearLayout { } val unreadCount = thread.unreadCount if (thread.recipient.isBlocked) { - binding.accentView.setBackgroundResource(R.color.destructive) + binding.accentView.setBackgroundColor(ThemeUtil.getThemedColor(context, R.attr.danger)) binding.accentView.visibility = View.VISIBLE } else { val accentColor = context.getAccentColor() @@ -122,7 +121,7 @@ class ConversationView : LinearLayout { !thread.isOutgoing -> binding.statusIndicatorImageView.visibility = View.GONE thread.isFailed -> { val drawable = ContextCompat.getDrawable(context, R.drawable.ic_error)?.mutate() - drawable?.setTint(ContextCompat.getColor(context, R.color.destructive)) + drawable?.setTint(ThemeUtil.getThemedColor(context, R.attr.danger)) binding.statusIndicatorImageView.setImageDrawable(drawable) } thread.isPending -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_dot_dot_dot) @@ -141,11 +140,10 @@ class ConversationView : LinearLayout { else -> recipient.toShortString() // Internally uses the Contact API } - private fun ThreadRecord.getSnippet(): CharSequence = - concatSnippet(getSnippetPrefix(), getDisplayBody(context)) - - private fun concatSnippet(prefix: CharSequence?, body: CharSequence): CharSequence = - prefix?.let { TextUtils.concat(it, ": ", body) } ?: body + private fun ThreadRecord.getSnippet(): CharSequence = listOfNotNull( + getSnippetPrefix(), + getDisplayBody(context) + ).joinToString(": ") private fun ThreadRecord.getSnippetPrefix(): CharSequence? = when { recipient.isLocalNumber || lastMessage?.isControlMessage == true -> null diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/EmptyView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/EmptyView.kt new file mode 100644 index 00000000000..aa4e0d9017e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/EmptyView.kt @@ -0,0 +1,88 @@ +package org.thoughtcrime.securesms.home + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.Divider +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.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalType + +@Composable +internal fun EmptyView(newAccount: Boolean) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(horizontal = 50.dp) + ) { + Spacer(modifier = Modifier.weight(1f)) + Icon( + painter = painterResource(id = if (newAccount) R.drawable.emoji_tada_large else R.drawable.ic_logo_large), + contentDescription = null, + tint = Color.Unspecified + ) + if (newAccount) { + Text( + stringResource(R.string.onboardingAccountCreated), + style = LocalType.current.h4, + textAlign = TextAlign.Center + ) + Text( + stringResource(R.string.welcome_to_session), + style = LocalType.current.base, + color = LocalColors.current.primary, + textAlign = TextAlign.Center + ) + } + + Divider(modifier = Modifier.padding(vertical = LocalDimensions.current.smallSpacing)) + + Text( + stringResource(R.string.conversationsNone), + style = LocalType.current.h8, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = LocalDimensions.current.xsSpacing)) + Text( + stringResource(R.string.onboardingHitThePlusButton), + style = LocalType.current.small, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.weight(2f)) + } +} + +@Preview +@Composable +fun PreviewEmptyView( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + EmptyView(newAccount = false) + } +} + +@Preview +@Composable +fun PreviewEmptyViewNew( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + EmptyView(newAccount = true) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 4b0cf60c3c9..b534e6c9d97 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -4,13 +4,14 @@ import android.Manifest import android.app.NotificationManager import android.content.ClipData import android.content.ClipboardManager +import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle -import android.text.SpannableString import android.widget.Toast import androidx.activity.viewModels import androidx.core.os.bundleOf +import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -18,11 +19,10 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R @@ -44,7 +44,7 @@ import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.thoughtcrime.securesms.conversation.start.NewConversationFragment +import org.thoughtcrime.securesms.conversation.start.StartConversationFragment import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils import org.thoughtcrime.securesms.crypto.IdentityKeyUtil @@ -59,36 +59,35 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter import org.thoughtcrime.securesms.home.search.GlobalSearchInputLayout +import org.thoughtcrime.securesms.home.search.GlobalSearchResult import org.thoughtcrime.securesms.home.search.GlobalSearchViewModel import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.notifications.PushRegistry -import org.thoughtcrime.securesms.onboarding.SeedActivity -import org.thoughtcrime.securesms.onboarding.SeedReminderViewDelegate import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.preferences.SettingsActivity +import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity import org.thoughtcrime.securesms.showMuteDialog import org.thoughtcrime.securesms.showSessionDialog +import org.thoughtcrime.securesms.ui.setThemedContent import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.IP2Country import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.show +import org.thoughtcrime.securesms.util.start import java.io.IOException import javax.inject.Inject +private const val NEW_ACCOUNT = "HomeActivity_NEW_ACCOUNT" +private const val FROM_ONBOARDING = "HomeActivity_FROM_ONBOARDING" + @AndroidEntryPoint class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickListener, - SeedReminderViewDelegate, GlobalSearchInputLayout.GlobalSearchInputLayoutListener { - companion object { - const val FROM_ONBOARDING = "HomeActivity_FROM_ONBOARDING" - } - - private lateinit var binding: ActivityHomeBinding private lateinit var glide: GlideRequests @@ -104,8 +103,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private val globalSearchViewModel by viewModels() private val homeViewModel by viewModels() - private val publicKey: String - get() = textSecurePreferences.getLocalNumber()!! + private val publicKey: String by lazy { textSecurePreferences.getLocalNumber()!! } private val homeAdapter: HomeAdapter by lazy { HomeAdapter(context = this, configFactory = configFactory, listener = this, ::showMessageRequests, ::hideMessageRequests) @@ -113,47 +111,37 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private val globalSearchAdapter = GlobalSearchAdapter { model -> when (model) { - is GlobalSearchAdapter.Model.Message -> { - val threadId = model.messageResult.threadId - val timestamp = model.messageResult.sentTimestampMs - val author = model.messageResult.messageRecipient.address - - val intent = Intent(this, ConversationActivityV2::class.java) - intent.putExtra(ConversationActivityV2.THREAD_ID, threadId) - intent.putExtra(ConversationActivityV2.SCROLL_MESSAGE_ID, timestamp) - intent.putExtra(ConversationActivityV2.SCROLL_MESSAGE_AUTHOR, author) - push(intent) + is GlobalSearchAdapter.Model.Message -> push { + model.messageResult.run { + putExtra(ConversationActivityV2.THREAD_ID, threadId) + putExtra(ConversationActivityV2.SCROLL_MESSAGE_ID, sentTimestampMs) + putExtra(ConversationActivityV2.SCROLL_MESSAGE_AUTHOR, messageRecipient.address) + } } - is GlobalSearchAdapter.Model.SavedMessages -> { - val intent = Intent(this, ConversationActivityV2::class.java) - intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey)) - push(intent) + is GlobalSearchAdapter.Model.SavedMessages -> push { + putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey)) } - is GlobalSearchAdapter.Model.Contact -> { - val address = model.contact.sessionID - - val intent = Intent(this, ConversationActivityV2::class.java) - intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(address)) - push(intent) + is GlobalSearchAdapter.Model.Contact -> push { + putExtra(ConversationActivityV2.ADDRESS, model.contact.accountID.let(Address::fromSerialized)) } - is GlobalSearchAdapter.Model.GroupConversation -> { - val groupAddress = Address.fromSerialized(model.groupRecord.encodedId) - val threadId = threadDb.getThreadIdIfExistsFor(Recipient.from(this, groupAddress, false)) - if (threadId >= 0) { - val intent = Intent(this, ConversationActivityV2::class.java) - intent.putExtra(ConversationActivityV2.THREAD_ID, threadId) - push(intent) + is GlobalSearchAdapter.Model.GroupConversation -> model.groupRecord.encodedId + .let { Recipient.from(this, Address.fromSerialized(it), false) } + .let(threadDb::getThreadIdIfExistsFor) + .takeIf { it >= 0 } + ?.let { + push { putExtra(ConversationActivityV2.THREAD_ID, it) } } - } - else -> { - Log.d("Loki", "callback with model: $model") - } + else -> Log.d("Loki", "callback with model: $model") } } + private val isFromOnboarding: Boolean get() = intent.getBooleanExtra(FROM_ONBOARDING, false) + private val isNewAccount: Boolean get() = intent.getBooleanExtra(NEW_ACCOUNT, false) + // region Lifecycle override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) + // Set content view binding = ActivityHomeBinding.inflate(layoutInflater) setContentView(binding.root) @@ -164,20 +152,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // Set up toolbar buttons binding.profileButton.setOnClickListener { openSettings() } binding.searchViewContainer.setOnClickListener { + globalSearchViewModel.refresh() binding.globalSearchInputLayout.requestFocus() } binding.sessionToolbar.disableClipping() // Set up seed reminder view lifecycleScope.launchWhenStarted { - val hasViewedSeed = textSecurePreferences.getHasViewedSeed() - if (!hasViewedSeed) { - binding.seedReminderView.isVisible = true - binding.seedReminderView.title = SpannableString("You're almost finished! 80%") // Intentionally not yet translated - binding.seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_1) - binding.seedReminderView.setProgress(80, false) - binding.seedReminderView.delegate = this@HomeActivity - } else { - binding.seedReminderView.isVisible = false + binding.seedReminderView.setThemedContent { + if (!textSecurePreferences.getHasViewedSeed()) SeedReminder { start() } } } // Set up recycler view @@ -193,11 +175,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } // Set up empty state view - binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() } + binding.emptyStateContainer.setThemedContent { + EmptyView(isNewAccount) + } + IP2Country.configureIfNeeded(this@HomeActivity) // Set up new conversation button - binding.newConversationButton.setOnClickListener { showNewConversation() } + binding.newConversationButton.setOnClickListener { showStartConversation() } // Observe blocked contacts changed events // subscribe to outdated config updates, this should be removed after long enough time for device migration @@ -252,76 +237,95 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // monitor the global search VM query launch { binding.globalSearchInputLayout.query - .onEach(globalSearchViewModel::postQuery) - .collect() + .collect(globalSearchViewModel::setQuery) } // Get group results and display them launch { - globalSearchViewModel.result.collect { result -> - val currentUserPublicKey = publicKey - val contactAndGroupList = result.contacts.map { GlobalSearchAdapter.Model.Contact(it) } + - result.threads.map { GlobalSearchAdapter.Model.GroupConversation(it) } - - val contactResults = contactAndGroupList.toMutableList() - - if (contactResults.isEmpty()) { - contactResults.add(GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey)) - } - - val userIndex = contactResults.indexOfFirst { it is GlobalSearchAdapter.Model.Contact && it.contact.sessionID == currentUserPublicKey } - if (userIndex >= 0) { - contactResults[userIndex] = GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey) - } - - if (contactResults.isNotEmpty()) { - contactResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_contacts_groups)) - } - - val unreadThreadMap = result.messages - .groupBy { it.threadId }.keys - .map { it to mmsSmsDatabase.getUnreadCount(it) } - .toMap() - - val messageResults: MutableList = result.messages - .map { messageResult -> - GlobalSearchAdapter.Model.Message( - messageResult, - unreadThreadMap[messageResult.threadId] ?: 0 - ) - }.toMutableList() - - if (messageResults.isNotEmpty()) { - messageResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_messages)) + globalSearchViewModel.result.map { result -> + result.query to when { + result.query.isEmpty() -> buildList { + add(GlobalSearchAdapter.Model.Header(R.string.contacts)) + add(GlobalSearchAdapter.Model.SavedMessages(publicKey)) + addAll(result.groupedContacts) + } + else -> buildList { + result.contactAndGroupList.takeUnless { it.isEmpty() }?.let { + add(GlobalSearchAdapter.Model.Header(R.string.conversations)) + addAll(it) + } + result.messageResults.takeUnless { it.isEmpty() }?.let { + add(GlobalSearchAdapter.Model.Header(R.string.global_search_messages)) + addAll(it) + } + } } - - val newData = contactResults + messageResults - globalSearchAdapter.setNewData(result.query, newData) - } + }.collectLatest(globalSearchAdapter::setNewData) } } EventBus.getDefault().register(this@HomeActivity) - if (intent.hasExtra(FROM_ONBOARDING) - && intent.getBooleanExtra(FROM_ONBOARDING, false)) { + if (isFromOnboarding) { if (Build.VERSION.SDK_INT >= 33 && (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled().not()) { Permissions.with(this) .request(Manifest.permission.POST_NOTIFICATIONS) .execute() } - configFactory.user?.let { user -> - if (!user.isBlockCommunityMessageRequestsSet()) { - user.setCommunityMessageRequests(false) + configFactory.user + ?.takeUnless { it.isBlockCommunityMessageRequestsSet() } + ?.setCommunityMessageRequests(false) + } + } + + private val GlobalSearchResult.groupedContacts: List get() { + class NamedValue(val name: String?, val value: T) + + // Unknown is temporarily to be grouped together with numbers title. + // https://optf.atlassian.net/browse/SES-2287 + val numbersTitle = "#" + val unknownTitle = numbersTitle + + return contacts + // Remove ourself, we're shown above. + .filter { it.accountID != publicKey } + // Get the name that we will display and sort by, and uppercase it to + // help with sorting and we need the char uppercased later. + .map { (it.nickname?.takeIf(String::isNotEmpty) ?: it.name?.takeIf(String::isNotEmpty)) + .let { name -> NamedValue(name?.uppercase(), it) } } + // Digits are all grouped under a #, the rest are grouped by their first character.uppercased() + // If there is no name, they go under Unknown + .groupBy { it.name?.run { first().takeUnless(Char::isDigit)?.toString() ?: numbersTitle } ?: unknownTitle } + // place the # at the end, after all the names starting with alphabetic chars + .toSortedMap(compareBy { + when (it) { + unknownTitle -> Char.MAX_VALUE + numbersTitle -> Char.MAX_VALUE - 1 + else -> it.first() } + }) + // Flatten the map of char to lists into an actual List that can be displayed. + .flatMap { (key, contacts) -> + listOf( + GlobalSearchAdapter.Model.SubHeader(key) + ) + contacts.sortedBy { it.name ?: it.value.accountID }.map { it.value }.map { GlobalSearchAdapter.Model.Contact(it, it.nickname ?: it.name, it.accountID == publicKey) } } + } + + private val GlobalSearchResult.contactAndGroupList: List get() = + contacts.map { GlobalSearchAdapter.Model.Contact(it, it.nickname ?: it.name, it.accountID == publicKey) } + + threads.map(GlobalSearchAdapter.Model::GroupConversation) + + private val GlobalSearchResult.messageResults: List get() { + val unreadThreadMap = messages + .map { it.threadId }.toSet() + .associateWith { mmsSmsDatabase.getUnreadCount(it) } + + return messages.map { + GlobalSearchAdapter.Model.Message(it, unreadThreadMap[it.threadId] ?: 0, it.conversationRecipient.isLocalNumber) } } override fun onInputFocusChanged(hasFocus: Boolean) { - if (hasFocus) { - setSearchShown(true) - } else { - setSearchShown(!binding.globalSearchInputLayout.query.value.isNullOrEmpty()) - } + setSearchShown(hasFocus || binding.globalSearchInputLayout.query.value.isNotEmpty()) } private fun setSearchShown(isShown: Boolean) { @@ -330,7 +334,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), binding.recyclerView.isVisible = !isShown binding.emptyStateContainer.isVisible = (binding.recyclerView.adapter as HomeAdapter).itemCount == 0 && binding.recyclerView.isVisible binding.seedReminderView.isVisible = !TextSecurePreferences.getHasViewedSeed(this) && !isShown - binding.globalSearchRecycler.isVisible = isShown + binding.globalSearchRecycler.isInvisible = !isShown binding.newConversationButton.isVisible = !isShown } @@ -351,12 +355,9 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), updateLegacyConfigView() - // TODO: remove this after enough updates that we can rely on ConfigBase.isNewConfigEnabled to always return true - // This will only run if we aren't using new configs, as they are schedule to sync when there are changes applied - if (textSecurePreferences.getConfigurationMessageSynced()) { - lifecycleScope.launch(Dispatchers.IO) { - ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity) - } + // Sync config changes if there are any + lifecycleScope.launch(Dispatchers.IO) { + ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity) } } @@ -397,16 +398,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // region Interaction @Deprecated("Deprecated in Java") override fun onBackPressed() { - if (binding.globalSearchRecycler.isVisible) { - binding.globalSearchInputLayout.clearSearch(true) - return - } - super.onBackPressed() - } - - override fun handleSeedReminderViewContinueButtonTapped() { - val intent = Intent(this, SeedActivity::class.java) - show(intent) + if (binding.globalSearchRecycler.isVisible) binding.globalSearchInputLayout.clearSearch(true) + else super.onBackPressed() } override fun onConversationClick(thread: ThreadRecord) { @@ -431,17 +424,17 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), bottomSheet.onCopyConversationId = onCopyConversationId@{ bottomSheet.dismiss() if (!thread.recipient.isGroupRecipient && !thread.recipient.isLocalNumber) { - val clip = ClipData.newPlainText("Session ID", thread.recipient.address.toString()) - val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Account ID", thread.recipient.address.toString()) + val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager manager.setPrimaryClip(clip) Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() } else if (thread.recipient.isCommunityRecipient) { - val threadId = threadDb.getThreadIdIfExistsFor(thread.recipient) ?: return@onCopyConversationId Unit + val threadId = threadDb.getThreadIdIfExistsFor(thread.recipient) val openGroup = DatabaseComponent.get(this@HomeActivity).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return@onCopyConversationId Unit val clip = ClipData.newPlainText("Community URL", openGroup.joinURL) - val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager + val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager manager.setPrimaryClip(clip) Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() } @@ -569,7 +562,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), val message = if (recipient.isGroupRecipient) { val group = groupDatabase.getGroup(recipient.address.toString()).orNull() if (group != null && group.admins.map { it.toString() }.contains(textSecurePreferences.getLocalNumber())) { - "Because you are the creator of this group it will be deleted for everyone. This cannot be undone." + getString(R.string.admin_group_leave_warning) } else { resources.getString(R.string.activity_home_leave_group_dialog_message) } @@ -625,7 +618,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private fun hideMessageRequests() { showSessionDialog { - text("Hide message requests?") + text(getString(R.string.hide_message_requests)) button(R.string.yes) { textSecurePreferences.setHasHiddenMessageRequests() homeViewModel.tryReload() @@ -634,9 +627,15 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } - private fun showNewConversation() { - NewConversationFragment().show(supportFragmentManager, "NewConversationFragment") + private fun showStartConversation() { + StartConversationFragment().show(supportFragmentManager, "StartConversationFragment") } +} - // endregion +fun Context.startHomeActivity(isFromOnboarding: Boolean, isNewAccount: Boolean) { + Intent(this, HomeActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + putExtra(NEW_ACCOUNT, isNewAccount) + putExtra(FROM_ONBOARDING, isFromOnboarding) + }.also(::startActivity) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt index 571adb7358f..5410df9d478 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.home import android.content.Context import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListUpdateCallback @@ -12,8 +11,6 @@ import network.loki.messenger.R import network.loki.messenger.databinding.ViewMessageRequestBannerBinding import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.mms.GlideRequests -import org.thoughtcrime.securesms.util.DateUtils -import java.util.Locale class HomeAdapter( private val context: Context, @@ -115,7 +112,7 @@ class HomeAdapter( val offset = if (hasHeaderView()) position - 1 else position val thread = data.threads[offset] val isTyping = data.typingThreadIDs.contains(thread.threadId) - holder.view.bind(thread, isTyping, glide) + holder.view.bind(thread, isTyping) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt b/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt new file mode 100644 index 00000000000..33bdd2f2f60 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt @@ -0,0 +1,83 @@ +package org.thoughtcrime.securesms.home + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.SessionShieldIcon +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.components.SlimPrimaryOutlineButton +import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.theme.LocalType + +@Composable +internal fun SeedReminder(startRecoveryPasswordActivity: () -> Unit) { + Column { + // Color Strip + Box( + Modifier + .fillMaxWidth() + .height(LocalDimensions.current.indicatorHeight) + .background(LocalColors.current.primary) + ) + Row( + Modifier + .background(LocalColors.current.backgroundSecondary) + .padding( + horizontal = LocalDimensions.current.spacing, + vertical = LocalDimensions.current.smallSpacing + ) + ) { + Column(Modifier.weight(1f)) { + Row { + Text( + stringResource(R.string.save_your_recovery_password), + style = LocalType.current.h8 + ) + Spacer(Modifier.requiredWidth(LocalDimensions.current.xxsSpacing)) + SessionShieldIcon() + } + Text( + stringResource(R.string.save_your_recovery_password_to_make_sure_you_don_t_lose_access_to_your_account), + style = LocalType.current.small + ) + } + Spacer(Modifier.width(LocalDimensions.current.xsSpacing)) + SlimPrimaryOutlineButton( + text = stringResource(R.string.continue_2), + modifier = Modifier + .align(Alignment.CenterVertically) + .contentDescription(R.string.AccessibilityId_reveal_recovery_phrase_button), + onClick = startRecoveryPasswordActivity + ) + } + } +} + +@Preview +@Composable +private fun PreviewSeedReminder( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + SeedReminder {} + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt index bd38d0df863..cae399dcbfa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt @@ -56,7 +56,6 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { val threadRecipient = threadDb.getRecipientForThreadId(threadID) ?: return dismiss() with(binding) { profilePictureView.publicKey = publicKey - profilePictureView.isLarge = true profilePictureView.update(recipient) nameTextViewContainer.visibility = View.VISIBLE nameTextViewContainer.setOnClickListener { @@ -99,7 +98,7 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { publicKeyTextView.setOnLongClickListener { val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Session ID", publicKey) + val clip = ClipData.newPlainText("Account ID", publicKey) clipboard.setPrimaryClip(clip) Toast.makeText(requireContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT) .show() @@ -138,7 +137,7 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { else { newNickName = previousContactNickname } val publicKey = recipient.address.serialize() val storage = MessagingModuleConfiguration.shared.storage - val contact = storage.getContactWithSessionID(publicKey) ?: Contact(publicKey) + val contact = storage.getContactWithAccountID(publicKey) ?: Contact(publicKey) contact.nickname = newNickName storage.setContact(contact) nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt index 7cf953be24a..71c2c625068 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt @@ -9,23 +9,27 @@ import androidx.recyclerview.widget.RecyclerView import network.loki.messenger.R import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding import network.loki.messenger.databinding.ViewGlobalSearchResultBinding +import network.loki.messenger.databinding.ViewGlobalSearchSubheaderBinding import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.search.model.MessageResult +import org.thoughtcrime.securesms.ui.GetString import java.security.InvalidParameterException import org.session.libsession.messaging.contacts.Contact as ContactModel -class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerView.Adapter() { +class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerView.Adapter() { companion object { const val HEADER_VIEW_TYPE = 0 - const val CONTENT_VIEW_TYPE = 1 + const val SUB_HEADER_VIEW_TYPE = 1 + const val CONTENT_VIEW_TYPE = 2 } private var data: List = listOf() private var query: String? = null + fun setNewData(data: Pair>) = setNewData(data.first, data.second) + fun setNewData(query: String, newData: List) { val diffResult = DiffUtil.calculateDiff(GlobalSearchDiff(this.query, query, data, newData)) this.query = query @@ -34,21 +38,26 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi } override fun getItemViewType(position: Int): Int = - if (data[position] is Model.Header) HEADER_VIEW_TYPE else CONTENT_VIEW_TYPE + when(data[position]) { + is Model.Header -> HEADER_VIEW_TYPE + is Model.SubHeader -> SUB_HEADER_VIEW_TYPE + else -> CONTENT_VIEW_TYPE + } override fun getItemCount(): Int = data.size override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = - if (viewType == HEADER_VIEW_TYPE) { - HeaderView( - LayoutInflater.from(parent.context) - .inflate(R.layout.view_global_search_header, parent, false) + when (viewType) { + HEADER_VIEW_TYPE -> HeaderView( + LayoutInflater.from(parent.context).inflate(R.layout.view_global_search_header, parent, false) + ) + SUB_HEADER_VIEW_TYPE -> SubHeaderView( + LayoutInflater.from(parent.context).inflate(R.layout.view_global_search_subheader, parent, false) + ) + else -> ContentView( + LayoutInflater.from(parent.context).inflate(R.layout.view_global_search_result, parent, false), + modelCallback ) - } else { - ContentView( - LayoutInflater.from(parent.context) - .inflate(R.layout.view_global_search_result, parent, false) - , modelCallback) } override fun onBindViewHolder( @@ -61,10 +70,10 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi holder.bindPayload(newUpdateQuery, data[position]) return } - if (holder is HeaderView) { - holder.bind(data[position] as Model.Header) - } else if (holder is ContentView) { - holder.bind(query.orEmpty(), data[position]) + when (holder) { + is HeaderView -> holder.bind(data[position] as Model.Header) + is SubHeaderView -> holder.bind(data[position] as Model.SubHeader) + is ContentView -> holder.bind(query.orEmpty(), data[position]) } } @@ -77,7 +86,16 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi val binding = ViewGlobalSearchHeaderBinding.bind(view) fun bind(header: Model.Header) { - binding.searchHeader.setText(header.title) + binding.searchHeader.setText(header.title.string(binding.root.context)) + } + } + + class SubHeaderView(view: View) : RecyclerView.ViewHolder(view) { + + val binding = ViewGlobalSearchSubheaderBinding.bind(view) + + fun bind(header: Model.SubHeader) { + binding.searchHeader.text = header.title.string(binding.root.context) } } @@ -102,25 +120,24 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi is Model.Contact -> bindModel(query, model) is Model.Message -> bindModel(query, model) is Model.SavedMessages -> bindModel(model) - is Model.Header -> throw InvalidParameterException("Can't display Model.Header as ContentView") + else -> throw InvalidParameterException("Can't display as ContentView") } binding.root.setOnClickListener { modelCallback(model) } } - } - data class MessageModel( - val threadRecipient: Recipient, - val messageRecipient: Recipient, - val messageSnippet: String - ) - sealed class Model { - data class Header(@StringRes val title: Int) : Model() + data class Header(val title: GetString): Model() { + constructor(@StringRes title: Int): this(GetString(title)) + constructor(title: String): this(GetString(title)) + } + data class SubHeader(val title: GetString): Model() { + constructor(@StringRes title: Int): this(GetString(title)) + constructor(title: String): this(GetString(title)) + } data class SavedMessages(val currentUserPublicKey: String): Model() - data class Contact(val contact: ContactModel) : Model() - data class GroupConversation(val groupRecord: GroupRecord) : Model() - data class Message(val messageResult: MessageResult, val unread: Int) : Model() + data class Contact(val contact: ContactModel, val name: String?, val isSelf: Boolean): Model() + data class GroupConversation(val groupRecord: GroupRecord): Model() + data class Message(val messageResult: MessageResult, val unread: Int, val isSelf: Boolean): Model() } - -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt index 5371bb71c9d..d390776d1ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt @@ -10,11 +10,13 @@ import network.loki.messenger.R import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.truncateIdForDisplay import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages +import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SubHeader import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.SearchUtil import java.util.Locale @@ -63,7 +65,7 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) { )) binding.searchResultSubtitle.text = textSpannable binding.searchResultSubtitle.isVisible = true - binding.searchResultTitle.text = model.messageResult.conversationRecipient.toShortString() + binding.searchResultTitle.text = model.messageResult.conversationRecipient.getSearchName() } is GroupConversation -> { binding.searchResultTitle.text = getHighlight( @@ -72,12 +74,12 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) { ) val membersString = model.groupRecord.members.joinToString { address -> - val recipient = Recipient.from(binding.root.context, address, false) - recipient.name ?: "${address.serialize().take(4)}...${address.serialize().takeLast(4)}" + Recipient.from(binding.root.context, address, false).getSearchName() } binding.searchResultSubtitle.text = getHighlight(query, membersString) } is Header, // do nothing for header + is SubHeader, // do nothing for subheader is SavedMessages -> Unit // do nothing for saved messages (displays note to self) } } @@ -88,7 +90,6 @@ private fun getHighlight(query: String?, toSearch: String): Spannable? { fun ContentView.bindModel(query: String?, model: GroupConversation) { binding.searchResultProfilePicture.isVisible = true - binding.searchResultSavedMessages.isVisible = false binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup binding.searchResultTimestamp.isVisible = false val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false) @@ -98,64 +99,65 @@ fun ContentView.bindModel(query: String?, model: GroupConversation) { val groupRecipients = model.groupRecord.members.map { Recipient.from(binding.root.context, it, false) } - val membersString = groupRecipients.joinToString { - val address = it.address.serialize() - it.name ?: "${address.take(4)}...${address.takeLast(4)}" - } + val membersString = groupRecipients.joinToString(transform = Recipient::getSearchName) if (model.groupRecord.isClosedGroup) { binding.searchResultSubtitle.text = getHighlight(query, membersString) } } -fun ContentView.bindModel(query: String?, model: ContactModel) { - binding.searchResultProfilePicture.isVisible = true - binding.searchResultSavedMessages.isVisible = false - binding.searchResultSubtitle.isVisible = false - binding.searchResultTimestamp.isVisible = false - binding.searchResultSubtitle.text = null - val recipient = - Recipient.from(binding.root.context, Address.fromSerialized(model.contact.sessionID), false) - binding.searchResultProfilePicture.update(recipient) - val nameString = model.contact.getSearchName() - binding.searchResultTitle.text = getHighlight(query, nameString) +fun ContentView.bindModel(query: String?, model: ContactModel) = binding.run { + searchResultProfilePicture.isVisible = true + searchResultSubtitle.isVisible = false + searchResultTimestamp.isVisible = false + searchResultSubtitle.text = null + val recipient = Recipient.from(root.context, Address.fromSerialized(model.contact.accountID), false) + searchResultProfilePicture.update(recipient) + val nameString = if (model.isSelf) root.context.getString(R.string.note_to_self) + else model.contact.getSearchName() + searchResultTitle.text = getHighlight(query, nameString) } fun ContentView.bindModel(model: SavedMessages) { binding.searchResultSubtitle.isVisible = false binding.searchResultTimestamp.isVisible = false binding.searchResultTitle.setText(R.string.note_to_self) - binding.searchResultProfilePicture.isVisible = false - binding.searchResultSavedMessages.isVisible = true + binding.searchResultProfilePicture.update(Address.fromSerialized(model.currentUserPublicKey)) + binding.searchResultProfilePicture.isVisible = true } -fun ContentView.bindModel(query: String?, model: Message) { - binding.searchResultProfilePicture.isVisible = true - binding.searchResultSavedMessages.isVisible = false - binding.searchResultTimestamp.isVisible = true +fun ContentView.bindModel(query: String?, model: Message) = binding.apply { + searchResultProfilePicture.isVisible = true + searchResultTimestamp.isVisible = true // val hasUnreads = model.unread > 0 -// binding.unreadCountIndicator.isVisible = hasUnreads +// unreadCountIndicator.isVisible = hasUnreads // if (hasUnreads) { -// binding.unreadCountTextView.text = model.unread.toString() +// unreadCountTextView.text = model.unread.toString() // } - binding.searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(binding.root.context, Locale.getDefault(), model.messageResult.sentTimestampMs) - binding.searchResultProfilePicture.update(model.messageResult.conversationRecipient) + searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(root.context, Locale.getDefault(), model.messageResult.sentTimestampMs) + searchResultProfilePicture.update(model.messageResult.conversationRecipient) val textSpannable = SpannableStringBuilder() if (model.messageResult.conversationRecipient != model.messageResult.messageRecipient) { // group chat, bind - val text = "${model.messageResult.messageRecipient.getSearchName()}: " + val text = "${model.messageResult.messageRecipient.toShortString()}: " textSpannable.append(text) } textSpannable.append(getHighlight( query, model.messageResult.bodySnippet )) - binding.searchResultSubtitle.text = textSpannable - binding.searchResultTitle.text = model.messageResult.conversationRecipient.toShortString() - binding.searchResultSubtitle.isVisible = true + searchResultSubtitle.text = textSpannable + searchResultTitle.text = if (model.isSelf) root.context.getString(R.string.note_to_self) + else model.messageResult.conversationRecipient.getSearchName() + searchResultSubtitle.isVisible = true } -fun Recipient.getSearchName(): String = name ?: address.serialize().let { address -> "${address.take(4)}...${address.takeLast(4)}" } +fun Recipient.getSearchName(): String = + name?.takeIf { it.isNotEmpty() && !it.looksLikeAccountId } + ?: address.serialize().let(::truncateIdForDisplay) fun Contact.getSearchName(): String = - if (nickname.isNullOrEmpty()) name ?: "${sessionID.take(4)}...${sessionID.takeLast(4)}" - else "${name ?: "${sessionID.take(4)}...${sessionID.takeLast(4)}"} ($nickname)" \ No newline at end of file + nickname?.takeIf { it.isNotEmpty() && !it.looksLikeAccountId } + ?: name?.takeIf { it.isNotEmpty() && !it.looksLikeAccountId } + ?: truncateIdForDisplay(accountID) + +private val String.looksLikeAccountId: Boolean get() = length > 60 && all { it.isDigit() || it.isLetter() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchInputLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchInputLayout.kt index c22ccde1f1d..442e6159fdd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchInputLayout.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchInputLayout.kt @@ -16,42 +16,37 @@ import android.widget.TextView import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import network.loki.messenger.databinding.ViewGlobalSearchInputBinding +import org.thoughtcrime.securesms.util.SimpleTextWatcher +import org.thoughtcrime.securesms.util.addTextChangedListener class GlobalSearchInputLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : LinearLayout(context, attrs), View.OnFocusChangeListener, - View.OnClickListener, - TextWatcher, TextView.OnEditorActionListener { + TextView.OnEditorActionListener { var binding: ViewGlobalSearchInputBinding = ViewGlobalSearchInputBinding.inflate(LayoutInflater.from(context), this, true) var listener: GlobalSearchInputLayoutListener? = null - private val _query = MutableStateFlow(null) - val query: StateFlow = _query + private val _query = MutableStateFlow("") + val query: StateFlow = _query override fun onAttachedToWindow() { super.onAttachedToWindow() binding.searchInput.onFocusChangeListener = this - binding.searchInput.addTextChangedListener(this) + binding.searchInput.addTextChangedListener(::setQuery) binding.searchInput.setOnEditorActionListener(this) - binding.searchInput.setFilters( arrayOf(LengthFilter(100)) ) // 100 char search limit - binding.searchCancel.setOnClickListener(this) - binding.searchClear.setOnClickListener(this) - } - - override fun onDetachedFromWindow() { - super.onDetachedFromWindow() + binding.searchInput.filters = arrayOf(LengthFilter(100)) // 100 char search limit + binding.searchCancel.setOnClickListener { clearSearch(true) } + binding.searchClear.setOnClickListener { clearSearch(false) } } override fun onFocusChange(v: View?, hasFocus: Boolean) { if (v === binding.searchInput) { - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - if (!hasFocus) { - imm.hideSoftInputFromWindow(windowToken, 0) - } else { - imm.showSoftInput(v, 0) + (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).apply { + if (hasFocus) showSoftInput(v, 0) + else hideSoftInputFromWindow(windowToken, 0) } listener?.onInputFocusChanged(hasFocus) } @@ -65,27 +60,16 @@ class GlobalSearchInputLayout @JvmOverloads constructor( return false } - override fun onClick(v: View?) { - if (v === binding.searchCancel) { - clearSearch(true) - } else if (v === binding.searchClear) { - clearSearch(false) - } - } - fun clearSearch(clearFocus: Boolean) { binding.searchInput.text = null + setQuery("") if (clearFocus) { binding.searchInput.clearFocus() } } - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} - - override fun afterTextChanged(s: Editable?) { - _query.value = s?.toString() + private fun setQuery(query: String) { + _query.value = query } interface GlobalSearchInputLayoutListener { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt index c85ffa87453..29e11067a0d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt @@ -2,33 +2,25 @@ package org.thoughtcrime.securesms.home.search import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.GroupRecord -import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.search.model.MessageResult import org.thoughtcrime.securesms.search.model.SearchResult data class GlobalSearchResult( - val query: String, - val contacts: List, - val threads: List, - val messages: List + val query: String, + val contacts: List = emptyList(), + val threads: List = emptyList(), + val messages: List = emptyList() ) { - val isEmpty: Boolean get() = contacts.isEmpty() && threads.isEmpty() && messages.isEmpty() companion object { - - val EMPTY = GlobalSearchResult("", emptyList(), emptyList(), emptyList()) - const val SEARCH_LIMIT = 5 - - fun from(searchResult: SearchResult): GlobalSearchResult { - val query = searchResult.query - val contactList = searchResult.contacts.toList() - val threads = searchResult.conversations.toList() - val messages = searchResult.messages.toList() - searchResult.close() - return GlobalSearchResult(query, contactList, threads, messages) - } - + val EMPTY = GlobalSearchResult("") } } + +fun SearchResult.toGlobalSearchResult(): GlobalSearchResult = try { + GlobalSearchResult(query, contacts.toList(), conversations.toList(), messages.toList()) +} finally { + close() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt index 1ff0a395fe8..fb4a61a5587 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt @@ -3,15 +3,22 @@ package org.thoughtcrime.securesms.home.search import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.buffer -import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import kotlinx.coroutines.plus import org.session.libsignal.utilities.SettableFuture import org.thoughtcrime.securesms.search.SearchRepository @@ -19,49 +26,51 @@ import org.thoughtcrime.securesms.search.model.SearchResult import java.util.concurrent.TimeUnit import javax.inject.Inject +@OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel -class GlobalSearchViewModel @Inject constructor(private val searchRepository: SearchRepository) : ViewModel() { +class GlobalSearchViewModel @Inject constructor( + private val searchRepository: SearchRepository, +) : ViewModel() { + private val scope = viewModelScope + SupervisorJob() + private val refreshes = MutableSharedFlow() + private val _queryText = MutableStateFlow("") - private val executor = viewModelScope + SupervisorJob() - - private val _result: MutableStateFlow = MutableStateFlow(GlobalSearchResult.EMPTY) - - val result: StateFlow = _result - - private val _queryText: MutableStateFlow = MutableStateFlow("") + val result = _queryText + .reEmit(refreshes) + .buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST) + .mapLatest { query -> + if (query.trim().isEmpty()) { + // searching for 05 as contactDb#getAllContacts was not returning contacts + // without a nickname/name who haven't approved us. + GlobalSearchResult(query.toString(), searchRepository.queryContacts("05").first.toList()) + } else { + // User input delay in case we get a new query within a few hundred ms this + // coroutine will be cancelled and the expensive query will not be run. + delay(300) + val settableFuture = SettableFuture() + searchRepository.query(query.toString(), settableFuture::set) + try { + // search repository doesn't play nicely with suspend functions (yet) + settableFuture.get(10_000, TimeUnit.MILLISECONDS).toGlobalSearchResult() + } catch (e: Exception) { + GlobalSearchResult(query.toString()) + } + } + } - fun postQuery(charSequence: CharSequence?) { - charSequence ?: return + fun setQuery(charSequence: CharSequence) { _queryText.value = charSequence } - init { - // - _queryText - .buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST) - .mapLatest { query -> - // Early exit on empty search query - if (query.trim().isEmpty()) { - SearchResult.EMPTY - } else { - // User input delay in case we get a new query within a few hundred ms this - // coroutine will be cancelled and the expensive query will not be run. - delay(300) - - val settableFuture = SettableFuture() - searchRepository.query(query.toString(), settableFuture::set) - try { - // search repository doesn't play nicely with suspend functions (yet) - settableFuture.get(10_000, TimeUnit.MILLISECONDS) - } catch (e: Exception) { - SearchResult.EMPTY - } - } - } - .onEach { result -> - // update the latest _result value - _result.value = GlobalSearchResult.from(result) - } - .launchIn(executor) + fun refresh() { + viewModelScope.launch { + refreshes.emit(Unit) + } } -} \ No newline at end of file +} + +/** + * Re-emit whenever refreshes emits. + * */ +@OptIn(ExperimentalCoroutinesApi::class) +private fun Flow.reEmit(refreshes: Flow) = flatMapLatest { query -> merge(flowOf(query), refreshes.map { query }) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/logging/AndroidLogger.java b/app/src/main/java/org/thoughtcrime/securesms/logging/AndroidLogger.java index d62aac647c4..196e77e45af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logging/AndroidLogger.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logging/AndroidLogger.java @@ -35,6 +35,5 @@ public void wtf(String tag, String message, Throwable t) { } @Override - public void blockUntilAllWritesFinished() { - } + public void blockUntilAllWritesFinished() { } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt index a916d8e4d6f..6584f4e5196 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt @@ -34,13 +34,13 @@ class MessageRequestView : LinearLayout { // region Updating fun bind(thread: ThreadRecord, glide: GlideRequests) { this.thread = thread - val senderDisplayName = getUserDisplayName(thread.recipient) - ?: thread.recipient.address.toString() + + val senderDisplayName = getUserDisplayName(thread.recipient) ?: thread.recipient.address.toString() + binding.displayNameTextView.text = senderDisplayName binding.timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), thread.date) - val rawSnippet = thread.getDisplayBody(context) val snippet = highlightMentions( - text = rawSnippet, + text = thread.getDisplayBody(context), formatOnly = true, // no styling here, only text formatting threadID = thread.threadId, context = context diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt index 10142cc8fca..07f3f02c662 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt @@ -1,15 +1,17 @@ package org.thoughtcrime.securesms.messagerequests import android.content.Context -import android.content.res.ColorStateList import android.database.Cursor +import android.os.Build import android.text.SpannableString import android.text.style.ForegroundColorSpan import android.view.ContextThemeWrapper import android.view.ViewGroup import android.widget.PopupMenu +import androidx.core.graphics.drawable.DrawableCompat import androidx.recyclerview.widget.RecyclerView import network.loki.messenger.R +import org.session.libsession.utilities.ThemeUtil import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent @@ -60,11 +62,19 @@ class MessageRequestsAdapter( for (i in 0 until popupMenu.menu.size()) { val item = popupMenu.menu.getItem(i) val s = SpannableString(item.title) - s.setSpan(ForegroundColorSpan(context.getColor(R.color.destructive)), 0, s.length, 0) - item.iconTintList = ColorStateList.valueOf(context.getColor(R.color.destructive)) + val danger = ThemeUtil.getThemedColor(context, R.attr.danger) + s.setSpan(ForegroundColorSpan(danger), 0, s.length, 0) + item.icon?.let { + DrawableCompat.setTint( + it, + danger + ) + } item.title = s } - popupMenu.setForceShowIcon(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + popupMenu.setForceShowIcon(true) + } popupMenu.show() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java deleted file mode 100644 index f322d170919..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Copyright (C) 2011 Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.thoughtcrime.securesms.mms; - -import android.content.Context; -import android.content.res.Resources.Theme; -import android.net.Uri; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress; -import org.thoughtcrime.securesms.util.MediaUtil; -import org.session.libsignal.utilities.guava.Optional; - -import org.session.libsession.messaging.sending_receiving.attachments.Attachment; -import org.session.libsession.messaging.sending_receiving.attachments.UriAttachment; -import org.session.libsession.utilities.Util; - -import java.security.SecureRandom; - -import network.loki.messenger.R; - -public abstract class Slide { - - protected final Attachment attachment; - protected final Context context; - - public Slide(@NonNull Context context, @NonNull Attachment attachment) { - this.context = context; - this.attachment = attachment; - } - - public String getContentType() { - return attachment.getContentType(); - } - - @Nullable - public Uri getUri() { - return attachment.getDataUri(); - } - - @Nullable - public Uri getThumbnailUri() { - return attachment.getThumbnailUri(); - } - - @NonNull - public Optional getBody() { - String attachmentString = context.getString(R.string.attachment); - - if (MediaUtil.isAudio(attachment)) { - // A missing file name is the legacy way to determine if an audio attachment is - // a voice note vs. other arbitrary audio attachments. - if (attachment.isVoiceNote() || attachment.getFileName() == null || - attachment.getFileName().isEmpty()) { - attachmentString = context.getString(R.string.attachment_type_voice_message); - return Optional.fromNullable("🎤 " + attachmentString); - } - } - return Optional.fromNullable(emojiForMimeType() + attachmentString); - } - - private String emojiForMimeType() { - if (MediaUtil.isImage(attachment)) { - return "📷 "; - } else if (MediaUtil.isVideo(attachment)) { - return "🎥 "; - } else if (MediaUtil.isAudio(attachment)) { - return "🎧 "; - } else if (MediaUtil.isFile(attachment)) { - return "📎 "; - } else { - return "🎡 "; - } - } - - @NonNull - public Optional getCaption() { - return Optional.fromNullable(attachment.getCaption()); - } - - @NonNull - public Optional getFileName() { - return Optional.fromNullable(attachment.getFileName()); - } - - @Nullable - public String getFastPreflightId() { - return attachment.getFastPreflightId(); - } - - public long getFileSize() { - return attachment.getSize(); - } - - public boolean hasImage() { - return false; - } - - public boolean hasVideo() { - return false; - } - - public boolean hasAudio() { - return false; - } - - public boolean hasDocument() { - return false; - } - - public @NonNull String getContentDescription() { return ""; } - - public @NonNull Attachment asAttachment() { - return attachment; - } - - public boolean isInProgress() { - return attachment.isInProgress(); - } - - public boolean isPendingDownload() { - return getTransferState() == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED || - getTransferState() == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING; - } - - public int getTransferState() { - return attachment.getTransferState(); - } - - public @DrawableRes int getPlaceholderRes(Theme theme) { - throw new AssertionError("getPlaceholderRes() called for non-drawable slide"); - } - - public boolean hasPlaceholder() { - return false; - } - - public boolean hasPlayOverlay() { - return false; - } - - protected static Attachment constructAttachmentFromUri(@NonNull Context context, - @NonNull Uri uri, - @NonNull String defaultMime, - long size, - int width, - int height, - boolean hasThumbnail, - @Nullable String fileName, - @Nullable String caption, - boolean voiceNote, - boolean quote) - { - String resolvedType = Optional.fromNullable(MediaUtil.getMimeType(context, uri)).or(defaultMime); - String fastPreflightId = String.valueOf(new SecureRandom().nextLong()); - return new UriAttachment(uri, - hasThumbnail ? uri : null, - resolvedType, - AttachmentTransferProgress.TRANSFER_PROGRESS_STARTED, - size, - width, - height, - fileName, - fastPreflightId, - voiceNote, - quote, - caption); - } - - @Override - public boolean equals(Object other) { - if (other == null) return false; - if (!(other instanceof Slide)) return false; - - Slide that = (Slide)other; - - return Util.equals(this.getContentType(), that.getContentType()) && - this.hasAudio() == that.hasAudio() && - this.hasImage() == that.hasImage() && - this.hasVideo() == that.hasVideo() && - this.getTransferState() == that.getTransferState() && - Util.equals(this.getUri(), that.getUri()) && - Util.equals(this.getThumbnailUri(), that.getThumbnailUri()); - } - - @Override - public int hashCode() { - return Util.hashCode(getContentType(), hasAudio(), hasImage(), - hasVideo(), getUri(), getThumbnailUri(), getTransferState()); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt new file mode 100644 index 00000000000..e284c1ce22d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt @@ -0,0 +1,180 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see //www.gnu.org/licenses/>. + */ +package org.thoughtcrime.securesms.mms + +import android.content.Context +import android.content.res.Resources +import android.net.Uri +import androidx.annotation.DrawableRes +import com.squareup.phrase.Phrase +import java.security.SecureRandom +import network.loki.messenger.R +import org.session.libsession.messaging.sending_receiving.attachments.Attachment +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress +import org.session.libsession.messaging.sending_receiving.attachments.UriAttachment +import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY +import org.session.libsession.utilities.Util.equals +import org.session.libsession.utilities.Util.hashCode +import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.conversation.v2.Util +import org.thoughtcrime.securesms.util.MediaUtil + +abstract class Slide(@JvmField protected val context: Context, protected val attachment: Attachment) { + val contentType: String + get() = attachment.contentType + + val uri: Uri? + get() = attachment.dataUri + + open val thumbnailUri: Uri? + get() = attachment.thumbnailUri + + val body: Optional + get() { + if (MediaUtil.isAudio(attachment)) { + // A missing file name is the legacy way to determine if an audio attachment is + // a voice note vs. other arbitrary audio attachments. + if (attachment.isVoiceNote || attachment.fileName.isNullOrEmpty()) { + val baseString = context.getString(R.string.attachment_type_voice_message) + val languageIsLTR = Util.usingLeftToRightLanguage(context) + val attachmentString = if (languageIsLTR) { + "🎙 $baseString" + } else { + "$baseString 🎙" + } + return Optional.fromNullable(attachmentString) + } + } + val txt = Phrase.from(context, R.string.attachmentsNotification) + .put(EMOJI_KEY, emojiForMimeType()) + .format().toString() + return Optional.fromNullable(txt) + } + + private fun emojiForMimeType(): String { + return if (MediaUtil.isGif(attachment)) { + "🎡" + } else if (MediaUtil.isImage(attachment)) { + "📷" + } else if (MediaUtil.isVideo(attachment)) { + "🎥" + } else if (MediaUtil.isAudio(attachment)) { + "🎧" + } else if (MediaUtil.isFile(attachment)) { + "📎" + } else { + // We don't provide emojis for other mime-types such as VCARD + "" + } + } + + val caption: Optional + get() = Optional.fromNullable(attachment.caption) + + val fileName: Optional + get() = Optional.fromNullable(attachment.fileName) + + val fastPreflightId: String? + get() = attachment.fastPreflightId + + val fileSize: Long + get() = attachment.size + + open fun hasImage(): Boolean { return false } + + open fun hasVideo(): Boolean { return false } + + open fun hasAudio(): Boolean { return false } + + open fun hasDocument(): Boolean { return false } + + open val contentDescription: String + get() = "" + + fun asAttachment(): Attachment { return attachment } + + val isInProgress: Boolean + get() = attachment.isInProgress + + val isPendingDownload: Boolean + get() = transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED || + transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING + + val transferState: Int + get() = attachment.transferState + + @DrawableRes + open fun getPlaceholderRes(theme: Resources.Theme?): Int { + throw AssertionError("getPlaceholderRes() called for non-drawable slide") + } + + open fun hasPlaceholder(): Boolean { return false } + + open fun hasPlayOverlay(): Boolean { return false } + + override fun equals(other: Any?): Boolean { + if (other == null) return false + if (other !is Slide) return false + + return (equals(this.contentType, other.contentType) && + hasAudio() == other.hasAudio() && + hasImage() == other.hasImage() && + hasVideo() == other.hasVideo()) && + this.transferState == other.transferState && + equals(this.uri, other.uri) && + equals(this.thumbnailUri, other.thumbnailUri) + } + + override fun hashCode(): Int { + return hashCode(contentType, hasAudio(), hasImage(), hasVideo(), uri, thumbnailUri, transferState) + } + + companion object { + @JvmStatic + protected fun constructAttachmentFromUri( + context: Context, + uri: Uri, + defaultMime: String, + size: Long, + width: Int, + height: Int, + hasThumbnail: Boolean, + fileName: String?, + caption: String?, + voiceNote: Boolean, + quote: Boolean + ): Attachment { + val resolvedType = + Optional.fromNullable(MediaUtil.getMimeType(context, uri)).or(defaultMime) + val fastPreflightId = SecureRandom().nextLong().toString() + return UriAttachment( + uri, + if (hasThumbnail) uri else null, + resolvedType!!, + AttachmentTransferProgress.TRANSFER_PROGRESS_STARTED, + size, + width, + height, + fileName, + fastPreflightId, + voiceNote, + quote, + caption + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/SlideDeck.java b/app/src/main/java/org/thoughtcrime/securesms/mms/SlideDeck.java index 6db38e6bc58..ffe91704df6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/SlideDeck.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/SlideDeck.java @@ -24,6 +24,7 @@ import com.annimon.stream.Stream; import org.session.libsession.messaging.sending_receiving.attachments.Attachment; +import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.guava.Optional; import org.thoughtcrime.securesms.util.MediaUtil; @@ -47,8 +48,7 @@ public SlideDeck(@NonNull Context context, @NonNull Attachment attachment) { if (slide != null) slides.add(slide); } - public SlideDeck() { - } + public SlideDeck() { } public void clear() { slides.clear(); @@ -65,7 +65,6 @@ public String getBody() { body = slideBody.get(); } } - return body; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt index e583fb0ca55..63f6d07da1e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt @@ -76,7 +76,7 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor } override fun doWork(): Result { - if (TextSecurePreferences.getLocalNumber(context) == null || !TextSecurePreferences.hasSeenWelcomeScreen(context)) { + if (TextSecurePreferences.getLocalNumber(context) == null) { Log.v(TAG, "User not registered yet.") return Result.failure() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java index 8a891fb9b94..e80c47a9f61 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -29,6 +29,7 @@ import android.os.AsyncTask; import android.os.Build; import android.service.notification.StatusBarNotification; +import android.text.SpannableString; import android.text.TextUtils; import androidx.annotation.NonNull; @@ -42,7 +43,7 @@ import org.session.libsession.messaging.open_groups.OpenGroup; import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; -import org.session.libsession.messaging.utilities.SessionId; +import org.session.libsession.messaging.utilities.AccountId; import org.session.libsession.messaging.utilities.SodiumUtilities; import org.session.libsession.snode.SnodeAPI; import org.session.libsession.utilities.Address; @@ -145,9 +146,8 @@ public void notifyMessageDeliveryFailed(Context context, Recipient recipient, lo } public void notifyMessagesPending(Context context) { - if (!TextSecurePreferences.isNotificationsEnabled(context)) { - return; - } + + if (!TextSecurePreferences.isNotificationsEnabled(context)) { return; } PendingMessageNotificationBuilder builder = new PendingMessageNotificationBuilder(context, TextSecurePreferences.getNotificationPrivacy(context)); ServiceUtil.getNotificationManager(context).notify(PENDING_MESSAGES_ID, builder.build()); @@ -185,9 +185,9 @@ private void cancelOrphanedNotifications(@NonNull Context context, NotificationS for (StatusBarNotification notification : activeNotifications) { boolean validNotification = false; - if (notification.getId() != SUMMARY_NOTIFICATION_ID && - notification.getId() != KeyCachingService.SERVICE_RUNNING_ID && - notification.getId() != FOREGROUND_ID && + if (notification.getId() != SUMMARY_NOTIFICATION_ID && + notification.getId() != KeyCachingService.SERVICE_RUNNING_ID && + notification.getId() != FOREGROUND_ID && notification.getId() != PENDING_MESSAGES_ID) { for (NotificationItem item : notificationState.getNotifications()) { @@ -197,9 +197,7 @@ private void cancelOrphanedNotifications(@NonNull Context context, NotificationS } } - if (!validNotification) { - notifications.cancel(notification.getId()); - } + if (!validNotification) { notifications.cancel(notification.getId()); } } } } catch (Throwable e) { @@ -231,7 +229,7 @@ public void updateNotification(@NonNull Context context, long threadId) @Override public void updateNotification(@NonNull Context context, long threadId, boolean signal) { - boolean isVisible = visibleThread == threadId; + boolean isVisible = visibleThread == threadId; ThreadDatabase threads = DatabaseComponent.get(context).threadDatabase(); Recipient recipient = threads.getRecipientForThreadId(threadId); @@ -271,7 +269,7 @@ public void updateNotification(@NonNull Context context, boolean signal, int rem try { telcoCursor = DatabaseComponent.get(context).mmsSmsDatabase().getUnread(); // TODO: add a notification specific lighter query here - if ((telcoCursor == null || telcoCursor.isAfterLast()) || !TextSecurePreferences.hasSeenWelcomeScreen(context)) + if ((telcoCursor == null || telcoCursor.isAfterLast()) || TextSecurePreferences.getLocalNumber(context) == null) { updateBadge(context, 0); cancelActiveNotifications(context); @@ -348,14 +346,19 @@ private void sendSingleThreadNotification(@NonNull Context context, builder.setThread(notifications.get(0).getRecipient()); builder.setMessageCount(notificationState.getMessageCount()); - // TODO: Removing highlighting mentions in the notification because this context is the libsession one which - // TODO: doesn't have access to the `R.attr.message_sent_text_color` and `R.attr.message_received_text_color` - // TODO: attributes to perform the colour lookup. Also, it makes little sense to highlight the mentions using - // TODO: the app theme as it may result in insufficient contrast with the notification background which will - // TODO: be using the SYSTEM theme. - builder.setPrimaryMessageBody(recipient, notifications.get(0).getIndividualRecipient(), - //MentionUtilities.highlightMentions(text == null ? "" : text, notifications.get(0).getThreadId(), context), // Removing hightlighting mentions -ACL - text == null ? "" : text, + CharSequence builderCS = text == null ? "" : text; + SpannableString ss = MentionUtilities.highlightMentions( + builderCS, + false, + false, + true, + bundled ? notifications.get(0).getThreadId() : 0, + context + ); + + builder.setPrimaryMessageBody(recipient, + notifications.get(0).getIndividualRecipient(), + ss, notifications.get(0).getSlideDeck()); builder.setContentIntent(notifications.get(0).getPendingIntent(context)); @@ -505,24 +508,39 @@ private NotificationState constructNotificationState(@NonNull Context context, continue; } } + + // If this is a message request from an unknown user.. if (messageRequest) { body = SpanUtil.italic(context.getString(R.string.message_requests_notification)); + + // If we received some manner of notification but Session is locked.. } else if (KeyCachingService.isLocked(context)) { body = SpanUtil.italic(context.getString(R.string.MessageNotifier_locked_message)); + + // ----- All further cases assume we know the contact and that Session isn't locked ----- + + // If this is a notification about a multimedia message from a contact we know about.. } else if (record.isMms() && !((MmsMessageRecord) record).getSharedContacts().isEmpty()) { Contact contact = ((MmsMessageRecord) record).getSharedContacts().get(0); body = ContactUtil.getStringSummary(context, contact); + + // If this is a notification about a multimedia message which contains no text but DOES contain a slide deck with at least one slide.. } else if (record.isMms() && TextUtils.isEmpty(body) && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) { slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck(); body = SpanUtil.italic(slideDeck.getBody()); + + // If this is a notification about a multimedia message, but it's not ITSELF a multimedia notification AND it contains a slide deck with at least one slide.. } else if (record.isMms() && !record.isMmsNotification() && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) { slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck(); String message = slideDeck.getBody() + ": " + record.getBody(); int italicLength = message.length() - body.length(); body = SpanUtil.italic(message, italicLength); + + // If this is a notification about an invitation to a community.. } else if (record.isOpenGroupInvitation()) { body = SpanUtil.italic(context.getString(R.string.ThreadRecord_open_group_invitation)); } + String userPublicKey = TextSecurePreferences.getLocalNumber(context); String blindedPublicKey = cache.get(threadId); if (blindedPublicKey == null) { @@ -576,7 +594,7 @@ private NotificationState constructNotificationState(@NonNull Context context, if (openGroup != null && edKeyPair != null) { KeyPair blindedKeyPair = SodiumUtilities.blindedKeyPair(openGroup.getPublicKey(), edKeyPair); if (blindedKeyPair != null) { - return new SessionId(IdPrefix.BLINDED, blindedKeyPair.getPublicKey().getAsBytes()).getHexString(); + return new AccountId(IdPrefix.BLINDED, blindedKeyPair.getPublicKey().getAsBytes()).getHexString(); } } return null; diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java index 4d32a8cda51..8db5f810b4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java @@ -1,11 +1,9 @@ package org.thoughtcrime.securesms.notifications; - import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.os.AsyncTask; - import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java index cad3b6f6c58..7042a1766a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java @@ -118,11 +118,11 @@ public Notification build() { */ private String getGroupDisplayName(Recipient recipient, boolean openGroupRecipient) { SessionContactDatabase contactDB = DatabaseComponent.get(context).sessionContactDatabase(); - String sessionID = recipient.getAddress().serialize(); - Contact contact = contactDB.getContactWithSessionID(sessionID); - if (contact == null) { return sessionID; } + String accountID = recipient.getAddress().serialize(); + Contact contact = contactDB.getContactWithAccountID(accountID); + if (contact == null) { return accountID; } String displayName = contact.displayName(openGroupRecipient ? Contact.ContactContext.OPEN_GROUP : Contact.ContactContext.REGULAR); - if (displayName == null) { return sessionID; } + if (displayName == null) { return accountID; } return displayName; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt index bf16333b15d..42ae798366b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt @@ -10,6 +10,7 @@ import kotlinx.serialization.json.decodeFromStream import nl.komponents.kovenant.Promise import nl.komponents.kovenant.functional.map import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody import org.session.libsession.messaging.sending_receiving.notifications.Response @@ -99,7 +100,7 @@ class PushRegistryV2 @Inject constructor(private val pushReceiver: PushReceiver) private inline fun getResponseBody(path: String, requestParameters: String): Promise { val server = Server.LATEST val url = "${server.url}/$path" - val body = RequestBody.create(MediaType.get("application/json"), requestParameters) + val body = RequestBody.create("application/json".toMediaType(), requestParameters) val request = Request.Builder().url(url).post(body).build() return OnionRequestAPI.sendOnionRequest( diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java index 2aaa593b580..d5aeba6022f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java @@ -339,11 +339,11 @@ private static Drawable getPlaceholderDrawable(Context context, Recipient recipi */ private String getGroupDisplayName(Recipient recipient, boolean openGroupRecipient) { SessionContactDatabase contactDB = DatabaseComponent.get(context).sessionContactDatabase(); - String sessionID = recipient.getAddress().serialize(); - Contact contact = contactDB.getContactWithSessionID(sessionID); - if (contact == null) { return sessionID; } + String accountID = recipient.getAddress().serialize(); + Contact contact = contactDB.getContactWithAccountID(accountID); + if (contact == null) { return accountID; } String displayName = contact.displayName(openGroupRecipient ? Contact.ContactContext.OPEN_GROUP : Contact.ContactContext.REGULAR); - if (displayName == null) { return sessionID; } + if (displayName == null) { return accountID; } return displayName; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/DisplayNameActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/DisplayNameActivity.kt deleted file mode 100644 index c0699e3eb5a..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/DisplayNameActivity.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.thoughtcrime.securesms.onboarding - -import android.content.Intent -import android.os.Bundle -import android.view.KeyEvent -import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputMethodManager -import android.widget.TextView.OnEditorActionListener -import android.widget.Toast -import network.loki.messenger.R -import network.loki.messenger.databinding.ActivityDisplayNameBinding -import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol -import org.thoughtcrime.securesms.BaseActionBarActivity -import org.thoughtcrime.securesms.util.push -import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo -import org.session.libsession.utilities.TextSecurePreferences - -class DisplayNameActivity : BaseActionBarActivity() { - private lateinit var binding: ActivityDisplayNameBinding - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setUpActionBarSessionLogo() - binding = ActivityDisplayNameBinding.inflate(layoutInflater) - setContentView(binding.root) - with(binding) { - displayNameEditText.imeOptions = displayNameEditText.imeOptions or 16777216 // Always use incognito keyboard - displayNameEditText.setOnEditorActionListener( - OnEditorActionListener { _, actionID, event -> - if (actionID == EditorInfo.IME_ACTION_SEARCH || - actionID == EditorInfo.IME_ACTION_DONE || - (event.action == KeyEvent.ACTION_DOWN && - event.keyCode == KeyEvent.KEYCODE_ENTER)) { - register() - return@OnEditorActionListener true - } - false - }) - registerButton.setOnClickListener { register() } - } - } - - private fun register() { - val displayName = binding.displayNameEditText.text.toString().trim() - if (displayName.isEmpty()) { - return Toast.makeText(this, R.string.activity_display_name_display_name_missing_error, Toast.LENGTH_SHORT).show() - } - if (displayName.toByteArray().size > ProfileManagerProtocol.Companion.NAME_PADDED_LENGTH) { - return Toast.makeText(this, R.string.activity_display_name_display_name_too_long_error, Toast.LENGTH_SHORT).show() - } - val inputMethodManager = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager - inputMethodManager.hideSoftInputFromWindow(binding.displayNameEditText.windowToken, 0) - TextSecurePreferences.setProfileName(this, displayName) - val intent = Intent(this, PNModeActivity::class.java) - push(intent) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/FakeChatView.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/FakeChatView.kt deleted file mode 100644 index dcd4d783e7e..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/FakeChatView.kt +++ /dev/null @@ -1,79 +0,0 @@ -package org.thoughtcrime.securesms.onboarding - -import android.animation.FloatEvaluator -import android.animation.ValueAnimator -import android.content.Context -import android.os.Handler -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.widget.ScrollView -import network.loki.messenger.R -import network.loki.messenger.databinding.ViewFakeChatBinding -import org.thoughtcrime.securesms.util.disableClipping - -class FakeChatView : ScrollView { - private lateinit var binding: ViewFakeChatBinding - // region Settings - private val spacing = context.resources.getDimension(R.dimen.medium_spacing) - private val startDelay: Long = 1000 - private val delayBetweenMessages: Long = 1500 - private val animationDuration: Long = 400 - // endregion - - // region Lifecycle - constructor(context: Context) : super(context) { - setUpViewHierarchy() - } - - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { - setUpViewHierarchy() - } - - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { - setUpViewHierarchy() - } - - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) { - setUpViewHierarchy() - } - - private fun setUpViewHierarchy() { - binding = ViewFakeChatBinding.inflate(LayoutInflater.from(context), this, true) - binding.root.disableClipping() - isVerticalScrollBarEnabled = false - } - // endregion - - // region Animation - fun startAnimating() { - listOf( binding.bubble1, binding.bubble2, binding.bubble3, binding.bubble4, binding.bubble5 ).forEach { it.alpha = 0.0f } - fun show(bubble: View) { - val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f) - animation.duration = animationDuration - animation.addUpdateListener { animator -> - bubble.alpha = animator.animatedValue as Float - } - animation.start() - } - Handler().postDelayed({ - show(binding.bubble1) - Handler().postDelayed({ - show(binding.bubble2) - Handler().postDelayed({ - show(binding.bubble3) - smoothScrollTo(0, (binding.bubble1.height + spacing).toInt()) - Handler().postDelayed({ - show(binding.bubble4) - smoothScrollTo(0, (binding.bubble1.height + spacing).toInt() + (binding.bubble2.height + spacing).toInt()) - Handler().postDelayed({ - show(binding.bubble5) - smoothScrollTo(0, (binding.bubble1.height + spacing).toInt() + (binding.bubble2.height + spacing).toInt() + (binding.bubble3.height + spacing).toInt()) - }, delayBetweenMessages) - }, delayBetweenMessages) - }, delayBetweenMessages) - }, delayBetweenMessages) - }, startDelay) - } - // endregion -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt deleted file mode 100644 index 1c10571dbd0..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt +++ /dev/null @@ -1,230 +0,0 @@ -package org.thoughtcrime.securesms.onboarding - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.text.InputType -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputMethodManager -import android.widget.Toast -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentPagerAdapter -import androidx.lifecycle.lifecycleScope -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.launch -import network.loki.messenger.R -import network.loki.messenger.databinding.ActivityLinkDeviceBinding -import network.loki.messenger.databinding.FragmentRecoveryPhraseBinding -import org.session.libsession.snode.SnodeModule -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.crypto.MnemonicCodec -import org.session.libsignal.database.LokiAPIDatabaseProtocol -import org.session.libsignal.utilities.Hex -import org.session.libsignal.utilities.KeyHelper -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.hexEncodedPublicKey -import org.thoughtcrime.securesms.ApplicationContext -import org.thoughtcrime.securesms.BaseActionBarActivity -import org.thoughtcrime.securesms.crypto.KeyPairUtilities -import org.thoughtcrime.securesms.crypto.MnemonicUtilities -import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment -import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate -import org.thoughtcrime.securesms.util.push -import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo -import javax.inject.Inject - -@AndroidEntryPoint -class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDelegate { - - @Inject - lateinit var configFactory: ConfigFactory - - private lateinit var binding: ActivityLinkDeviceBinding - internal val database: LokiAPIDatabaseProtocol - get() = SnodeModule.shared.storage - private val adapter = LinkDeviceActivityAdapter(this) - private var restoreJob: Job? = null - - @Deprecated("Deprecated in Java") - override fun onBackPressed() { - if (restoreJob?.isActive == true) return // Don't allow going back with a pending job - super.onBackPressed() - } - - // region Lifecycle - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setUpActionBarSessionLogo() - TextSecurePreferences.apply { - setHasViewedSeed(this@LinkDeviceActivity, true) - setConfigurationMessageSynced(this@LinkDeviceActivity, false) - setRestorationTime(this@LinkDeviceActivity, System.currentTimeMillis()) - setLastProfileUpdateTime(this@LinkDeviceActivity, 0) - } - binding = ActivityLinkDeviceBinding.inflate(layoutInflater) - setContentView(binding.root) - binding.viewPager.adapter = adapter - binding.tabLayout.setupWithViewPager(binding.viewPager) - } - // endregion - - // region Interaction - override fun handleQRCodeScanned(mnemonic: String) { - try { - val seed = Hex.fromStringCondensed(mnemonic) - continueWithSeed(seed) - } catch (e: Exception) { - Log.e("Loki","Error getting seed from QR code", e) - Toast.makeText(this, "An error occurred.", Toast.LENGTH_LONG).show() - } - } - - fun continueWithMnemonic(mnemonic: String) { - val loadFileContents: (String) -> String = { fileName -> - MnemonicUtilities.loadFileContents(this, fileName) - } - try { - val hexEncodedSeed = MnemonicCodec(loadFileContents).decode(mnemonic) - val seed = Hex.fromStringCondensed(hexEncodedSeed) - continueWithSeed(seed) - } catch (error: Exception) { - val message = if (error is MnemonicCodec.DecodingError) { - error.description - } else { - "An error occurred." - } - Toast.makeText(this, message, Toast.LENGTH_LONG).show() - } - } - - private fun continueWithSeed(seed: ByteArray) { - - // only have one sync job running at a time (prevent QR from trying to spawn a new job) - if (restoreJob?.isActive == true) return - - restoreJob = lifecycleScope.launch { - // This is here to resolve a case where the app restarts before a user completes onboarding - // which can result in an invalid database state - database.clearAllLastMessageHashes() - database.clearReceivedMessageHashValues() - - // RestoreActivity handles seed this way - val keyPairGenerationResult = KeyPairUtilities.generate(seed) - val x25519KeyPair = keyPairGenerationResult.x25519KeyPair - KeyPairUtilities.store(this@LinkDeviceActivity, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair) - configFactory.keyPairChanged() - val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey - val registrationID = KeyHelper.generateRegistrationId(false) - TextSecurePreferences.setLocalRegistrationId(this@LinkDeviceActivity, registrationID) - TextSecurePreferences.setLocalNumber(this@LinkDeviceActivity, userHexEncodedPublicKey) - TextSecurePreferences.setRestorationTime(this@LinkDeviceActivity, System.currentTimeMillis()) - TextSecurePreferences.setHasViewedSeed(this@LinkDeviceActivity, true) - - binding.loader.isVisible = true - val snackBar = Snackbar.make(binding.containerLayout, R.string.activity_link_device_skip_prompt,Snackbar.LENGTH_INDEFINITE) - .setAction(R.string.registration_activity__skip) { register(true) } - - val skipJob = launch { - delay(15_000L) - snackBar.show() - } - // start polling and wait for updated message - ApplicationContext.getInstance(this@LinkDeviceActivity).apply { - startPollingIfNeeded() - } - TextSecurePreferences.events.filter { it == TextSecurePreferences.CONFIGURATION_SYNCED }.collect { - // handle we've synced - snackBar.dismiss() - skipJob.cancel() - register(false) - } - - binding.loader.isVisible = false - } - } - - private fun register(skipped: Boolean) { - restoreJob?.cancel() - binding.loader.isVisible = false - TextSecurePreferences.setLastConfigurationSyncTime(this, System.currentTimeMillis()) - val intent = Intent(this@LinkDeviceActivity, if (skipped) DisplayNameActivity::class.java else PNModeActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - push(intent) - } - // endregion -} - -// region Adapter -private class LinkDeviceActivityAdapter(private val activity: LinkDeviceActivity) : FragmentPagerAdapter(activity.supportFragmentManager) { - val recoveryPhraseFragment = RecoveryPhraseFragment() - - override fun getCount(): Int { - return 2 - } - - override fun getItem(index: Int): Fragment { - return when (index) { - 0 -> recoveryPhraseFragment - 1 -> { - val result = ScanQRCodeWrapperFragment() - result.delegate = activity - result.message = activity.getString(R.string.activity_link_device_qr_message) - result - } - else -> throw IllegalStateException() - } - } - - override fun getPageTitle(index: Int): CharSequence { - return when (index) { - 0 -> activity.getString(R.string.activity_link_device_recovery_phrase) - 1 -> activity.getString(R.string.activity_link_device_scan_qr_code) - else -> throw IllegalStateException() - } - } -} -// endregion - -// region Recovery Phrase Fragment -class RecoveryPhraseFragment : Fragment() { - private lateinit var binding: FragmentRecoveryPhraseBinding - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = FragmentRecoveryPhraseBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - with(binding) { - mnemonicEditText.imeOptions = EditorInfo.IME_ACTION_DONE or 16777216 // Always use incognito keyboard - mnemonicEditText.setRawInputType(InputType.TYPE_CLASS_TEXT) - mnemonicEditText.setOnEditorActionListener { v, actionID, _ -> - if (actionID == EditorInfo.IME_ACTION_DONE) { - val imm = v.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(v.windowToken, 0) - handleContinueButtonTapped() - true - } else { - false - } - } - continueButton.setOnClickListener { handleContinueButtonTapped() } - } - } - - private fun handleContinueButtonTapped() { - val mnemonic = binding.mnemonicEditText.text?.trim().toString() - (requireActivity() as LinkDeviceActivity).continueWithMnemonic(mnemonic) - } -} -// endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/OnboardingBackPressAlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/OnboardingBackPressAlertDialog.kt new file mode 100644 index 00000000000..2820a64a66b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/OnboardingBackPressAlertDialog.kt @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.onboarding + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.DialogButtonModel +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.theme.LocalColors + +@Composable +fun OnboardingBackPressAlertDialog( + dismissDialog: () -> Unit, + @StringRes textId: Int = R.string.you_cannot_go_back_further_in_order_to_stop_loading_your_account_session_needs_to_quit, + quit: () -> Unit +) { + AlertDialog( + onDismissRequest = dismissDialog, + title = stringResource(R.string.warning), + text = stringResource(textId), + buttons = listOf( + DialogButtonModel( + GetString(stringResource(R.string.quit)), + color = LocalColors.current.danger, + onClick = quit + ), + DialogButtonModel( + GetString(stringResource(R.string.cancel)) + ) + ) + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt deleted file mode 100644 index e4e8e6a9a62..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt +++ /dev/null @@ -1,179 +0,0 @@ -package org.thoughtcrime.securesms.onboarding - -import android.animation.ArgbEvaluator -import android.animation.ValueAnimator -import android.content.Intent -import android.graphics.drawable.TransitionDrawable -import android.net.Uri -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.widget.Toast -import androidx.annotation.ColorInt -import androidx.annotation.DrawableRes -import dagger.hilt.android.AndroidEntryPoint -import network.loki.messenger.R -import network.loki.messenger.databinding.ActivityPnModeBinding -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.ThemeUtil -import org.thoughtcrime.securesms.ApplicationContext -import org.thoughtcrime.securesms.BaseActionBarActivity -import org.thoughtcrime.securesms.home.HomeActivity -import org.thoughtcrime.securesms.notifications.PushManager -import org.thoughtcrime.securesms.notifications.PushRegistry -import org.thoughtcrime.securesms.showSessionDialog -import org.thoughtcrime.securesms.util.GlowViewUtilities -import org.thoughtcrime.securesms.util.PNModeView -import org.thoughtcrime.securesms.util.disableClipping -import org.thoughtcrime.securesms.util.getAccentColor -import org.thoughtcrime.securesms.util.getColorWithID -import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo -import org.thoughtcrime.securesms.util.show -import javax.inject.Inject - -@AndroidEntryPoint -class PNModeActivity : BaseActionBarActivity() { - - @Inject lateinit var pushRegistry: PushRegistry - - private lateinit var binding: ActivityPnModeBinding - private var selectedOptionView: PNModeView? = null - - // region Lifecycle - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setUpActionBarSessionLogo(true) - TextSecurePreferences.setHasSeenWelcomeScreen(this, true) - binding = ActivityPnModeBinding.inflate(layoutInflater) - setContentView(binding.root) - with(binding) { - contentView.disableClipping() - fcmOptionView.setOnClickListener { toggleFCM() } - fcmOptionView.mainColor = ThemeUtil.getThemedColor(root.context, R.attr.colorPrimary) - fcmOptionView.strokeColor = resources.getColorWithID(R.color.pn_option_border, theme) - backgroundPollingOptionView.setOnClickListener { toggleBackgroundPolling() } - backgroundPollingOptionView.mainColor = ThemeUtil.getThemedColor(root.context, R.attr.colorPrimary) - backgroundPollingOptionView.strokeColor = resources.getColorWithID(R.color.pn_option_border, theme) - registerButton.setOnClickListener { register() } - } - toggleFCM() - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_pn_mode, menu) - return true - } - // endregion - - // region Animation - private fun performTransition(@DrawableRes transitionID: Int, subject: View) { - val drawable = resources.getDrawable(transitionID, theme) as TransitionDrawable - subject.background = drawable - drawable.startTransition(250) - } - // endregion - - // region Interaction - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when(item.itemId) { - R.id.learnMoreButton -> learnMore() - else -> { /* Do nothing */ } - } - return super.onOptionsItemSelected(item) - } - - private fun learnMore() { - try { - val url = "https://getsession.org/faq/#privacy" - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - startActivity(intent) - } catch (e: Exception) { - Toast.makeText(this, R.string.invalid_url, Toast.LENGTH_SHORT).show() - } - } - - private fun toggleFCM() = with(binding) { - val accentColor = getAccentColor() - when (selectedOptionView) { - null -> { - performTransition(R.drawable.pn_option_background_select_transition, fcmOptionView) - GlowViewUtilities.animateShadowColorChange(fcmOptionView, resources.getColorWithID(R.color.transparent, theme), accentColor) - animateStrokeColorChange(fcmOptionView, resources.getColorWithID(R.color.pn_option_border, theme), accentColor) - selectedOptionView = fcmOptionView - } - fcmOptionView -> { - performTransition(R.drawable.pn_option_background_deselect_transition, fcmOptionView) - GlowViewUtilities.animateShadowColorChange(fcmOptionView, accentColor, resources.getColorWithID(R.color.transparent, theme)) - animateStrokeColorChange(fcmOptionView, accentColor, resources.getColorWithID(R.color.pn_option_border, theme)) - selectedOptionView = null - } - backgroundPollingOptionView -> { - performTransition(R.drawable.pn_option_background_select_transition, fcmOptionView) - GlowViewUtilities.animateShadowColorChange(fcmOptionView, resources.getColorWithID(R.color.transparent, theme), accentColor) - animateStrokeColorChange(fcmOptionView, resources.getColorWithID(R.color.pn_option_border, theme), accentColor) - performTransition(R.drawable.pn_option_background_deselect_transition, backgroundPollingOptionView) - GlowViewUtilities.animateShadowColorChange(backgroundPollingOptionView, accentColor, resources.getColorWithID(R.color.transparent, theme)) - animateStrokeColorChange(backgroundPollingOptionView, accentColor, resources.getColorWithID(R.color.pn_option_border, theme)) - selectedOptionView = fcmOptionView - } - } - } - - private fun toggleBackgroundPolling() = with(binding) { - val accentColor = getAccentColor() - when (selectedOptionView) { - null -> { - performTransition(R.drawable.pn_option_background_select_transition, backgroundPollingOptionView) - GlowViewUtilities.animateShadowColorChange(backgroundPollingOptionView, resources.getColorWithID(R.color.transparent, theme), accentColor) - animateStrokeColorChange(backgroundPollingOptionView, resources.getColorWithID(R.color.pn_option_border, theme), accentColor) - selectedOptionView = backgroundPollingOptionView - } - backgroundPollingOptionView -> { - performTransition(R.drawable.pn_option_background_deselect_transition, backgroundPollingOptionView) - GlowViewUtilities.animateShadowColorChange(backgroundPollingOptionView, accentColor, resources.getColorWithID(R.color.transparent, theme)) - animateStrokeColorChange(backgroundPollingOptionView, accentColor, resources.getColorWithID(R.color.pn_option_border, theme)) - selectedOptionView = null - } - fcmOptionView -> { - performTransition(R.drawable.pn_option_background_select_transition, backgroundPollingOptionView) - GlowViewUtilities.animateShadowColorChange(backgroundPollingOptionView, resources.getColorWithID(R.color.transparent, theme), accentColor) - animateStrokeColorChange(backgroundPollingOptionView, resources.getColorWithID(R.color.pn_option_border, theme), accentColor) - performTransition(R.drawable.pn_option_background_deselect_transition, fcmOptionView) - GlowViewUtilities.animateShadowColorChange(fcmOptionView, accentColor, resources.getColorWithID(R.color.transparent, theme)) - animateStrokeColorChange(fcmOptionView, accentColor, resources.getColorWithID(R.color.pn_option_border, theme)) - selectedOptionView = backgroundPollingOptionView - } - } - } - - private fun animateStrokeColorChange(bubble: PNModeView, @ColorInt startColor: Int, @ColorInt endColor: Int) { - val animation = ValueAnimator.ofObject(ArgbEvaluator(), startColor, endColor) - animation.duration = 250 - animation.addUpdateListener { animator -> - val color = animator.animatedValue as Int - bubble.strokeColor = color - } - animation.start() - } - - private fun register() { - if (selectedOptionView == null) { - showSessionDialog { - title(R.string.activity_pn_mode_no_option_picked_dialog_title) - button(R.string.ok) - } - return - } - - TextSecurePreferences.setPushEnabled(this, (selectedOptionView == binding.fcmOptionView)) - val application = ApplicationContext.getInstance(this) - application.startPollingIfNeeded() - pushRegistry.refresh(true) - val intent = Intent(this, HomeActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - intent.putExtra(HomeActivity.FROM_ONBOARDING, true) - show(intent) - } - // endregion -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt deleted file mode 100644 index 13e5b51f0e5..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt +++ /dev/null @@ -1,165 +0,0 @@ -package org.thoughtcrime.securesms.onboarding - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent -import android.graphics.Typeface -import android.net.Uri -import android.os.Bundle -import android.os.Handler -import android.text.Spannable -import android.text.SpannableStringBuilder -import android.text.method.LinkMovementMethod -import android.text.style.ClickableSpan -import android.text.style.StyleSpan -import android.view.View -import android.widget.Toast -import com.goterl.lazysodium.utils.KeyPair -import dagger.hilt.android.AndroidEntryPoint -import network.loki.messenger.R -import network.loki.messenger.databinding.ActivityRegisterBinding -import org.session.libsession.snode.SnodeModule -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.crypto.ecc.ECKeyPair -import org.session.libsignal.database.LokiAPIDatabaseProtocol -import org.session.libsignal.utilities.KeyHelper -import org.session.libsignal.utilities.hexEncodedPublicKey -import org.thoughtcrime.securesms.BaseActionBarActivity -import org.thoughtcrime.securesms.crypto.KeyPairUtilities -import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.util.push -import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo -import javax.inject.Inject - -@AndroidEntryPoint -class RegisterActivity : BaseActionBarActivity() { - - private val temporarySeedKey = "TEMPORARY_SEED_KEY" - - @Inject - lateinit var configFactory: ConfigFactory - - private lateinit var binding: ActivityRegisterBinding - internal val database: LokiAPIDatabaseProtocol - get() = SnodeModule.shared.storage - private var seed: ByteArray? = null - private var ed25519KeyPair: KeyPair? = null - private var x25519KeyPair: ECKeyPair? = null - set(value) { field = value; updatePublicKeyTextView() } - - // region Lifecycle - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding = ActivityRegisterBinding.inflate(layoutInflater) - setContentView(binding.root) - setUpActionBarSessionLogo() - TextSecurePreferences.apply { - setHasViewedSeed(this@RegisterActivity, false) - setConfigurationMessageSynced(this@RegisterActivity, true) - setRestorationTime(this@RegisterActivity, 0) - setLastProfileUpdateTime(this@RegisterActivity, System.currentTimeMillis()) - } - binding.registerButton.setOnClickListener { register() } - binding.copyButton.setOnClickListener { copyPublicKey() } - val termsExplanation = SpannableStringBuilder("By using this service, you agree to our Terms of Service and Privacy Policy") - termsExplanation.setSpan(StyleSpan(Typeface.BOLD), 40, 56, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - termsExplanation.setSpan(object : ClickableSpan() { - - override fun onClick(widget: View) { - openURL("https://getsession.org/terms-of-service/") - } - }, 40, 56, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - termsExplanation.setSpan(StyleSpan(Typeface.BOLD), 61, 75, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - termsExplanation.setSpan(object : ClickableSpan() { - - override fun onClick(widget: View) { - openURL("https://getsession.org/privacy-policy/") - } - }, 61, 75, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - binding.termsTextView.movementMethod = LinkMovementMethod.getInstance() - binding.termsTextView.text = termsExplanation - updateKeyPair(savedInstanceState?.getByteArray(temporarySeedKey)) - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - seed?.let { tempSeed -> - outState.putByteArray(temporarySeedKey, tempSeed) - } - } - // endregion - - // region Updating - private fun updateKeyPair(temporaryKey: ByteArray?) { - val keyPairGenerationResult = temporaryKey?.let(KeyPairUtilities::generate) ?: KeyPairUtilities.generate() - seed = keyPairGenerationResult.seed - ed25519KeyPair = keyPairGenerationResult.ed25519KeyPair - x25519KeyPair = keyPairGenerationResult.x25519KeyPair - } - - private fun updatePublicKeyTextView() { - val hexEncodedPublicKey = x25519KeyPair!!.hexEncodedPublicKey - val characterCount = hexEncodedPublicKey.count() - var count = 0 - val limit = 32 - fun animate() { - val numberOfIndexesToShuffle = 32 - count - val indexesToShuffle = (0 until characterCount).shuffled().subList(0, numberOfIndexesToShuffle) - var mangledHexEncodedPublicKey = hexEncodedPublicKey - for (index in indexesToShuffle) { - try { - mangledHexEncodedPublicKey = mangledHexEncodedPublicKey.substring(0, index) + "0123456789abcdef__".random() + mangledHexEncodedPublicKey.substring(index + 1, mangledHexEncodedPublicKey.count()) - } catch (exception: Exception) { - // Do nothing - } - } - count += 1 - if (count < limit) { - binding.publicKeyTextView.text = mangledHexEncodedPublicKey - Handler().postDelayed({ - animate() - }, 32) - } else { - binding.publicKeyTextView.text = hexEncodedPublicKey - } - } - animate() - } - // endregion - - // region Interaction - private fun register() { - // This is here to resolve a case where the app restarts before a user completes onboarding - // which can result in an invalid database state - database.clearAllLastMessageHashes() - database.clearReceivedMessageHashValues() - KeyPairUtilities.store(this, seed!!, ed25519KeyPair!!, x25519KeyPair!!) - configFactory.keyPairChanged() - val userHexEncodedPublicKey = x25519KeyPair!!.hexEncodedPublicKey - val registrationID = KeyHelper.generateRegistrationId(false) - TextSecurePreferences.setLocalRegistrationId(this, registrationID) - TextSecurePreferences.setLocalNumber(this, userHexEncodedPublicKey) - TextSecurePreferences.setRestorationTime(this, 0) - TextSecurePreferences.setHasViewedSeed(this, false) - val intent = Intent(this, DisplayNameActivity::class.java) - push(intent) - } - - private fun copyPublicKey() { - val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Session ID", x25519KeyPair!!.hexEncodedPublicKey) - clipboard.setPrimaryClip(clip) - Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() - } - - private fun openURL(url: String) { - try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - startActivity(intent) - } catch (e: Exception) { - Toast.makeText(this, R.string.invalid_url, Toast.LENGTH_SHORT).show() - } - } - // endregion -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/SeedActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/SeedActivity.kt deleted file mode 100644 index 0eab58fa0c0..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/SeedActivity.kt +++ /dev/null @@ -1,95 +0,0 @@ -package org.thoughtcrime.securesms.onboarding - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.os.Bundle -import android.text.Spannable -import android.text.SpannableString -import android.text.style.ForegroundColorSpan -import android.widget.LinearLayout -import android.widget.Toast -import network.loki.messenger.R -import network.loki.messenger.databinding.ActivitySeedBinding -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.getColorFromAttr -import org.session.libsignal.crypto.MnemonicCodec -import org.session.libsignal.utilities.hexEncodedPrivateKey -import org.thoughtcrime.securesms.BaseActionBarActivity -import org.thoughtcrime.securesms.crypto.IdentityKeyUtil -import org.thoughtcrime.securesms.crypto.MnemonicUtilities -import org.thoughtcrime.securesms.util.getAccentColor - -class SeedActivity : BaseActionBarActivity() { - - private lateinit var binding: ActivitySeedBinding - - private val seed by lazy { - var hexEncodedSeed = IdentityKeyUtil.retrieve(this, IdentityKeyUtil.LOKI_SEED) - if (hexEncodedSeed == null) { - hexEncodedSeed = IdentityKeyUtil.getIdentityKeyPair(this).hexEncodedPrivateKey // Legacy account - } - val loadFileContents: (String) -> String = { fileName -> - MnemonicUtilities.loadFileContents(this, fileName) - } - MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english) - } - - // region Lifecycle - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding = ActivitySeedBinding.inflate(layoutInflater) - setContentView(binding.root) - supportActionBar!!.title = resources.getString(R.string.activity_seed_title) - val seedReminderViewTitle = SpannableString("You're almost finished! 90%") // Intentionally not yet translated - seedReminderViewTitle.setSpan(ForegroundColorSpan(getAccentColor()), 24, 27, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - with(binding) { - seedReminderView.title = seedReminderViewTitle - seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_2) - seedReminderView.setProgress(90, false) - seedReminderView.hideContinueButton() - var redactedSeed = seed - var index = 0 - for (character in seed) { - if (character.isLetter()) { - redactedSeed = redactedSeed.replaceRange(index, index + 1, "▆") - } - index += 1 - } - seedTextView.setTextColor(getAccentColor()) - seedTextView.text = redactedSeed - seedTextView.setOnLongClickListener { revealSeed(); true } - revealButton.setOnLongClickListener { revealSeed(); true } - copyButton.setOnClickListener { copySeed() } - } - } - // endregion - - // region Updating - private fun revealSeed() { - val seedReminderViewTitle = SpannableString("Account secured! 100%") // Intentionally not yet translated - seedReminderViewTitle.setSpan(ForegroundColorSpan(getAccentColor()), 17, 21, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - with(binding) { - seedReminderView.title = seedReminderViewTitle - seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_3) - seedReminderView.setProgress(100, true) - val seedTextViewLayoutParams = seedTextView.layoutParams as LinearLayout.LayoutParams - seedTextViewLayoutParams.height = seedTextView.height - seedTextView.layoutParams = seedTextViewLayoutParams - seedTextView.setTextColor(getColorFromAttr(android.R.attr.textColorPrimary)) - seedTextView.text = seed - } - TextSecurePreferences.setHasViewedSeed(this, true) - } - // endregion - - // region Interaction - private fun copySeed() { - revealSeed() - val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Seed", seed) - clipboard.setPrimaryClip(clip) - Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() - } - // endregion -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/SeedReminderView.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/SeedReminderView.kt deleted file mode 100644 index 28611985fa6..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/SeedReminderView.kt +++ /dev/null @@ -1,59 +0,0 @@ -package org.thoughtcrime.securesms.onboarding - -import android.content.Context -import android.os.Build -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.widget.FrameLayout -import network.loki.messenger.databinding.ViewSeedReminderBinding - -class SeedReminderView : FrameLayout { - private lateinit var binding: ViewSeedReminderBinding - - var title: CharSequence - get() = binding.titleTextView.text - set(value) { binding.titleTextView.text = value } - var subtitle: CharSequence - get() = binding.subtitleTextView.text - set(value) { binding.subtitleTextView.text = value } - var delegate: SeedReminderViewDelegate? = null - - constructor(context: Context) : super(context) { - setUpViewHierarchy() - } - - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { - setUpViewHierarchy() - } - - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { - setUpViewHierarchy() - } - - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) { - setUpViewHierarchy() - } - - private fun setUpViewHierarchy() { - binding = ViewSeedReminderBinding.inflate(LayoutInflater.from(context), this, true) - binding.button.setOnClickListener { delegate?.handleSeedReminderViewContinueButtonTapped() } - } - - fun setProgress(progress: Int, isAnimated: Boolean) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - binding.progressBar.setProgress(progress, isAnimated) - } else { - binding.progressBar.progress = progress - } - } - - fun hideContinueButton() { - binding.button.visibility = View.GONE - } -} - -interface SeedReminderViewDelegate { - - fun handleSeedReminderViewContinueButtonTapped() -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt new file mode 100644 index 00000000000..e40328b1d85 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt @@ -0,0 +1,237 @@ +package org.thoughtcrime.securesms.onboarding.landing + +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.DialogButtonModel +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.components.BorderlessHtmlButton +import org.thoughtcrime.securesms.ui.components.PrimaryFillButton +import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton +import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import kotlin.time.Duration.Companion.milliseconds + +@Preview +@Composable +private fun PreviewLandingScreen( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + LandingScreen({}, {}, {}, {}) + } +} + +@Composable +internal fun LandingScreen( + createAccount: () -> Unit, + loadAccount: () -> Unit, + openTerms: () -> Unit, + openPrivacyPolicy: () -> Unit, +) { + var count by remember { mutableStateOf(0) } + val listState = rememberLazyListState() + + var isUrlDialogVisible by remember { mutableStateOf(false) } + + if (isUrlDialogVisible) { + AlertDialog( + onDismissRequest = { isUrlDialogVisible = false }, + title = stringResource(R.string.urlOpen), + text = stringResource(R.string.urlOpenBrowser), + showCloseButton = true, // display the 'x' button + buttons = listOf( + DialogButtonModel( + text = GetString(R.string.activity_landing_terms_of_service), + contentDescription = GetString(R.string.AccessibilityId_terms_of_service_button), + onClick = openTerms + ), + DialogButtonModel( + text = GetString(R.string.activity_landing_privacy_policy), + contentDescription = GetString(R.string.AccessibilityId_privacy_policy_button), + onClick = openPrivacyPolicy + ) + ) + ) + } + + LaunchedEffect(Unit) { + delay(500.milliseconds) + while(count < MESSAGES.size) { + count += 1 + listState.animateScrollToItem(0.coerceAtLeast((count - 1))) + delay(1500L) + } + } + + Column { + Column(modifier = Modifier + .weight(1f) + .padding(horizontal = LocalDimensions.current.mediumSpacing) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + stringResource(R.string.onboardingBubblePrivacyInYourPocket), + modifier = Modifier.align(Alignment.CenterHorizontally), + style = LocalType.current.h4, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + + LazyColumn( + state = listState, + modifier = Modifier + .heightIn(min = 200.dp) + .fillMaxWidth() + .weight(3f), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) + ) { + items( + MESSAGES.take(count), + key = { it.stringId } + ) { item -> + AnimateMessageText( + stringResource(item.stringId), + item.isOutgoing + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + } + + Column(modifier = Modifier.padding(horizontal = LocalDimensions.current.xlargeSpacing)) { + PrimaryFillButton( + text = stringResource(R.string.onboardingAccountCreate), + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterHorizontally) + .contentDescription(R.string.AccessibilityId_create_account_button), + onClick = createAccount + ) + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + PrimaryOutlineButton( + stringResource(R.string.onboardingAccountExists), + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterHorizontally) + .contentDescription(R.string.AccessibilityId_restore_account_button), + onClick = loadAccount + ) + BorderlessHtmlButton( + textId = R.string.onboardingTosPrivacy, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterHorizontally) + .contentDescription(R.string.AccessibilityId_open_url), + onClick = { isUrlDialogVisible = true } + ) + Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing)) + } + } +} + +@Composable +private fun AnimateMessageText(text: String, isOutgoing: Boolean, modifier: Modifier = Modifier) { + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { visible = true } + + Box { + // TODO [SES-2077] Use LazyList itemAnimation when we update to compose 1.7 or so. + MessageText(text, isOutgoing, Modifier.alpha(0f)) + + AnimatedVisibility( + visible = visible, + enter = fadeIn(animationSpec = tween(durationMillis = 300)) + + slideInVertically(animationSpec = tween(durationMillis = 300)) { it } + ) { + MessageText(text, isOutgoing, modifier) + } + } +} + +@Composable +private fun MessageText(text: String, isOutgoing: Boolean, modifier: Modifier) { + Box(modifier = modifier then Modifier.fillMaxWidth()) { + MessageText( + text, + color = if (isOutgoing) LocalColors.current.primary else LocalColors.current.backgroundBubbleReceived, + textColor = if (isOutgoing) LocalColors.current.textBubbleSent else LocalColors.current.textBubbleReceived, + modifier = Modifier.align(if (isOutgoing) Alignment.TopEnd else Alignment.TopStart) + ) + } +} + +@Composable +private fun MessageText( + text: String, + color: Color, + modifier: Modifier = Modifier, + textColor: Color = Color.Unspecified +) { + Box( + modifier = modifier.fillMaxWidth(0.666f) + .background(color = color, shape = MaterialTheme.shapes.small) + ) { + Text( + text, + style = LocalType.current.large, + color = textColor, + modifier = Modifier.padding( + horizontal = LocalDimensions.current.smallSpacing, + vertical = LocalDimensions.current.xsSpacing + ) + ) + } +} + +private data class TextData( + @StringRes val stringId: Int, + val isOutgoing: Boolean = false +) + +private val MESSAGES = listOf( + TextData(R.string.onboardingBubbleWelcomeToSession), + TextData(R.string.onboardingBubbleSessionIsEngineered, isOutgoing = true), + TextData(R.string.onboardingBubbleNoPhoneNumber), + TextData(R.string.onboardingBubbleCreatingAnAccountIsEasy, isOutgoing = true) +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/LandingActivity.kt similarity index 50% rename from app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt rename to app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/LandingActivity.kt index c878a79eef6..3be3eafcc2f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/LandingActivity.kt @@ -1,16 +1,25 @@ -package org.thoughtcrime.securesms.onboarding +package org.thoughtcrime.securesms.onboarding.landing import android.content.Intent +import android.net.Uri import android.os.Bundle -import network.loki.messenger.databinding.ActivityLandingBinding +import dagger.hilt.android.AndroidEntryPoint import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.crypto.IdentityKeyUtil +import org.thoughtcrime.securesms.onboarding.loadaccount.LoadAccountActivity +import org.thoughtcrime.securesms.onboarding.pickname.startPickDisplayNameActivity import org.thoughtcrime.securesms.service.KeyCachingService -import org.thoughtcrime.securesms.util.push +import org.thoughtcrime.securesms.ui.setComposeContent import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo +import org.thoughtcrime.securesms.util.start +import javax.inject.Inject -class LandingActivity : BaseActionBarActivity() { +@AndroidEntryPoint +class LandingActivity: BaseActionBarActivity() { + + @Inject + internal lateinit var prefs: TextSecurePreferences override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -19,28 +28,24 @@ class LandingActivity : BaseActionBarActivity() { // Session then close this activity to resume the last activity from the previous instance. if (!isTaskRoot) { finish(); return } - val binding = ActivityLandingBinding.inflate(layoutInflater) - setContentView(binding.root) setUpActionBarSessionLogo(true) - with(binding) { - fakeChatView.startAnimating() - registerButton.setOnClickListener { register() } - restoreButton.setOnClickListener { link() } - linkButton.setOnClickListener { link() } + + setComposeContent { + LandingScreen( + createAccount = { startPickDisplayNameActivity() }, + loadAccount = { start() }, + openTerms = { open("https://getsession.org/terms-of-service") }, + openPrivacyPolicy = { open("https://getsession.org/privacy-policy") } + ) } + IdentityKeyUtil.generateIdentityKeyPair(this) TextSecurePreferences.setPasswordDisabled(this, true) // AC: This is a temporary workaround to trick the old code that the screen is unlocked. KeyCachingService.setMasterSecret(applicationContext, Object()) } - private fun register() { - val intent = Intent(this, RegisterActivity::class.java) - push(intent) - } - - private fun link() { - val intent = Intent(this, LinkDeviceActivity::class.java) - push(intent) + private fun open(url: String) { + Intent(Intent.ACTION_VIEW, Uri.parse(url)).let(::startActivity) } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt new file mode 100644 index 00000000000..cc1033ee964 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt @@ -0,0 +1,117 @@ +package org.thoughtcrime.securesms.onboarding.loadaccount + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import kotlinx.coroutines.flow.Flow +import network.loki.messenger.R +import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode +import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField +import org.thoughtcrime.securesms.ui.components.SessionTabRow +import org.thoughtcrime.securesms.ui.theme.LocalType + +private val TITLES = listOf(R.string.sessionRecoveryPassword, R.string.qrScan) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun LoadAccountScreen( + state: State, + qrErrors: Flow, + onChange: (String) -> Unit = {}, + onContinue: () -> Unit = {}, + onScan: (String) -> Unit = {} +) { + val pagerState = rememberPagerState { TITLES.size } + + Column { + SessionTabRow(pagerState, TITLES) + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(1f) + ) { page -> + when (TITLES[page]) { + R.string.sessionRecoveryPassword -> RecoveryPassword(state, onChange, onContinue) + R.string.qrScan -> MaybeScanQrCode(qrErrors, onScan = onScan) + } + } + } +} + +@Preview +@Composable +private fun PreviewRecoveryPassword() { + PreviewTheme { + RecoveryPassword(state = State()) + } +} + +@Composable +private fun RecoveryPassword(state: State, onChange: (String) -> Unit = {}, onContinue: () -> Unit = {}) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Spacer(Modifier.weight(1f)) + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + Column( + modifier = Modifier.padding(horizontal = LocalDimensions.current.mediumSpacing) + ) { + Row { + Text( + text = stringResource(R.string.sessionRecoveryPassword), + style = LocalType.current.h4 + ) + Spacer(Modifier.width(LocalDimensions.current.xxsSpacing)) + Icon( + modifier = Modifier.align(Alignment.CenterVertically), + painter = painterResource(id = R.drawable.ic_shield_outline), + contentDescription = null, + ) + } + Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) + Text( + stringResource(R.string.activity_link_enter_your_recovery_password_to_load_your_account_if_you_haven_t_saved_it_you_can_find_it_in_your_app_settings), + style = LocalType.current.base + ) + Spacer(Modifier.height(LocalDimensions.current.spacing)) + SessionOutlinedTextField( + text = state.recoveryPhrase, + modifier = Modifier.fillMaxWidth(), + contentDescription = stringResource(R.string.AccessibilityId_recovery_phrase_input), + placeholder = stringResource(R.string.recoveryPasswordEnter), + onChange = onChange, + onContinue = onContinue, + error = state.error, + isTextErrorColor = state.isTextErrorColor + ) + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + Spacer(Modifier.weight(2f)) + + ContinuePrimaryOutlineButton(modifier = Modifier.align(Alignment.CenterHorizontally), onContinue) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt new file mode 100644 index 00000000000..3c7a6f6a56c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.onboarding.loadaccount + +import android.os.Bundle +import androidx.activity.viewModels +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.BaseActionBarActivity +import org.thoughtcrime.securesms.onboarding.manager.LoadAccountManager +import org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsActivity +import org.thoughtcrime.securesms.ui.setComposeContent +import org.thoughtcrime.securesms.util.start +import javax.inject.Inject + +@AndroidEntryPoint +class LoadAccountActivity : BaseActionBarActivity() { + + @Inject + internal lateinit var prefs: TextSecurePreferences + @Inject + internal lateinit var loadAccountManager: LoadAccountManager + + private val viewModel: LoadAccountViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + supportActionBar?.setTitle(R.string.activity_link_load_account) + prefs.setConfigurationMessageSynced(false) + prefs.setRestorationTime(System.currentTimeMillis()) + prefs.setLastProfileUpdateTime(0) + + lifecycleScope.launch { + viewModel.events.collect { + loadAccountManager.load(it.mnemonic) + start() + } + } + + setComposeContent { + val state by viewModel.stateFlow.collectAsState() + LoadAccountScreen(state, viewModel.qrErrors, viewModel::onChange, viewModel::onContinue, viewModel::onScanQrCode) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountViewModel.kt new file mode 100644 index 00000000000..f98c725deac --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountViewModel.kt @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.onboarding.loadaccount + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsignal.crypto.MnemonicCodec +import org.session.libsignal.crypto.MnemonicCodec.DecodingError.InputTooShort +import org.session.libsignal.crypto.MnemonicCodec.DecodingError.InvalidWord +import org.thoughtcrime.securesms.crypto.MnemonicUtilities +import javax.inject.Inject + +class LoadAccountEvent(val mnemonic: ByteArray) + +internal data class State( + val recoveryPhrase: String = "", + val isTextErrorColor: Boolean = false, + val error: String? = null +) + +@HiltViewModel +internal class LoadAccountViewModel @Inject constructor( + private val application: Application +): AndroidViewModel(application) { + private val state = MutableStateFlow(State()) + val stateFlow = state.asStateFlow() + + private val _events = MutableSharedFlow() + val events = _events.asSharedFlow() + + private val _qrErrors = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val qrErrors = _qrErrors.asSharedFlow() + .mapNotNull { application.getString(R.string.qrNotRecoveryPassword) } + + private val codec by lazy { MnemonicCodec { MnemonicUtilities.loadFileContents(getApplication(), it) } } + + fun onContinue() { + viewModelScope.launch { + try { + codec.sanitizeAndDecodeAsByteArray(state.value.recoveryPhrase).let(::onSuccess) + } catch (e: Exception) { + onFailure(e) + } + } + } + + fun onScanQrCode(string: String) { + viewModelScope.launch { + try { + codec.decodeMnemonicOrHexAsByteArray(string).let(::onSuccess) + } catch (e: Exception) { + onQrCodeScanFailure(e) + } + } + } + + fun onChange(recoveryPhrase: String) { + state.update { it.copy(recoveryPhrase = recoveryPhrase, isTextErrorColor = false) } + } + + private fun onSuccess(seed: ByteArray) { + viewModelScope.launch { _events.emit(LoadAccountEvent(seed)) } + } + + private fun onFailure(error: Throwable) { + state.update { + it.copy( + isTextErrorColor = true, + error = when (error) { + is InvalidWord -> R.string.recoveryPasswordErrorMessageIncorrect + is InputTooShort -> R.string.recoveryPasswordErrorMessageShort + else -> R.string.recoveryPasswordErrorMessageGeneric + }.let(application::getString) + ) + } + } + + private fun onQrCodeScanFailure(error: Throwable) { + viewModelScope.launch { _qrErrors.emit(error) } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/Loading.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/Loading.kt new file mode 100644 index 00000000000..8c2e10e765f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/Loading.kt @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms.onboarding.loading + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.ProgressArc +import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.theme.LocalType + +@Composable +internal fun LoadingScreen(progress: Float) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(modifier = Modifier.weight(1f)) + ProgressArc( + progress, + modifier = Modifier.contentDescription(R.string.AccessibilityId_loading_animation) + ) + Text( + stringResource(R.string.waitOneMoment), + style = LocalType.current.h7 + ) + Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) + Text( + stringResource(R.string.loadAccountProgressMessage), + style = LocalType.current.base + ) + Spacer(modifier = Modifier.weight(2f)) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingActivity.kt new file mode 100644 index 00000000000..abf0471598e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingActivity.kt @@ -0,0 +1,60 @@ +package org.thoughtcrime.securesms.onboarding.loading + +import android.os.Bundle +import androidx.activity.viewModels +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.BaseActionBarActivity +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.home.startHomeActivity +import org.thoughtcrime.securesms.onboarding.pickname.startPickDisplayNameActivity +import org.thoughtcrime.securesms.ui.setComposeContent +import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo +import javax.inject.Inject + +@AndroidEntryPoint +class LoadingActivity: BaseActionBarActivity() { + + @Inject + internal lateinit var configFactory: ConfigFactory + + @Inject + internal lateinit var prefs: TextSecurePreferences + + private val viewModel: LoadingViewModel by viewModels() + + private fun register(loadFailed: Boolean) { + prefs.setLastConfigurationSyncTime(System.currentTimeMillis()) + + when { + loadFailed -> startPickDisplayNameActivity(loadFailed = true) + else -> startHomeActivity(isNewAccount = false, isFromOnboarding = true) + } + + finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setUpActionBarSessionLogo() + + setComposeContent { + val progress by viewModel.progress.collectAsState() + LoadingScreen(progress) + } + + lifecycleScope.launch { + viewModel.events.collect { + when (it) { + Event.TIMEOUT -> register(loadFailed = true) + Event.SUCCESS -> register(loadFailed = false) + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingViewModel.kt new file mode 100644 index 00000000000..a7871d5620e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingViewModel.kt @@ -0,0 +1,121 @@ +package org.thoughtcrime.securesms.onboarding.loading + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.timeout +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.session.libsession.utilities.TextSecurePreferences +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +enum class State { + LOADING, + SUCCESS, + FAIL +} + +private val ANIMATE_TO_DONE_TIME = 500.milliseconds +private val IDLE_DONE_TIME = 1.seconds +private val TIMEOUT_TIME = 15.seconds + +private val REFRESH_TIME = 50.milliseconds + +@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) +@HiltViewModel +internal class LoadingViewModel @Inject constructor( + val prefs: TextSecurePreferences +): ViewModel() { + + private val state = MutableStateFlow(State.LOADING) + + private val _progress = MutableStateFlow(0f) + val progress = _progress.asStateFlow() + + private val _events = MutableSharedFlow() + val events = _events.asSharedFlow() + + init { + viewModelScope.launch(Dispatchers.IO) { + state.flatMapLatest { + when (it) { + State.LOADING -> progress(0f, 1f, TIMEOUT_TIME) + else -> progress(progress.value, 1f, ANIMATE_TO_DONE_TIME) + } + }.buffer(0, BufferOverflow.DROP_OLDEST) + .collectLatest { _progress.value = it } + } + + viewModelScope.launch(Dispatchers.IO) { + try { + TextSecurePreferences.events + .filter { it == TextSecurePreferences.CONFIGURATION_SYNCED } + .onStart { emit(TextSecurePreferences.CONFIGURATION_SYNCED) } + .filter { prefs.getConfigurationMessageSynced() } + .timeout(TIMEOUT_TIME) + .collectLatest { onSuccess() } + } catch (e: Exception) { + onFail() + } + } + } + + private suspend fun onSuccess() { + withContext(Dispatchers.Main) { + state.value = State.SUCCESS + delay(IDLE_DONE_TIME) + _events.emit(Event.SUCCESS) + } + } + + private suspend fun onFail() { + withContext(Dispatchers.Main) { + state.value = State.FAIL + delay(IDLE_DONE_TIME) + _events.emit(Event.TIMEOUT) + } + } +} + +sealed interface Event { + object SUCCESS: Event + object TIMEOUT: Event +} + +private fun progress( + init: Float, + target: Float, + time: Duration, + refreshRate: Duration = REFRESH_TIME +): Flow = flow { + val startMs = System.currentTimeMillis() + val timeMs = time.inWholeMilliseconds + val finishMs = startMs + timeMs + val range = target - init + + generateSequence { System.currentTimeMillis() }.takeWhile { it < finishMs }.forEach { + emit((it - startMs) * range / timeMs + init) + delay(refreshRate) + } + + emit(target) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt new file mode 100644 index 00000000000..40bf5772553 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms.onboarding.manager + +import android.app.Application +import org.session.libsession.snode.SnodeModule +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.database.LokiAPIDatabaseProtocol +import org.session.libsignal.utilities.KeyHelper +import org.session.libsignal.utilities.hexEncodedPublicKey +import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities +import org.thoughtcrime.securesms.util.VersionDataFetcher +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CreateAccountManager @Inject constructor( + private val application: Application, + private val prefs: TextSecurePreferences, + private val configFactory: ConfigFactory, + private val versionDataFetcher: VersionDataFetcher +) { + private val database: LokiAPIDatabaseProtocol + get() = SnodeModule.shared.storage + + fun createAccount(displayName: String) { + prefs.setProfileName(displayName) + + // This is here to resolve a case where the app restarts before a user completes onboarding + // which can result in an invalid database state + database.clearAllLastMessageHashes() + database.clearReceivedMessageHashValues() + + val keyPairGenerationResult = KeyPairUtilities.generate() + val seed = keyPairGenerationResult.seed + val ed25519KeyPair = keyPairGenerationResult.ed25519KeyPair + val x25519KeyPair = keyPairGenerationResult.x25519KeyPair + + KeyPairUtilities.store(application, seed, ed25519KeyPair, x25519KeyPair) + val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey + val registrationID = KeyHelper.generateRegistrationId(false) + prefs.setLocalRegistrationId(registrationID) + prefs.setLocalNumber(userHexEncodedPublicKey) + prefs.setRestorationTime(0) + + // we'll rely on the config syncing in the homeActivity resume + configFactory.keyPairChanged() + configFactory.user?.setName(displayName) + + versionDataFetcher.startTimedVersionCheck() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt new file mode 100644 index 00000000000..51d1b24609c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.onboarding.manager + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.session.libsession.snode.SnodeModule +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.database.LokiAPIDatabaseProtocol +import org.session.libsignal.utilities.hexEncodedPublicKey +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.util.VersionDataFetcher +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LoadAccountManager @Inject constructor( + @dagger.hilt.android.qualifiers.ApplicationContext private val context: Context, + private val configFactory: ConfigFactory, + private val prefs: TextSecurePreferences, + private val versionDataFetcher: VersionDataFetcher +) { + private val database: LokiAPIDatabaseProtocol + get() = SnodeModule.shared.storage + + private var restoreJob: Job? = null + + private val scope = CoroutineScope(Dispatchers.IO) + + fun load(seed: ByteArray) { + // only have one sync job running at a time (prevent QR from trying to spawn a new job) + if (restoreJob?.isActive == true) return + + restoreJob = scope.launch { + // This is here to resolve a case where the app restarts before a user completes onboarding + // which can result in an invalid database state + database.clearAllLastMessageHashes() + database.clearReceivedMessageHashValues() + + // RestoreActivity handles seed this way + val keyPairGenerationResult = KeyPairUtilities.generate(seed) + val x25519KeyPair = keyPairGenerationResult.x25519KeyPair + KeyPairUtilities.store(context, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair) + configFactory.keyPairChanged() + val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey + val registrationID = org.session.libsignal.utilities.KeyHelper.generateRegistrationId(false) + prefs.apply { + setLocalRegistrationId(registrationID) + setLocalNumber(userHexEncodedPublicKey) + setRestorationTime(System.currentTimeMillis()) + setHasViewedSeed(true) + } + + versionDataFetcher.startTimedVersionCheck() + + ApplicationContext.getInstance(context).retrieveUserProfile() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt new file mode 100644 index 00000000000..e56b55aaab7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt @@ -0,0 +1,143 @@ +package org.thoughtcrime.securesms.onboarding.messagenotifications + +import androidx.annotation.StringRes +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import org.thoughtcrime.securesms.onboarding.OnboardingBackPressAlertDialog +import org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsViewModel.UiState +import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator +import org.thoughtcrime.securesms.ui.components.RadioButton +import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.theme.LocalType + +@Composable +internal fun MessageNotificationsScreen( + state: UiState = UiState(), + setEnabled: (Boolean) -> Unit = {}, + onContinue: () -> Unit = {}, + quit: () -> Unit = {}, + dismissDialog: () -> Unit = {} +) { + if (state.clearData) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(LocalColors.current.primary) + } + + return + } + + if (state.showDialog) OnboardingBackPressAlertDialog(dismissDialog, quit = quit) + + Column { + Spacer(Modifier.weight(1f)) + + Column(modifier = Modifier.padding(horizontal = LocalDimensions.current.mediumSpacing)) { + Text(stringResource(R.string.notificationsMessage), style = LocalType.current.h4) + Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) + Text(stringResource(R.string.onboardingMessageNotificationExplaination), style = LocalType.current.base) + Spacer(Modifier.height(LocalDimensions.current.spacing)) + } + + NotificationRadioButton( + R.string.activity_pn_mode_fast_mode, + R.string.activity_pn_mode_fast_mode_explanation, + modifier = Modifier.contentDescription(R.string.AccessibilityId_fast_mode_notifications_button), + tag = R.string.activity_pn_mode_recommended_option_tag, + checked = state.pushEnabled, + onClick = { setEnabled(true) } + ) + + // spacing between buttons is provided by ripple/downstate of NotificationRadioButton + + NotificationRadioButton( + R.string.activity_pn_mode_slow_mode, + R.string.activity_pn_mode_slow_mode_explanation, + modifier = Modifier.contentDescription(R.string.AccessibilityId_slow_mode_notifications_button), + checked = state.pushDisabled, + onClick = { setEnabled(false) } + ) + + Spacer(Modifier.weight(1f)) + + ContinuePrimaryOutlineButton(Modifier.align(Alignment.CenterHorizontally), onContinue) + } +} + +@Composable +private fun NotificationRadioButton( + @StringRes title: Int, + @StringRes explanation: Int, + modifier: Modifier = Modifier, + @StringRes tag: Int? = null, + checked: Boolean = false, + onClick: () -> Unit = {} +) { + RadioButton( + onClick = onClick, + modifier = modifier, + selected = checked, + contentPadding = PaddingValues(horizontal = LocalDimensions.current.mediumSpacing, vertical = 7.dp) + ) { + Box( + modifier = Modifier + .weight(1f) + .border( + LocalDimensions.current.borderStroke, + LocalColors.current.borders, + RoundedCornerShape(8.dp) + ), + ) { + Column(modifier = Modifier + .padding(horizontal = LocalDimensions.current.smallSpacing, + vertical = LocalDimensions.current.xsSpacing) + ) { + Text(stringResource(title), style = LocalType.current.h8) + + Text(stringResource(explanation), style = LocalType.current.small, modifier = Modifier.padding(top = LocalDimensions.current.xxsSpacing)) + tag?.let { + Text( + stringResource(it), + modifier = Modifier.padding(top = LocalDimensions.current.xxsSpacing), + color = LocalColors.current.primary, + style = LocalType.current.h9 + ) + } + } + } + } +} + +@Preview +@Composable +private fun MessageNotificationsScreenPreview( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + MessageNotificationsScreen() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsActivity.kt new file mode 100644 index 00000000000..9ea4fd2bd6a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsActivity.kt @@ -0,0 +1,81 @@ +package org.thoughtcrime.securesms.onboarding.messagenotifications + +import android.app.Activity +import android.os.Bundle +import androidx.activity.viewModels +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.BaseActionBarActivity +import org.thoughtcrime.securesms.home.startHomeActivity +import org.thoughtcrime.securesms.onboarding.loading.LoadingActivity +import org.thoughtcrime.securesms.onboarding.manager.LoadAccountManager +import org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsActivity.Companion.EXTRA_PROFILE_NAME +import org.thoughtcrime.securesms.ui.setComposeContent +import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo +import org.thoughtcrime.securesms.util.start +import javax.inject.Inject + +@AndroidEntryPoint +class MessageNotificationsActivity : BaseActionBarActivity() { + + companion object { + const val EXTRA_PROFILE_NAME = "EXTRA_PROFILE_NAME" + } + + @Inject + internal lateinit var viewModelFactory: MessageNotificationsViewModel.AssistedFactory + + @Inject lateinit var prefs: TextSecurePreferences + @Inject lateinit var loadAccountManager: LoadAccountManager + + val profileName by lazy { intent.getStringExtra(EXTRA_PROFILE_NAME) } + + private val viewModel: MessageNotificationsViewModel by viewModels { + viewModelFactory.create(profileName) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setUpActionBarSessionLogo() + + setComposeContent { MessageNotificationsScreen() } + + lifecycleScope.launch { + viewModel.events.collect { + when (it) { + Event.Loading -> start() + Event.OnboardingComplete -> startHomeActivity(isNewAccount = true, isFromOnboarding = true) + } + } + } + } + + @Deprecated("Deprecated in Java") + override fun onBackPressed() { + if (viewModel.onBackPressed()) return + + @Suppress("DEPRECATION") + super.onBackPressed() + } + + @Composable + private fun MessageNotificationsScreen() { + val uiState by viewModel.uiStates.collectAsState() + MessageNotificationsScreen( + uiState, + setEnabled = viewModel::setEnabled, + onContinue = viewModel::onContinue, + quit = viewModel::quit, + dismissDialog = viewModel::dismissDialog + ) + } +} + +fun Activity.startMessageNotificationsActivity(profileName: String) { + start { putExtra(EXTRA_PROFILE_NAME, profileName) } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsViewModel.kt new file mode 100644 index 00000000000..a39f270bf24 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsViewModel.kt @@ -0,0 +1,120 @@ +package org.thoughtcrime.securesms.onboarding.messagenotifications + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.notifications.PushRegistry +import org.thoughtcrime.securesms.onboarding.manager.CreateAccountManager + +internal class MessageNotificationsViewModel( + private val state: State, + private val application: Application, + private val prefs: TextSecurePreferences, + private val pushRegistry: PushRegistry, + private val createAccountManager: CreateAccountManager +): AndroidViewModel(application) { + private val _uiStates = MutableStateFlow(UiState()) + val uiStates = _uiStates.asStateFlow() + + private val _events = MutableSharedFlow() + val events = _events.asSharedFlow() + + fun setEnabled(enabled: Boolean) { + _uiStates.update { UiState(pushEnabled = enabled) } + } + + fun onContinue() { + viewModelScope.launch(Dispatchers.IO) { + if (state is State.CreateAccount) createAccountManager.createAccount(state.displayName) + + prefs.setPushEnabled(uiStates.value.pushEnabled) + pushRegistry.refresh(true) + + _events.emit( + when (state) { + is State.CreateAccount -> Event.OnboardingComplete + else -> Event.Loading + } + ) + } + } + + /** + * @return [true] if the back press was handled. + */ + fun onBackPressed(): Boolean = when (state) { + is State.CreateAccount -> false + is State.LoadAccount -> { + _uiStates.update { it.copy(showDialog = true) } + + true + } + } + + fun dismissDialog() { + _uiStates.update { it.copy(showDialog = false) } + } + + fun quit() { + _uiStates.update { it.copy(clearData = true) } + + viewModelScope.launch(Dispatchers.IO) { + ApplicationContext.getInstance(application).clearAllData() + } + } + + data class UiState( + val pushEnabled: Boolean = true, + val showDialog: Boolean = false, + val clearData: Boolean = false + ) { + val pushDisabled get() = !pushEnabled + } + + sealed interface State { + class CreateAccount(val displayName: String): State + object LoadAccount: State + } + + @dagger.assisted.AssistedFactory + interface AssistedFactory { + fun create(profileName: String?): Factory + } + + @Suppress("UNCHECKED_CAST") + class Factory @AssistedInject constructor( + @Assisted private val profileName: String?, + private val application: Application, + private val prefs: TextSecurePreferences, + private val pushRegistry: PushRegistry, + private val createAccountManager: CreateAccountManager, + ) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + return MessageNotificationsViewModel( + state = profileName?.let(State::CreateAccount) ?: State.LoadAccount, + application = application, + prefs = prefs, + pushRegistry = pushRegistry, + createAccountManager = createAccountManager + ) as T + } + } +} + +enum class Event { + OnboardingComplete, Loading +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt new file mode 100644 index 00000000000..04124a5bfdf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt @@ -0,0 +1,83 @@ +package org.thoughtcrime.securesms.onboarding.pickname + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import network.loki.messenger.R +import org.thoughtcrime.securesms.onboarding.OnboardingBackPressAlertDialog +import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField +import org.thoughtcrime.securesms.ui.theme.LocalType + +@Preview +@Composable +private fun PreviewPickDisplayName() { + PreviewTheme { + PickDisplayName(State()) + } +} + +@Composable +internal fun PickDisplayName( + state: State, + onChange: (String) -> Unit = {}, + onContinue: () -> Unit = {}, + dismissDialog: () -> Unit = {}, + quit: () -> Unit = {} +) { + + if (state.showDialog) OnboardingBackPressAlertDialog( + dismissDialog, + R.string.you_cannot_go_back_further_cancel_account_creation, + quit + ) + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Spacer(Modifier.weight(1f)) + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + Column( + modifier = Modifier.padding(horizontal = LocalDimensions.current.mediumSpacing) + ) { + Text(stringResource(state.title), style = LocalType.current.h4) + Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) + Text( + stringResource(state.description), + style = LocalType.current.base, + modifier = Modifier.padding(bottom = LocalDimensions.current.xsSpacing)) + Spacer(Modifier.height(LocalDimensions.current.spacing)) + SessionOutlinedTextField( + text = state.displayName, + modifier = Modifier.fillMaxWidth(), + contentDescription = stringResource(R.string.AccessibilityId_enter_display_name), + placeholder = stringResource(R.string.displayNameEnter), + onChange = onChange, + onContinue = onContinue, + error = state.error?.let { stringResource(it) }, + isTextErrorColor = state.isTextErrorColor + ) + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + Spacer(Modifier.weight(2f)) + + ContinuePrimaryOutlineButton(modifier = Modifier.align(Alignment.CenterHorizontally), onContinue) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameActivity.kt new file mode 100644 index 00000000000..8ade5b4e8e4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameActivity.kt @@ -0,0 +1,79 @@ +package org.thoughtcrime.securesms.onboarding.pickname + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.BaseActionBarActivity +import org.thoughtcrime.securesms.home.startHomeActivity +import org.thoughtcrime.securesms.onboarding.messagenotifications.startMessageNotificationsActivity +import org.thoughtcrime.securesms.ui.setComposeContent +import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo +import javax.inject.Inject + +private const val EXTRA_LOAD_FAILED = "extra_load_failed" + +@AndroidEntryPoint +class PickDisplayNameActivity : BaseActionBarActivity() { + + @Inject + internal lateinit var viewModelFactory: PickDisplayNameViewModel.AssistedFactory + @Inject + internal lateinit var prefs: TextSecurePreferences + + private val loadFailed get() = intent.getBooleanExtra(EXTRA_LOAD_FAILED, false) + + private val viewModel: PickDisplayNameViewModel by viewModels { + viewModelFactory.create(loadFailed) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setUpActionBarSessionLogo() + + setComposeContent { DisplayNameScreen(viewModel) } + + lifecycleScope.launch(Dispatchers.Main) { + viewModel.events.collect { + when (it) { + is Event.CreateAccount -> startMessageNotificationsActivity(it.profileName) + Event.LoadAccountComplete -> startHomeActivity(isNewAccount = false, isFromOnboarding = true) + } + } + } + } + + @Composable + private fun DisplayNameScreen(viewModel: PickDisplayNameViewModel) { + PickDisplayName( + viewModel.states.collectAsState().value, + viewModel::onChange, + viewModel::onContinue, + viewModel::dismissDialog, + quit = { viewModel.dismissDialog(); finish() } + ) + } + + @Deprecated("Deprecated in Java") + override fun onBackPressed() { + if (viewModel.onBackPressed()) return + + @Suppress("DEPRECATION") + super.onBackPressed() + } +} + +fun Context.startPickDisplayNameActivity(loadFailed: Boolean = false, flags: Int = 0) { + Intent(this, PickDisplayNameActivity::class.java) + .apply { putExtra(EXTRA_LOAD_FAILED, loadFailed) } + .also { it.flags = flags } + .also(::startActivity) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameViewModel.kt new file mode 100644 index 00000000000..d17c6f602c5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameViewModel.kt @@ -0,0 +1,117 @@ +package org.thoughtcrime.securesms.onboarding.pickname + +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol.Companion.NAME_PADDED_LENGTH +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsViewModel + +internal class PickDisplayNameViewModel( + private val loadFailed: Boolean, + private val prefs: TextSecurePreferences, + private val configFactory: ConfigFactory +): ViewModel() { + private val isCreateAccount = !loadFailed + + private val _states = MutableStateFlow(if (loadFailed) pickNewNameState() else State()) + val states = _states.asStateFlow() + + private val _events = MutableSharedFlow() + val events = _events.asSharedFlow() + + fun onContinue() { + _states.update { it.copy(displayName = it.displayName.trim()) } + + val displayName = _states.value.displayName + + when { + displayName.isEmpty() -> { _states.update { it.copy(isTextErrorColor = true, error = R.string.displayNameErrorDescription) } } + displayName.toByteArray().size > NAME_PADDED_LENGTH -> { _states.update { it.copy(isTextErrorColor = true, error = R.string.displayNameErrorDescriptionShorter) } } + else -> { + // success - clear the error as we can still see it during the transition to the + // next screen. + _states.update { it.copy(isTextErrorColor = false, error = null) } + + viewModelScope.launch(Dispatchers.IO) { + if (loadFailed) { + prefs.setProfileName(displayName) + // we'll rely on the config syncing in the homeActivity resume + configFactory.user?.setName(displayName) + + _events.emit(Event.LoadAccountComplete) + } else _events.emit(Event.CreateAccount(displayName)) + } + } + } + } + + fun onChange(value: String) { + _states.update { state -> + state.copy( + displayName = value, + isTextErrorColor = false + ) + } + } + + /** + * @return [true] if the back press was handled. + */ + fun onBackPressed(): Boolean = isCreateAccount.also { + if (it) _states.update { it.copy(showDialog = true) } + } + + fun dismissDialog() { + _states.update { it.copy(showDialog = false) } + } + + @dagger.assisted.AssistedFactory + interface AssistedFactory { + fun create(loadFailed: Boolean): Factory + } + + @Suppress("UNCHECKED_CAST") + class Factory @AssistedInject constructor( + @Assisted private val loadFailed: Boolean, + private val prefs: TextSecurePreferences, + private val configFactory: ConfigFactory + ) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + return PickDisplayNameViewModel(loadFailed, prefs, configFactory) as T + } + } +} + +data class State( + @StringRes val title: Int = R.string.displayNamePick, + @StringRes val description: Int = R.string.displayNameDescription, + val showDialog: Boolean = false, + val isTextErrorColor: Boolean = false, + @StringRes val error: Int? = null, + val displayName: String = "" +) + +fun pickNewNameState() = State( + title = R.string.displayNameNew, + description = R.string.displayNameErrorNew +) + +sealed interface Event { + class CreateAccount(val profileName: String): Event + object LoadAccountComplete: Event +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/ui/ContinueButton.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/ui/ContinueButton.kt new file mode 100644 index 00000000000..0d31363d76a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/ui/ContinueButton.kt @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.onboarding.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton +import org.thoughtcrime.securesms.ui.contentDescription + +@Composable +fun ContinuePrimaryOutlineButton(modifier: Modifier, onContinue: () -> Unit) { + PrimaryOutlineButton( + stringResource(R.string.continue_2), + modifier = modifier + .contentDescription(R.string.AccessibilityId_continue) + .fillMaxWidth() + .padding(horizontal = LocalDimensions.current.xlargeSpacing) + .padding(bottom = LocalDimensions.current.smallSpacing), + onClick = onContinue, + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt index 31f2782f8fd..17d97dec7ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt @@ -4,12 +4,13 @@ import android.app.Dialog import android.os.Bundle import android.view.LayoutInflater import android.view.View -import androidx.core.view.isGone +import android.widget.Toast import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -24,17 +25,26 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities class ClearAllDataDialog : DialogFragment() { + private val TAG = "ClearAllDataDialog" + private lateinit var binding: DialogClearAllDataBinding - enum class Steps { + private enum class Steps { INFO_PROMPT, NETWORK_PROMPT, - DELETING + DELETING, + RETRY_LOCAL_DELETE_ONLY_PROMPT + } + + // Rather than passing a bool around we'll use an enum to clarify our intent + private enum class DeletionScope { + DeleteLocalDataOnly, + DeleteBothLocalAndNetworkData } - var clearJob: Job? = null + private var clearJob: Job? = null - var step = Steps.INFO_PROMPT + private var step = Steps.INFO_PROMPT set(value) { field = value updateUI() @@ -46,8 +56,8 @@ class ClearAllDataDialog : DialogFragment() { private fun createView(): View { binding = DialogClearAllDataBinding.inflate(LayoutInflater.from(requireContext())) - val device = radioOption("deviceOnly", R.string.dialog_clear_all_data_clear_device_only) - val network = radioOption("deviceAndNetwork", R.string.dialog_clear_all_data_clear_device_and_network) + val device = radioOption("deviceOnly", R.string.clearDeviceOnly) + val network = radioOption("deviceAndNetwork", R.string.clearDeviceAndNetwork) var selectedOption: RadioOption = device val optionAdapter = RadioOptionAdapter { selectedOption = it } binding.recyclerView.apply { @@ -57,18 +67,21 @@ class ClearAllDataDialog : DialogFragment() { setHasFixedSize(true) } optionAdapter.submitList(listOf(device, network)) + binding.cancelButton.setOnClickListener { dismiss() } + binding.clearAllDataButton.setOnClickListener { - when(step) { + when (step) { Steps.INFO_PROMPT -> if (selectedOption == network) { step = Steps.NETWORK_PROMPT } else { - clearAllData(false) + clearAllData(DeletionScope.DeleteLocalDataOnly) } - Steps.NETWORK_PROMPT -> clearAllData(true) + Steps.NETWORK_PROMPT -> clearAllData(DeletionScope.DeleteBothLocalAndNetworkData) Steps.DELETING -> { /* do nothing intentionally */ } + Steps.RETRY_LOCAL_DELETE_ONLY_PROMPT -> clearAllData(DeletionScope.DeleteLocalDataOnly) } } return binding.root @@ -86,8 +99,13 @@ class ClearAllDataDialog : DialogFragment() { binding.dialogDescriptionText.setText(R.string.dialog_clear_all_data_clear_device_and_network_confirmation) } Steps.DELETING -> { /* do nothing intentionally */ } + Steps.RETRY_LOCAL_DELETE_ONLY_PROMPT -> { + binding.dialogDescriptionText.setText(R.string.clearDataErrorDescriptionGeneric) + binding.clearAllDataButton.text = getString(R.string.clearDevice) + } } - binding.recyclerView.isGone = step == Steps.NETWORK_PROMPT + + binding.recyclerView.isVisible = step == Steps.INFO_PROMPT binding.cancelButton.isVisible = !isLoading binding.clearAllDataButton.isVisible = !isLoading binding.progressBar.isVisible = isLoading @@ -97,45 +115,55 @@ class ClearAllDataDialog : DialogFragment() { } } - private fun clearAllData(deleteNetworkMessages: Boolean) { - clearJob = lifecycleScope.launch(Dispatchers.IO) { - val previousStep = step - withContext(Dispatchers.Main) { - step = Steps.DELETING + private suspend fun performDeleteLocalDataOnlyStep() { + try { + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()).get() + } catch (e: Exception) { + Log.e(TAG, "Failed to force sync when deleting data", e) + withContext(Main) { + Toast.makeText(ApplicationContext.getInstance(requireContext()), R.string.errorUnknown, Toast.LENGTH_LONG).show() } - - if (!deleteNetworkMessages) { - try { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()).get() - } catch (e: Exception) { - Log.e("Loki", "Failed to force sync", e) - } - ApplicationContext.getInstance(context).clearAllData(false) - withContext(Dispatchers.Main) { + return + } + ApplicationContext.getInstance(context).clearAllData().let { success -> + withContext(Main) { + if (success) { dismiss() + } else { + Toast.makeText(ApplicationContext.getInstance(requireContext()), R.string.errorUnknown, Toast.LENGTH_LONG).show() } - } else { - // finish - val result = try { - val openGroups = DatabaseComponent.get(requireContext()).lokiThreadDatabase().getAllOpenGroups() - openGroups.map { it.value.server }.toSet().forEach { server -> - OpenGroupApi.deleteAllInboxMessages(server).get() - } - SnodeAPI.deleteAllMessages().get() - } catch (e: Exception) { - null + } + } + } + + private fun clearAllData(deletionScope: DeletionScope) { + step = Steps.DELETING + + clearJob = lifecycleScope.launch(Dispatchers.IO) { + when (deletionScope) { + DeletionScope.DeleteLocalDataOnly -> { + performDeleteLocalDataOnlyStep() } + DeletionScope.DeleteBothLocalAndNetworkData -> { + val deletionResultMap: Map? = try { + val openGroups = DatabaseComponent.get(requireContext()).lokiThreadDatabase().getAllOpenGroups() + openGroups.map { it.value.server }.toSet().forEach { server -> + OpenGroupApi.deleteAllInboxMessages(server).get() + } + SnodeAPI.deleteAllMessages().get() + } catch (e: Exception) { + Log.e(TAG, "Failed to delete network messages - offering user option to delete local data only.", e) + null + } - if (result == null || result.values.any { !it } || result.isEmpty()) { - // didn't succeed (at least one) - withContext(Dispatchers.Main) { - step = previousStep + // If one or more deletions failed then inform the user and allow them to clear the device only if they wish.. + if (deletionResultMap == null || deletionResultMap.values.any { !it } || deletionResultMap.isEmpty()) { + withContext(Main) { step = Steps.RETRY_LOCAL_DELETE_ONLY_PROMPT } } - } else if (result.values.all { it }) { - // don't force sync because all the messages are deleted? - ApplicationContext.getInstance(context).clearAllData(false) - withContext(Dispatchers.Main) { - dismiss() + else if (deletionResultMap.values.all { it }) { + // ..otherwise if the network data deletion was successful proceed to delete the local data as well. + ApplicationContext.getInstance(context).clearAllData() + withContext(Main) { dismiss() } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt index e7e5f2d5f61..339047bbf6f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt @@ -36,7 +36,7 @@ class HelpSettingsFragment: CorrectedPreferenceFragment() { private const val FAQ = "faq" private const val SUPPORT = "support" - private const val CROWDIN_URL = "https://crowdin.com/project/session-android" + private const val CROWDIN_URL = "https://getsession.org/translate" private const val FEEDBACK_URL = "https://getsession.org/survey" private const val FAQ_URL = "https://getsession.org/faq" private const val SUPPORT_URL = "https://sessionapp.zendesk.com/hc/en-us" diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationSettingsActivity.kt index b18859ea07c..2a34de808b2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationSettingsActivity.kt @@ -4,7 +4,6 @@ import android.os.Bundle import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.thoughtcrime.securesms.preferences.NotificationsPreferenceFragment @AndroidEntryPoint class NotificationSettingsActivity : PassphraseRequiredActionBarActivity() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt index 20f67cd29ee..9778a1c8b8c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt @@ -1,141 +1,115 @@ package org.thoughtcrime.securesms.preferences -import android.content.Intent -import android.graphics.Bitmap import android.os.Bundle -import android.os.Environment -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentPagerAdapter +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import network.loki.messenger.R -import network.loki.messenger.databinding.ActivityQrCodeBinding -import network.loki.messenger.databinding.FragmentViewMyQrCodeBinding import org.session.libsession.utilities.Address import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.PublicKeyValidation import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.util.FileProviderUtil -import org.thoughtcrime.securesms.util.QRCodeUtilities -import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment -import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate -import org.thoughtcrime.securesms.util.toPx -import java.io.File -import java.io.FileOutputStream +import org.thoughtcrime.securesms.database.threadDatabase +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode +import org.thoughtcrime.securesms.ui.components.QrImage +import org.thoughtcrime.securesms.ui.components.SessionTabRow +import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.setComposeContent +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.util.start -class QRCodeActivity : PassphraseRequiredActionBarActivity(), ScanQRCodeWrapperFragmentDelegate { - private lateinit var binding: ActivityQrCodeBinding - private val adapter = QRCodeActivityAdapter(this) +private val TITLES = listOf(R.string.view, R.string.scan) + +class QRCodeActivity : PassphraseRequiredActionBarActivity() { + + private val errors = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) - // region Lifecycle override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) - binding = ActivityQrCodeBinding.inflate(layoutInflater) - // Set content view - setContentView(binding.root) - // Set title supportActionBar!!.title = resources.getString(R.string.activity_qr_code_title) - // Set up view pager - binding.viewPager.adapter = adapter - binding.tabLayout.setupWithViewPager(binding.viewPager) - } - // endregion - - // region Interaction - override fun handleQRCodeScanned(hexEncodedPublicKey: String) { - createPrivateChatIfPossible(hexEncodedPublicKey) - } - - fun createPrivateChatIfPossible(hexEncodedPublicKey: String) { - if (!PublicKeyValidation.isValid(hexEncodedPublicKey)) { return Toast.makeText(this, R.string.invalid_session_id, Toast.LENGTH_SHORT).show() } - val recipient = Recipient.from(this, Address.fromSerialized(hexEncodedPublicKey), false) - val intent = Intent(this, ConversationActivityV2::class.java) - intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) - intent.setDataAndType(getIntent().data, getIntent().type) - val existingThread = DatabaseComponent.get(this).threadDatabase().getThreadIdIfExistsFor(recipient) - intent.putExtra(ConversationActivityV2.THREAD_ID, existingThread) - startActivity(intent) - finish() - } - // endregion -} - -// region Adapter -private class QRCodeActivityAdapter(val activity: QRCodeActivity) : FragmentPagerAdapter(activity.supportFragmentManager) { - override fun getCount(): Int { - return 2 - } - - override fun getItem(index: Int): Fragment { - return when (index) { - 0 -> ViewMyQRCodeFragment() - 1 -> { - val result = ScanQRCodeWrapperFragment() - result.delegate = activity - result.message = activity.resources.getString(R.string.activity_qr_code_view_scan_qr_code_explanation) - result - } - else -> throw IllegalStateException() + setComposeContent { + Tabs( + TextSecurePreferences.getLocalNumber(this)!!, + errors.asSharedFlow(), + onScan = ::onScan + ) } } - override fun getPageTitle(index: Int): CharSequence? { - return when (index) { - 0 -> activity.resources.getString(R.string.activity_qr_code_view_my_qr_code_tab_title) - 1 -> activity.resources.getString(R.string.activity_qr_code_view_scan_qr_code_tab_title) - else -> throw IllegalStateException() + fun onScan(string: String) { + if (!PublicKeyValidation.isValid(string)) { + errors.tryEmit(getString(R.string.this_qr_code_does_not_contain_an_account_id)) + } else if (!isFinishing) { + val recipient = Recipient.from(this, Address.fromSerialized(string), false) + start { + putExtra(ConversationActivityV2.ADDRESS, recipient.address) + setDataAndType(intent.data, intent.type) + val existingThread = threadDatabase().getThreadIdIfExistsFor(recipient) + putExtra(ConversationActivityV2.THREAD_ID, existingThread) + } + finish() } } } -// endregion -// region View My QR Code Fragment -class ViewMyQRCodeFragment : Fragment() { - private lateinit var binding: FragmentViewMyQrCodeBinding +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun Tabs(accountId: String, errors: Flow, onScan: (String) -> Unit) { + val pagerState = rememberPagerState { TITLES.size } - private val hexEncodedPublicKey: String - get() { - return TextSecurePreferences.getLocalNumber(requireContext())!! + Column { + SessionTabRow(pagerState, TITLES) + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(1f) + ) { page -> + when (TITLES[page]) { + R.string.view -> QrPage(accountId) + R.string.scan -> MaybeScanQrCode(errors, onScan = onScan) + } } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = FragmentViewMyQrCodeBinding.inflate(inflater, container, false) - return binding.root } +} - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val size = toPx(280, resources) - val qrCode = QRCodeUtilities.encode(hexEncodedPublicKey, size, false, false) - binding.qrCodeImageView.setImageBitmap(qrCode) -// val explanation = SpannableStringBuilder("This is your unique public QR code. Other users can scan this to start a conversation with you.") -// explanation.setSpan(StyleSpan(Typeface.BOLD), 8, 34, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - binding.explanationTextView.text = resources.getString(R.string.fragment_view_my_qr_code_explanation) - binding.shareButton.setOnClickListener { shareQRCode() } - } +@Composable +fun QrPage(string: String) { + Column( + modifier = Modifier + .background(LocalColors.current.background) + .padding(horizontal = LocalDimensions.current.mediumSpacing) + .fillMaxSize() + ) { + QrImage( + string = string, + modifier = Modifier + .padding(top = LocalDimensions.current.mediumSpacing, bottom = LocalDimensions.current.xsSpacing) + .contentDescription(R.string.AccessibilityId_qr_code), + icon = R.drawable.session + ) - private fun shareQRCode() { - val directory = requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES) - val fileName = "$hexEncodedPublicKey.png" - val file = File(directory, fileName) - file.createNewFile() - val fos = FileOutputStream(file) - val size = toPx(280, resources) - val qrCode = QRCodeUtilities.encode(hexEncodedPublicKey, size, false, false) - qrCode.compress(Bitmap.CompressFormat.PNG, 100, fos) - fos.flush() - fos.close() - val intent = Intent(Intent.ACTION_SEND) - intent.putExtra(Intent.EXTRA_STREAM, FileProviderUtil.getUriFor(requireActivity(), file)) - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - intent.type = "image/png" - startActivity(Intent.createChooser(intent, resources.getString(R.string.fragment_view_my_qr_code_share_title))) + Text( + text = stringResource(R.string.this_is_your_account_id_other_users_can_scan_it_to_start_a_conversation_with_you), + color = LocalColors.current.textSecondary, + textAlign = TextAlign.Center, + style = LocalType.current.small + ) } } -// endregion \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt index f80acee64ec..60b5fb8b2c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt @@ -10,7 +10,6 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import network.loki.messenger.R import network.loki.messenger.databinding.ItemSelectableBinding -import network.loki.messenger.libsession_util.util.ExpiryMode import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.ui.GetString import java.util.Objects @@ -68,7 +67,6 @@ class RadioOptionAdapter( } } } - } data class RadioOption( diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SeedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SeedDialog.kt deleted file mode 100644 index bae5f19605c..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SeedDialog.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.thoughtcrime.securesms.preferences - -import android.app.Dialog -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.os.Bundle -import android.widget.Toast -import androidx.fragment.app.DialogFragment -import network.loki.messenger.R -import org.session.libsignal.crypto.MnemonicCodec -import org.session.libsignal.utilities.hexEncodedPrivateKey -import org.thoughtcrime.securesms.createSessionDialog -import org.thoughtcrime.securesms.crypto.IdentityKeyUtil -import org.thoughtcrime.securesms.crypto.MnemonicUtilities - -class SeedDialog: DialogFragment() { - private val seed by lazy { - val hexEncodedSeed = IdentityKeyUtil.retrieve(requireContext(), IdentityKeyUtil.LOKI_SEED) - ?: IdentityKeyUtil.getIdentityKeyPair(requireContext()).hexEncodedPrivateKey // Legacy account - - MnemonicCodec { fileName -> MnemonicUtilities.loadFileContents(requireContext(), fileName) } - .encode(hexEncodedSeed, MnemonicCodec.Language.Configuration.english) - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { - title(R.string.dialog_seed_title) - text(R.string.dialog_seed_explanation) - text(seed, R.style.SessionIDTextView) - button(R.string.copy, R.string.AccessibilityId_copy_recovery_phrase) { copySeed() } - button(R.string.close) { dismiss() } - } - - private fun copySeed() { - val clipboard = requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Seed", seed) - clipboard.setPrimaryClip(clip) - Toast.makeText(requireContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() - dismiss() - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index b66df5d2552..fc98070ca40 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -2,15 +2,13 @@ package org.thoughtcrime.securesms.preferences import android.Manifest import android.app.Activity -import android.content.ClipData -import android.content.ClipboardManager +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.net.Uri -import android.os.AsyncTask +import android.os.Build import android.os.Bundle -import android.os.Handler -import android.os.Looper import android.os.Parcelable import android.util.SparseArray import android.view.ActionMode @@ -20,40 +18,75 @@ import android.view.View import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.Toast +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import androidx.localbroadcastmanager.content.LocalBroadcastManager import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch import network.loki.messenger.BuildConfig import network.loki.messenger.R import network.loki.messenger.databinding.ActivitySettingsBinding import network.loki.messenger.libsession_util.util.UserPic -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.all import nl.komponents.kovenant.ui.alwaysUi +import nl.komponents.kovenant.ui.failUi import nl.komponents.kovenant.ui.successUi import org.session.libsession.avatars.AvatarHelper import org.session.libsession.avatars.ProfileContactPhoto import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.SnodeAPI -import org.session.libsession.utilities.* +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.ProfileKeyUtil +import org.session.libsession.utilities.ProfilePictureUtilities import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient -import org.session.libsignal.utilities.getProperty +import org.session.libsession.utilities.truncateIdForDisplay +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.avatar.AvatarSelection import org.thoughtcrime.securesms.components.ProfilePictureView import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.home.PathActivity import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity -import org.thoughtcrime.securesms.mms.GlideApp -import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints +import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity import org.thoughtcrime.securesms.showSessionDialog +import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.Divider +import org.thoughtcrime.securesms.ui.LargeItemButton +import org.thoughtcrime.securesms.ui.LargeItemButtonWithDrawable +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.dangerButtonColors +import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton +import org.thoughtcrime.securesms.ui.components.PrimaryOutlineCopyButton +import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.setThemedContent import org.thoughtcrime.securesms.util.BitmapDecodingException import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities -import org.thoughtcrime.securesms.util.disableClipping +import org.thoughtcrime.securesms.util.NetworkUtils import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.show import java.io.File @@ -62,23 +95,21 @@ import javax.inject.Inject @AndroidEntryPoint class SettingsActivity : PassphraseRequiredActionBarActivity() { + private val TAG = "SettingsActivity" @Inject lateinit var configFactory: ConfigFactory + @Inject + lateinit var prefs: TextSecurePreferences private lateinit var binding: ActivitySettingsBinding private var displayNameEditActionMode: ActionMode? = null set(value) { field = value; handleDisplayNameEditActionModeChanged() } - private lateinit var glide: GlideRequests private var tempFile: File? = null - private val hexEncodedPublicKey: String - get() { - return TextSecurePreferences.getLocalNumber(this)!! - } + private val hexEncodedPublicKey: String get() = TextSecurePreferences.getLocalNumber(this)!! companion object { - const val updatedProfileResultCode = 1234 private const val SCROLL_STATE = "SCROLL_STATE" } @@ -87,31 +118,24 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { super.onCreate(savedInstanceState, isReady) binding = ActivitySettingsBinding.inflate(layoutInflater) setContentView(binding.root) - val displayName = getDisplayName() - glide = GlideApp.with(this) - with(binding) { + } + + override fun onStart() { + super.onStart() + + binding.run { setupProfilePictureView(profilePictureView) profilePictureView.setOnClickListener { showEditProfilePictureUI() } ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) } - btnGroupNameDisplay.text = displayName + btnGroupNameDisplay.text = getDisplayName() publicKeyTextView.text = hexEncodedPublicKey - copyButton.setOnClickListener { copyPublicKey() } - shareButton.setOnClickListener { sharePublicKey() } - pathButton.setOnClickListener { showPath() } - pathContainer.disableClipping() - privacyButton.setOnClickListener { showPrivacySettings() } - notificationsButton.setOnClickListener { showNotificationSettings() } - messageRequestsButton.setOnClickListener { showMessageRequests() } - chatsButton.setOnClickListener { showChatSettings() } - appearanceButton.setOnClickListener { showAppearanceSettings() } - inviteFriendButton.setOnClickListener { sendInvitation() } - helpButton.setOnClickListener { showHelp() } - seedButton.setOnClickListener { showSeed() } - clearAllDataButton.setOnClickListener { clearAllData() } - val gitCommitFirstSixChars = BuildConfig.GIT_HASH.take(6) versionTextView.text = String.format(getString(R.string.version_s), "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE} - $gitCommitFirstSixChars)") } + + binding.composeView.setThemedContent { + Buttons() + } } private fun getDisplayName(): String = @@ -121,7 +145,6 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { view.apply { publicKey = hexEncodedPublicKey displayName = getDisplayName() - isLarge = true update() } } @@ -142,13 +165,16 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.settings_general, menu) + if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + menu.findItem(R.id.action_qr_code)?.contentDescription = resources.getString(R.string.AccessibilityId_view_qr_code) + } return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.action_qr_code -> { - showQRCode() + push() true } else -> super.onOptionsItemSelected(item) @@ -158,30 +184,22 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) + if (resultCode != Activity.RESULT_OK) return when (requestCode) { AvatarSelection.REQUEST_CODE_AVATAR -> { - if (resultCode != Activity.RESULT_OK) { - return - } val outputFile = Uri.fromFile(File(cacheDir, "cropped")) - var inputFile: Uri? = data?.data - if (inputFile == null && tempFile != null) { - inputFile = Uri.fromFile(tempFile) - } + val inputFile: Uri? = data?.data ?: tempFile?.let(Uri::fromFile) AvatarSelection.circularCropImage(this, inputFile, outputFile, R.string.CropImageActivity_profile_avatar) } AvatarSelection.REQUEST_CODE_CROP_IMAGE -> { - if (resultCode != Activity.RESULT_OK) { - return - } - AsyncTask.execute { + lifecycleScope.launch(Dispatchers.IO) { try { val profilePictureToBeUploaded = BitmapUtil.createScaledBytes(this@SettingsActivity, AvatarSelection.getResultUri(data), ProfileMediaConstraints()).bitmap - Handler(Looper.getMainLooper()).post { - updateProfile(true, profilePictureToBeUploaded) + launch(Dispatchers.Main) { + updateProfilePicture(profilePictureToBeUploaded) } } catch (e: BitmapDecodingException) { - e.printStackTrace() + Log.e(TAG, e) } } } @@ -196,10 +214,10 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { // region Updating private fun handleDisplayNameEditActionModeChanged() { - val isEditingDisplayName = this.displayNameEditActionMode !== null + val isEditingDisplayName = this.displayNameEditActionMode != null - binding.btnGroupNameDisplay.visibility = if (isEditingDisplayName) View.INVISIBLE else View.VISIBLE - binding.displayNameEditText.visibility = if (isEditingDisplayName) View.VISIBLE else View.INVISIBLE + binding.btnGroupNameDisplay.isInvisible = isEditingDisplayName + binding.displayNameEditText.isInvisible = !isEditingDisplayName val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager if (isEditingDisplayName) { @@ -227,56 +245,118 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { } } - private fun updateProfile( - isUpdatingProfilePicture: Boolean, - profilePicture: ByteArray? = null, - displayName: String? = null - ) { + private fun updateDisplayName(displayName: String): Boolean { binding.loader.isVisible = true - val promises = mutableListOf>() - if (displayName != null) { + + // We'll assume we fail & flip the flag on success + var updateWasSuccessful = false + + val haveNetworkConnection = NetworkUtils.haveValidNetworkConnection(this@SettingsActivity); + if (!haveNetworkConnection) { + Log.w(TAG, "Cannot update display name - no network connection.") + } else { + // if we have a network connection then attempt to update the display name TextSecurePreferences.setProfileName(this, displayName) - configFactory.user?.setName(displayName) + val user = configFactory.user + if (user == null) { + Log.w(TAG, "Cannot update display name - missing user details from configFactory.") + } else { + user.setName(displayName) + binding.btnGroupNameDisplay.text = displayName + updateWasSuccessful = true + } + } + + // Inform the user if we failed to update the display name + if (!updateWasSuccessful) { + Toast.makeText(this@SettingsActivity, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show() } + + binding.loader.isVisible = false + return updateWasSuccessful + } + + // Helper method used by updateProfilePicture and removeProfilePicture to sync it online + private fun syncProfilePicture(profilePicture: ByteArray, onFail: () -> Unit) { + binding.loader.isVisible = true + + // Grab the profile key and kick of the promise to update the profile picture val encodedProfileKey = ProfileKeyUtil.generateEncodedProfileKey(this) - if (isUpdatingProfilePicture) { - if (profilePicture != null) { - promises.add(ProfilePictureUtilities.upload(profilePicture, encodedProfileKey, this)) - } else { + 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 compoundPromise = all(promises) - compoundPromise.successUi { // Do this on the UI thread so that it happens before the alwaysUi clause below + val userConfig = configFactory.user - if (isUpdatingProfilePicture) { - AvatarHelper.setAvatar(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!), profilePicture) - TextSecurePreferences.setProfileAvatarId(this, profilePicture?.let { SecureRandom().nextInt() } ?: 0 ) - ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey) - // new config - val url = TextSecurePreferences.getProfilePictureURL(this) - val profileKey = ProfileKeyUtil.getProfileKey(this) - if (profilePicture == null) { - userConfig?.setPic(UserPic.DEFAULT) - } else if (!url.isNullOrEmpty() && profileKey.isNotEmpty()) { - userConfig?.setPic(UserPic(url, profileKey)) - } + AvatarHelper.setAvatar(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!), profilePicture) + prefs.setProfileAvatarId(SecureRandom().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() } - compoundPromise.alwaysUi { - if (displayName != null) { - binding.btnGroupNameDisplay.text = displayName - } - if (isUpdatingProfilePicture) { - binding.profilePictureView.recycle() // Clear the cached image before updating - binding.profilePictureView.update() - } - binding.loader.isVisible = false + + // 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 @@ -291,17 +371,11 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { Toast.makeText(this, R.string.activity_settings_display_name_missing_error, Toast.LENGTH_SHORT).show() return false } - if (displayName.toByteArray().size > ProfileManagerProtocol.Companion.NAME_PADDED_LENGTH) { + if (displayName.toByteArray().size > ProfileManagerProtocol.NAME_PADDED_LENGTH) { Toast.makeText(this, R.string.activity_settings_display_name_too_long_error, Toast.LENGTH_SHORT).show() return false } - updateProfile(false, displayName = displayName) - return true - } - - private fun showQRCode() { - val intent = Intent(this, QRCodeActivity::class.java) - push(intent) + return updateDisplayName(displayName) } private fun showEditProfilePictureUI() { @@ -309,27 +383,23 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { title(R.string.activity_settings_set_display_picture) view(R.layout.dialog_change_avatar) button(R.string.activity_settings_upload) { startAvatarSelection() } - if (TextSecurePreferences.getProfileAvatarId(context) != 0) { - button(R.string.activity_settings_remove) { removeAvatar() } + if (prefs.getProfileAvatarId() != 0) { + button(R.string.activity_settings_remove) { removeProfilePicture() } } cancelButton() }.apply { - val profilePic = findViewById(R.id.profile_picture_view) - ?.also(::setupProfilePictureView) + val profilePic = findViewById(R.id.profile_picture_view) + ?.also(::setupProfilePictureView) - val pictureIcon = findViewById(R.id.ic_pictures) + val pictureIcon = findViewById(R.id.ic_pictures) - val recipient = Recipient.from(context, Address.fromSerialized(hexEncodedPublicKey), false) + val recipient = Recipient.from(context, Address.fromSerialized(hexEncodedPublicKey), false) - val photoSet = (recipient.contactPhoto as ProfileContactPhoto).avatarObject !in setOf("0", "") + val photoSet = (recipient.contactPhoto as ProfileContactPhoto).avatarObject !in setOf("0", "") - profilePic?.isVisible = photoSet - pictureIcon?.isVisible = !photoSet - } - } - - private fun removeAvatar() { - updateProfile(true) + profilePic?.isVisible = photoSet + pictureIcon?.isVisible = !photoSet + } } private fun startAvatarSelection() { @@ -341,76 +411,6 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { } .execute() } - - private fun copyPublicKey() { - val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Session ID", hexEncodedPublicKey) - clipboard.setPrimaryClip(clip) - Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() - } - - private fun sharePublicKey() { - val intent = Intent() - intent.action = Intent.ACTION_SEND - intent.putExtra(Intent.EXTRA_TEXT, hexEncodedPublicKey) - intent.type = "text/plain" - val chooser = Intent.createChooser(intent, getString(R.string.share)) - startActivity(chooser) - } - - private fun showPrivacySettings() { - val intent = Intent(this, PrivacySettingsActivity::class.java) - push(intent) - } - - private fun showNotificationSettings() { - val intent = Intent(this, NotificationSettingsActivity::class.java) - push(intent) - } - - private fun showMessageRequests() { - val intent = Intent(this, MessageRequestsActivity::class.java) - push(intent) - } - - private fun showChatSettings() { - val intent = Intent(this, ChatSettingsActivity::class.java) - push(intent) - } - - private fun showAppearanceSettings() { - val intent = Intent(this, AppearanceSettingsActivity::class.java) - push(intent) - } - - private fun sendInvitation() { - val intent = Intent() - intent.action = Intent.ACTION_SEND - val invitation = "Hey, I've been using Session to chat with complete privacy and security. Come join me! Download it at https://getsession.org/. My Session ID is $hexEncodedPublicKey !" - intent.putExtra(Intent.EXTRA_TEXT, invitation) - intent.type = "text/plain" - val chooser = Intent.createChooser(intent, getString(R.string.activity_settings_invite_button_title)) - startActivity(chooser) - } - - private fun showHelp() { - val intent = Intent(this, HelpSettingsActivity::class.java) - push(intent) - } - - private fun showPath() { - val intent = Intent(this, PathActivity::class.java) - show(intent) - } - - private fun showSeed() { - SeedDialog().show(supportFragmentManager, "Recovery Phrase Dialog") - } - - private fun clearAllData() { - ClearAllDataDialog().show(supportFragmentManager, "Clear All Data Dialog") - } - // endregion private inner class DisplayNameEditActionModeCallback: ActionMode.Callback { @@ -439,7 +439,74 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { return true } } - return false; + return false } } -} \ No newline at end of file + + @Composable + fun Buttons() { + Column { + Row( + modifier = Modifier + .padding(horizontal = LocalDimensions.current.spacing) + .padding(top = LocalDimensions.current.xxsSpacing), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing), + ) { + PrimaryOutlineButton( + stringResource(R.string.share), + modifier = Modifier.weight(1f), + onClick = ::sendInvitationToUseSession + ) + + PrimaryOutlineCopyButton( + modifier = Modifier.weight(1f), + onClick = ::copyPublicKey, + ) + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + + val hasPaths by hasPaths().collectAsState(initial = false) + + Cell { + Column { + Crossfade(if (hasPaths) R.drawable.ic_status else R.drawable.ic_path_yellow, label = "path") { + LargeItemButtonWithDrawable(R.string.activity_path_title, it) { show() } + } + Divider() + LargeItemButton(R.string.activity_settings_privacy_button_title, R.drawable.ic_privacy_icon) { show() } + Divider() + LargeItemButton(R.string.activity_settings_notifications_button_title, R.drawable.ic_speaker, Modifier.contentDescription(R.string.AccessibilityId_notifications)) { show() } + Divider() + LargeItemButton(R.string.activity_settings_conversations_button_title, R.drawable.ic_conversations, Modifier.contentDescription(R.string.AccessibilityId_conversations)) { show() } + Divider() + LargeItemButton(R.string.activity_settings_message_requests_button_title, R.drawable.ic_message_requests, Modifier.contentDescription(R.string.AccessibilityId_message_requests)) { show() } + Divider() + LargeItemButton(R.string.activity_settings_message_appearance_button_title, R.drawable.ic_appearance, Modifier.contentDescription(R.string.AccessibilityId_appearance)) { show() } + Divider() + LargeItemButton(R.string.activity_settings_invite_button_title, R.drawable.ic_invite_friend, Modifier.contentDescription(R.string.AccessibilityId_invite_friend)) { sendInvitationToUseSession() } + Divider() + if (!prefs.getHidePassword()) { + LargeItemButton(R.string.sessionRecoveryPassword, R.drawable.ic_shield_outline, Modifier.contentDescription(R.string.AccessibilityId_recovery_password_menu_item)) { show() } + Divider() + } + LargeItemButton(R.string.activity_settings_help_button, R.drawable.ic_help, Modifier.contentDescription(R.string.AccessibilityId_help)) { show() } + Divider() + LargeItemButton(R.string.activity_settings_clear_all_data_button_title, R.drawable.ic_message_details__trash, Modifier.contentDescription(R.string.AccessibilityId_clear_data), dangerButtonColors()) { ClearAllDataDialog().show(supportFragmentManager, "Clear All Data Dialog") } + } + } + } + } +} + +private fun Context.hasPaths(): Flow = LocalBroadcastManager.getInstance(this).hasPaths() +private fun LocalBroadcastManager.hasPaths(): Flow = callbackFlow { + val receiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { trySend(Unit) } + } + + registerReceiver(receiver, IntentFilter("buildingPaths")) + registerReceiver(receiver, IntentFilter("pathsBuilt")) + + awaitClose { unregisterReceiver(receiver) } +}.onStart { emit(Unit) }.map { OnionRequestAPI.paths.isNotEmpty() } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/Util.kt new file mode 100644 index 00000000000..1271ece02e8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/Util.kt @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.preferences + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.widget.Toast +import network.loki.messenger.R +import org.session.libsession.utilities.TextSecurePreferences + +fun Context.sendInvitationToUseSession() { + Intent().apply { + action = Intent.ACTION_SEND + putExtra( + Intent.EXTRA_TEXT, + getString( + R.string.accountIdShare, + TextSecurePreferences.getLocalNumber(this@sendInvitationToUseSession) + ) + ) + type = "text/plain" + }.let { Intent.createChooser(it, getString(R.string.activity_settings_invite_button_title)) } + .let(::startActivity) +} + +fun Context.copyPublicKey() { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Account ID", TextSecurePreferences.getLocalNumber(this)) + clipboard.setPrimaryClip(clip) + Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt index 823728c3599..34547c999e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt @@ -5,7 +5,6 @@ import android.os.Parcelable import android.util.SparseArray import android.view.View import androidx.activity.viewModels -import androidx.appcompat.widget.SwitchCompat import androidx.core.view.children import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint @@ -31,8 +30,8 @@ class AppearanceSettingsActivity: PassphraseRequiredActionBarActivity(), View.On var currentTheme: ThemeState? = null - private val accentColors - get() = mapOf( + private val accentColors by lazy { + mapOf( binding.accentGreen to R.style.PrimaryGreen, binding.accentBlue to R.style.PrimaryBlue, binding.accentYellow to R.style.PrimaryYellow, @@ -41,9 +40,10 @@ class AppearanceSettingsActivity: PassphraseRequiredActionBarActivity(), View.On binding.accentOrange to R.style.PrimaryOrange, binding.accentRed to R.style.PrimaryRed ) + } - private val themeViews - get() = listOf( + private val themeViews by lazy { + listOf( binding.themeOptionClassicDark, binding.themeRadioClassicDark, binding.themeOptionClassicLight, @@ -53,6 +53,7 @@ class AppearanceSettingsActivity: PassphraseRequiredActionBarActivity(), View.On binding.themeOptionOceanLight, binding.themeRadioOceanLight ) + } override fun onClick(v: View?) { v ?: return diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsViewModel.kt index c5694413824..2547e23e228 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsViewModel.kt @@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.ui.theme.invalidateComposeThemeColors import org.thoughtcrime.securesms.util.ThemeState import org.thoughtcrime.securesms.util.themeState import javax.inject.Inject @@ -20,17 +21,22 @@ class AppearanceSettingsViewModel @Inject constructor(private val prefs: TextSec prefs.setAccentColorStyle(newAccentColorStyle) // update UI state _uiState.value = prefs.themeState() + + invalidateComposeThemeColors() } fun setNewStyle(newThemeStyle: String) { prefs.setThemeStyle(newThemeStyle) // update UI state _uiState.value = prefs.themeState() + + invalidateComposeThemeColors() } fun setNewFollowSystemSettings(followSystemSettings: Boolean) { prefs.setFollowSystemSettings(followSystemSettings) _uiState.value = prefs.themeState() - } -} \ No newline at end of file + invalidateComposeThemeColors() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java index 1c05e68bdff..34a427547b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java @@ -9,7 +9,7 @@ import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; -import org.session.libsession.messaging.utilities.SessionId; +import org.session.libsession.messaging.utilities.AccountId; import org.thoughtcrime.securesms.components.ProfilePictureView; import org.thoughtcrime.securesms.components.emoji.EmojiImageView; import org.thoughtcrime.securesms.database.model.MessageId; @@ -161,7 +161,7 @@ void bind(@NonNull ReactionDetails reaction) { this.remove.setVisibility(View.VISIBLE); } else { String name = reaction.getSender().getName(); - if (name != null && new SessionId(name).getPrefix() != null) { + if (name != null && new AccountId(name).getPrefix() != null) { name = name.substring(0, 4) + "..." + name.substring(name.length() - 4); } this.recipient.setText(name); diff --git a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt new file mode 100644 index 00000000000..5f59943432b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt @@ -0,0 +1,187 @@ +package org.thoughtcrime.securesms.recoverypassword + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.CellWithPaddingAndMargin +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.SessionShieldIcon +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.components.QrImage +import org.thoughtcrime.securesms.ui.components.SlimOutlineButton +import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton +import org.thoughtcrime.securesms.ui.components.border +import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.monospace + +@Composable +internal fun RecoveryPasswordScreen( + mnemonic: String, + seed: String? = null, + copyMnemonic:() -> Unit = {}, + onHide:() -> Unit = {} +) { + Column( + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing), + modifier = Modifier + .contentDescription(R.string.AccessibilityId_recovery_password) + .verticalScroll(rememberScrollState()) + .padding(bottom = LocalDimensions.current.smallSpacing) + ) { + RecoveryPasswordCell(mnemonic, seed, copyMnemonic) + HideRecoveryPasswordCell(onHide) + } +} + +@Composable +private fun RecoveryPasswordCell( + mnemonic: String, + seed: String?, + copyMnemonic:() -> Unit = {} +) { + var showQr by remember { + mutableStateOf(false) + } + + CellWithPaddingAndMargin { + Column { + Row { + Text( + stringResource(R.string.sessionRecoveryPassword), + style = LocalType.current.h8 + ) + Spacer(Modifier.width(LocalDimensions.current.xxsSpacing)) + SessionShieldIcon() + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing)) + + Text( + stringResource(R.string.recoveryPasswordDescription), + style = LocalType.current.base + ) + + AnimatedVisibility(!showQr) { + RecoveryPassword(mnemonic) + } + + AnimatedVisibility( + showQr, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) { + QrImage( + seed, + modifier = Modifier + .padding(vertical = LocalDimensions.current.spacing) + .contentDescription(R.string.AccessibilityId_qr_code), + contentPadding = 10.dp, + icon = R.drawable.session_shield + ) + } + + AnimatedVisibility(!showQr) { + Row( + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing), + verticalAlignment = Alignment.CenterVertically + ) { + SlimOutlineCopyButton( + Modifier.weight(1f), + onClick = copyMnemonic + ) + SlimOutlineButton( + stringResource(R.string.qrView), + Modifier.weight(1f), + ) { showQr = !showQr } + } + } + + AnimatedVisibility(showQr, modifier = Modifier.align(Alignment.CenterHorizontally)) { + SlimOutlineButton( + stringResource(R.string.recoveryPasswordView), + onClick = { showQr = !showQr } + ) + } + } + } +} + +@Composable +private fun RecoveryPassword(mnemonic: String) { + Text( + mnemonic, + modifier = Modifier + .contentDescription(R.string.AccessibilityId_recovery_password_container) + .padding(vertical = LocalDimensions.current.spacing) + .border() + .padding(LocalDimensions.current.spacing), + textAlign = TextAlign.Center, + style = LocalType.current.extraSmall.monospace(), + color = LocalColors.current.run { if (isLight) text else primary }, + ) +} + +@Composable +private fun HideRecoveryPasswordCell(onHide: () -> Unit = {}) { + CellWithPaddingAndMargin { + Row { + Column( + Modifier.weight(1f) + ) { + Text( + stringResource(R.string.recoveryPasswordHideRecoveryPassword), + style = LocalType.current.h8 + ) + Text( + stringResource(R.string.recoveryPasswordHideRecoveryPasswordDescription), + style = LocalType.current.base + ) + } + Spacer(modifier = Modifier.width(LocalDimensions.current.xsSpacing)) + SlimOutlineButton( + text = stringResource(R.string.hide), + modifier = Modifier + .wrapContentWidth() + .align(Alignment.CenterVertically) + .contentDescription(R.string.AccessibilityId_hide_recovery_password_button), + color = LocalColors.current.danger, + onClick = onHide + ) + } + } +} + +@Preview +@Composable +private fun PreviewRecoveryPasswordScreen( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + RecoveryPasswordScreen(mnemonic = "voyage urban toyed maverick peculiar tuxedo penguin tree grass building listen speak withdraw terminal plane") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordActivity.kt new file mode 100644 index 00000000000..a46b4a1d633 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordActivity.kt @@ -0,0 +1,56 @@ +package org.thoughtcrime.securesms.recoverypassword + +import android.os.Bundle +import androidx.activity.viewModels +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import network.loki.messenger.R +import org.thoughtcrime.securesms.BaseActionBarActivity +import org.thoughtcrime.securesms.showSessionDialog +import org.thoughtcrime.securesms.ui.setComposeContent + +class RecoveryPasswordActivity : BaseActionBarActivity() { + + private val viewModel: RecoveryPasswordViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + supportActionBar!!.title = resources.getString(R.string.sessionRecoveryPassword) + + setComposeContent { + val mnemonic by viewModel.mnemonic.collectAsState("") + val seed by viewModel.seed.collectAsState(null) + + RecoveryPasswordScreen( + mnemonic = mnemonic, + seed = seed, + copyMnemonic = viewModel::copyMnemonic, + onHide = ::onHide + ) + } + } + + private fun onHide() { + showSessionDialog { + title(R.string.recoveryPasswordHidePermanently) + htmlText(R.string.recoveryPasswordHidePermanentlyDescription1) + dangerButton(R.string.continue_2, R.string.AccessibilityId_continue) { onHideConfirm() } + cancelButton() + } + } + + private fun onHideConfirm() { + showSessionDialog { + title(R.string.recoveryPasswordHidePermanently) + text(R.string.recoveryPasswordHidePermanentlyDescription2) + cancelButton() + dangerButton( + R.string.yes, + contentDescription = R.string.AccessibilityId_confirm_button + ) { + viewModel.permanentlyHidePassword() + finish() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordViewModel.kt new file mode 100644 index 00000000000..0ad207cd236 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordViewModel.kt @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.recoverypassword + +import android.app.Application +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.session.libsession.utilities.AppTextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.crypto.MnemonicCodec +import org.session.libsignal.utilities.hexEncodedPrivateKey +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil +import org.thoughtcrime.securesms.crypto.MnemonicUtilities +import javax.inject.Inject + +@HiltViewModel +class RecoveryPasswordViewModel @Inject constructor( + private val application: Application +): AndroidViewModel(application) { + val prefs = AppTextSecurePreferences(application) + + val seed = MutableStateFlow(null) + val mnemonic = seed.filterNotNull() + .map { MnemonicCodec { MnemonicUtilities.loadFileContents(application, it) }.encode(it, MnemonicCodec.Language.Configuration.english) } + .stateIn(viewModelScope, SharingStarted.Eagerly, "") + + fun permanentlyHidePassword() { + prefs.setHidePassword(true) + } + + fun copyMnemonic() { + prefs.setHasViewedSeed(true) + ClipData.newPlainText("Seed", mnemonic.value) + .let(application.clipboard::setPrimaryClip) + } + + init { + viewModelScope.launch(Dispatchers.IO) { + seed.emit(IdentityKeyUtil.retrieve(application, IdentityKeyUtil.LOKI_SEED) + ?: IdentityKeyUtil.getIdentityKeyPair(application).hexEncodedPrivateKey) // Legacy account + } + } +} + +private val Context.clipboard get() = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 8a7a2dfd0f0..2a00440ddaf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -99,7 +99,7 @@ class DefaultConversationRepository @Inject constructor( if (!recipient.isOpenGroupInboxRecipient) return null return Recipient.from( context, - Address.fromSerialized(GroupUtil.getDecodedOpenGroupInboxSessionId(recipient.address.serialize())), + Address.fromSerialized(GroupUtil.getDecodedOpenGroupInboxAccountId(recipient.address.serialize())), false ) } @@ -281,9 +281,9 @@ class DefaultConversationRepository @Inject constructor( override suspend fun banUser(threadId: Long, recipient: Recipient): ResultOf = suspendCoroutine { continuation -> - val sessionID = recipient.address.toString() + val accountID = recipient.address.toString() val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! - OpenGroupApi.ban(sessionID, openGroup.room, openGroup.server) + OpenGroupApi.ban(accountID, openGroup.room, openGroup.server) .success { continuation.resume(ResultOf.Success(Unit)) }.fail { error -> @@ -293,11 +293,11 @@ class DefaultConversationRepository @Inject constructor( override suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): ResultOf = suspendCoroutine { continuation -> - // Note: This sessionId could be the blinded Id - val sessionID = recipient.address.toString() + // Note: This accountId could be the blinded Id + val accountID = recipient.address.toString() val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! - OpenGroupApi.banAndDeleteAll(sessionID, openGroup.room, openGroup.server) + OpenGroupApi.banAndDeleteAll(accountID, openGroup.room, openGroup.server) .success { continuation.resume(ResultOf.Success(Unit)) }.fail { error -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java index f2adbf23492..204b63f8027 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java @@ -72,10 +72,6 @@ public SearchRepository(@NonNull Context context, public void query(@NonNull String query, @NonNull Callback callback) { // If the sanitized search is empty then abort without search String cleanQuery = sanitizeQuery(query).trim(); - if (cleanQuery.isEmpty()) { - callback.onResult(SearchResult.EMPTY); - return; - } executor.execute(() -> { Stopwatch timer = new Stopwatch("FtsQuery"); @@ -110,7 +106,7 @@ public void query(@NonNull String query, long threadId, @NonNull Callback, List> queryContacts(String query) { + public Pair, List> queryContacts(String query) { Cursor contacts = contactDatabase.queryContactsByName(query); List
contactList = new ArrayList<>(); List contactStrings = new ArrayList<>(); @@ -118,10 +114,10 @@ private Pair, List> queryContacts(String query) { while (contacts.moveToNext()) { try { Contact contact = contactDatabase.contactFromCursor(contacts); - String contactSessionId = contact.getSessionID(); - Address address = Address.fromSerialized(contactSessionId); + String contactAccountId = contact.getAccountID(); + Address address = Address.fromSerialized(contactAccountId); contactList.add(address); - contactStrings.add(contactSessionId); + contactStrings.add(contactAccountId); } catch (Exception e) { Log.e("Loki", "Error building Contact from cursor in query", e); } @@ -211,7 +207,7 @@ public ContactModelBuilder(SessionContactDatabase contactDb, ThreadDatabase thre @Override public Contact build(@NonNull Cursor cursor) { ThreadRecord threadRecord = threadDb.readerFor(cursor).getCurrent(); - Contact contact = contactDb.getContactWithSessionID(threadRecord.getRecipient().getAddress().serialize()); + Contact contact = contactDb.getContactWithAccountID(threadRecord.getRecipient().getAddress().serialize()); if (contact == null) { contact = new Contact(threadRecord.getRecipient().getAddress().serialize()); contact.setThreadID(threadRecord.getThreadId()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/TelephonyHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/service/TelephonyHandler.kt new file mode 100644 index 00000000000..3e78c223fa9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/TelephonyHandler.kt @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.service + +import android.os.Build +import android.telephony.PhoneStateListener +import android.telephony.PhoneStateListener.LISTEN_NONE +import android.telephony.TelephonyManager +import androidx.annotation.RequiresApi +import org.thoughtcrime.securesms.webrtc.HangUpRtcOnPstnCallAnsweredListener +import org.thoughtcrime.securesms.webrtc.HangUpRtcTelephonyCallback +import java.util.concurrent.ExecutorService + +internal interface TelephonyHandler { + fun register(telephonyManager: TelephonyManager) + fun unregister(telephonyManager: TelephonyManager) +} + +internal fun TelephonyHandler(serviceExecutor: ExecutorService, callback: () -> Unit) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + TelephonyHandlerV31(serviceExecutor, callback) +} else { + TelephonyHandlerV23(callback) +} + +@RequiresApi(Build.VERSION_CODES.S) +private class TelephonyHandlerV31(val serviceExecutor: ExecutorService, callback: () -> Unit): TelephonyHandler { + private val callback = HangUpRtcTelephonyCallback(callback) + + override fun register(telephonyManager: TelephonyManager) { + telephonyManager.registerTelephonyCallback(serviceExecutor, callback) + } + + override fun unregister(telephonyManager: TelephonyManager) { + telephonyManager.unregisterTelephonyCallback(callback) + } +} + +private class TelephonyHandlerV23(callback: () -> Unit): TelephonyHandler { + val callback = HangUpRtcOnPstnCallAnsweredListener(callback) + + override fun register(telephonyManager: TelephonyManager) { + telephonyManager.listen(callback, PhoneStateListener.LISTEN_CALL_STATE) + } + + override fun unregister(telephonyManager: TelephonyManager) { + telephonyManager.listen(callback, LISTEN_NONE) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt index cfe1f38f585..f40a075d8d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.service -import android.app.ForegroundServiceStartNotAllowedException import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -9,11 +8,7 @@ import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.content.IntentFilter import android.content.pm.PackageManager import android.media.AudioManager -import android.os.Build -import android.os.IBinder import android.os.ResultReceiver -import android.telephony.PhoneStateListener -import android.telephony.PhoneStateListener.LISTEN_NONE import android.telephony.TelephonyManager import androidx.core.content.ContextCompat import androidx.core.os.bundleOf @@ -34,13 +29,29 @@ import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.TYPE_IN import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.TYPE_INCOMING_PRE_OFFER import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.TYPE_INCOMING_RINGING import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.TYPE_OUTGOING_RINGING -import org.thoughtcrime.securesms.webrtc.* +import org.thoughtcrime.securesms.webrtc.AudioManagerCommand +import org.thoughtcrime.securesms.webrtc.CallManager +import org.thoughtcrime.securesms.webrtc.CallViewModel +import org.thoughtcrime.securesms.webrtc.IncomingPstnCallReceiver +import org.thoughtcrime.securesms.webrtc.NetworkChangeReceiver +import org.thoughtcrime.securesms.webrtc.PeerConnectionException +import org.thoughtcrime.securesms.webrtc.PowerButtonReceiver +import org.thoughtcrime.securesms.webrtc.ProximityLockRelease +import org.thoughtcrime.securesms.webrtc.UncaughtExceptionHandlerManager +import org.thoughtcrime.securesms.webrtc.WiredHeadsetStateReceiver import org.thoughtcrime.securesms.webrtc.audio.OutgoingRinger import org.thoughtcrime.securesms.webrtc.data.Event import org.thoughtcrime.securesms.webrtc.locks.LockManager -import org.webrtc.* -import org.webrtc.PeerConnection.IceConnectionState.* -import java.util.* +import org.webrtc.DataChannel +import org.webrtc.IceCandidate +import org.webrtc.MediaStream +import org.webrtc.PeerConnection +import org.webrtc.PeerConnection.IceConnectionState.CONNECTED +import org.webrtc.PeerConnection.IceConnectionState.DISCONNECTED +import org.webrtc.PeerConnection.IceConnectionState.FAILED +import org.webrtc.RtpReceiver +import org.webrtc.SessionDescription +import java.util.UUID import java.util.concurrent.ExecutionException import java.util.concurrent.Executors import java.util.concurrent.ScheduledFuture @@ -81,6 +92,7 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { const val EXTRA_RECIPIENT_ADDRESS = "RECIPIENT_ID" const val EXTRA_ENABLED = "ENABLED" const val EXTRA_AUDIO_COMMAND = "AUDIO_COMMAND" + const val EXTRA_SWAPPED = "is_video_swapped" const val EXTRA_MUTE = "mute_value" const val EXTRA_AVAILABLE = "enabled_value" const val EXTRA_REMOTE_DESCRIPTION = "remote_description" @@ -208,16 +220,8 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { private val serviceExecutor = Executors.newSingleThreadExecutor() private val timeoutExecutor = Executors.newScheduledThreadPool(1) - private val hangupOnCallAnswered by lazy { - HangUpRtcOnPstnCallAnsweredListener { - ContextCompat.startForegroundService(this, hangupIntent(this)) - } - } - - private val hangupTelephonyCallback by lazy { - HangUpRtcTelephonyCallback { - ContextCompat.startForegroundService(this, hangupIntent(this)) - } + private val telephonyHandler = TelephonyHandler(serviceExecutor) { + ContextCompat.startForegroundService(this, hangupIntent(this)) } private var networkChangedReceiver: NetworkChangeReceiver? = null @@ -250,17 +254,12 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { return callManager.callId == expectedCallId } - private fun isPreOffer() = callManager.isPreOffer() private fun isBusy(intent: Intent) = callManager.isBusy(this, getCallId(intent)) private fun isIdle() = callManager.isIdle() - override fun onBind(intent: Intent): IBinder? { - return super.onBind(intent) - } - override fun onHangup() { serviceExecutor.execute { callManager.handleRemoteHangup() @@ -275,38 +274,38 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) if (intent == null || intent.action == null) return START_NOT_STICKY serviceExecutor.execute { val action = intent.action val callId = ((intent.getSerializableExtra(EXTRA_CALL_ID) as? UUID)?.toString() ?: "No callId") Log.i("Loki", "Handling ${intent.action} for call: ${callId}") - when { - action == ACTION_INCOMING_RING && isSameCall(intent) && callManager.currentConnectionState == CallState.Reconnecting -> handleNewOffer( - intent - ) - action == ACTION_PRE_OFFER && isIdle() -> handlePreOffer(intent) - action == ACTION_INCOMING_RING && isBusy(intent) -> handleBusyCall(intent) - action == ACTION_INCOMING_RING && isPreOffer() -> handleIncomingRing(intent) - action == ACTION_OUTGOING_CALL && isIdle() -> handleOutgoingCall(intent) - action == ACTION_ANSWER_CALL -> handleAnswerCall(intent) - action == ACTION_DENY_CALL -> handleDenyCall(intent) - action == ACTION_LOCAL_HANGUP -> handleLocalHangup(intent) - action == ACTION_REMOTE_HANGUP -> handleRemoteHangup(intent) - action == ACTION_SET_MUTE_AUDIO -> handleSetMuteAudio(intent) - action == ACTION_SET_MUTE_VIDEO -> handleSetMuteVideo(intent) - action == ACTION_FLIP_CAMERA -> handleSetCameraFlip(intent) - action == ACTION_WIRED_HEADSET_CHANGE -> handleWiredHeadsetChanged(intent) - action == ACTION_SCREEN_OFF -> handleScreenOffChange(intent) - action == ACTION_RESPONSE_MESSAGE && isSameCall(intent) && callManager.currentConnectionState == CallState.Reconnecting -> handleResponseMessage( - intent - ) - action == ACTION_RESPONSE_MESSAGE -> handleResponseMessage(intent) - action == ACTION_ICE_MESSAGE -> handleRemoteIceCandidate(intent) - action == ACTION_ICE_CONNECTED -> handleIceConnected(intent) - action == ACTION_CHECK_TIMEOUT -> handleCheckTimeout(intent) - action == ACTION_CHECK_RECONNECT -> handleCheckReconnect(intent) - action == ACTION_IS_IN_CALL_QUERY -> handleIsInCallQuery(intent) - action == ACTION_UPDATE_AUDIO -> handleUpdateAudio(intent) + when (action) { + ACTION_PRE_OFFER -> if (isIdle()) handlePreOffer(intent) + ACTION_INCOMING_RING -> when { + isSameCall(intent) && callManager.currentConnectionState == CallState.Reconnecting -> { + handleNewOffer(intent) + } + isBusy(intent) -> handleBusyCall(intent) + isPreOffer() -> handleIncomingRing(intent) + } + ACTION_OUTGOING_CALL -> if (isIdle()) handleOutgoingCall(intent) + ACTION_ANSWER_CALL -> handleAnswerCall(intent) + ACTION_DENY_CALL -> handleDenyCall(intent) + ACTION_LOCAL_HANGUP -> handleLocalHangup(intent) + ACTION_REMOTE_HANGUP -> handleRemoteHangup(intent) + ACTION_SET_MUTE_AUDIO -> handleSetMuteAudio(intent) + ACTION_SET_MUTE_VIDEO -> handleSetMuteVideo(intent) + ACTION_FLIP_CAMERA -> handleSetCameraFlip(intent) + ACTION_WIRED_HEADSET_CHANGE -> handleWiredHeadsetChanged(intent) + ACTION_SCREEN_OFF -> handleScreenOffChange(intent) + ACTION_RESPONSE_MESSAGE -> handleResponseMessage(intent) + ACTION_ICE_MESSAGE -> handleRemoteIceCandidate(intent) + ACTION_ICE_CONNECTED -> handleIceConnected(intent) + ACTION_CHECK_TIMEOUT -> handleCheckTimeout(intent) + ACTION_CHECK_RECONNECT -> handleCheckReconnect(intent) + ACTION_IS_IN_CALL_QUERY -> handleIsInCallQuery(intent) + ACTION_UPDATE_AUDIO -> handleUpdateAudio(intent) } } return START_NOT_STICKY @@ -321,13 +320,7 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { registerWiredHeadsetStateReceiver() registerWantsToAnswerReceiver() if (checkSelfPermission(android.Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { - getSystemService(TelephonyManager::class.java) - .listen(hangupOnCallAnswered, PhoneStateListener.LISTEN_CALL_STATE) - } else { - getSystemService(TelephonyManager::class.java) - .registerTelephonyCallback(serviceExecutor, hangupTelephonyCallback) - } + telephonyHandler.register(getSystemService(TelephonyManager::class.java)) } registerUncaughtExceptionHandler() networkChangedReceiver = NetworkChangeReceiver(::networkChange) @@ -734,9 +727,8 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { CallNotificationBuilder.WEBRTC_NOTIFICATION, CallNotificationBuilder.getCallInProgressNotification(this, type, recipient) ) - } - catch(e: ForegroundServiceStartNotAllowedException) { - Log.e(TAG, "Failed to setCallInProgressNotification as a foreground service for type: ${type}, trying to update instead") + } catch (e: IllegalStateException) { + Log.e(TAG, "Failed to setCallInProgressNotification as a foreground service for type: ${type}, trying to update instead", e) } if (!CallNotificationBuilder.areNotificationsEnabled(this) && type == TYPE_INCOMING_PRE_OFFER) { @@ -749,11 +741,7 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { } private fun getOptionalRemoteRecipient(intent: Intent): Recipient? = - if (intent.hasExtra(EXTRA_RECIPIENT_ADDRESS)) { - getRemoteRecipient(intent) - } else { - null - } + intent.takeIf { it.hasExtra(EXTRA_RECIPIENT_ADDRESS) }?.let(::getRemoteRecipient) private fun getRemoteRecipient(intent: Intent): Recipient { val remoteAddress = intent.getParcelableExtra
(EXTRA_RECIPIENT_ADDRESS) @@ -762,10 +750,9 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { return Recipient.from(this, remoteAddress, true) } - private fun getCallId(intent: Intent): UUID { - return intent.getSerializableExtra(EXTRA_CALL_ID) as? UUID + private fun getCallId(intent: Intent): UUID = + intent.getSerializableExtra(EXTRA_CALL_ID) as? UUID ?: throw AssertionError("No callId in intent!") - } private fun insertMissedCall(recipient: Recipient, signal: Boolean) { callManager.insertCallMessage( @@ -787,8 +774,8 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { callReceiver?.let { receiver -> unregisterReceiver(receiver) } - wiredHeadsetStateReceiver?.let { unregisterReceiver(it) } - powerButtonReceiver?.let { unregisterReceiver(it) } + wiredHeadsetStateReceiver?.let(::unregisterReceiver) + powerButtonReceiver?.let(::unregisterReceiver) networkChangedReceiver?.unregister(this) wantsToAnswerReceiver?.let { receiver -> LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver) @@ -803,14 +790,7 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { currentTimeouts = 0 isNetworkAvailable = false if (checkSelfPermission(android.Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) { - val telephonyManager = getSystemService(TelephonyManager::class.java) - with(telephonyManager) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { - this.listen(hangupOnCallAnswered, LISTEN_NONE) - } else { - this.unregisterTelephonyCallback(hangupTelephonyCallback) - } - } + telephonyHandler.unregister(getSystemService(TelephonyManager::class.java)) } super.onDestroy() } @@ -818,13 +798,12 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { private fun networkChange(networkAvailable: Boolean) { Log.d("Loki", "flipping network available to $networkAvailable") isNetworkAvailable = networkAvailable - if (networkAvailable && !callManager.isReestablishing && callManager.currentConnectionState == CallState.Connected) { + if (networkAvailable && callManager.currentConnectionState == CallState.Connected) { Log.d("Loki", "Should reconnected") } } - private class CheckReconnectedRunnable(private val callId: UUID, private val context: Context) : - Runnable { + private class CheckReconnectedRunnable(private val callId: UUID, private val context: Context) : Runnable { override fun run() { val intent = Intent(context, WebRtcCallService::class.java) .setAction(ACTION_CHECK_RECONNECT) @@ -833,18 +812,7 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { } } - private class ReconnectTimeoutRunnable(private val callId: UUID, private val context: Context) : - Runnable { - override fun run() { - val intent = Intent(context, WebRtcCallService::class.java) - .setAction(ACTION_CHECK_RECONNECT_TIMEOUT) - .putExtra(EXTRA_CALL_ID, callId) - context.startService(intent) - } - } - - private class TimeoutRunnable(private val callId: UUID, private val context: Context) : - Runnable { + private class TimeoutRunnable(private val callId: UUID, private val context: Context) : Runnable { override fun run() { val intent = Intent(context, WebRtcCallService::class.java) .setAction(ACTION_CHECK_TIMEOUT) diff --git a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt index 8b1975865d1..d6383ab7fcd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt @@ -5,7 +5,7 @@ import network.loki.messenger.libsession_util.util.UserPic import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob -import org.session.libsession.messaging.utilities.SessionId +import org.session.libsession.messaging.utilities.AccountId import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient @@ -18,10 +18,10 @@ class ProfileManager(private val context: Context, private val configFactory: Co override fun setNickname(context: Context, recipient: Recipient, nickname: String?) { if (recipient.isLocalNumber) return - val sessionID = recipient.address.serialize() + val accountID = recipient.address.serialize() val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() - var contact = contactDatabase.getContactWithSessionID(sessionID) - if (contact == null) contact = Contact(sessionID) + var contact = contactDatabase.getContactWithAccountID(accountID) + if (contact == null) contact = Contact(accountID) contact.threadID = DatabaseComponent.get(context).storage().getThreadId(recipient.address) if (contact.nickname != nickname) { contact.nickname = nickname @@ -33,10 +33,10 @@ class ProfileManager(private val context: Context, private val configFactory: Co override fun setName(context: Context, recipient: Recipient, name: String?) { // New API if (recipient.isLocalNumber) return - val sessionID = recipient.address.serialize() + val accountID = recipient.address.serialize() val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() - var contact = contactDatabase.getContactWithSessionID(sessionID) - if (contact == null) contact = Contact(sessionID) + var contact = contactDatabase.getContactWithAccountID(accountID) + if (contact == null) contact = Contact(accountID) contact.threadID = DatabaseComponent.get(context).storage().getThreadId(recipient.address) if (contact.name != name) { contact.name = name @@ -67,10 +67,10 @@ class ProfileManager(private val context: Context, private val configFactory: Co newProfileKey = profileKey, newProfilePicture = profilePictureURL ) - val sessionID = recipient.address.serialize() + val accountID = recipient.address.serialize() val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() - var contact = contactDatabase.getContactWithSessionID(sessionID) - if (contact == null) contact = Contact(sessionID) + var contact = contactDatabase.getContactWithAccountID(accountID) + if (contact == null) contact = Contact(accountID) contact.threadID = DatabaseComponent.get(context).storage().getThreadId(recipient.address) if (!contact.profilePictureEncryptionKey.contentEquals(profileKey) || contact.profilePictureURL != profilePictureURL) { contact.profilePictureEncryptionKey = profileKey @@ -91,10 +91,10 @@ class ProfileManager(private val context: Context, private val configFactory: Co override fun contactUpdatedInternal(contact: Contact): String? { val contactConfig = configFactory.contacts ?: return null - if (contact.sessionID == TextSecurePreferences.getLocalNumber(context)) return null - val sessionId = SessionId(contact.sessionID) - if (sessionId.prefix != IdPrefix.STANDARD) return null // only internally store standard session IDs - contactConfig.upsertContact(contact.sessionID) { + if (contact.accountID == TextSecurePreferences.getLocalNumber(context)) return null + val accountId = AccountId(contact.accountID) + if (accountId.prefix != IdPrefix.STANDARD) return null // only internally store standard account IDs + contactConfig.upsertContact(contact.accountID) { this.name = contact.name.orEmpty() this.nickname = contact.nickname.orEmpty() val url = contact.profilePictureURL @@ -108,7 +108,7 @@ class ProfileManager(private val context: Context, private val configFactory: Co if (contactConfig.needsPush()) { ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) } - return contactConfig.get(contact.sessionID)?.hashCode()?.toString() + return contactConfig.get(contact.accountID)?.hashCode()?.toString() } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt new file mode 100644 index 00000000000..df66cef4718 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt @@ -0,0 +1,190 @@ +package org.thoughtcrime.securesms.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.bold + + +class DialogButtonModel( + val text: GetString, + val contentDescription: GetString = text, + val color: Color = Color.Unspecified, + val dismissOnClick: Boolean = true, + val onClick: () -> Unit = {}, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AlertDialog( + onDismissRequest: () -> Unit, + title: String? = null, + text: String? = null, + buttons: List? = null, + showCloseButton: Boolean = false, + content: @Composable () -> Unit = {} +) { + BasicAlertDialog( + onDismissRequest = onDismissRequest, + content = { + Box( + modifier = Modifier.background(color = LocalColors.current.backgroundSecondary, + shape = MaterialTheme.shapes.small) + ) { + // only show the 'x' button is required + if(showCloseButton) { + IconButton( + onClick = onDismissRequest, + modifier = Modifier.align(Alignment.TopEnd) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_dialog_x), + tint = LocalColors.current.text, + contentDescription = "back" + ) + } + } + + Column(modifier = Modifier.fillMaxWidth()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(top = LocalDimensions.current.smallSpacing) + .padding(horizontal = LocalDimensions.current.smallSpacing) + ) { + title?.let { + Text( + it, + textAlign = TextAlign.Center, + style = LocalType.current.h7, + modifier = Modifier.padding(bottom = LocalDimensions.current.xxsSpacing) + ) + } + text?.let { + Text( + it, + textAlign = TextAlign.Center, + style = LocalType.current.large, + modifier = Modifier.padding(bottom = LocalDimensions.current.xxsSpacing) + ) + } + content() + } + buttons?.takeIf { it.isNotEmpty() }?.let { + Row(Modifier.height(IntrinsicSize.Min)) { + it.forEach { + DialogButton( + text = it.text(), + modifier = Modifier + .fillMaxHeight() + .contentDescription(it.contentDescription()) + .weight(1f), + color = it.color + ) { + it.onClick() + if (it.dismissOnClick) onDismissRequest() + } + } + } + } + } + } + } + ) +} + +@Composable +fun DialogButton(text: String, modifier: Modifier, color: Color = Color.Unspecified, onClick: () -> Unit) { + TextButton( + modifier = modifier, + shape = RectangleShape, + onClick = onClick + ) { + Text( + text, + color = color.takeOrElse { LocalColors.current.text }, + style = LocalType.current.large.bold(), + textAlign = TextAlign.Center, + modifier = Modifier.padding( + top = LocalDimensions.current.smallSpacing, + bottom = LocalDimensions.current.spacing + ) + ) + } +} + +@Preview +@Composable +fun PreviewSimpleDialog(){ + PreviewTheme { + AlertDialog( + onDismissRequest = {}, + title = stringResource(R.string.warning), + text = stringResource(R.string.you_cannot_go_back_further_in_order_to_stop_loading_your_account_session_needs_to_quit), + buttons = listOf( + DialogButtonModel( + GetString(stringResource(R.string.quit)), + color = LocalColors.current.danger, + onClick = {} + ), + DialogButtonModel( + GetString(stringResource(R.string.cancel)) + ) + ) + ) + } +} + +@Preview +@Composable +fun PreviewXCloseDialog(){ + PreviewTheme { + AlertDialog( + title = stringResource(R.string.urlOpen), + text = stringResource(R.string.urlOpenBrowser), + showCloseButton = true, // display the 'x' button + buttons = listOf( + DialogButtonModel( + text = GetString(R.string.activity_landing_terms_of_service), + contentDescription = GetString(R.string.AccessibilityId_terms_of_service_button), + onClick = {} + ), + DialogButtonModel( + text = GetString(R.string.activity_landing_privacy_policy), + contentDescription = GetString(R.string.AccessibilityId_privacy_policy_button), + onClick = {} + ) + ), + onDismissRequest = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt index 0b7b6d6b4c5..ea632d46e78 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt @@ -2,44 +2,176 @@ package org.thoughtcrime.securesms.ui import androidx.annotation.DrawableRes import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Card -import androidx.compose.material.Icon -import androidx.compose.material.IconButton +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.blackAlpha40 +import org.thoughtcrime.securesms.ui.theme.pillShape +import kotlin.math.absoluteValue +import kotlin.math.sign @OptIn(ExperimentalFoundationApi::class) @Composable fun BoxScope.HorizontalPagerIndicator(pagerState: PagerState) { - if (pagerState.pageCount >= 2) Card( - shape = RoundedCornerShape(50.dp), - backgroundColor = Color.Black.copy(alpha = 0.4f), + if (pagerState.pageCount >= 2) Box( modifier = Modifier + .background(color = blackAlpha40, shape = pillShape) .align(Alignment.BottomCenter) - .padding(8.dp) + .padding(LocalDimensions.current.xxsSpacing) ) { - Box(modifier = Modifier.padding(8.dp)) { - com.google.accompanist.pager.HorizontalPagerIndicator( + Box(modifier = Modifier.padding(LocalDimensions.current.xxsSpacing)) { + ClickableHorizontalPagerIndicator( pagerState = pagerState, - pageCount = pagerState.pageCount, - activeColor = Color.White, - inactiveColor = classicDarkColors[5]) + pageCount = pagerState.pageCount + ) + } + } +} + +internal interface PagerStateBridge { + val currentPage: Int + val currentPageOffset: Float +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ClickableHorizontalPagerIndicator( + pagerState: PagerState, + pageCount: Int, + modifier: Modifier = Modifier, + pageIndexMapping: (Int) -> Int = { it }, + activeColor: Color = Color.White, + inactiveColor: Color = LocalColors.current.disabled, + indicatorWidth: Dp = LocalDimensions.current.xxsSpacing, + indicatorHeight: Dp = indicatorWidth, + spacing: Dp = indicatorWidth, + indicatorShape: Shape = CircleShape, +) { + val scope = rememberCoroutineScope() + + val stateBridge = remember(pagerState) { + object : PagerStateBridge { + override val currentPage: Int + get() = pagerState.currentPage + override val currentPageOffset: Float + get() = pagerState.currentPageOffsetFraction + } + } + + HorizontalPagerIndicator( + pagerState = stateBridge, + pageCount = pageCount, + modifier = modifier, + pageIndexMapping = pageIndexMapping, + activeColor = activeColor, + inactiveColor = inactiveColor, + indicatorHeight = indicatorHeight, + indicatorWidth = indicatorWidth, + spacing = spacing, + indicatorShape = indicatorShape, + ) { + scope.launch { + pagerState.animateScrollToPage(it) + } + } +} + +@Composable +private fun HorizontalPagerIndicator( + pagerState: PagerStateBridge, + pageCount: Int, + modifier: Modifier = Modifier, + pageIndexMapping: (Int) -> Int = { it }, + activeColor: Color = Color.White, + inactiveColor: Color = LocalColors.current.disabled, + indicatorWidth: Dp = LocalDimensions.current.xxsSpacing, + indicatorHeight: Dp = indicatorWidth, + spacing: Dp = indicatorWidth, + indicatorShape: Shape = CircleShape, + onIndicatorClick: (Int) -> Unit, +) { + + val indicatorWidthPx = LocalDensity.current.run { indicatorWidth.roundToPx() } + val spacingPx = LocalDensity.current.run { spacing.roundToPx() } + + Box( + modifier = modifier, + contentAlignment = Alignment.CenterStart + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalAlignment = Alignment.CenterVertically, + ) { + + repeat(pageCount) { + Box( + modifier = Modifier + .size(width = indicatorWidth, height = indicatorHeight) + .clip(indicatorShape) + .background(color = inactiveColor) + .clickable { onIndicatorClick(it) } //modified here + ) + } } + + Box( + Modifier + .offset { + val position = pageIndexMapping(pagerState.currentPage) + val offset = pagerState.currentPageOffset + val next = pageIndexMapping(pagerState.currentPage + offset.sign.toInt()) + val scrollPosition = ((next - position) * offset.absoluteValue + position) + .coerceIn( + 0f, + (pageCount - 1) + .coerceAtLeast(0) + .toFloat() + ) + + IntOffset( + x = ((spacingPx + indicatorWidthPx) * scrollPosition).toInt(), + y = 0 + ) + } + .size(width = indicatorWidth, height = indicatorHeight) + .then( + if (pageCount > 0) Modifier.background( + color = activeColor, + shape = indicatorShape, + ) + else Modifier + ) + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Colors.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Colors.kt deleted file mode 100644 index 55bc1be62e5..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Colors.kt +++ /dev/null @@ -1,63 +0,0 @@ -package org.thoughtcrime.securesms.ui - -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color - -val colorDestructive = Color(0xffFF453A) - -const val classicDark0 = 0xff111111 -const val classicDark1 = 0xff1B1B1B -const val classicDark2 = 0xff2D2D2D -const val classicDark3 = 0xff414141 -const val classicDark4 = 0xff767676 -const val classicDark5 = 0xffA1A2A1 -const val classicDark6 = 0xffFFFFFF - -const val classicLight0 = 0xff000000 -const val classicLight1 = 0xff6D6D6D -const val classicLight2 = 0xffA1A2A1 -const val classicLight3 = 0xffDFDFDF -const val classicLight4 = 0xffF0F0F0 -const val classicLight5 = 0xffF9F9F9 -const val classicLight6 = 0xffFFFFFF - -const val oceanDark0 = 0xff000000 -const val oceanDark1 = 0xff1A1C28 -const val oceanDark2 = 0xff252735 -const val oceanDark3 = 0xff2B2D40 -const val oceanDark4 = 0xff3D4A5D -const val oceanDark5 = 0xffA6A9CE -const val oceanDark6 = 0xff5CAACC -const val oceanDark7 = 0xffFFFFFF - -const val oceanLight0 = 0xff000000 -const val oceanLight1 = 0xff19345D -const val oceanLight2 = 0xff6A6E90 -const val oceanLight3 = 0xff5CAACC -const val oceanLight4 = 0xffB3EDF2 -const val oceanLight5 = 0xffE7F3F4 -const val oceanLight6 = 0xffECFAFB -const val oceanLight7 = 0xffFCFFFF - -val ocean_accent = Color(0xff57C9FA) - -val oceanLights = arrayOf(oceanLight0, oceanLight1, oceanLight2, oceanLight3, oceanLight4, oceanLight5, oceanLight6, oceanLight7) -val oceanDarks = arrayOf(oceanDark0, oceanDark1, oceanDark2, oceanDark3, oceanDark4, oceanDark5, oceanDark6, oceanDark7) -val classicLights = arrayOf(classicLight0, classicLight1, classicLight2, classicLight3, classicLight4, classicLight5, classicLight6) -val classicDarks = arrayOf(classicDark0, classicDark1, classicDark2, classicDark3, classicDark4, classicDark5, classicDark6) - -val oceanLightColors = oceanLights.map(::Color) -val oceanDarkColors = oceanDarks.map(::Color) -val classicLightColors = classicLights.map(::Color) -val classicDarkColors = classicDarks.map(::Color) - -val blackAlpha40 = Color.Black.copy(alpha = 0.4f) - -@Composable -fun transparentButtonColors() = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent) - -@Composable -fun destructiveButtonColors() = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent, contentColor = colorDestructive) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 6c223a45f2c..daeb6b853db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -1,15 +1,19 @@ package org.thoughtcrime.securesms.ui import androidx.annotation.DrawableRes -import androidx.compose.foundation.BorderStroke +import androidx.annotation.StringRes +import androidx.appcompat.content.res.AppCompatResources +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -17,42 +21,58 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ButtonColors -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Card -import androidx.compose.material.Colors -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedButton -import androidx.compose.material.RadioButton -import androidx.compose.material.Text -import androidx.compose.material.TextButton +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import network.loki.messenger.R import org.session.libsession.utilities.recipients.Recipient -import org.session.libsession.utilities.runIf import org.thoughtcrime.securesms.components.ProfilePictureView -import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.OptionsCard +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.OptionsCardData +import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator +import org.thoughtcrime.securesms.ui.components.TitledRadioButton +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.transparentButtonColors import kotlin.math.min +import kotlin.math.roundToInt interface Callbacks { fun onSetClick(): Any? @@ -74,53 +94,174 @@ data class RadioOption( ) @Composable -fun OptionsCard(card: OptionsCard, callbacks: Callbacks) { - Text(text = card.title()) - CellNoMargin { - LazyColumn( - modifier = Modifier.heightIn(max = 5000.dp) - ) { - itemsIndexed(card.options) { i, it -> - if (i != 0) Divider() - TitledRadioButton(it) { callbacks.setValue(it.value) } +fun OptionsCard(card: OptionsCardData, callbacks: Callbacks) { + Column { + Text( + modifier = Modifier.padding(start = LocalDimensions.current.smallSpacing), + text = card.title(), + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + CellNoMargin { + LazyColumn( + modifier = Modifier.heightIn(max = 5000.dp) + ) { + itemsIndexed(card.options) { i, it -> + if (i != 0) Divider() + TitledRadioButton(option = it) { callbacks.setValue(it.value) } + } } } } } +@Composable +fun LargeItemButtonWithDrawable( + @StringRes textId: Int, + @DrawableRes icon: Int, + modifier: Modifier = Modifier, + colors: ButtonColors = transparentButtonColors(), + onClick: () -> Unit +) { + ItemButtonWithDrawable( + textId, icon, modifier.heightIn(min = LocalDimensions.current.minLargeItemButtonHeight), + LocalType.current.h8, colors, onClick + ) +} + +@Composable +fun ItemButtonWithDrawable( + @StringRes textId: Int, + @DrawableRes icon: Int, + modifier: Modifier = Modifier, + textStyle: TextStyle = LocalType.current.xl, + colors: ButtonColors = transparentButtonColors(), + onClick: () -> Unit +) { + val context = LocalContext.current + + ItemButton( + text = stringResource(textId), + modifier = modifier, + icon = { + Image( + painter = rememberDrawablePainter(drawable = AppCompatResources.getDrawable(context, icon)), + contentDescription = null, + modifier = Modifier.align(Alignment.Center) + ) + }, + textStyle = textStyle, + colors = colors, + onClick = onClick + ) +} + +@Composable +fun LargeItemButton( + @StringRes textId: Int, + @DrawableRes icon: Int, + modifier: Modifier = Modifier, + colors: ButtonColors = transparentButtonColors(), + onClick: () -> Unit +) { + ItemButton( + textId, icon, modifier.heightIn(min = LocalDimensions.current.minLargeItemButtonHeight), + LocalType.current.h8, colors, onClick + ) +} + +/** + * Courtesy [ItemButton] implementation that takes a [DrawableRes] for the [icon] + */ +@Composable +fun ItemButton( + @StringRes textId: Int, + @DrawableRes icon: Int, + modifier: Modifier = Modifier, + textStyle: TextStyle = LocalType.current.xl, + colors: ButtonColors = transparentButtonColors(), + onClick: () -> Unit +) { + ItemButton( + text = stringResource(textId), + modifier = modifier, + icon = { + Icon( + painter = painterResource(id = icon), + contentDescription = null, + modifier = Modifier.align(Alignment.Center) + ) + }, + textStyle = textStyle, + colors = colors, + onClick = onClick + ) +} +/** +* Base [ItemButton] implementation. + * + * A button to be used in a list of buttons, usually in a [Cell] or [Card] +*/ @Composable fun ItemButton( text: String, - @DrawableRes icon: Int, + icon: @Composable BoxScope.() -> Unit, + modifier: Modifier = Modifier, + textStyle: TextStyle = LocalType.current.xl, colors: ButtonColors = transparentButtonColors(), - contentDescription: String = text, onClick: () -> Unit ) { TextButton( - modifier = Modifier - .fillMaxWidth() - .height(60.dp), + modifier = modifier.fillMaxWidth(), colors = colors, onClick = onClick, shape = RectangleShape, ) { - Box(modifier = Modifier - .width(80.dp) - .fillMaxHeight()) { - Icon( - painter = painterResource(id = icon), - contentDescription = contentDescription, - modifier = Modifier.align(Alignment.Center) - ) + Box( + modifier = Modifier + .width(50.dp) + .wrapContentHeight() + .align(Alignment.CenterVertically) + ) { + icon() } - Text(text, modifier = Modifier.fillMaxWidth()) + + Spacer(modifier = Modifier.width(LocalDimensions.current.smallSpacing)) + + Text( + text, + Modifier + .fillMaxWidth() + .padding(vertical = LocalDimensions.current.xsSpacing) + .align(Alignment.CenterVertically), + style = textStyle + ) + } +} + +@Preview +@Composable +fun PrewviewItemButton() { + PreviewTheme { + ItemButton( + textId = R.string.activity_create_group_title, + icon = R.drawable.ic_group, + onClick = {} + ) } } @Composable -fun Cell(content: @Composable () -> Unit) { - CellWithPaddingAndMargin(padding = 0.dp) { content() } +fun Cell( + padding: Dp = 0.dp, + margin: Dp = LocalDimensions.current.spacing, + content: @Composable () -> Unit +) { + CellWithPaddingAndMargin(padding, margin) { content() } } @Composable fun CellNoMargin(content: @Composable () -> Unit) { @@ -129,89 +270,41 @@ fun CellNoMargin(content: @Composable () -> Unit) { @Composable fun CellWithPaddingAndMargin( - padding: Dp = 24.dp, - margin: Dp = 32.dp, + padding: Dp = LocalDimensions.current.spacing, + margin: Dp = LocalDimensions.current.spacing, content: @Composable () -> Unit ) { - Card( - backgroundColor = MaterialTheme.colors.cellColor, - shape = RoundedCornerShape(16.dp), - elevation = 0.dp, + Box( modifier = Modifier + .padding(horizontal = margin) + .background(color = LocalColors.current.backgroundSecondary, + shape = MaterialTheme.shapes.small) .wrapContentHeight() - .fillMaxWidth() - .padding(horizontal = margin), + .fillMaxWidth(), ) { Box(Modifier.padding(padding)) { content() } } } @Composable -fun TitledRadioButton(option: RadioOption, onClick: () -> Unit) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .runIf(option.enabled) { clickable { if (!option.selected) onClick() } } - .heightIn(min = 60.dp) - .padding(horizontal = 32.dp) - .contentDescription(option.contentDescription) - ) { - Column(modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically)) { - Column { - Text( - text = option.title(), - fontSize = 16.sp, - modifier = Modifier.alpha(if (option.enabled) 1f else 0.5f) - ) - option.subtitle?.let { - Text( - text = it(), - fontSize = 11.sp, - modifier = Modifier.alpha(if (option.enabled) 1f else 0.5f) - ) - } - } - } - RadioButton( - selected = option.selected, - onClick = null, - enabled = option.enabled, - modifier = Modifier - .height(26.dp) - .align(Alignment.CenterVertically) - ) - } +fun Modifier.contentDescription(text: GetString?): Modifier { + return text?.let { + val context = LocalContext.current + semantics { contentDescription = it(context) } + } ?: this } @Composable -fun Modifier.contentDescription(text: GetString?): Modifier { +fun Modifier.contentDescription(@StringRes id: Int?): Modifier { val context = LocalContext.current - return text?.let { semantics { contentDescription = it(context) } } ?: this + return id?.let { semantics { contentDescription = context.getString(it) } } ?: this } @Composable -fun OutlineButton(text: GetString, contentDescription: GetString? = text, modifier: Modifier = Modifier, onClick: () -> Unit) { - OutlinedButton( - modifier = modifier.size(108.dp, 34.dp) - .contentDescription(contentDescription), - onClick = onClick, - border = BorderStroke(1.dp, LocalExtraColors.current.prominentButtonColor), - shape = RoundedCornerShape(50), // = 50% percent - colors = ButtonDefaults.outlinedButtonColors( - contentColor = LocalExtraColors.current.prominentButtonColor, - backgroundColor = MaterialTheme.colors.background - ) - ){ - Text(text = text()) - } +fun Modifier.contentDescription(text: String?): Modifier { + return text?.let { semantics { contentDescription = it } } ?: this } -private val Colors.cellColor: Color - @Composable - get() = LocalExtraColors.current.settingsBackground - fun Modifier.fadingEdges( scrollState: ScrollState, topEdgeHeight: Dp = 0.dp, @@ -251,9 +344,11 @@ fun Modifier.fadingEdges( ) @Composable -fun Divider() { - androidx.compose.material.Divider( - modifier = Modifier.padding(horizontal = 16.dp), +fun Divider(modifier: Modifier = Modifier, startIndent: Dp = 0.dp) { + HorizontalDivider( + modifier = modifier.padding(horizontal = LocalDimensions.current.smallSpacing) + .padding(start = startIndent), + color = LocalColors.current.borders, ) } @@ -274,3 +369,81 @@ fun RowScope.Avatar(recipient: Recipient) { ) } } + +@Composable +fun ProgressArc(progress: Float, modifier: Modifier = Modifier) { + val text = (progress * 100).roundToInt() + + Box(modifier = modifier) { + Arc(percentage = progress, modifier = Modifier.align(Alignment.Center)) + Text( + "${text}%", + color = Color.White, + modifier = Modifier.align(Alignment.Center), + style = LocalType.current.h2 + ) + } +} + +@Composable +fun Arc( + modifier: Modifier = Modifier, + percentage: Float = 0.25f, + fillColor: Color = LocalColors.current.primary, + backgroundColor: Color = LocalColors.current.borders, + strokeWidth: Dp = 18.dp, + sweepAngle: Float = 310f, + startAngle: Float = (360f - sweepAngle) / 2 + 90f +) { + Canvas( + modifier = modifier + .padding(strokeWidth) + .size(186.dp) + ) { + // Background Line + drawArc( + color = backgroundColor, + startAngle, + sweepAngle, + false, + style = Stroke(strokeWidth.toPx(), cap = StrokeCap.Round), + size = Size(size.width, size.height) + ) + + drawArc( + color = fillColor, + startAngle, + percentage * sweepAngle, + false, + style = Stroke(strokeWidth.toPx(), cap = StrokeCap.Round), + size = Size(size.width, size.height) + ) + } +} + +@Composable +fun RowScope.SessionShieldIcon() { + Icon( + painter = painterResource(R.drawable.session_shield), + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterVertically) + .wrapContentSize(unbounded = true) + ) +} + +@Composable +fun LaunchedEffectAsync(block: suspend CoroutineScope.() -> Unit) { + val scope = rememberCoroutineScope() + LaunchedEffect(Unit) { scope.launch(Dispatchers.IO) { block() } } +} + +@Composable +fun LoadingArcOr(loading: Boolean, content: @Composable () -> Unit) { + AnimatedVisibility(loading) { + SmallCircularProgressIndicator(color = LocalContentColor.current) + } + AnimatedVisibility(!loading) { + content() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Strings.kt similarity index 95% rename from app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt rename to app/src/main/java/org/thoughtcrime/securesms/ui/Strings.kt index e4722090051..91500da7b28 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Strings.kt @@ -40,7 +40,6 @@ sealed class GetString { data class FromMap(val value: T, val function: (Context, T) -> String): GetString() { @Composable override fun string(): String = function(LocalContext.current, value) - override fun string(context: Context): String = function(context, value) } } @@ -48,7 +47,7 @@ sealed class GetString { fun GetString(@StringRes resId: Int) = GetString.FromResId(resId) fun GetString(string: String) = GetString.FromString(string) fun GetString(function: (Context) -> String) = GetString.FromFun(function) -fun GetString(value: T, function: (Context, T) -> String) = GetString.FromMap(value, function) +fun GetString(value: T, function: Context.(T) -> String) = GetString.FromMap(value, function) fun GetString(duration: Duration) = GetString.FromMap(duration, ExpirationUtil::getExpirationDisplayValue) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt deleted file mode 100644 index 3fa861fb719..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt +++ /dev/null @@ -1,78 +0,0 @@ -package org.thoughtcrime.securesms.ui - -import android.content.Context -import androidx.annotation.AttrRes -import androidx.appcompat.view.ContextThemeWrapper -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import com.google.accompanist.themeadapter.appcompat.AppCompatTheme -import com.google.android.material.color.MaterialColors -import network.loki.messenger.R - -val LocalExtraColors = staticCompositionLocalOf { error("No Custom Attribute value provided") } - - -data class ExtraColors( - val settingsBackground: Color, - val prominentButtonColor: Color -) - -/** - * Converts current Theme to Compose Theme. - */ -@Composable -fun AppTheme( - content: @Composable () -> Unit -) { - val extraColors = LocalContext.current.run { - ExtraColors( - settingsBackground = getColorFromTheme(R.attr.colorSettingsBackground), - prominentButtonColor = getColorFromTheme(R.attr.prominentButtonColor), - ) - } - - CompositionLocalProvider(LocalExtraColors provides extraColors) { - AppCompatTheme { - content() - } - } -} - -fun Context.getColorFromTheme(@AttrRes attr: Int, defaultValue: Int = 0x0): Color = - MaterialColors.getColor(this, attr, defaultValue).let(::Color) - -/** - * Set the theme and a background for Compose Previews. - */ -@Composable -fun PreviewTheme( - themeResId: Int, - content: @Composable () -> Unit -) { - CompositionLocalProvider( - LocalContext provides ContextThemeWrapper(LocalContext.current, themeResId) - ) { - AppTheme { - Box(modifier = Modifier.background(color = MaterialTheme.colors.background)) { - content() - } - } - } -} - -class ThemeResPreviewParameterProvider : PreviewParameterProvider { - override val values = sequenceOf( - R.style.Classic_Dark, - R.style.Classic_Light, - R.style.Ocean_Dark, - R.style.Ocean_Light, - ) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt new file mode 100644 index 00000000000..b49f9c6d606 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.ui + +import android.app.Activity +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme + +fun Activity.setComposeContent(content: @Composable () -> Unit) { + ComposeView(this) + .apply { setThemedContent(content) } + .let(::setContentView) +} + +fun Fragment.createThemedComposeView(content: @Composable () -> Unit): ComposeView = requireContext().createThemedComposeView(content) +fun Context.createThemedComposeView(content: @Composable () -> Unit): ComposeView = ComposeView(this).apply { + setThemedContent(content) +} + +fun ComposeView.setThemedContent(content: @Composable () -> Unit) = setContent { + SessionMaterialTheme { + content() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt new file mode 100644 index 00000000000..b64a482d8a1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors + +@Preview +@Composable +fun AppBarPreview( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + AppBar(title = "Title", {}, {}) + } +} + +@Composable +fun AppBar(title: String, onClose: () -> Unit = {}, onBack: (() -> Unit)? = null) { + Row(modifier = Modifier.height(LocalDimensions.current.appBarHeight), verticalAlignment = Alignment.CenterVertically) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(LocalDimensions.current.appBarHeight)) { + onBack?.let { + IconButton(onClick = it) { + Icon(painter = painterResource(id = R.drawable.ic_prev), contentDescription = "back") + } + } + } + Spacer(modifier = Modifier.weight(1f)) + Text(text = title, style = LocalType.current.h4) + Spacer(modifier = Modifier.weight(1f)) + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(LocalDimensions.current.appBarHeight)) { + IconButton(onClick = onClose) { + Icon(painter = painterResource(id = R.drawable.ic_x), contentDescription = "close") + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Border.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Border.kt new file mode 100644 index 00000000000..4420e91e78a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Border.kt @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.compose.foundation.border +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalColors + +@Composable +fun Modifier.border() = this.border( + width = LocalDimensions.current.borderStroke, + brush = SolidColor(LocalColors.current.borders), + shape = MaterialTheme.shapes.small +) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt new file mode 100644 index 00000000000..5834f2f859b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt @@ -0,0 +1,321 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filter +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.LaunchedEffectAsync +import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.bold +import org.thoughtcrime.securesms.ui.theme.buttonShape +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Base [Button] implementation + */ +@Composable +fun Button( + onClick: () -> Unit, + type: ButtonType, + modifier: Modifier = Modifier, + enabled: Boolean = true, + style: ButtonStyle = ButtonStyle.Large, + shape: Shape = buttonShape, + border: BorderStroke? = type.border(enabled), + colors: ButtonColors = type.buttonColors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + contentPadding: PaddingValues = type.contentPadding, + content: @Composable RowScope.() -> Unit +) { + style.applyButtonConstraints { + androidx.compose.material3.Button( + onClick = onClick, + modifier = modifier.heightIn(min = style.minHeight), + enabled = enabled, + interactionSource = interactionSource, + elevation = null, + shape = shape, + border = border, + colors = colors, + contentPadding = contentPadding + ) { + // Button sets LocalTextStyle, so text style is applied inside to override that. + style.applyTextConstraints { + content() + } + } + } +} + +/** + * Courtesy [Button] implementation for buttons that just display text. + */ +@Composable +fun Button( + text: String, + onClick: () -> Unit, + type: ButtonType, + modifier: Modifier = Modifier, + enabled: Boolean = true, + style: ButtonStyle = ButtonStyle.Large, + shape: Shape = buttonShape, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + Button(onClick, type, modifier, enabled, style, shape, interactionSource = interactionSource) { + Text(text) + } +} + +@Composable fun FillButton(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { + Button(text, onClick, ButtonType.Fill, modifier, enabled) +} + +@Composable fun PrimaryFillButton(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { + Button(text, onClick, ButtonType.PrimaryFill, modifier, enabled) +} + +@Composable fun OutlineButton(text: String, modifier: Modifier = Modifier, color: Color = LocalColors.current.text, enabled: Boolean = true, onClick: () -> Unit) { + Button(text, onClick, ButtonType.Outline(color), modifier, enabled) +} + +@Composable fun OutlineButton(modifier: Modifier = Modifier, color: Color = LocalColors.current.text, enabled: Boolean = true, onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { + Button( + onClick = onClick, + type = ButtonType.Outline(color), + modifier = modifier, + enabled = enabled, + content = content + ) +} + +@Composable fun PrimaryOutlineButton(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { + Button(text, onClick, ButtonType.Outline(LocalColors.current.primaryButtonFill), modifier, enabled) +} + +@Composable fun PrimaryOutlineButton(modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { + Button(onClick, ButtonType.Outline(LocalColors.current.primaryButtonFill), modifier, enabled, content = content) +} + +@Composable fun SlimOutlineButton(modifier: Modifier = Modifier, color: Color = LocalColors.current.text, enabled: Boolean = true, onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { + Button(onClick, ButtonType.Outline(color), modifier, enabled, ButtonStyle.Slim, content = content) +} + +/** + * Courtesy [SlimOutlineButton] implementation for buttons that just display text. + */ +@Composable fun SlimOutlineButton(text: String, modifier: Modifier = Modifier, color: Color = LocalColors.current.text, enabled: Boolean = true, onClick: () -> Unit) { + Button(text, onClick, ButtonType.Outline(color), modifier, enabled, ButtonStyle.Slim) +} + +@Composable fun SlimPrimaryOutlineButton(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { + Button(text, onClick, ButtonType.Outline(LocalColors.current.primaryButtonFill), modifier, enabled, ButtonStyle.Slim) +} + +@Composable +fun PrimaryOutlineCopyButton( + modifier: Modifier = Modifier, + style: ButtonStyle = ButtonStyle.Large, + onClick: () -> Unit +) { + OutlineCopyButton(modifier, style, LocalColors.current.primaryButtonFill, onClick) +} + +@Composable +fun SlimOutlineCopyButton( + modifier: Modifier = Modifier, + color: Color = LocalColors.current.text, + onClick: () -> Unit +) { + OutlineCopyButton(modifier, ButtonStyle.Slim, color, onClick) +} + +@Composable +fun OutlineCopyButton( + modifier: Modifier = Modifier, + style: ButtonStyle = ButtonStyle.Large, + color: Color = LocalColors.current.text, + onClick: () -> Unit +) { + val interactionSource = remember { MutableInteractionSource() } + + Button( + modifier = modifier.contentDescription(R.string.AccessibilityId_copy_button), + interactionSource = interactionSource, + style = style, + type = ButtonType.Outline(color), + onClick = onClick + ) { + CopyButtonContent(interactionSource) + } +} + +@Composable +fun CopyButtonContent(interactionSource: MutableInteractionSource) { + TemporaryClickedContent( + interactionSource = interactionSource, + content = { Text(stringResource(R.string.copy)) }, + temporaryContent = { Text(stringResource(R.string.copied)) } + ) +} + +@Composable +fun TemporaryClickedContent( + interactionSource: MutableInteractionSource, + content: @Composable AnimatedVisibilityScope.() -> Unit, + temporaryContent: @Composable AnimatedVisibilityScope.() -> Unit, + temporaryDelay: Duration = 2.seconds +) { + var clicked by remember { mutableStateOf(false) } + + LaunchedEffectAsync { + interactionSource.releases.collectLatest { + clicked = true + delay(temporaryDelay) + clicked = false + } + } + + // Using a Box because the Buttons add children in a Row + // and they will jank as they are added and removed. + Box(contentAlignment = Alignment.Center) { + AnimatedVisibility(!clicked, enter = fadeIn(), exit = fadeOut(), content = content) + AnimatedVisibility(clicked, enter = fadeIn(), exit = fadeOut(), content = temporaryContent) + } +} + +/** + * Base [BorderlessButton] implementation. + */ +@Composable +fun BorderlessButton( + modifier: Modifier = Modifier, + color: Color = LocalColors.current.text, + onClick: () -> Unit, + content: @Composable RowScope.() -> Unit +) { + Button( + onClick = onClick, + modifier = modifier, + style = ButtonStyle.Borderless, + type = ButtonType.Borderless(color), + content = content + ) +} + +/** + * Courtesy [BorderlessButton] implementation that accepts [text] as a [String]. + */ +@Composable +fun BorderlessButton( + text: String, + modifier: Modifier = Modifier, + color: Color = LocalColors.current.text, + onClick: () -> Unit +) { + BorderlessButton(modifier, color, onClick) { Text(text) } +} + +@Composable +fun BorderlessButtonWithIcon( + text: String, + @DrawableRes iconRes: Int, + modifier: Modifier = Modifier, + style: TextStyle = LocalType.current.base.bold(), + color: Color = LocalColors.current.text, + onClick: () -> Unit +) { + BorderlessButton( + modifier = modifier, + color = color, + onClick = onClick + ) { + AnnotatedTextWithIcon(text, iconRes, style = style) + } +} + +@Composable +fun BorderlessHtmlButton( + textId: Int, + modifier: Modifier = Modifier, + color: Color = LocalColors.current.text, + onClick: () -> Unit +) { + BorderlessButton(modifier, color, onClick) { + Text( + text = annotatedStringResource(textId), + modifier = Modifier.padding(horizontal = 2.dp) + ) + } +} + +val MutableInteractionSource.releases + get() = interactions.filter { it is PressInteraction.Release } + +@OptIn(ExperimentalLayoutApi::class) +@Preview +@Composable +private fun VariousButtons( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + FlowRow( + modifier = Modifier.padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(8.dp), + maxItemsInEachRow = 2 + ) { + PrimaryFillButton("Primary Fill") {} + PrimaryFillButton("Primary Fill Disabled", enabled = false) {} + FillButton("Fill Button") {} + FillButton("Fill Button Disabled", enabled = false) {} + PrimaryOutlineButton("Primary Outline Button") {} + PrimaryOutlineButton("Primary Outline Disabled", enabled = false) {} + OutlineButton("Outline Button") {} + OutlineButton("Outline Button Disabled", enabled = false) {} + SlimOutlineButton("Slim Outline") {} + SlimOutlineButton("Slim Outline Disabled", enabled = false) {} + SlimPrimaryOutlineButton("Slim Primary") {} + SlimOutlineButton("Slim Danger", color = LocalColors.current.danger) {} + BorderlessButton("Borderless Button") {} + BorderlessButton("Borderless Secondary", color = LocalColors.current.textSecondary) {} + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonStyle.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonStyle.kt new file mode 100644 index 00000000000..e8fcca06cf4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonStyle.kt @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.ui.components + +import android.annotation.SuppressLint +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement +import androidx.compose.material3.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.bold + +interface ButtonStyle { + @OptIn(ExperimentalMaterial3Api::class) + @SuppressLint("ComposableNaming") + @Composable fun applyButtonConstraints(content: @Composable () -> Unit) { + CompositionLocalProvider( + LocalMinimumInteractiveComponentEnforcement provides false, + content = content + ) + } + + @SuppressLint("ComposableNaming") + @Composable fun applyTextConstraints(content: @Composable () -> Unit) { + CompositionLocalProvider( + LocalTextStyle provides textStyle(), + content = content + ) + } + + @Composable + fun textStyle() : TextStyle + + val minHeight: Dp + + object Large: ButtonStyle { + @Composable + override fun textStyle() = LocalType.current.base.bold() + .copy(textAlign = TextAlign.Center) + override val minHeight = 41.dp + } + + object Slim: ButtonStyle { + @Composable + override fun textStyle() = LocalType.current.extraSmall.bold() + .copy(textAlign = TextAlign.Center) + override val minHeight = 29.dp + } + + object Borderless: ButtonStyle { + @Composable + override fun textStyle() = LocalType.current.extraSmall + .copy(textAlign = TextAlign.Center) + override val minHeight = 37.dp + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt new file mode 100644 index 00000000000..54478e69b5d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt @@ -0,0 +1,81 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions + +private val disabledBorder @Composable get() = BorderStroke( + width = LocalDimensions.current.borderStroke, + color = LocalColors.current.disabled +) + +interface ButtonType { + val contentPadding: PaddingValues get() = ButtonDefaults.ContentPadding + + @Composable + fun border(enabled: Boolean): BorderStroke? + @Composable + fun buttonColors(): ButtonColors + + class Outline( + private val contentColor: Color, + private val borderColor: Color = contentColor + ): ButtonType { + @Composable + override fun border(enabled: Boolean) = BorderStroke( + width = LocalDimensions.current.borderStroke, + color = if (enabled) borderColor else LocalColors.current.disabled + ) + @Composable + override fun buttonColors() = ButtonDefaults.buttonColors( + contentColor = contentColor, + containerColor = Color.Transparent, + disabledContentColor = LocalColors.current.disabled, + disabledContainerColor = Color.Transparent + ) + } + + object Fill: ButtonType { + @Composable + override fun border(enabled: Boolean) = if (enabled) null else disabledBorder + @Composable + override fun buttonColors() = ButtonDefaults.buttonColors( + contentColor = LocalColors.current.background, + containerColor = LocalColors.current.text, + disabledContentColor = LocalColors.current.disabled, + disabledContainerColor = Color.Transparent + ) + } + + object PrimaryFill: ButtonType { + @Composable + override fun border(enabled: Boolean) = if (enabled) null else disabledBorder + @Composable + override fun buttonColors() = ButtonDefaults.buttonColors( + contentColor = LocalColors.current.primaryButtonFillText, + containerColor = LocalColors.current.primaryButtonFill, + disabledContentColor = LocalColors.current.disabled, + disabledContainerColor = Color.Transparent + ) + } + + class Borderless(private val color: Color): ButtonType { + override val contentPadding: PaddingValues + get() = PaddingValues(horizontal = 16.dp, vertical = 12.dp) + @Composable + override fun border(enabled: Boolean) = null + @Composable + override fun buttonColors() = ButtonDefaults.outlinedButtonColors( + contentColor = color, + containerColor = Color.Transparent, + disabledContentColor = LocalColors.current.disabled, + disabledContainerColor = Color.Transparent + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt new file mode 100644 index 00000000000..bbad82d29e4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun CircularProgressIndicator(color: Color = LocalContentColor.current) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(40.dp), + color = color, + strokeWidth = 2.dp + ) +} + +@Composable +fun SmallCircularProgressIndicator(color: Color = LocalContentColor.current) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = color, + strokeWidth = 2.dp + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Html.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Html.kt new file mode 100644 index 00000000000..951db1816e3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Html.kt @@ -0,0 +1,180 @@ +package org.thoughtcrime.securesms.ui.components + +import android.content.res.Resources +import android.graphics.Typeface +import android.text.Spanned +import android.text.SpannedString +import android.text.style.* +import android.util.Log +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.core.text.HtmlCompat + +// TODO Remove this file once we update to composeVersion=1.7.0-alpha06 fixes https://issuetracker.google.com/issues/139320238?pli=1 +// which allows Stylized string in string resources +@Composable +@ReadOnlyComposable +private fun resources(): Resources { + LocalConfiguration.current + return LocalContext.current.resources +} + +fun Spanned.toHtmlWithoutParagraphs(): String { + return HtmlCompat.toHtml(this, HtmlCompat.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE) + .substringAfter("

").substringBeforeLast("

") +} + +fun Resources.getText(@StringRes id: Int, vararg args: Any): CharSequence { + val escapedArgs = args.map { + if (it is Spanned) it.toHtmlWithoutParagraphs() else it + }.toTypedArray() + val resource = SpannedString(getText(id)) + val htmlResource = resource.toHtmlWithoutParagraphs() + val formattedHtml = String.format(htmlResource, *escapedArgs) + return HtmlCompat.fromHtml(formattedHtml, HtmlCompat.FROM_HTML_MODE_LEGACY) +} + +@Composable +fun annotatedStringResource(@StringRes id: Int, vararg formatArgs: Any): AnnotatedString { + val resources = resources() + val density = LocalDensity.current + return remember(id, formatArgs) { + val text = resources.getText(id, *formatArgs) + spannableStringToAnnotatedString(text, density) + } +} + +@Composable +fun annotatedStringResource(@StringRes id: Int): AnnotatedString { + val resources = resources() + val density = LocalDensity.current + return remember(id) { + val text = resources.getText(id) + spannableStringToAnnotatedString(text, density) + } +} + +private fun spannableStringToAnnotatedString( + text: CharSequence, + density: Density +): AnnotatedString { + return if (text is Spanned) { + with(density) { + buildAnnotatedString { + append((text.toString())) + text.getSpans(0, text.length, Any::class.java).forEach { + val start = text.getSpanStart(it) + val end = text.getSpanEnd(it) + when (it) { + is StyleSpan -> when (it.style) { + Typeface.NORMAL -> addStyle( + SpanStyle( + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Normal + ), + start, + end + ) + Typeface.BOLD -> addStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Normal + ), + start, + end + ) + Typeface.ITALIC -> addStyle( + SpanStyle( + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Italic + ), + start, + end + ) + Typeface.BOLD_ITALIC -> addStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Italic + ), + start, + end + ) + } + is TypefaceSpan -> addStyle( + SpanStyle( + fontFamily = when (it.family) { + FontFamily.SansSerif.name -> FontFamily.SansSerif + FontFamily.Serif.name -> FontFamily.Serif + FontFamily.Monospace.name -> FontFamily.Monospace + FontFamily.Cursive.name -> FontFamily.Cursive + else -> FontFamily.Default + } + ), + start, + end + ) + is BulletSpan -> { + Log.d("StringResources", "BulletSpan not supported yet") + addStyle(SpanStyle(), start, end) + } + is AbsoluteSizeSpan -> addStyle( + SpanStyle(fontSize = if (it.dip) it.size.dp.toSp() else it.size.toSp()), + start, + end + ) + is RelativeSizeSpan -> addStyle( + SpanStyle(fontSize = it.sizeChange.em), + start, + end + ) + is StrikethroughSpan -> addStyle( + SpanStyle(textDecoration = TextDecoration.LineThrough), + start, + end + ) + is UnderlineSpan -> addStyle( + SpanStyle(textDecoration = TextDecoration.Underline), + start, + end + ) + is SuperscriptSpan -> addStyle( + SpanStyle(baselineShift = BaselineShift.Superscript), + start, + end + ) + is SubscriptSpan -> addStyle( + SpanStyle(baselineShift = BaselineShift.Subscript), + start, + end + ) + is ForegroundColorSpan -> addStyle( + SpanStyle(color = Color(it.foregroundColor)), + start, + end + ) + else -> addStyle(SpanStyle(), start, end) + } + } + } + } + } else { + AnnotatedString(text.toString()) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt new file mode 100644 index 00000000000..c58f7dc97ff --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt @@ -0,0 +1,242 @@ +package org.thoughtcrime.securesms.ui.components + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import java.util.concurrent.Executors + +private const val TAG = "NewMessageFragment" + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun MaybeScanQrCode( + errors: Flow, + onClickSettings: () -> Unit = LocalContext.current.run { { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + }.let(::startActivity) + } }, + onScan: (String) -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + ) { + LocalSoftwareKeyboardController.current?.hide() + + val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) + + if (cameraPermissionState.status.isGranted) { + ScanQrCode(errors, onScan) + } else if (cameraPermissionState.status.shouldShowRationale) { + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 60.dp) + ) { + Text( + stringResource(R.string.activity_link_camera_permission_permanently_denied_configure_in_settings), + style = LocalType.current.base, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.size(LocalDimensions.current.spacing)) + OutlineButton( + stringResource(R.string.sessionSettings), + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = onClickSettings + ) + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(LocalDimensions.current.xlargeSpacing), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(1f)) + Text(stringResource(R.string.fragment_scan_qr_code_camera_access_explanation), + style = LocalType.current.xl, textAlign = TextAlign.Center) + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + PrimaryOutlineButton( + stringResource(R.string.cameraGrantAccess), + modifier = Modifier.fillMaxWidth(), + onClick = { cameraPermissionState.run { launchPermissionRequest() } } + ) + Spacer(modifier = Modifier.weight(1f)) + } + } + } +} + +@Composable +fun ScanQrCode(errors: Flow, onScan: (String) -> Unit) { + val localContext = LocalContext.current + val cameraProvider = remember { ProcessCameraProvider.getInstance(localContext) } + + val preview = Preview.Builder().build() + val selector = CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_BACK) + .build() + + runCatching { + cameraProvider.get().unbindAll() + + val options = BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + val scanner = BarcodeScanning.getClient(options) + + cameraProvider.get().bindToLifecycle( + LocalLifecycleOwner.current, + selector, + preview, + buildAnalysisUseCase(scanner, onScan) + ) + }.onFailure { Log.e(TAG, "error binding camera", it) } + + DisposableEffect(cameraProvider) { + onDispose { + cameraProvider.get().unbindAll() + } + } + + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + errors.collect { error -> + snackbarHostState + .takeIf { it.currentSnackbarData == null } + ?.run { + scope.launch { + // showSnackbar() suspends until the Snackbar is dismissed. + // Launch in new scope so we drop new QR scan events, to prevent spamming + // snackbars to the user, or worse, queuing a chain of snackbars one after + // another to show and hide for the next minute or 2. + // Don't use debounce() because many QR scans can come through each second, + // and each scan could restart the timer which could mean no scan gets + // through until the user stops scanning; quite perplexing. + snackbarHostState.showSnackbar(message = error) + } + } + } + } + + Scaffold( + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.padding(LocalDimensions.current.smallSpacing) + ) { data -> + Snackbar( + snackbarData = data, + modifier = Modifier.padding(LocalDimensions.current.smallSpacing) + ) + } + } + ) { padding -> + Box(modifier = Modifier.padding(padding)) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { PreviewView(it).apply { preview.setSurfaceProvider(surfaceProvider) } } + ) + + Box( + Modifier + .aspectRatio(1f) + .padding(LocalDimensions.current.spacing) + .clip(shape = RoundedCornerShape(26.dp)) + .background(Color(0x33ffffff)) + .align(Alignment.Center) + ) + } + } +} + +@SuppressLint("UnsafeOptInUsageError") +private fun buildAnalysisUseCase( + scanner: BarcodeScanner, + onBarcodeScanned: (String) -> Unit +): ImageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build().apply { + setAnalyzer(Executors.newSingleThreadExecutor(), Analyzer(scanner, onBarcodeScanned)) + } + +class Analyzer( + private val scanner: BarcodeScanner, + private val onBarcodeScanned: (String) -> Unit +): ImageAnalysis.Analyzer { + @SuppressLint("UnsafeOptInUsageError") + override fun analyze(image: ImageProxy) { + InputImage.fromMediaImage( + image.image!!, + image.imageInfo.rotationDegrees + ).let(scanner::process).apply { + addOnSuccessListener { barcodes -> + barcodes.forEach { + it.rawValue?.let(onBarcodeScanned) + } + } + addOnCompleteListener { + image.close() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QrImage.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QrImage.kt new file mode 100644 index 00000000000..c5f927b4abd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QrImage.kt @@ -0,0 +1,108 @@ +package org.thoughtcrime.securesms.ui.components + +import android.graphics.Bitmap +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.util.QRCodeUtilities + +@Composable +fun QrImage( + string: String?, + modifier: Modifier = Modifier, + contentPadding: Dp = LocalDimensions.current.smallSpacing, + icon: Int = R.drawable.session_shield +) { + var bitmap: Bitmap? by remember { + mutableStateOf(null) + } + + val scope = rememberCoroutineScope() + LaunchedEffect(string) { + if (string != null) scope.launch(Dispatchers.IO) { + bitmap = (300..500 step 100).firstNotNullOf { + runCatching { QRCodeUtilities.encode(string, it) }.getOrNull() + } + } + } + + Box( + modifier = modifier.background(color = LocalColors.current.qrCodeBackground, + shape = MaterialTheme.shapes.small) + ) { Content(bitmap, icon, Modifier.padding(contentPadding), backgroundColor = LocalColors.current.qrCodeBackground) } +} + +@Composable +private fun Content( + bitmap: Bitmap?, + icon: Int, + modifier: Modifier = Modifier, + qrColor: Color = LocalColors.current.qrCodeContent, + backgroundColor: Color, +) { + Box( + modifier = modifier + .fillMaxWidth() + .aspectRatio(1f) + ) { + AnimatedVisibility( + visible = bitmap != null, + enter = fadeIn(), + ) { + bitmap?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "", + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + colorFilter = ColorFilter.tint(qrColor), + // Use FilterQuality.None to keep QR edges sharp + filterQuality = FilterQuality.None + ) + } + } + + Icon( + painter = painterResource(id = icon), + contentDescription = "", + tint = qrColor, + modifier = Modifier + .size(62.dp) + .align(Alignment.Center) + .background(color = backgroundColor) + .size(66.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt new file mode 100644 index 00000000000..cabe5367673 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt @@ -0,0 +1,198 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.transparentButtonColors +import kotlin.time.Duration.Companion.days + +@Composable +fun RadioButton( + onClick: () -> Unit = {}, + modifier: Modifier = Modifier, + selected: Boolean = false, + enabled: Boolean = true, + contentPadding: PaddingValues = PaddingValues(), + content: @Composable RowScope.() -> Unit = {} +) { + TextButton( + modifier = modifier + .fillMaxWidth() + .selectable( + selected = selected, + enabled = true, + role = Role.RadioButton, + onClick = onClick + ), + enabled = enabled, + colors = transparentButtonColors(), + onClick = onClick, + shape = RectangleShape, + contentPadding = contentPadding + ) { + content() + + Spacer(modifier = Modifier.width(20.dp)) + RadioButtonIndicator( + selected = selected && enabled, // disabled radio shouldn't be selected + modifier = Modifier + .size(22.dp) + .align(Alignment.CenterVertically) + ) + } +} + +@Composable +private fun RadioButtonIndicator( + selected: Boolean, + modifier: Modifier +) { + Box(modifier = modifier) { + AnimatedVisibility( + selected, + modifier = Modifier + .padding(2.5.dp) + .clip(CircleShape), + enter = scaleIn(), + exit = scaleOut() + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + color = LocalColors.current.primary, + shape = CircleShape + ) + ) + } + Box( + modifier = Modifier + .aspectRatio(1f) + .border( + width = LocalDimensions.current.borderStroke, + color = LocalContentColor.current, + shape = CircleShape + ) + ) {} + } +} + +@Composable +fun TitledRadioButton( + modifier: Modifier = Modifier, + option: RadioOption, + onClick: () -> Unit +) { + RadioButton( + modifier = modifier.heightIn(min = 60.dp) + .contentDescription(option.contentDescription), + onClick = onClick, + selected = option.selected, + enabled = option.enabled, + contentPadding = PaddingValues(horizontal = LocalDimensions.current.spacing), + content = { + Column( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically) + ) { + Column { + Text( + text = option.title(), + style = LocalType.current.large + ) + option.subtitle?.let { + Text( + text = it(), + style = LocalType.current.extraSmall + ) + } + } + } + } + ) +} + +@Preview +@Composable +fun PreviewTextRadioButton() { + PreviewTheme { + TitledRadioButton( + option = RadioOption( + value = ExpiryType.AFTER_SEND.mode(7.days), + title = GetString(7.days), + subtitle = GetString("This is a subtitle"), + enabled = true, + selected = true + ) + ) {} + } +} + +@Preview +@Composable +fun PreviewDisabledTextRadioButton() { + PreviewTheme { + TitledRadioButton( + option = RadioOption( + value = ExpiryType.AFTER_SEND.mode(7.days), + title = GetString(7.days), + subtitle = GetString("This is a subtitle"), + enabled = false, + selected = true + ) + ) {} + } +} + +@Preview +@Composable +fun PreviewDeselectedTextRadioButton() { + PreviewTheme { + TitledRadioButton( + option = RadioOption( + value = ExpiryType.AFTER_SEND.mode(7.days), + title = GetString(7.days), + subtitle = GetString("This is a subtitle"), + enabled = true, + selected = false + ) + ) {} + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionTabRow.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionTabRow.kt new file mode 100644 index 00000000000..5dae714380b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionTabRow.kt @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.TabRowDefaults +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors + +private val TITLES = listOf(R.string.sessionRecoveryPassword, R.string.qrScan) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SessionTabRow(pagerState: PagerState, titles: List) { + TabRow( + containerColor = Color.Unspecified, + selectedTabIndex = pagerState.currentPage, + contentColor = LocalColors.current.text, + indicator = { tabPositions -> + TabRowDefaults.SecondaryIndicator( + Modifier.tabIndicatorOffset(tabPositions[pagerState.currentPage]), + color = LocalColors.current.primary, + height = LocalDimensions.current.indicatorHeight + ) + }, + divider = { HorizontalDivider(color = LocalColors.current.borders) } + ) { + val animationScope = rememberCoroutineScope() + titles.forEachIndexed { i, it -> + Tab( + modifier = Modifier.heightIn(min = 48.dp), + selected = i == pagerState.currentPage, + onClick = { animationScope.launch { pagerState.animateScrollToPage(i) } }, + selectedContentColor = LocalColors.current.text, + unselectedContentColor = LocalColors.current.text, + ) { + Text( + text = stringResource(id = it), + style = LocalType.current.h8 + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@androidx.compose.ui.tooling.preview.Preview +@Composable +fun PreviewSessionTabRow( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + val pagerState = rememberPagerState { TITLES.size } + SessionTabRow(pagerState = pagerState, titles = TITLES) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt new file mode 100644 index 00000000000..5e09f78b65b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt @@ -0,0 +1,189 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.annotation.DrawableRes +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.borders +import org.thoughtcrime.securesms.ui.theme.text +import org.thoughtcrime.securesms.ui.theme.textSecondary +import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.bold + +@Preview +@Composable +fun PreviewSessionOutlinedTextField() { + PreviewTheme { + Column(modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp)) { + SessionOutlinedTextField( + text = "text", + placeholder = "", + ) + + SessionOutlinedTextField( + text = "", + placeholder = "placeholder" + ) + + SessionOutlinedTextField( + text = "text", + placeholder = "", + error = "error" + ) + + SessionOutlinedTextField( + text = "text onChange after error", + placeholder = "", + error = "error", + isTextErrorColor = false + ) + } + } +} + +@Composable +fun SessionOutlinedTextField( + text: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + onChange: (String) -> Unit = {}, + textStyle: TextStyle = LocalType.current.base, + placeholder: String = "", + onContinue: () -> Unit = {}, + error: String? = null, + isTextErrorColor: Boolean = error != null +) { + Column(modifier = modifier.animateContentSize()) { + Box( + modifier = Modifier.border( + width = LocalDimensions.current.borderStroke, + color = LocalColors.current.borders(error != null), + shape = MaterialTheme.shapes.small + ) + .fillMaxWidth() + .wrapContentHeight() + .padding(vertical = 28.dp) + .padding(horizontal = 21.dp) + ) { + if (text.isEmpty()) { + Text( + text = placeholder, + style = LocalType.current.base, + color = LocalColors.current.textSecondary(isTextErrorColor), + modifier = Modifier.wrapContentSize() + .align(Alignment.CenterStart) + .wrapContentSize() + ) + } + + BasicTextField( + value = text, + onValueChange = onChange, + modifier = Modifier.wrapContentHeight().fillMaxWidth().contentDescription(contentDescription), + textStyle = textStyle.copy(color = LocalColors.current.text(isTextErrorColor)), + cursorBrush = SolidColor(LocalColors.current.text(isTextErrorColor)), + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { onContinue() }, + onGo = { onContinue() }, + onSearch = { onContinue() }, + onSend = { onContinue() }, + ) + ) + } + error?.let { + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + Text( + it, + modifier = Modifier.fillMaxWidth() + .contentDescription(R.string.AccessibilityId_error_message), + textAlign = TextAlign.Center, + style = LocalType.current.base.bold(), + color = LocalColors.current.danger + ) + } + } +} + +@Composable +fun AnnotatedTextWithIcon( + text: String, + @DrawableRes iconRes: Int, + modifier: Modifier = Modifier, + style: TextStyle = LocalType.current.base, + color: Color = Color.Unspecified, + iconSize: TextUnit = 12.sp +) { + val myId = "inlineContent" + val annotated = buildAnnotatedString { + append(text) + appendInlineContent(myId, "[icon]") + } + + val inlineContent = mapOf( + Pair( + myId, + InlineTextContent( + Placeholder( + width = iconSize, + height = iconSize, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter + ) + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + modifier = Modifier.padding(1.dp), + tint = color + ) + } + ) + ) + + Text( + text = annotated, + modifier = modifier.fillMaxWidth(), + style = style, + color = color, + textAlign = TextAlign.Center, + inlineContent = inlineContent + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Colors.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Colors.kt new file mode 100644 index 00000000000..0b630ab233c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Colors.kt @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms.ui.theme + +import androidx.compose.ui.graphics.Color + +val classicDark0 = Color.Black +val classicDark1 = Color(0xff1B1B1B) +val classicDark2 = Color(0xff2D2D2D) +val classicDark3 = Color(0xff414141) +val classicDark4 = Color(0xff767676) +val classicDark5 = Color(0xffA1A2A1) +val classicDark6 = Color.White + +val classicLight0 = Color.Black +val classicLight1 = Color(0xff6D6D6D) +val classicLight2 = Color(0xffA1A2A1) +val classicLight3 = Color(0xffDFDFDF) +val classicLight4 = Color(0xffF0F0F0) +val classicLight5 = Color(0xffF9F9F9) +val classicLight6 = Color.White + +val oceanDark0 = Color.Black +val oceanDark1 = Color(0xff1A1C28) +val oceanDark2 = Color(0xff252735) +val oceanDark3 = Color(0xff2B2D40) +val oceanDark4 = Color(0xff3D4A5D) +val oceanDark5 = Color(0xffA6A9CE) +val oceanDark6 = Color(0xff5CAACC) +val oceanDark7 = Color.White + +val oceanLight0 = Color.Black +val oceanLight1 = Color(0xff19345D) +val oceanLight2 = Color(0xff6A6E90) +val oceanLight3 = Color(0xff5CAACC) +val oceanLight4 = Color(0xffB3EDF2) +val oceanLight5 = Color(0xffE7F3F4) +val oceanLight6 = Color(0xffECFAFB) +val oceanLight7 = Color(0xffFCFFFF) + +val primaryGreen = Color(0xFF31F196) +val primaryBlue = Color(0xFF57C9FA) +val primaryPurple = Color(0xFFC993FF) +val primaryPink = Color(0xFFFF95EF) +val primaryRed = Color(0xFFFF9C8E) +val primaryOrange = Color(0xFFFCB159) +val primaryYellow = Color(0xFFFAD657) + +val dangerDark = Color(0xFFFF3A3A) +val dangerLight = Color(0xFFE12D19) +val disabledDark = Color(0xFFA1A2A1) +val disabledLight = Color(0xFF6D6D6D) + +val blackAlpha40 = Color.Black.copy(alpha = 0.4f) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt new file mode 100644 index 00000000000..66e5b74e8bc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.ui.theme + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +val LocalDimensions = staticCompositionLocalOf { Dimensions() } + +data class Dimensions( + val xxxsSpacing: Dp = 4.dp, + val xxsSpacing: Dp = 8.dp, + val xsSpacing: Dp = 12.dp, + val smallSpacing: Dp = 16.dp, + val spacing: Dp = 24.dp, + val mediumSpacing: Dp = 36.dp, + val xlargeSpacing: Dp = 64.dp, + + val dividerIndent: Dp = 60.dp, + val appBarHeight: Dp = 64.dp, + val minLargeItemButtonHeight: Dp = 60.dp, + + val indicatorHeight: Dp = 4.dp, + val borderStroke: Dp = 1.dp +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/SessionTypography.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/SessionTypography.kt new file mode 100644 index 00000000000..602affa6afc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/SessionTypography.kt @@ -0,0 +1,145 @@ +package org.thoughtcrime.securesms.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + + +fun TextStyle.bold() = TextStyle.Default.copy( + fontWeight = FontWeight.Bold +) + +fun TextStyle.monospace() = TextStyle.Default.copy( + fontFamily = FontFamily.Monospace +) + +val sessionTypography = SessionTypography() + +data class SessionTypography( + // Body + val xl: TextStyle = TextStyle( + fontSize = 18.sp, + lineHeight = 21.6.sp, + fontWeight = FontWeight.Normal + ), + + val large: TextStyle = TextStyle( + fontSize = 16.sp, + lineHeight = 19.2.sp, + fontWeight = FontWeight.Normal + ), + + val base: TextStyle = TextStyle( + fontSize = 14.sp, + lineHeight = 16.8.sp, + fontWeight = FontWeight.Normal + ), + + val small: TextStyle = TextStyle( + fontSize = 12.sp, + lineHeight = 14.4.sp, + fontWeight = FontWeight.Normal + ), + + val extraSmall: TextStyle = TextStyle( + fontSize = 11.sp, + lineHeight = 13.2.sp, + fontWeight = FontWeight.Normal + ), + + val fine: TextStyle = TextStyle( + fontSize = 9.sp, + lineHeight = 10.8.sp, + fontWeight = FontWeight.Normal + ), + + // Headings + val h1: TextStyle = TextStyle( + fontSize = 36.sp, + lineHeight = 43.2.sp, + fontWeight = FontWeight.Bold + ), + + val h2: TextStyle = TextStyle( + fontSize = 32.sp, + lineHeight = 38.4.sp, + fontWeight = FontWeight.Bold + ), + + val h3: TextStyle = TextStyle( + fontSize = 29.sp, + lineHeight = 34.8.sp, + fontWeight = FontWeight.Bold + ), + + val h4: TextStyle = TextStyle( + fontSize = 26.sp, + lineHeight = 31.2.sp, + fontWeight = FontWeight.Bold + ), + + val h5: TextStyle = TextStyle( + fontSize = 23.sp, + lineHeight = 27.6.sp, + fontWeight = FontWeight.Bold + ), + + val h6: TextStyle = TextStyle( + fontSize = 20.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Bold + ), + + val h7: TextStyle = TextStyle( + fontSize = 18.sp, + lineHeight = 21.6.sp, + fontWeight = FontWeight.Bold + ), + + val h8: TextStyle = TextStyle( + fontSize = 16.sp, + lineHeight = 19.2.sp, + fontWeight = FontWeight.Bold + ), + + val h9: TextStyle = TextStyle( + fontSize = 14.sp, + lineHeight = 16.8.sp, + fontWeight = FontWeight.Bold + ) +) { + + // An opinionated override of Material's defaults + @Composable + fun asMaterialTypography() = Typography( + // Display + displayLarge = h1, + displayMedium = h1, + displaySmall = h1, + + // Headline + headlineLarge = h2, + headlineMedium = h3, + headlineSmall = h4, + + // Title + titleLarge = h5, + titleMedium = h6, + titleSmall = h7, + + // Body + bodyLarge = large, + bodyMedium = base, + bodySmall = small, + + // Label + labelLarge = extraSmall, + labelMedium = fine, + labelSmall = fine + ) +} + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt new file mode 100644 index 00000000000..252497f0230 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt @@ -0,0 +1,205 @@ +package org.thoughtcrime.securesms.ui.theme + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.selection.TextSelectionColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter + +interface ThemeColors { + // properties to override for each theme + val isLight: Boolean + val primary: Color + val danger: Color + val disabled: Color + val background: Color + val backgroundSecondary: Color + val text: Color + val textSecondary: Color + val borders: Color + val textBubbleSent: Color + val backgroundBubbleReceived: Color + val textBubbleReceived: Color + val qrCodeContent: Color + val qrCodeBackground: Color + val primaryButtonFill: Color + val primaryButtonFillText: Color +} + +// extra functions and properties that work for all themes +val ThemeColors.textSelectionColors + get() = TextSelectionColors( + handleColor = primary, + backgroundColor = primary.copy(alpha = 0.5f) + ) + +fun ThemeColors.text(isError: Boolean): Color = if (isError) danger else text +fun ThemeColors.textSecondary(isError: Boolean): Color = if (isError) danger else textSecondary +fun ThemeColors.borders(isError: Boolean): Color = if (isError) danger else borders + +fun ThemeColors.toMaterialColors() = if (isLight) { + lightColorScheme( + primary = background, + secondary = backgroundSecondary, + tertiary = backgroundSecondary, + onPrimary = text, + onSecondary = text, + onTertiary = text, + background = background, + surface = background, + surfaceVariant = background, + onBackground = text, + onSurface = text, + scrim = blackAlpha40, + outline = text, + outlineVariant = text + ) +} else { + darkColorScheme( + primary = background, + secondary = backgroundSecondary, + tertiary = backgroundSecondary, + onPrimary = text, + onSecondary = text, + onTertiary = text, + background = background, + surface = background, + surfaceVariant = background, + onBackground = text, + onSurface = text, + scrim = blackAlpha40, + outline = text, + outlineVariant = text + ) +} + + +@Composable +fun transparentButtonColors() = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + disabledContentColor = LocalColors.current.disabled +) + +@Composable +fun dangerButtonColors() = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = LocalColors.current.danger +) + + +// Our themes +data class ClassicDark(override val primary: Color = primaryGreen) : ThemeColors { + override val isLight = false + override val danger = dangerDark + override val disabled = disabledDark + override val background = classicDark0 + override val backgroundSecondary = classicDark1 + override val text = classicDark6 + override val textSecondary = classicDark5 + override val borders = classicDark3 + override val textBubbleSent = Color.Black + override val backgroundBubbleReceived = classicDark2 + override val textBubbleReceived = Color.White + override val qrCodeContent = background + override val qrCodeBackground = text + override val primaryButtonFill = primary + override val primaryButtonFillText = Color.Black +} + +data class ClassicLight(override val primary: Color = primaryGreen) : ThemeColors { + override val isLight = true + override val danger = dangerLight + override val disabled = disabledLight + override val background = classicLight6 + override val backgroundSecondary = classicLight5 + override val text = classicLight0 + override val textSecondary = classicLight1 + override val borders = classicLight3 + override val textBubbleSent = text + override val backgroundBubbleReceived = classicLight4 + override val textBubbleReceived = classicLight4 + override val qrCodeContent = text + override val qrCodeBackground = backgroundSecondary + override val primaryButtonFill = text + override val primaryButtonFillText = Color.White +} + +data class OceanDark(override val primary: Color = primaryBlue) : ThemeColors { + override val isLight = false + override val danger = dangerDark + override val disabled = disabledDark + override val background = oceanDark2 + override val backgroundSecondary = oceanDark1 + override val text = oceanDark7 + override val textSecondary = oceanDark5 + override val borders = oceanDark4 + override val textBubbleSent = Color.Black + override val backgroundBubbleReceived = oceanDark4 + override val textBubbleReceived = oceanDark4 + override val qrCodeContent = background + override val qrCodeBackground = text + override val primaryButtonFill = primary + override val primaryButtonFillText = Color.Black +} + +data class OceanLight(override val primary: Color = primaryBlue) : ThemeColors { + override val isLight = true + override val danger = dangerLight + override val disabled = disabledLight + override val background = oceanLight7 + override val backgroundSecondary = oceanLight6 + override val text = oceanLight1 + override val textSecondary = oceanLight2 + override val borders = oceanLight3 + override val textBubbleSent = text + override val backgroundBubbleReceived = oceanLight4 + override val textBubbleReceived = oceanLight1 + override val qrCodeContent = text + override val qrCodeBackground = backgroundSecondary + override val primaryButtonFill = text + override val primaryButtonFillText = Color.White +} + +@Preview +@Composable +fun PreviewThemeColors( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { ThemeColors() } +} + +@Composable +private fun ThemeColors() { + Column { + Box(Modifier.background(LocalColors.current.primary)) { + Text("primary", style = LocalType.current.base) + } + Box(Modifier.background(LocalColors.current.background)) { + Text("background", style = LocalType.current.base) + } + Box(Modifier.background(LocalColors.current.backgroundSecondary)) { + Text("backgroundSecondary", style = LocalType.current.base) + } + Box(Modifier.background(LocalColors.current.text)) { + Text("text", style = LocalType.current.base) + } + Box(Modifier.background(LocalColors.current.textSecondary)) { + Text("textSecondary", style = LocalType.current.base) + } + Box(Modifier.background(LocalColors.current.danger)) { + Text("danger", style = LocalType.current.base) + } + Box(Modifier.background(LocalColors.current.borders)) { + Text("border", style = LocalType.current.base) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColorsProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColorsProvider.kt new file mode 100644 index 00000000000..0713254afc4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColorsProvider.kt @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable + +fun interface ThemeColorsProvider { + @Composable + fun get(): ThemeColors +} + +@Suppress("FunctionName") +fun FollowSystemThemeColorsProvider(light: ThemeColors, dark: ThemeColors) = ThemeColorsProvider { + when { + isSystemInDarkTheme() -> dark + else -> light + } +} + +@Suppress("FunctionName") +fun ThemeColorsProvider(colors: ThemeColors) = ThemeColorsProvider { colors } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeFromPreferences.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeFromPreferences.kt new file mode 100644 index 00000000000..403235ef79a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeFromPreferences.kt @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.ui.theme + +import androidx.compose.ui.graphics.Color +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences.Companion.BLUE_ACCENT +import org.session.libsession.utilities.TextSecurePreferences.Companion.ORANGE_ACCENT +import org.session.libsession.utilities.TextSecurePreferences.Companion.PINK_ACCENT +import org.session.libsession.utilities.TextSecurePreferences.Companion.PURPLE_ACCENT +import org.session.libsession.utilities.TextSecurePreferences.Companion.RED_ACCENT +import org.session.libsession.utilities.TextSecurePreferences.Companion.YELLOW_ACCENT + + +/** + * Returns the compose theme based on saved preferences + * Some behaviour is hardcoded to cater for legacy usage of people with themes already set + * But future themes will be picked and set directly from the "Appearance" screen + */ +fun TextSecurePreferences.getColorsProvider(): ThemeColorsProvider { + val selectedTheme = getThemeStyle() + + // get the chosen primary color from the preferences + val selectedPrimary = primaryColor() + + val isOcean = "ocean" in selectedTheme + + val createLight = if (isOcean) ::OceanLight else ::ClassicLight + val createDark = if (isOcean) ::OceanDark else ::ClassicDark + + return when { + getFollowSystemSettings() -> FollowSystemThemeColorsProvider( + light = createLight(selectedPrimary), + dark = createDark(selectedPrimary) + ) + "light" in selectedTheme -> ThemeColorsProvider(createLight(selectedPrimary)) + else -> ThemeColorsProvider(createDark(selectedPrimary)) + } +} + +fun TextSecurePreferences.primaryColor(): Color = when(getSelectedAccentColor()) { + BLUE_ACCENT -> primaryBlue + PURPLE_ACCENT -> primaryPurple + PINK_ACCENT -> primaryPink + RED_ACCENT -> primaryRed + ORANGE_ACCENT -> primaryOrange + YELLOW_ACCENT -> primaryYellow + else -> primaryGreen +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt new file mode 100644 index 00000000000..2f4957565b8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt @@ -0,0 +1,97 @@ +package org.thoughtcrime.securesms.ui.theme + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import org.session.libsession.utilities.AppTextSecurePreferences + +// Globally accessible composition local objects +val LocalColors = compositionLocalOf { ClassicDark() } +val LocalType = compositionLocalOf { sessionTypography } + +var cachedColorsProvider: ThemeColorsProvider? = null + +fun invalidateComposeThemeColors() { + // invalidate compose theme colors + cachedColorsProvider = null +} + +/** + * Apply a Material2 compose theme based on user selections in SharedPreferences. + */ +@Composable +fun SessionMaterialTheme( + content: @Composable () -> Unit +) { + val context = LocalContext.current + val preferences = AppTextSecurePreferences(context) + + val cachedColors = cachedColorsProvider ?: preferences.getColorsProvider().also { cachedColorsProvider = it } + + SessionMaterialTheme( + colors = cachedColors.get(), + content = content + ) +} + +/** + * Apply a given [ThemeColors], and our typography and shapes as a Material 2 Compose Theme. + **/ +@Composable +fun SessionMaterialTheme( + colors: ThemeColors, + content: @Composable () -> Unit +) { + MaterialTheme( + colorScheme = colors.toMaterialColors(), + typography = sessionTypography.asMaterialTypography(), + shapes = sessionShapes, + ) { + CompositionLocalProvider( + LocalColors provides colors, + LocalType provides sessionTypography, + LocalContentColor provides colors.text, + LocalTextSelectionColors provides colors.textSelectionColors, + content = content + ) + } +} + +val pillShape = RoundedCornerShape(percent = 50) +val buttonShape = pillShape + +val sessionShapes = Shapes( + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(16.dp) +) + +/** + * Set the Material 2 theme and a background for Compose Previews. + */ +@Composable +fun PreviewTheme( + colors: ThemeColors = LocalColors.current, + content: @Composable () -> Unit +) { + SessionMaterialTheme(colors) { + Box(modifier = Modifier.background(color = LocalColors.current.background)) { + content() + } + } +} + +// used for previews +class SessionColorsParameterProvider : PreviewParameterProvider { + override val values = sequenceOf(ClassicDark(), ClassicLight(), OceanDark(), OceanLight()) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ActivityUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ActivityUtilities.kt index 5ff823a15c1..c3b7eaca96f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ActivityUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ActivityUtilities.kt @@ -1,8 +1,10 @@ package org.thoughtcrime.securesms.util import android.annotation.SuppressLint +import android.app.Activity import android.content.Context import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP import android.view.View import androidx.annotation.StyleRes import androidx.appcompat.app.AppCompatActivity @@ -10,6 +12,10 @@ import androidx.appcompat.widget.Toolbar import androidx.fragment.app.DialogFragment import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences.Companion.CLASSIC_DARK +import org.session.libsession.utilities.TextSecurePreferences.Companion.CLASSIC_LIGHT +import org.session.libsession.utilities.TextSecurePreferences.Companion.OCEAN_DARK +import org.session.libsession.utilities.TextSecurePreferences.Companion.OCEAN_LIGHT import org.thoughtcrime.securesms.BaseActionBarActivity fun BaseActionBarActivity.setUpActionBarSessionLogo(hideBackButton: Boolean = false) { @@ -82,20 +88,25 @@ fun TextSecurePreferences.themeState(): ThemeState { @StyleRes fun String.getThemeStyle(): Int = when (this) { - TextSecurePreferences.CLASSIC_DARK -> R.style.Classic_Dark - TextSecurePreferences.CLASSIC_LIGHT -> R.style.Classic_Light - TextSecurePreferences.OCEAN_DARK -> R.style.Ocean_Dark - TextSecurePreferences.OCEAN_LIGHT -> R.style.Ocean_Light + CLASSIC_DARK -> R.style.Classic_Dark + CLASSIC_LIGHT -> R.style.Classic_Light + OCEAN_DARK -> R.style.Ocean_Dark + OCEAN_LIGHT -> R.style.Ocean_Light else -> throw NullPointerException("The style [$this] is not supported") } @StyleRes -fun Int.getDefaultAccentColor(): Int = - if (this == R.style.Ocean_Dark || this == R.style.Ocean_Light) R.style.PrimaryBlue - else R.style.PrimaryGreen +fun Int.getDefaultAccentColor(): Int = when (this) { + R.style.Ocean_Dark, R.style.Ocean_Light -> R.style.PrimaryBlue + else -> R.style.PrimaryGreen +} data class ThemeState ( @StyleRes val theme: Int, @StyleRes val accentStyle: Int, val followSystem: Boolean -) \ No newline at end of file +) + +inline fun Activity.show() = Intent(this, T::class.java).also(::startActivity).let { overridePendingTransition(R.anim.slide_from_bottom, R.anim.fade_scale_out) } +inline fun Activity.push(modify: Intent.() -> Unit = {}) = Intent(this, T::class.java).also(modify).also(::startActivity).let { overridePendingTransition(R.anim.slide_from_right, R.anim.fade_scale_out) } +inline fun Context.start(modify: Intent.() -> Unit = {}) = Intent(this, T::class.java).also(modify).apply { addFlags(FLAG_ACTIVITY_SINGLE_TOP) }.let(::startActivity) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt index e0f04924456..e59d3aae178 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt @@ -24,6 +24,7 @@ import org.session.libsession.utilities.WindowDebouncer import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.ThreadDatabase @@ -31,10 +32,12 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent import java.util.Timer object ConfigurationMessageUtilities { + private const val TAG = "ConfigMessageUtils" private val debouncer = WindowDebouncer(3000, Timer()) private fun scheduleConfigSync(userPublicKey: String) { + debouncer.publish { // don't schedule job if we already have one val storage = MessagingModuleConfiguration.shared.storage @@ -44,23 +47,20 @@ object ConfigurationMessageUtilities { (currentStorageJob as ConfigurationSyncJob).shouldRunAgain.set(true) return@publish } - val newConfigSync = ConfigurationSyncJob(ourDestination) - JobQueue.shared.add(newConfigSync) + val newConfigSyncJob = ConfigurationSyncJob(ourDestination) + JobQueue.shared.add(newConfigSyncJob) } } @JvmStatic fun syncConfigurationIfNeeded(context: Context) { - // add if check here to schedule new config job process and return early - val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return + val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Log.w(TAG, "User Public Key is null") scheduleConfigSync(userPublicKey) } fun forceSyncConfigurationNowIfNeeded(context: Context): Promise { - // add if check here to schedule new config job process and return early val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Promise.ofFail(NullPointerException("User Public Key is null")) - // schedule job if none exist - // don't schedule job if we already have one + // Schedule a new job if one doesn't already exist (only) scheduleConfigSync(userPublicKey) return Promise.ofSuccess(Unit) } @@ -95,10 +95,10 @@ object ConfigurationMessageUtilities { val storage = MessagingModuleConfiguration.shared.storage val localUserKey = storage.getUserPublicKey() ?: return null val contactsWithSettings = storage.getAllContacts().filter { recipient -> - recipient.sessionID != localUserKey && recipient.sessionID.startsWith(IdPrefix.STANDARD.value) - && storage.getThreadId(recipient.sessionID) != null + recipient.accountID != localUserKey && recipient.accountID.startsWith(IdPrefix.STANDARD.value) + && storage.getThreadId(recipient.accountID) != null }.map { contact -> - val address = Address.fromSerialized(contact.sessionID) + val address = Address.fromSerialized(contact.accountID) val thread = storage.getThreadId(address) val isPinned = if (thread != null) { storage.isPinned(thread) @@ -117,7 +117,7 @@ object ConfigurationMessageUtilities { } val contactInfo = Contact( - id = contact.sessionID, + id = contact.accountID, name = contact.name.orEmpty(), nickname = contact.nickname.orEmpty(), blocked = settings.isBlocked, @@ -205,7 +205,7 @@ object ConfigurationMessageUtilities { val admins = group.admins.map { it.serialize() to true }.toMap() val members = group.members.filterNot { it.serialize() !in admins.keys }.map { it.serialize() to false }.toMap() GroupInfo.LegacyGroupInfo( - sessionId = groupPublicKey, + accountId = groupPublicKey, name = group.title, members = admins + members, priority = if (isPinned) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt b/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt index c7d53c1fef4..a0c0da24fe6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt @@ -118,7 +118,7 @@ class PNModeView : LinearLayout, GlowView { // endregion } -class NewConversationButtonImageView : androidx.appcompat.widget.AppCompatImageView, GlowView { +class StartConversationButtonImageView : androidx.appcompat.widget.AppCompatImageView, GlowView { @ColorInt override var mainColor: Int = 0 set(newValue) { field = newValue; paint.color = newValue } @ColorInt override var sessionShadowColor: Int = 0 diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt b/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt index bc76b80f2c0..004dec8a5ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt @@ -99,7 +99,7 @@ class IP2Country private constructor(private val context: Context) { val bestMatchCountry = comps.lastOrNull { it.key <= Ipv4Int(ip) }?.let { (_, code) -> if (code != null) { - countryToNames[code] + " [" + ip + "]" + countryToNames[code] } else { null } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java index 4ee43e1e9ce..f193827efda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java @@ -22,7 +22,7 @@ public static ParcelFileDescriptor getParcelFileDescriptor(MemoryFile file) thro int fd = field.getInt(fileDescriptor); - return ParcelFileDescriptor.adoptFd(fd); + return ParcelFileDescriptor.fromFd(fd); } catch (IllegalAccessException e) { throw new IOException(e); } catch (InvocationTargetException e) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt b/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt index 4d00da8f962..a7eb864893e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt @@ -55,7 +55,7 @@ object MockDataGenerator { val stringContent: List = "abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789 ".map { it.toString() } val wordContent: List = listOf("alias", "consequatur", "aut", "perferendis", "sit", "voluptatem", "accusantium", "doloremque", "aperiam", "eaque", "ipsa", "quae", "ab", "illo", "inventore", "veritatis", "et", "quasi", "architecto", "beatae", "vitae", "dicta", "sunt", "explicabo", "aspernatur", "aut", "odit", "aut", "fugit", "sed", "quia", "consequuntur", "magni", "dolores", "eos", "qui", "ratione", "voluptatem", "sequi", "nesciunt", "neque", "dolorem", "ipsum", "quia", "dolor", "sit", "amet", "consectetur", "adipisci", "velit", "sed", "quia", "non", "numquam", "eius", "modi", "tempora", "incidunt", "ut", "labore", "et", "dolore", "magnam", "aliquam", "quaerat", "voluptatem", "ut", "enim", "ad", "minima", "veniam", "quis", "nostrum", "exercitationem", "ullam", "corporis", "nemo", "enim", "ipsam", "voluptatem", "quia", "voluptas", "sit", "suscipit", "laboriosam", "nisi", "ut", "aliquid", "ex", "ea", "commodi", "consequatur", "quis", "autem", "vel", "eum", "iure", "reprehenderit", "qui", "in", "ea", "voluptate", "velit", "esse", "quam", "nihil", "molestiae", "et", "iusto", "odio", "dignissimos", "ducimus", "qui", "blanditiis", "praesentium", "laudantium", "totam", "rem", "voluptatum", "deleniti", "atque", "corrupti", "quos", "dolores", "et", "quas", "molestias", "excepturi", "sint", "occaecati", "cupiditate", "non", "provident", "sed", "ut", "perspiciatis", "unde", "omnis", "iste", "natus", "error", "similique", "sunt", "in", "culpa", "qui", "officia", "deserunt", "mollitia", "animi", "id", "est", "laborum", "et", "dolorum", "fuga", "et", "harum", "quidem", "rerum", "facilis", "est", "et", "expedita", "distinctio", "nam", "libero", "tempore", "cum", "soluta", "nobis", "est", "eligendi", "optio", "cumque", "nihil", "impedit", "quo", "porro", "quisquam", "est", "qui", "minus", "id", "quod", "maxime", "placeat", "facere", "possimus", "omnis", "voluptas", "assumenda", "est", "omnis", "dolor", "repellendus", "temporibus", "autem", "quibusdam", "et", "aut", "consequatur", "vel", "illum", "qui", "dolorem", "eum", "fugiat", "quo", "voluptas", "nulla", "pariatur", "at", "vero", "eos", "et", "accusamus", "officiis", "debitis", "aut", "rerum", "necessitatibus", "saepe", "eveniet", "ut", "et", "voluptates", "repudiandae", "sint", "et", "molestiae", "non", "recusandae", "itaque", "earum", "rerum", "hic", "tenetur", "a", "sapiente", "delectus", "ut", "aut", "reiciendis", "voluptatibus", "maiores", "doloribus", "asperiores", "repellat") val timestampNow: Long = System.currentTimeMillis() - val userSessionId: String = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!! + val userAccountId: String = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!! val logProgress: ((String, String) -> Unit) = logProgress@{ title, event -> if (!printProgress) { return@logProgress } @@ -84,7 +84,7 @@ object MockDataGenerator { logProgress("DM Thread $threadIndex", "Start") val dataBytes = (0 until 16).map { dmThreadRandomGenerator.nextInt(UByte.MAX_VALUE.toInt()).toByte() } - val randomSessionId: String = KeyPairUtilities.generate(dataBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey + val randomAccountId: String = KeyPairUtilities.generate(dataBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey val isMessageRequest: Boolean = dmThreadRandomGenerator.nextBoolean() val contactNameLength: Int = (5 + dmThreadRandomGenerator.nextInt(15)) @@ -94,8 +94,8 @@ object MockDataGenerator { ) // Generate the thread - val recipient = Recipient.from(context, Address.fromSerialized(randomSessionId), false) - val contact = Contact(randomSessionId) + val recipient = Recipient.from(context, Address.fromSerialized(randomAccountId), false) + val contact = Contact(randomAccountId) val threadId = threadDb.getOrCreateThreadIdFor(recipient) // Generate the contact @@ -194,16 +194,16 @@ object MockDataGenerator { ) // Generate the Contacts in the group - val members: MutableList = mutableListOf(userSessionId) + val members: MutableList = mutableListOf(userAccountId) logProgress("Closed Group Thread $threadIndex", "Generate $numGroupMembers Contacts") (0 until numGroupMembers).forEach { val contactBytes = (0 until 16).map { cgThreadRandomGenerator.nextInt(UByte.MAX_VALUE.toInt()).toByte() } - val randomSessionId: String = KeyPairUtilities.generate(contactBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey + val randomAccountId: String = KeyPairUtilities.generate(contactBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey val contactNameLength: Int = (5 + cgThreadRandomGenerator.nextInt(15)) - val recipient = Recipient.from(context, Address.fromSerialized(randomSessionId), false) - val contact = Contact(randomSessionId) + val recipient = Recipient.from(context, Address.fromSerialized(randomAccountId), false) + val contact = Contact(randomAccountId) contactDb.setContact(contact) recipientDb.setApproved(recipient, true) recipientDb.setApprovedMe(recipient, true) @@ -213,7 +213,7 @@ object MockDataGenerator { .joinToString() recipientDb.setProfileName(recipient, contact.name) contactDb.setContact(contact) - members.add(randomSessionId) + members.add(randomAccountId) } val groupId = GroupUtil.doubleEncodeGroupID(randomGroupPublicKey) @@ -237,7 +237,7 @@ object MockDataGenerator { storage.createInitialConfigGroup(randomGroupPublicKey, groupName, GroupUtil.createConfigMemberMap(members, setOf(adminUserId)), System.currentTimeMillis(), encryptionKeyPair, 0) // Add the group created message - if (userSessionId == adminUserId) { + if (userAccountId == adminUserId) { storage.insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, groupName, members, listOf(adminUserId), threadId, (timestampNow - (numMessages * 5000))) } else { storage.insertIncomingInfoMessage(context, adminUserId, groupId, SignalServiceGroup.Type.CREATION, groupName, members, listOf(adminUserId), (timestampNow - (numMessages * 5000))) @@ -250,7 +250,7 @@ object MockDataGenerator { val messageWords: Int = (1 + cgThreadRandomGenerator.nextInt(19)) val senderId: String = members.random(cgThreadRandomGenerator.asKotlinRandom()) - if (senderId != userSessionId) { + if (senderId != userAccountId) { smsDb.insertMessageInbox( IncomingTextMessage( Address.fromSerialized(senderId), @@ -331,16 +331,16 @@ object MockDataGenerator { ) // Generate the Contacts in the group - val members: MutableList = mutableListOf(userSessionId) + val members: MutableList = mutableListOf(userAccountId) logProgress("Open Group Thread $threadIndex", "Generate $numGroupMembers Contacts") (0 until numGroupMembers).forEach { val contactBytes = (0 until 16).map { ogThreadRandomGenerator.nextInt(UByte.MAX_VALUE.toInt()).toByte() } - val randomSessionId: String = KeyPairUtilities.generate(contactBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey + val randomAccountId: String = KeyPairUtilities.generate(contactBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey val contactNameLength: Int = (5 + ogThreadRandomGenerator.nextInt(15)) - val recipient = Recipient.from(context, Address.fromSerialized(randomSessionId), false) - val contact = Contact(randomSessionId) + val recipient = Recipient.from(context, Address.fromSerialized(randomAccountId), false) + val contact = Contact(randomAccountId) contactDb.setContact(contact) recipientDb.setApproved(recipient, true) recipientDb.setApprovedMe(recipient, true) @@ -350,7 +350,7 @@ object MockDataGenerator { .joinToString() recipientDb.setProfileName(recipient, contact.name) contactDb.setContact(contact) - members.add(randomSessionId) + members.add(randomAccountId) } // Create the open group model and the thread @@ -377,7 +377,7 @@ object MockDataGenerator { val messageWords: Int = (1 + ogThreadRandomGenerator.nextInt(19)) val senderId: String = members.random(ogThreadRandomGenerator.asKotlinRandom()) - if (senderId != userSessionId) { + if (senderId != userAccountId) { smsDb.insertMessageInbox( IncomingTextMessage( Address.fromSerialized(senderId), diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/NetworkUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/NetworkUtils.kt new file mode 100644 index 00000000000..b4756192b19 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/NetworkUtils.kt @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms.util + +import android.content.Context +import android.content.Context.CONNECTIVITY_SERVICE +import android.net.ConnectivityManager +import android.net.NetworkCapabilities + +class NetworkUtils { + + companion object { + + // Method to determine if we have a valid Internet connection or not + fun haveValidNetworkConnection(context: Context) : Boolean { + val cm = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager + + // Early exit if we have no active network.. + if (cm.activeNetwork == null) return false + + // ..otherwise determine what capabilities are available to the active network. + val networkCapabilities = cm.getNetworkCapabilities(cm.activeNetwork) + val internetConnectionValid = cm.activeNetwork != null && + networkCapabilities != null && + networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + + return internetConnectionValid + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/QRCodeUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/QRCodeUtilities.kt index f7d1e3e8adb..80eccae41aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/QRCodeUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/QRCodeUtilities.kt @@ -4,28 +4,35 @@ import android.graphics.Bitmap import android.graphics.Color import com.google.zxing.BarcodeFormat import com.google.zxing.EncodeHintType -import com.google.zxing.WriterException import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel object QRCodeUtilities { - fun encode(data: String, size: Int, isInverted: Boolean = false, hasTransparentBackground: Boolean = true): Bitmap { - try { - val hints = hashMapOf( EncodeHintType.MARGIN to 1 ) - val result = QRCodeWriter().encode(data, BarcodeFormat.QR_CODE, size, size, hints) - val bitmap = Bitmap.createBitmap(result.width, result.height, Bitmap.Config.ARGB_8888) + fun encode( + data: String, + size: Int, + isInverted: Boolean = false, + hasTransparentBackground: Boolean = true, + dark: Int = Color.BLACK, + light: Int = Color.WHITE, + ): Bitmap? = runCatching { + val hints = hashMapOf( + EncodeHintType.MARGIN to 0, + EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M + ) + val color = if (isInverted) light else dark + val background = if (isInverted) dark else light + val result = QRCodeWriter().encode(data, BarcodeFormat.QR_CODE, size, size, hints) + Bitmap.createBitmap(result.width, result.height, Bitmap.Config.ARGB_8888).apply { for (y in 0 until result.height) { for (x in 0 until result.width) { - if (result.get(x, y)) { - bitmap.setPixel(x, y, if (isInverted) Color.WHITE else Color.BLACK) - } else if (!hasTransparentBackground) { - bitmap.setPixel(x, y, if (isInverted) Color.BLACK else Color.WHITE) + when { + result.get(x, y) -> setPixel(x, y, color) + !hasTransparentBackground -> setPixel(x, y, background) } } } - return bitmap - } catch (e: WriterException) { - return Bitmap.createBitmap(512, 512, Bitmap.Config.ARGB_8888) } - } -} \ No newline at end of file + }.getOrNull() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/VersionDataFetcher.kt b/app/src/main/java/org/thoughtcrime/securesms/util/VersionDataFetcher.kt new file mode 100644 index 00000000000..aba814524c8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/VersionDataFetcher.kt @@ -0,0 +1,60 @@ +package org.thoughtcrime.securesms.util + +import android.os.Handler +import android.os.Looper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.session.libsession.messaging.file_server.FileServerApi +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.hours + +private val TAG: String = VersionDataFetcher::class.java.simpleName +private val REFRESH_TIME_MS = 4.hours.inWholeMilliseconds + +@Singleton +class VersionDataFetcher @Inject constructor( + private val prefs: TextSecurePreferences +) { + private val handler = Handler(Looper.getMainLooper()) + private val fetchVersionData = Runnable { + scope.launch { + try { + // Perform the version check + val clientVersion = FileServerApi.getClientVersion() + Log.i(TAG, "Fetched version data: $clientVersion") + prefs.setLastVersionCheck() + startTimedVersionCheck() + } catch (e: Exception) { + // We can silently ignore the error + Log.e(TAG, "Error fetching version data", e) + // Schedule the next check for 4 hours from now, but do not setLastVersionCheck + // so the app will retry when the app is next foregrounded. + startTimedVersionCheck(REFRESH_TIME_MS) + } + } + } + + private val scope = CoroutineScope(Dispatchers.Default) + + /** + * Schedules fetching version data. + * + * @param delayMillis The delay before fetching version data. Default value is 4 hours from the + * last check or 0 if there was no previous check or if it was longer than 4 hours ago. + */ + @JvmOverloads + fun startTimedVersionCheck( + delayMillis: Long = REFRESH_TIME_MS + prefs.getLastVersionCheck() - System.currentTimeMillis() + ) { + stopTimedVersionCheck() + handler.postDelayed(fetchVersionData, delayMillis) + } + + fun stopTimedVersionCheck() { + handler.removeCallbacks(fetchVersionData) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt index c0477825fd1..a7ba6027d5e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt @@ -16,6 +16,7 @@ import androidx.annotation.DimenRes import network.loki.messenger.R import org.session.libsession.utilities.getColorFromAttr import android.view.inputmethod.InputMethodManager +import android.widget.EditText import androidx.annotation.AttrRes import androidx.annotation.ColorRes import androidx.core.graphics.applyCanvas @@ -111,3 +112,11 @@ fun Size.coerceAtMost(longestWidth: Int): Size = height.coerceAtMost(longestWidth).let { Size((it * aspect).roundToInt(), it) } } } + +fun EditText.addTextChangedListener(listener: (String) -> Unit) { + addTextChangedListener(object: SimpleTextWatcher() { + override fun onTextChanged(text: String) { + listener(text) + } + }) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt index ff5e4818954..90e1ce60854 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -6,11 +6,14 @@ import android.telephony.TelephonyManager import androidx.core.content.ContextCompat import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.boolean import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import nl.komponents.kovenant.Promise import nl.komponents.kovenant.functional.bind @@ -51,12 +54,14 @@ import org.webrtc.MediaStream import org.webrtc.PeerConnection import org.webrtc.PeerConnection.IceConnectionState import org.webrtc.PeerConnectionFactory +import org.webrtc.RendererCommon import org.webrtc.RtpReceiver import org.webrtc.SessionDescription import org.webrtc.SurfaceViewRenderer import java.nio.ByteBuffer import java.util.ArrayDeque import java.util.UUID +import kotlin.math.abs import org.thoughtcrime.securesms.webrtc.data.State as CallState class CallManager( @@ -105,10 +110,15 @@ class CallManager( private val _audioEvents = MutableStateFlow(AudioEnabled(false)) val audioEvents = _audioEvents.asSharedFlow() - private val _videoEvents = MutableStateFlow(VideoEnabled(false)) - val videoEvents = _videoEvents.asSharedFlow() - private val _remoteVideoEvents = MutableStateFlow(VideoEnabled(false)) - val remoteVideoEvents = _remoteVideoEvents.asSharedFlow() + + private val _videoState: MutableStateFlow = MutableStateFlow( + VideoState( + swapped = false, + userVideoEnabled = false, + remoteVideoEnabled = false + ) + ) + val videoState = _videoState.asStateFlow() private val stateProcessor = StateProcessor(CallState.Idle) @@ -127,7 +137,7 @@ class CallManager( val currentCallState get() = _callStateEvents.value - var iceState = IceConnectionState.CLOSED + private var iceState = IceConnectionState.CLOSED private var eglBase: EglBase? = null @@ -141,7 +151,6 @@ class CallManager( _recipientEvents.value = RecipientUpdate(value) } var callStartTime: Long = -1 - var isReestablishing: Boolean = false private var peerConnection: PeerConnectionWrapper? = null private var dataChannel: DataChannel? = null @@ -151,9 +160,9 @@ class CallManager( private val outgoingIceDebouncer = Debouncer(200L) - var localRenderer: SurfaceViewRenderer? = null + var floatingRenderer: SurfaceViewRenderer? = null var remoteRotationSink: RemoteRotationVideoProxySink? = null - var remoteRenderer: SurfaceViewRenderer? = null + var fullscreenRenderer: SurfaceViewRenderer? = null private var peerConnectionFactory: PeerConnectionFactory? = null fun clearPendingIceUpdates() { @@ -216,20 +225,18 @@ class CallManager( Util.runOnMainSync { val base = EglBase.create() eglBase = base - localRenderer = SurfaceViewRenderer(context).apply { -// setScalingType(SCALE_ASPECT_FIT) - } + floatingRenderer = SurfaceViewRenderer(context) + floatingRenderer?.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT) + + fullscreenRenderer = SurfaceViewRenderer(context) + fullscreenRenderer?.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT) - remoteRenderer = SurfaceViewRenderer(context).apply { -// setScalingType(SCALE_ASPECT_FIT) - } remoteRotationSink = RemoteRotationVideoProxySink() - localRenderer?.init(base.eglBaseContext, null) - localRenderer?.setMirror(localCameraState.activeDirection == CameraState.Direction.FRONT) - remoteRenderer?.init(base.eglBaseContext, null) - remoteRotationSink!!.setSink(remoteRenderer!!) + floatingRenderer?.init(base.eglBaseContext, null) + fullscreenRenderer?.init(base.eglBaseContext, null) + remoteRotationSink!!.setSink(fullscreenRenderer!!) val encoderFactory = DefaultVideoEncoderFactory(base.eglBaseContext, true, true) val decoderFactory = DefaultVideoDecoderFactory(base.eglBaseContext) @@ -363,7 +370,8 @@ class CallManager( val byteArray = ByteArray(buffer.data.remaining()) { buffer.data[it] } val json = Json.parseToJsonElement(byteArray.decodeToString()) as JsonObject if (json.containsKey("video")) { - _remoteVideoEvents.value = VideoEnabled((json["video"] as JsonPrimitive).boolean) + _videoState.update { it.copy(remoteVideoEnabled = json["video"]?.jsonPrimitive?.boolean ?: false) } + handleMirroring() } else if (json.containsKey("hangup")) { peerConnectionObservers.forEach(WebRtcListener::onHangup) } @@ -383,13 +391,13 @@ class CallManager( peerConnection?.dispose() peerConnection = null - localRenderer?.release() + floatingRenderer?.release() remoteRotationSink?.release() - remoteRenderer?.release() + fullscreenRenderer?.release() eglBase?.release() - localRenderer = null - remoteRenderer = null + floatingRenderer = null + fullscreenRenderer = null eglBase = null localCameraState = CameraState.UNKNOWN @@ -399,8 +407,11 @@ class CallManager( pendingOffer = null callStartTime = -1 _audioEvents.value = AudioEnabled(false) - _videoEvents.value = VideoEnabled(false) - _remoteVideoEvents.value = VideoEnabled(false) + _videoState.value = VideoState( + swapped = false, + userVideoEnabled = false, + remoteVideoEnabled = false + ) pendingOutgoingIceUpdates.clear() pendingIncomingIceUpdates.clear() } @@ -411,7 +422,7 @@ class CallManager( // If the camera we've switched to is the front one then mirror it to match what someone // would see when looking in the mirror rather than the left<-->right flipped version. - localRenderer?.setMirror(localCameraState.activeDirection == CameraState.Direction.FRONT) + handleMirroring() } fun onPreOffer(callId: UUID, recipient: Recipient, onSuccess: () -> Unit) { @@ -469,7 +480,7 @@ class CallManager( val recipient = recipient ?: return Promise.ofFail(NullPointerException("recipient is null")) val offer = pendingOffer ?: return Promise.ofFail(NullPointerException("pendingOffer is null")) val factory = peerConnectionFactory ?: return Promise.ofFail(NullPointerException("peerConnectionFactory is null")) - val local = localRenderer ?: return Promise.ofFail(NullPointerException("localRenderer is null")) + val local = floatingRenderer ?: return Promise.ofFail(NullPointerException("localRenderer is null")) val base = eglBase ?: return Promise.ofFail(NullPointerException("eglBase is null")) val connection = PeerConnectionWrapper( context, @@ -515,7 +526,7 @@ class CallManager( ?: return Promise.ofFail(NullPointerException("recipient is null")) val factory = peerConnectionFactory ?: return Promise.ofFail(NullPointerException("peerConnectionFactory is null")) - val local = localRenderer + val local = floatingRenderer ?: return Promise.ofFail(NullPointerException("localRenderer is null")) val base = eglBase ?: return Promise.ofFail(NullPointerException("eglBase is null")) @@ -609,13 +620,56 @@ class CallManager( } } + fun swapVideos() { + // update the state + _videoState.update { it.copy(swapped = !it.swapped) } + handleMirroring() + + if (_videoState.value.swapped) { + peerConnection?.rotationVideoSink?.setSink(fullscreenRenderer) + floatingRenderer?.let { remoteRotationSink?.setSink(it) } + } else { + peerConnection?.rotationVideoSink?.setSink(floatingRenderer) + fullscreenRenderer?.let { remoteRotationSink?.setSink(it) } + } + } + fun handleSetMuteAudio(muted: Boolean) { _audioEvents.value = AudioEnabled(!muted) peerConnection?.setAudioEnabled(!muted) } + /** + * Returns the renderer currently showing the user's video, not the contact's + */ + private fun getUserRenderer() = if (_videoState.value.swapped) fullscreenRenderer else floatingRenderer + + /** + * Returns the renderer currently showing the contact's video, not the user's + */ + private fun getRemoteRenderer() = if (_videoState.value.swapped) floatingRenderer else fullscreenRenderer + + /** + * Makes sure the user's renderer applies mirroring if necessary + */ + private fun handleMirroring() { + val videoState = _videoState.value + + // if we have user video and the camera is front facing, make sure to mirror stream + if (videoState.userVideoEnabled) { + getUserRenderer()?.setMirror(isCameraFrontFacing()) + } + + // the remote video is never mirrored + if (videoState.remoteVideoEnabled){ + getRemoteRenderer()?.setMirror(false) + } + } + fun handleSetMuteVideo(muted: Boolean, lockManager: LockManager) { - _videoEvents.value = VideoEnabled(!muted) + _videoState.update { it.copy(userVideoEnabled = !muted) } + handleMirroring() + val connection = peerConnection ?: return connection.setVideoEnabled(!muted) dataChannel?.let { channel -> @@ -651,9 +705,18 @@ class CallManager( } } - fun setDeviceRotation(newRotation: Int) { - peerConnection?.setDeviceRotation(newRotation) - remoteRotationSink?.rotation = newRotation + fun setDeviceOrientation(orientation: Orientation) { + // set rotation to the video based on the device's orientation and the camera facing direction + val rotation = when (orientation) { + Orientation.PORTRAIT -> 0 + Orientation.LANDSCAPE -> if (isCameraFrontFacing()) 90 else -90 + Orientation.REVERSED_LANDSCAPE -> 270 + else -> 0 + } + + // apply the rotation to the streams + peerConnection?.setDeviceRotation(rotation) + remoteRotationSink?.rotation = abs(rotation) // abs as we never need the remote video to be inverted } fun handleWiredHeadsetChanged(present: Boolean) { @@ -721,7 +784,7 @@ class CallManager( connection.setCommunicationMode() setAudioEnabled(true) dataChannel?.let { channel -> - val toSend = if (!_videoEvents.value.isEnabled) VIDEO_DISABLED_JSON else VIDEO_ENABLED_JSON + val toSend = if (_videoState.value.userVideoEnabled) VIDEO_ENABLED_JSON else VIDEO_DISABLED_JSON val buffer = DataChannel.Buffer(ByteBuffer.wrap(toSend.toString().encodeToByteArray()), false) channel.send(buffer) } @@ -750,6 +813,8 @@ class CallManager( fun isInitiator(): Boolean = peerConnection?.isInitiator() == true + fun isCameraFrontFacing() = localCameraState.activeDirection != CameraState.Direction.BACK + interface WebRtcListener: PeerConnection.Observer { fun onHangup() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt index 4f27e5d1ad7..da5eb075490 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.webrtc import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager @@ -29,60 +30,42 @@ class CallViewModel @Inject constructor(private val callManager: CallManager): V UNTRUSTED_IDENTITY, } - val localRenderer: SurfaceViewRenderer? - get() = callManager.localRenderer + val floatingRenderer: SurfaceViewRenderer? + get() = callManager.floatingRenderer - val remoteRenderer: SurfaceViewRenderer? - get() = callManager.remoteRenderer + val fullscreenRenderer: SurfaceViewRenderer? + get() = callManager.fullscreenRenderer - private var _videoEnabled: Boolean = false + var microphoneEnabled: Boolean = true + private set - val videoEnabled: Boolean - get() = _videoEnabled - - private var _microphoneEnabled: Boolean = true - - val microphoneEnabled: Boolean - get() = _microphoneEnabled - - private var _isSpeaker: Boolean = false - val isSpeaker: Boolean - get() = _isSpeaker + var isSpeaker: Boolean = false + private set val audioDeviceState - get() = callManager.audioDeviceEvents - .onEach { - _isSpeaker = it.selectedDevice == SignalAudioManager.AudioDevice.SPEAKER_PHONE - } + get() = callManager.audioDeviceEvents.onEach { + isSpeaker = it.selectedDevice == SignalAudioManager.AudioDevice.SPEAKER_PHONE + } val localAudioEnabledState get() = callManager.audioEvents.map { it.isEnabled } - .onEach { _microphoneEnabled = it } - - val localVideoEnabledState - get() = callManager.videoEvents - .map { it.isEnabled } - .onEach { _videoEnabled = it } + .onEach { microphoneEnabled = it } - val remoteVideoEnabledState - get() = callManager.remoteVideoEvents.map { it.isEnabled } + val videoState: StateFlow + get() = callManager.videoState - var deviceRotation: Int = 0 + var deviceOrientation: Orientation = Orientation.UNKNOWN set(value) { field = value - callManager.setDeviceRotation(value) + callManager.setDeviceOrientation(value) } - val currentCallState - get() = callManager.currentCallState - - val callState - get() = callManager.callStateEvents - - val recipient - get() = callManager.recipientEvents - - val callStartTime: Long - get() = callManager.callStartTime + val currentCallState get() = callManager.currentCallState + val callState get() = callManager.callStateEvents + val recipient get() = callManager.recipientEvents + val callStartTime: Long get() = callManager.callStartTime + fun swapVideos() { + callManager.swapVideos() + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/NetworkChangeReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/NetworkChangeReceiver.kt index 52ee5583d2a..bff332fbd4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/NetworkChangeReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/NetworkChangeReceiver.kt @@ -7,12 +7,13 @@ import android.content.IntentFilter import android.net.ConnectivityManager import android.net.Network import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.util.NetworkUtils class NetworkChangeReceiver(private val onNetworkChangedCallback: (Boolean)->Unit) { private val networkList: MutableSet = mutableSetOf() - val broadcastDelegate = object: BroadcastReceiver() { + private val broadcastDelegate = object: BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { receiveBroadcast(context, intent) } @@ -41,16 +42,11 @@ class NetworkChangeReceiver(private val onNetworkChangedCallback: (Boolean)->Uni } fun receiveBroadcast(context: Context, intent: Intent) { - val connected = context.isConnected() + val connected = NetworkUtils.haveValidNetworkConnection(context) Log.i("Loki", "received broadcast, network connected: $connected") onNetworkChangedCallback(connected) } - fun Context.isConnected() : Boolean { - val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - return cm.activeNetwork != null - } - fun register(context: Context) { val intentFilter = IntentFilter("android.net.conn.CONNECTIVITY_CHANGE") context.registerReceiver(broadcastDelegate, intentFilter) diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/Orientation.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/Orientation.kt new file mode 100644 index 00000000000..05370fda4ad --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/Orientation.kt @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.webrtc + +enum class Orientation { + PORTRAIT, + LANDSCAPE, + REVERSED_LANDSCAPE, + UNKNOWN +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt index f78b93d6b9c..b61edbb6d26 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt @@ -41,7 +41,7 @@ class PeerConnectionWrapper(private val context: Context, private val mediaStream: MediaStream private val videoSource: VideoSource? private val videoTrack: VideoTrack? - private val rotationVideoSink = RotationVideoSink() + public val rotationVideoSink = RotationVideoSink() val readyForIce get() = peerConnection?.localDescription != null && peerConnection?.remoteDescription != null @@ -103,7 +103,7 @@ class PeerConnectionWrapper(private val context: Context, context, rotationVideoSink ) - rotationVideoSink.mirrored = newCamera.activeDirection == CameraState.Direction.FRONT + rotationVideoSink.setSink(localRenderer) newVideoTrack.setEnabled(false) mediaStream.addTrack(newVideoTrack) diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/VideoState.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/VideoState.kt new file mode 100644 index 00000000000..55bb04038a1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/VideoState.kt @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.webrtc + +data class VideoState ( + val swapped: Boolean, + val userVideoEnabled: Boolean, + val remoteVideoEnabled: Boolean +){ + fun showFloatingVideo(): Boolean { + return userVideoEnabled && !swapped || + remoteVideoEnabled && swapped + } + + fun showFullscreenVideo(): Boolean { + return userVideoEnabled && swapped || + remoteVideoEnabled && !swapped + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/data/CallUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/data/CallUtils.kt deleted file mode 100644 index dc9f07d051c..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/data/CallUtils.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.thoughtcrime.securesms.webrtc.data - -// get the video rotation from a specific rotation, locked into 90 degree -// chunks offset by 45 degrees -fun Int.quadrantRotation() = when (this % 360) { - in 315 .. 360, - in 0 until 45 -> 0 - in 45 until 135 -> 90 - in 135 until 225 -> 180 - else -> 270 -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/RemoteRotationVideoProxySink.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/RemoteRotationVideoProxySink.kt index 2b0caef89c9..a2609187f03 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/RemoteRotationVideoProxySink.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/RemoteRotationVideoProxySink.kt @@ -1,6 +1,6 @@ package org.thoughtcrime.securesms.webrtc.video -import org.thoughtcrime.securesms.webrtc.data.quadrantRotation + import org.webrtc.VideoFrame import org.webrtc.VideoSink @@ -14,8 +14,7 @@ class RemoteRotationVideoProxySink: VideoSink { val thisSink = targetSink ?: return val thisFrame = frame ?: return - val quadrantRotation = rotation.quadrantRotation() - val modifiedRotation = thisFrame.rotation - quadrantRotation + val modifiedRotation = (thisFrame.rotation - rotation + 360) % 360 val newFrame = VideoFrame(thisFrame.buffer, modifiedRotation, thisFrame.timestampNs) thisSink.onFrame(newFrame) diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/RotationVideoSink.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/RotationVideoSink.kt index ec43daf2ef4..3522f06f9e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/RotationVideoSink.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/RotationVideoSink.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.webrtc.video -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.webrtc.data.quadrantRotation + import org.webrtc.CapturerObserver import org.webrtc.VideoFrame import org.webrtc.VideoProcessor @@ -12,7 +11,6 @@ import java.util.concurrent.atomic.AtomicBoolean class RotationVideoSink: CapturerObserver, VideoProcessor { var rotation: Int = 0 - var mirrored = false private val capturing = AtomicBoolean(false) private var capturerObserver = SoftReference(null) @@ -31,13 +29,14 @@ class RotationVideoSink: CapturerObserver, VideoProcessor { val observer = capturerObserver.get() if (videoFrame == null || observer == null || !capturing.get()) return - val quadrantRotation = rotation.quadrantRotation() - - val newFrame = VideoFrame(videoFrame.buffer, (videoFrame.rotation + quadrantRotation * if (mirrored && quadrantRotation in listOf(90,270)) -1 else 1) % 360, videoFrame.timestampNs) - val localFrame = VideoFrame(videoFrame.buffer, videoFrame.rotation * if (mirrored && quadrantRotation in listOf(90,270)) -1 else 1, videoFrame.timestampNs) + // cater for frame rotation so that the video is always facing up as we rotate pas a certain point + val newFrame = VideoFrame(videoFrame.buffer, videoFrame.rotation - rotation, videoFrame.timestampNs) + // the frame we are sending to our contact needs to cater for rotation observer.onFrameCaptured(newFrame) - sink.get()?.onFrame(localFrame) + + // the frame we see on the user's phone doesn't require changes + sink.get()?.onFrame(videoFrame) } override fun setSink(sink: VideoSink?) { diff --git a/app/src/main/res/color/button_destructive.xml b/app/src/main/res/color/button_danger.xml similarity index 81% rename from app/src/main/res/color/button_destructive.xml rename to app/src/main/res/color/button_danger.xml index cefbfed23a6..d1d41b95e5d 100644 --- a/app/src/main/res/color/button_destructive.xml +++ b/app/src/main/res/color/button_danger.xml @@ -1,5 +1,5 @@ - + diff --git a/app/src/main/res/color/state_list_call_action_mic_background.xml b/app/src/main/res/color/state_list_call_action_mic_background.xml index 1e40a3a054d..f8ec990e18b 100644 --- a/app/src/main/res/color/state_list_call_action_mic_background.xml +++ b/app/src/main/res/color/state_list_call_action_mic_background.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/session_id_text_view_background.xml b/app/src/main/res/drawable/account_id_text_view_background.xml similarity index 100% rename from app/src/main/res/drawable/session_id_text_view_background.xml rename to app/src/main/res/drawable/account_id_text_view_background.xml diff --git a/app/src/main/res/drawable/conversation_view_background.xml b/app/src/main/res/drawable/conversation_view_background.xml index 2f177318e0e..50e38698b49 100644 --- a/app/src/main/res/drawable/conversation_view_background.xml +++ b/app/src/main/res/drawable/conversation_view_background.xml @@ -4,6 +4,6 @@ android:color="?android:colorControlHighlight"> - + \ No newline at end of file diff --git a/app/src/main/res/drawable/conversation_view_search__background.xml b/app/src/main/res/drawable/conversation_view_search__background.xml new file mode 100644 index 00000000000..50e38698b49 --- /dev/null +++ b/app/src/main/res/drawable/conversation_view_search__background.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/destructive_dialog_button_background.xml b/app/src/main/res/drawable/danger_dialog_button_background.xml similarity index 78% rename from app/src/main/res/drawable/destructive_dialog_button_background.xml rename to app/src/main/res/drawable/danger_dialog_button_background.xml index 9d3ac00b075..0f72e98c740 100644 --- a/app/src/main/res/drawable/destructive_dialog_button_background.xml +++ b/app/src/main/res/drawable/danger_dialog_button_background.xml @@ -3,9 +3,9 @@ xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> - + - + \ No newline at end of file diff --git a/app/src/main/res/drawable/destructive_dialog_text_button_background.xml b/app/src/main/res/drawable/danger_dialog_text_button_background.xml similarity index 100% rename from app/src/main/res/drawable/destructive_dialog_text_button_background.xml rename to app/src/main/res/drawable/danger_dialog_text_button_background.xml diff --git a/app/src/main/res/drawable/destructive_outline_button_medium_background.xml b/app/src/main/res/drawable/danger_outline_button_medium_background.xml similarity index 79% rename from app/src/main/res/drawable/destructive_outline_button_medium_background.xml rename to app/src/main/res/drawable/danger_outline_button_medium_background.xml index c6e01ef98ed..7894c24be83 100644 --- a/app/src/main/res/drawable/destructive_outline_button_medium_background.xml +++ b/app/src/main/res/drawable/danger_outline_button_medium_background.xml @@ -1,12 +1,12 @@ + android:color="@color/button_danger"> diff --git a/app/src/main/res/drawable/default_bottom_sheet_background.xml b/app/src/main/res/drawable/default_bottom_sheet_background.xml index 63532b0d053..19300aae39d 100644 --- a/app/src/main/res/drawable/default_bottom_sheet_background.xml +++ b/app/src/main/res/drawable/default_bottom_sheet_background.xml @@ -3,7 +3,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> - + - + diff --git a/app/src/main/res/drawable/dialog_background.xml b/app/src/main/res/drawable/dialog_background.xml index d607bfc0220..e546e1f84ca 100644 --- a/app/src/main/res/drawable/dialog_background.xml +++ b/app/src/main/res/drawable/dialog_background.xml @@ -6,6 +6,6 @@ android:insetBottom="16dp"> - + \ No newline at end of file diff --git a/app/src/main/res/drawable/emoji_tada_large.xml b/app/src/main/res/drawable/emoji_tada_large.xml new file mode 100644 index 00000000000..ed802646ff1 --- /dev/null +++ b/app/src/main/res/drawable/emoji_tada_large.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_outline_bookmark_border_24.xml b/app/src/main/res/drawable/ic_baseline_screen_rotation_alt_24.xml similarity index 58% rename from app/src/main/res/drawable/ic_outline_bookmark_border_24.xml rename to app/src/main/res/drawable/ic_baseline_screen_rotation_alt_24.xml index 0cb95b77063..553db9c082c 100644 --- a/app/src/main/res/drawable/ic_outline_bookmark_border_24.xml +++ b/app/src/main/res/drawable/ic_baseline_screen_rotation_alt_24.xml @@ -6,5 +6,5 @@ android:tint="?attr/colorControlNormal"> + android:pathData="M4,7.59l5,-5c0.78,-0.78 2.05,-0.78 2.83,0L20.24,11h-2.83L10.4,4L5.41,9H8v2H2V5h2V7.59zM20,19h2v-6h-6v2h2.59l-4.99,5l-7.01,-7H3.76l8.41,8.41c0.78,0.78 2.05,0.78 2.83,0l5,-5V19z"/> diff --git a/app/src/main/res/drawable/ic_circle_question_mark.xml b/app/src/main/res/drawable/ic_circle_question_mark.xml new file mode 100644 index 00000000000..9bc2b817f18 --- /dev/null +++ b/app/src/main/res/drawable/ic_circle_question_mark.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_clear_data.xml b/app/src/main/res/drawable/ic_clear_data.xml deleted file mode 100644 index 320015bb232..00000000000 --- a/app/src/main/res/drawable/ic_clear_data.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_delete_24.xml b/app/src/main/res/drawable/ic_delete_24.xml index 178806738a3..48fa95783f2 100644 --- a/app/src/main/res/drawable/ic_delete_24.xml +++ b/app/src/main/res/drawable/ic_delete_24.xml @@ -3,7 +3,7 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24" - android:tint="@color/destructive"> + android:tint="?danger"> diff --git a/app/src/main/res/drawable/ic_dialog_x.xml b/app/src/main/res/drawable/ic_dialog_x.xml new file mode 100644 index 00000000000..a65f2abb88e --- /dev/null +++ b/app/src/main/res/drawable/ic_dialog_x.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_logo_large.xml b/app/src/main/res/drawable/ic_logo_large.xml new file mode 100644 index 00000000000..b494b176634 --- /dev/null +++ b/app/src/main/res/drawable/ic_logo_large.xml @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_path_yellow.xml b/app/src/main/res/drawable/ic_path_yellow.xml new file mode 100644 index 00000000000..04f0c51545c --- /dev/null +++ b/app/src/main/res/drawable/ic_path_yellow.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_recovery_phrase.xml b/app/src/main/res/drawable/ic_recovery_phrase.xml deleted file mode 100644 index 3d5bc18e21c..00000000000 --- a/app/src/main/res/drawable/ic_recovery_phrase.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_shield_outline.xml b/app/src/main/res/drawable/ic_shield_outline.xml new file mode 100644 index 00000000000..3db98f53d07 --- /dev/null +++ b/app/src/main/res/drawable/ic_shield_outline.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_status.xml b/app/src/main/res/drawable/ic_status.xml new file mode 100644 index 00000000000..7b19ad14134 --- /dev/null +++ b/app/src/main/res/drawable/ic_status.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/preference_bottom.xml b/app/src/main/res/drawable/preference_bottom.xml index b6c5f506fd3..888778b1cb5 100644 --- a/app/src/main/res/drawable/preference_bottom.xml +++ b/app/src/main/res/drawable/preference_bottom.xml @@ -6,7 +6,7 @@ android:bottom="@dimen/small_spacing" > - + diff --git a/app/src/main/res/drawable/preference_middle.xml b/app/src/main/res/drawable/preference_middle.xml index bf27aacc727..287645ab83a 100644 --- a/app/src/main/res/drawable/preference_middle.xml +++ b/app/src/main/res/drawable/preference_middle.xml @@ -4,7 +4,7 @@ - + - + diff --git a/app/src/main/res/drawable/preference_single_no_padding.xml b/app/src/main/res/drawable/preference_single_no_padding.xml index 252ab0aea34..483894fcc29 100644 --- a/app/src/main/res/drawable/preference_single_no_padding.xml +++ b/app/src/main/res/drawable/preference_single_no_padding.xml @@ -2,7 +2,7 @@ - + diff --git a/app/src/main/res/drawable/preference_top.xml b/app/src/main/res/drawable/preference_top.xml index 8f56ddc870b..180aa9f73fc 100644 --- a/app/src/main/res/drawable/preference_top.xml +++ b/app/src/main/res/drawable/preference_top.xml @@ -6,7 +6,7 @@ android:top="@dimen/small_spacing" > - + diff --git a/app/src/main/res/drawable/profile_picture_view_medium_background.xml b/app/src/main/res/drawable/profile_picture_view_background.xml similarity index 72% rename from app/src/main/res/drawable/profile_picture_view_medium_background.xml rename to app/src/main/res/drawable/profile_picture_view_background.xml index 37c5ce3e74d..b8a7398a373 100644 --- a/app/src/main/res/drawable/profile_picture_view_medium_background.xml +++ b/app/src/main/res/drawable/profile_picture_view_background.xml @@ -1,9 +1,8 @@ + android:shape="oval"> - \ No newline at end of file diff --git a/app/src/main/res/drawable/profile_picture_view_large_background.xml b/app/src/main/res/drawable/profile_picture_view_large_background.xml deleted file mode 100644 index 9b90660803f..00000000000 --- a/app/src/main/res/drawable/profile_picture_view_large_background.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/search_background.xml b/app/src/main/res/drawable/search_background.xml index a2090ca6bc8..58da2d85222 100644 --- a/app/src/main/res/drawable/search_background.xml +++ b/app/src/main/res/drawable/search_background.xml @@ -2,5 +2,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/session.xml b/app/src/main/res/drawable/session.xml new file mode 100644 index 00000000000..2c6e1081f76 --- /dev/null +++ b/app/src/main/res/drawable/session.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_logo.xml b/app/src/main/res/drawable/session_logo.xml index f88a4f21a9c..b2f931990f1 100644 --- a/app/src/main/res/drawable/session_logo.xml +++ b/app/src/main/res/drawable/session_logo.xml @@ -1,9 +1,9 @@ - - - - - + + diff --git a/app/src/main/res/drawable/session_shield.xml b/app/src/main/res/drawable/session_shield.xml new file mode 100644 index 00000000000..a7c6d1a24a6 --- /dev/null +++ b/app/src/main/res/drawable/session_shield.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/sheet_rounded_bg.xml b/app/src/main/res/drawable/sheet_rounded_bg.xml new file mode 100644 index 00000000000..848b177f4f8 --- /dev/null +++ b/app/src/main/res/drawable/sheet_rounded_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-sw400dp/activity_display_name.xml b/app/src/main/res/layout-sw400dp/activity_display_name.xml deleted file mode 100644 index d62faca0649..00000000000 --- a/app/src/main/res/layout-sw400dp/activity_display_name.xml +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - -