Compare commits
157 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
343aadcd9a | ||
|
|
c4ad6c2992 | ||
|
|
97dd756136 | ||
|
|
7989c40f52 | ||
|
|
0749905909 | ||
|
|
168481fee5 | ||
|
|
7866e2e29c | ||
|
|
4eb0dca8f6 | ||
|
|
bc54f6ca07 | ||
|
|
223c0c4bce | ||
|
|
b39099b84e | ||
|
|
22d6546704 | ||
|
|
a7af687f8e | ||
|
|
ce9cd132ec | ||
|
|
62fa99e0ee | ||
|
|
43e4cba3d7 | ||
|
|
6cbc2f684d | ||
|
|
ffc9e8caff | ||
|
|
49c9b0acde | ||
|
|
9c6908873c | ||
|
|
528fe67db9 | ||
|
|
39e14e922b | ||
|
|
0c8b6f8ef8 | ||
|
|
f65de84c19 | ||
|
|
88074134af | ||
|
|
b5cc570363 | ||
|
|
3cbf0933ff | ||
|
|
7f9c89483f | ||
|
|
8ef3d3fbbf | ||
|
|
c225c2b37d | ||
|
|
ff76c5fca5 | ||
|
|
5b99f590f8 | ||
|
|
2d0feca278 | ||
|
|
92e506b117 | ||
|
|
cac841d8e6 | ||
|
|
77cb9bc174 | ||
|
|
309e33016a | ||
|
|
938b24f623 | ||
|
|
82c637ef4b | ||
|
|
d9e8480a12 | ||
|
|
5115717f67 | ||
|
|
33ac48e771 | ||
|
|
c53f1fcecf | ||
|
|
78704dce8a | ||
|
|
7f3ba1978d | ||
|
|
891dfc1b68 | ||
|
|
b0ccb543d1 | ||
|
|
7752b3aba3 | ||
|
|
0fa13eb097 | ||
|
|
641db1cbe2 | ||
|
|
8d53c2392a | ||
|
|
8d0acb277c | ||
|
|
6e00920c95 | ||
|
|
13638dc1c9 | ||
|
|
1222d020ad | ||
|
|
d82b1ec69b | ||
|
|
8052c13526 | ||
|
|
ed8538547f | ||
|
|
eb8de536e0 | ||
|
|
76728c43e0 | ||
|
|
52cfb57d36 | ||
|
|
a385cb0b68 | ||
|
|
e01381379c | ||
|
|
d01a52c5a8 | ||
|
|
204fff1b9b | ||
|
|
1eda1477a8 | ||
|
|
3135685c0e | ||
|
|
58fdb26f04 | ||
|
|
9bcb1bad8e | ||
|
|
eb6ef3d005 | ||
|
|
f40ba0bf68 | ||
|
|
89df0a2c04 | ||
|
|
69fbd4f3fc | ||
|
|
45267f3590 | ||
|
|
3fb8c6eda8 | ||
|
|
27ce0fd65e | ||
|
|
7e91132e7e | ||
|
|
705839068a | ||
|
|
6625ac02d5 | ||
|
|
4b3580d98a | ||
|
|
6dbbec2631 | ||
|
|
a7b6ebe7fc | ||
|
|
76f52b9086 | ||
|
|
3310246351 | ||
|
|
f3d0b4a671 | ||
|
|
83b9fbac11 | ||
|
|
5ca843825f | ||
|
|
e92c83401b | ||
|
|
e18d9e665f | ||
|
|
cc99febe32 | ||
|
|
e72be42eff | ||
|
|
bad382e2f3 | ||
|
|
e637f15a43 | ||
|
|
6c55916cda | ||
|
|
fbabab0b70 | ||
|
|
e268887255 | ||
|
|
6b07922757 | ||
|
|
a464e57079 | ||
|
|
b5af691cc4 | ||
|
|
5c1b57e4ba | ||
|
|
a5c51ff801 | ||
|
|
d755e1e29e | ||
|
|
b9361112b6 | ||
|
|
32101f7dda | ||
|
|
29e697265c | ||
|
|
4cd9ccc0f1 | ||
|
|
8936d81bc7 | ||
|
|
cc36f83d77 | ||
|
|
64996a8db7 | ||
|
|
7267d77dcb | ||
|
|
2281e83607 | ||
|
|
e6b03b1a4a | ||
|
|
fb86fdfcd9 | ||
|
|
77cf029fdc | ||
|
|
556ca5a573 | ||
|
|
91645e6adc | ||
|
|
4d6bb95aa4 | ||
|
|
55ee68fa2d | ||
|
|
747bc7c3bf | ||
|
|
9c17201eaf | ||
|
|
a24b3d9a60 | ||
|
|
c93d882fe1 | ||
|
|
67403a6a9f | ||
|
|
5f9e72bb3c | ||
|
|
091b38ceb8 | ||
|
|
83dfb984fb | ||
|
|
9f14831fc4 | ||
|
|
48bfcc9b16 | ||
|
|
7028ca9411 | ||
|
|
5175375483 | ||
|
|
e2dbaa605b | ||
|
|
93fd6e7a55 | ||
|
|
b070e6962f | ||
|
|
b1d1b7e31e | ||
|
|
1a5ae603d5 | ||
|
|
3b42bda63d | ||
|
|
2f70a71a6c | ||
|
|
aae368c049 | ||
|
|
dee3d2ff2d | ||
|
|
da2e2a99af | ||
|
|
0c00426c0c | ||
|
|
ccc96d5bfa | ||
|
|
82c12c2f6b | ||
|
|
9416beb4aa | ||
|
|
39b80a48c7 | ||
|
|
d5491a2e84 | ||
|
|
07b19402e6 | ||
|
|
318b4703f2 | ||
|
|
d389697f27 | ||
|
|
7bcc338a49 | ||
|
|
ce2c2002c6 | ||
|
|
d5fbd10406 | ||
|
|
6f6da699a3 | ||
|
|
62d8c115ba | ||
|
|
fd01ee2a87 | ||
|
|
9aa517ad99 | ||
|
|
6c3e1b6a29 |
@@ -9,6 +9,7 @@ apply from: 'translations.gradle'
|
||||
apply from: 'witness-verifications.gradle'
|
||||
apply plugin: 'org.jetbrains.kotlin.android'
|
||||
apply plugin: 'app.cash.exhaustive'
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
|
||||
repositories {
|
||||
maven {
|
||||
@@ -66,8 +67,8 @@ protobuf {
|
||||
}
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 920
|
||||
def canonicalVersionName = "5.24.0"
|
||||
def canonicalVersionCode = 937
|
||||
def canonicalVersionName = "5.24.17"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
@@ -149,6 +150,7 @@ android {
|
||||
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api.directory.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDSH_URL", "\"https://cdsh.staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\""
|
||||
@@ -157,13 +159,15 @@ android {
|
||||
buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\""
|
||||
buildConfigField "int", "CONTENT_PROXY_PORT", "443"
|
||||
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
|
||||
buildConfigField "String", "CDSH_PUBLIC_KEY", "\"2fe57da347cd62431528daac5fbb290730fff684afc4cfc2ed90995f58cb3b74\""
|
||||
buildConfigField "String", "CDSH_CODE_HASH", "\"ec31a51880d19a5e9e0fed404740c1a3ff53a553125564b745acce475f0fded8\""
|
||||
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
|
||||
buildConfigField "KbsEnclave", "KBS_ENCLAVE", "new KbsEnclave(\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\"," +
|
||||
"\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\", " +
|
||||
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")";
|
||||
buildConfigField "KbsEnclave[]", "KBS_FALLBACKS", "new KbsEnclave[0]"
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X0=\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY\""
|
||||
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
|
||||
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
|
||||
buildConfigField "String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\""
|
||||
@@ -174,6 +178,8 @@ android {
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"unset\""
|
||||
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"unset\""
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"unset\""
|
||||
buildConfigField "String", "BADGE_STATIC_ROOT", "\"https://updates2.signal.org/static/badges/\""
|
||||
buildConfigField "String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\""
|
||||
|
||||
ndk {
|
||||
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
|
||||
@@ -341,7 +347,7 @@ android {
|
||||
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")"
|
||||
buildConfigField "KbsEnclave[]", "KBS_FALLBACKS", "new KbsEnclave[0]"
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdls=\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARB\""
|
||||
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\""
|
||||
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\""
|
||||
|
||||
@@ -451,6 +457,7 @@ dependencies {
|
||||
implementation project(':video')
|
||||
implementation project(':device-transfer')
|
||||
implementation project(':image-editor')
|
||||
implementation project(':donations')
|
||||
|
||||
implementation libs.signal.zkgroup.android
|
||||
implementation libs.signal.client.android
|
||||
@@ -541,6 +548,8 @@ dependencies {
|
||||
androidTestImplementation testLibs.androidx.test.ext.junit
|
||||
androidTestImplementation testLibs.espresso.core
|
||||
|
||||
testImplementation testLibs.espresso.core
|
||||
|
||||
implementation libs.kotlin.stdlib.jdk8
|
||||
implementation libs.kotlin.reflect
|
||||
implementation libs.jackson.module.kotlin
|
||||
|
||||
@@ -101,6 +101,10 @@
|
||||
android:theme="@style/TextSecure.LightTheme"
|
||||
android:largeHeap="true">
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.wallet.api.enabled"
|
||||
android:value="true" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.geo.API_KEY"
|
||||
android:value="AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 164 KiB |
BIN
app/src/main/assets/emoji/People_8.webp
Normal file
|
After Width: | Height: | Size: 106 KiB |
@@ -191,7 +191,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
long startTime = System.currentTimeMillis();
|
||||
Log.i(TAG, "App is now visible.");
|
||||
|
||||
ApplicationDependencies.getFrameRateTracker().begin();
|
||||
ApplicationDependencies.getFrameRateTracker().start();
|
||||
ApplicationDependencies.getMegaphoneRepository().onAppForegrounded();
|
||||
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
@@ -203,6 +203,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
KeyCachingService.onAppForegrounded(this);
|
||||
ApplicationDependencies.getShakeToReport().enable();
|
||||
checkBuildExpiration();
|
||||
ApplicationDependencies.getDeadlockDetector().start();
|
||||
});
|
||||
|
||||
Log.d(TAG, "onStart() took " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
@@ -213,8 +214,9 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
Log.i(TAG, "App is no longer visible.");
|
||||
KeyCachingService.onAppBackgrounded(this);
|
||||
ApplicationDependencies.getMessageNotifier().clearVisibleThread();
|
||||
ApplicationDependencies.getFrameRateTracker().end();
|
||||
ApplicationDependencies.getFrameRateTracker().stop();
|
||||
ApplicationDependencies.getShakeToReport().disable();
|
||||
ApplicationDependencies.getDeadlockDetector().stop();
|
||||
}
|
||||
|
||||
public PersistentLogger getPersistentLogger() {
|
||||
@@ -258,7 +260,10 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
|
||||
SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger());
|
||||
|
||||
SignalExecutors.UNBOUNDED.execute(() -> LogDatabase.getInstance(this).trimToSize());
|
||||
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||
Log.blockUntilAllWritesFinished();
|
||||
LogDatabase.getInstance(this).trimToSize();
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeCrashHandling() {
|
||||
|
||||
@@ -66,7 +66,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
void onAddToContactsClicked(@NonNull Contact contact);
|
||||
void onMessageSharedContactClicked(@NonNull List<Recipient> choices);
|
||||
void onInviteSharedContactClicked(@NonNull List<Recipient> choices);
|
||||
void onReactionClicked(@NonNull View reactionTarget, long messageId, boolean isMms);
|
||||
void onReactionClicked(@NonNull MultiselectPart multiselectPart, long messageId, boolean isMms);
|
||||
void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId);
|
||||
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
|
||||
void onMessageWithRecaptchaNeededClicked(@NonNull MessageRecord messageRecord);
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
|
||||
|
||||
import android.Manifest;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.PictureInPictureParams;
|
||||
@@ -79,6 +77,7 @@ import org.thoughtcrime.securesms.util.ThrottledDebouncer;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState;
|
||||
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
|
||||
|
||||
@@ -86,6 +85,8 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
|
||||
|
||||
public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChangeDialog.Callback {
|
||||
|
||||
private static final String TAG = Log.tag(WebRtcCallActivity.class);
|
||||
@@ -366,15 +367,15 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
|
||||
private void handleSetAudioHandset() {
|
||||
ApplicationDependencies.getSignalCallManager().setAudioSpeaker(false);
|
||||
ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.EARPIECE);
|
||||
}
|
||||
|
||||
private void handleSetAudioSpeaker() {
|
||||
ApplicationDependencies.getSignalCallManager().setAudioSpeaker(true);
|
||||
ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.SPEAKER_PHONE);
|
||||
}
|
||||
|
||||
private void handleSetAudioBluetooth() {
|
||||
ApplicationDependencies.getSignalCallManager().setAudioBluetooth(true);
|
||||
ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.BLUETOOTH);
|
||||
}
|
||||
|
||||
private void handleSetMuteAudio(boolean enabled) {
|
||||
|
||||
@@ -14,7 +14,6 @@ import android.view.animation.AccelerateInterpolator
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.view.animation.Interpolator
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
|
||||
private const val POSITION_ON_SCREEN = "signal.circleavatartransition.positiononscreen"
|
||||
private const val WIDTH = "signal.circleavatartransition.width"
|
||||
@@ -36,7 +35,7 @@ class CircleAvatarTransition(context: Context, attrs: AttributeSet?) : Transitio
|
||||
private fun captureValues(transitionValues: TransitionValues) {
|
||||
val view: View = transitionValues.view
|
||||
|
||||
if (view is AvatarImageView) {
|
||||
if (view.transitionName == "avatar") {
|
||||
val topLeft = intArrayOf(0, 0)
|
||||
view.getLocationOnScreen(topLeft)
|
||||
transitionValues.values[POSITION_ON_SCREEN] = topLeft
|
||||
@@ -51,7 +50,7 @@ class CircleAvatarTransition(context: Context, attrs: AttributeSet?) : Transitio
|
||||
}
|
||||
|
||||
val view: View = endValues.view
|
||||
if (view !is AvatarImageView || view.transitionName != "avatar") {
|
||||
if (view.transitionName != "avatar") {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.thoughtcrime.securesms.badges
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.core.content.res.use
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
private val TAG = Log.tag(BadgeImageView::class.java)
|
||||
|
||||
class BadgeImageView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : AppCompatImageView(context, attrs) {
|
||||
|
||||
private var badgeSize: Int = 0
|
||||
|
||||
init {
|
||||
context.obtainStyledAttributes(attrs, R.styleable.BadgeImageView).use {
|
||||
badgeSize = it.getInt(R.styleable.BadgeImageView_badge_size, 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun setBadgeFromRecipient(recipient: Recipient?) {
|
||||
if (recipient == null || recipient.badges.isEmpty()) {
|
||||
setBadge(null)
|
||||
} else {
|
||||
setBadge(recipient.badges[0])
|
||||
}
|
||||
}
|
||||
|
||||
fun setBadge(badge: Badge?) {
|
||||
visible = badge != null
|
||||
|
||||
val lifecycle = ViewUtil.getActivityLifecycle(this)
|
||||
if (lifecycle?.currentState == Lifecycle.State.DESTROYED) {
|
||||
return
|
||||
}
|
||||
|
||||
if (badge != null) {
|
||||
GlideApp
|
||||
.with(this)
|
||||
.load(badge)
|
||||
.downsample(DownsampleStrategy.NONE)
|
||||
.transform(BadgeSpriteTransformation(BadgeSpriteTransformation.Size.fromInteger(badgeSize), badge.imageDensity, ThemeUtil.isDarkTheme(context)))
|
||||
.into(this)
|
||||
} else {
|
||||
GlideApp
|
||||
.with(this)
|
||||
.clear(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.thoughtcrime.securesms.badges
|
||||
|
||||
import android.content.Context
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.ProfileUtil
|
||||
|
||||
class BadgeRepository(context: Context) {
|
||||
|
||||
private val context = context.applicationContext
|
||||
|
||||
fun setVisibilityForAllBadges(displayBadgesOnProfile: Boolean): Completable = Completable.fromAction {
|
||||
val badges = Recipient.self().badges.map { it.copy(visible = displayBadgesOnProfile) }
|
||||
ProfileUtil.uploadProfileWithBadges(context, badges)
|
||||
|
||||
val recipientDatabase: RecipientDatabase = DatabaseFactory.getRecipientDatabase(context)
|
||||
recipientDatabase.setBadges(Recipient.self().id, badges)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
|
||||
fun setFeaturedBadge(featuredBadge: Badge): Completable = Completable.fromAction {
|
||||
val badges = Recipient.self().badges
|
||||
val reOrderedBadges = listOf(featuredBadge) + (badges - featuredBadge)
|
||||
ProfileUtil.uploadProfileWithBadges(context, reOrderedBadges)
|
||||
|
||||
val recipientDatabase: RecipientDatabase = DatabaseFactory.getRecipientDatabase(context)
|
||||
recipientDatabase.setBadges(Recipient.self().id, reOrderedBadges)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.thoughtcrime.securesms.badges
|
||||
|
||||
import android.content.Context
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.flexbox.AlignItems
|
||||
import com.google.android.flexbox.FlexDirection
|
||||
import com.google.android.flexbox.FlexboxLayoutManager
|
||||
import com.google.android.flexbox.JustifyContent
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
|
||||
object Badges {
|
||||
fun DSLConfiguration.displayBadges(context: Context, badges: List<Badge>, selectedBadge: Badge? = null) {
|
||||
badges
|
||||
.map { Badge.Model(it, it == selectedBadge) }
|
||||
.forEach { customPref(it) }
|
||||
|
||||
val perRow = context.resources.getInteger(R.integer.badge_columns)
|
||||
val empties = (perRow - (badges.size % perRow)) % perRow
|
||||
repeat(empties) {
|
||||
customPref(Badge.EmptyModel())
|
||||
}
|
||||
}
|
||||
|
||||
fun createLayoutManagerForGridWithBadges(context: Context): RecyclerView.LayoutManager {
|
||||
val layoutManager = FlexboxLayoutManager(context)
|
||||
|
||||
layoutManager.flexDirection = FlexDirection.ROW
|
||||
layoutManager.alignItems = AlignItems.CENTER
|
||||
layoutManager.justifyContent = JustifyContent.CENTER
|
||||
|
||||
return layoutManager
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package org.thoughtcrime.securesms.badges.glide
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
|
||||
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
|
||||
import java.lang.IllegalArgumentException
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Cuts out the badge of the requested size from the sprite sheet.
|
||||
*/
|
||||
class BadgeSpriteTransformation(
|
||||
private val size: Size,
|
||||
private val density: String,
|
||||
private val isDarkTheme: Boolean
|
||||
) : BitmapTransformation() {
|
||||
|
||||
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
|
||||
messageDigest.update("BadgeSpriteTransformation(${size.code},$density,$isDarkTheme)".toByteArray(CHARSET))
|
||||
}
|
||||
|
||||
override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
|
||||
val outBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(outBitmap)
|
||||
val inBounds = getInBounds(density, size, isDarkTheme)
|
||||
val outBounds = Rect(0, 0, outWidth, outHeight)
|
||||
|
||||
canvas.drawBitmap(toTransform, inBounds, outBounds, null)
|
||||
|
||||
return outBitmap
|
||||
}
|
||||
|
||||
enum class Size(val code: String) {
|
||||
SMALL("small"),
|
||||
MEDIUM("medium"),
|
||||
LARGE("large"),
|
||||
XLARGE("xlarge");
|
||||
|
||||
companion object {
|
||||
fun fromInteger(integer: Int): Size {
|
||||
return when (integer) {
|
||||
0 -> SMALL
|
||||
1 -> MEDIUM
|
||||
2 -> LARGE
|
||||
3 -> XLARGE
|
||||
else -> LARGE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PADDING = 1
|
||||
|
||||
@VisibleForTesting
|
||||
fun getInBounds(density: String, size: Size, isDarkTheme: Boolean): Rect {
|
||||
val scaleFactor: Int = when (density) {
|
||||
"ldpi" -> 75
|
||||
"mdpi" -> 100
|
||||
"hdpi" -> 150
|
||||
"xhdpi" -> 200
|
||||
"xxhdpi" -> 300
|
||||
"xxxhdpi" -> 400
|
||||
else -> throw IllegalArgumentException("Unexpected density $density")
|
||||
}
|
||||
|
||||
val smallLength = 8 * scaleFactor / 100
|
||||
val mediumLength = 12 * scaleFactor / 100
|
||||
val largeLength = 18 * scaleFactor / 100
|
||||
val xlargeLength = 80 * scaleFactor / 100
|
||||
|
||||
val sideLength: Int = when (size) {
|
||||
Size.SMALL -> smallLength
|
||||
Size.MEDIUM -> mediumLength
|
||||
Size.LARGE -> largeLength
|
||||
Size.XLARGE -> xlargeLength
|
||||
}
|
||||
|
||||
val lightOffset: Int = when (size) {
|
||||
Size.LARGE -> PADDING
|
||||
Size.MEDIUM -> (largeLength + PADDING * 2) * 2 + PADDING
|
||||
Size.SMALL -> (largeLength + PADDING * 2) * 2 + (mediumLength + PADDING * 2) * 2 + PADDING
|
||||
Size.XLARGE -> (largeLength + PADDING * 2) * 2 + (mediumLength + PADDING * 2) * 2 + (smallLength + PADDING * 2) * 2 + PADDING
|
||||
}
|
||||
|
||||
val darkOffset = if (isDarkTheme) {
|
||||
when (size) {
|
||||
Size.XLARGE -> 0
|
||||
else -> sideLength + PADDING * 2
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
return Rect(
|
||||
lightOffset + darkOffset,
|
||||
PADDING,
|
||||
lightOffset + darkOffset + sideLength,
|
||||
sideLength + PADDING
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package org.thoughtcrime.securesms.badges.models
|
||||
|
||||
import android.animation.ObjectAnimator
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import com.bumptech.glide.load.Key
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil
|
||||
import java.security.MessageDigest
|
||||
|
||||
typealias OnBadgeClicked = (Badge, Boolean) -> Unit
|
||||
|
||||
/**
|
||||
* A Badge that can be collected and displayed by a user.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Badge(
|
||||
val id: String,
|
||||
val category: Category,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val imageUrl: Uri,
|
||||
val imageDensity: String,
|
||||
val expirationTimestamp: Long,
|
||||
val visible: Boolean,
|
||||
) : Parcelable, Key {
|
||||
|
||||
fun isExpired(): Boolean = expirationTimestamp < System.currentTimeMillis()
|
||||
|
||||
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
|
||||
messageDigest.update(id.toByteArray(Key.CHARSET))
|
||||
messageDigest.update(imageUrl.toString().toByteArray(Key.CHARSET))
|
||||
messageDigest.update(imageDensity.toByteArray(Key.CHARSET))
|
||||
}
|
||||
|
||||
fun resolveDescription(shortName: String): String {
|
||||
return description.replace("{short_name}", shortName)
|
||||
}
|
||||
|
||||
class EmptyModel : PreferenceModel<EmptyModel>() {
|
||||
override fun areItemsTheSame(newItem: EmptyModel): Boolean = true
|
||||
}
|
||||
|
||||
class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
|
||||
|
||||
private val name: TextView = itemView.findViewById(R.id.name)
|
||||
|
||||
init {
|
||||
itemView.isEnabled = false
|
||||
itemView.isFocusable = false
|
||||
itemView.isClickable = false
|
||||
itemView.visibility = View.INVISIBLE
|
||||
|
||||
name.text = " "
|
||||
}
|
||||
|
||||
override fun bind(model: EmptyModel) = Unit
|
||||
}
|
||||
|
||||
class Model(
|
||||
val badge: Badge,
|
||||
val isSelected: Boolean = false
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return newItem.badge.id == badge.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && badge == newItem.badge && isSelected == newItem.isSelected
|
||||
}
|
||||
|
||||
override fun getChangePayload(newItem: Model): Any? {
|
||||
return if (badge == newItem.badge && isSelected != newItem.isSelected) {
|
||||
SELECTION_CHANGED
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View, private val onBadgeClicked: OnBadgeClicked) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val check: ImageView = itemView.findViewById(R.id.checkmark)
|
||||
private val badge: ImageView = itemView.findViewById(R.id.badge)
|
||||
private val name: TextView = itemView.findViewById(R.id.name)
|
||||
|
||||
private var checkAnimator: ObjectAnimator? = null
|
||||
|
||||
init {
|
||||
check.isSelected = true
|
||||
}
|
||||
|
||||
override fun bind(model: Model) {
|
||||
itemView.setOnClickListener {
|
||||
onBadgeClicked(model.badge, model.isSelected)
|
||||
}
|
||||
|
||||
checkAnimator?.cancel()
|
||||
if (payload.isNotEmpty()) {
|
||||
checkAnimator = if (model.isSelected) {
|
||||
ObjectAnimator.ofFloat(check, "alpha", 1f)
|
||||
} else {
|
||||
ObjectAnimator.ofFloat(check, "alpha", 0f)
|
||||
}
|
||||
checkAnimator?.start()
|
||||
return
|
||||
}
|
||||
|
||||
badge.alpha = if (model.badge.isExpired()) 0.5f else 1f
|
||||
|
||||
GlideApp.with(badge)
|
||||
.load(model.badge)
|
||||
.downsample(DownsampleStrategy.NONE)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.transform(
|
||||
BadgeSpriteTransformation(BadgeSpriteTransformation.Size.XLARGE, model.badge.imageDensity, ThemeUtil.isDarkTheme(context)),
|
||||
)
|
||||
.into(badge)
|
||||
|
||||
if (model.isSelected) {
|
||||
check.alpha = 1f
|
||||
} else {
|
||||
check.alpha = 0f
|
||||
}
|
||||
|
||||
name.text = model.badge.name
|
||||
}
|
||||
}
|
||||
|
||||
enum class Category(val code: String) {
|
||||
Donor("donor"),
|
||||
Other("other"),
|
||||
Testing("testing"); // Will be removed before final release
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: String): Category {
|
||||
return when (code) {
|
||||
"donor" -> Donor
|
||||
"testing" -> Testing
|
||||
else -> Other
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val SELECTION_CHANGED = Any()
|
||||
|
||||
fun register(mappingAdapter: MappingAdapter, onBadgeClicked: OnBadgeClicked) {
|
||||
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onBadgeClicked) }, R.layout.badge_preference_view))
|
||||
mappingAdapter.registerFactory(EmptyModel::class.java, MappingAdapter.LayoutFactory({ EmptyViewHolder(it) }, R.layout.badge_preference_view))
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class ImageSet(
|
||||
val ldpi: String,
|
||||
val mdpi: String,
|
||||
val hdpi: String,
|
||||
val xhdpi: String,
|
||||
val xxhdpi: String,
|
||||
val xxxhdpi: String
|
||||
) : Parcelable {
|
||||
fun getByDensity(density: String): String {
|
||||
return when (density) {
|
||||
"ldpi" -> ldpi
|
||||
"mdpi" -> mdpi
|
||||
"hdpi" -> hdpi
|
||||
"xhdpi" -> xhdpi
|
||||
"xxhdpi" -> xxhdpi
|
||||
"xxxhdpi" -> xxxhdpi
|
||||
else -> xhdpi
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.thoughtcrime.securesms.badges.models
|
||||
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
object BadgePreview {
|
||||
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.featured_badge_preview_preference))
|
||||
mappingAdapter.registerFactory(SubscriptionModel::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.subscription_flow_badge_preview_preference))
|
||||
}
|
||||
|
||||
abstract class BadgeModel<T : BadgeModel<T>> : PreferenceModel<T>() {
|
||||
abstract val badge: Badge?
|
||||
}
|
||||
|
||||
data class Model(override val badge: Badge?) : BadgeModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return newItem.badge?.id == badge?.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && badge == newItem.badge
|
||||
}
|
||||
}
|
||||
|
||||
data class SubscriptionModel(override val badge: Badge?) : BadgeModel<SubscriptionModel>() {
|
||||
override fun areItemsTheSame(newItem: SubscriptionModel): Boolean {
|
||||
return newItem.badge?.id == badge?.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: SubscriptionModel): Boolean {
|
||||
return super.areContentsTheSame(newItem) && badge == newItem.badge
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder<T : BadgeModel<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
|
||||
|
||||
private val avatar: AvatarImageView = itemView.findViewById(R.id.avatar)
|
||||
private val badge: BadgeImageView = itemView.findViewById(R.id.badge)
|
||||
|
||||
override fun bind(model: T) {
|
||||
avatar.setRecipient(Recipient.self())
|
||||
avatar.disableQuickContact()
|
||||
badge.setBadge(model.badge)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.thoughtcrime.securesms.badges.models
|
||||
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
object ExpiredBadge {
|
||||
|
||||
class Model(val badge: Badge) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return newItem.badge.id == badge.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && newItem.badge == badge
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val badge: BadgeImageView = itemView.findViewById(R.id.expired_badge)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
badge.setBadge(model.badge)
|
||||
}
|
||||
}
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.expired_badge_preference))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.thoughtcrime.securesms.badges.models
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
data class LargeBadge(
|
||||
val badge: Badge
|
||||
) {
|
||||
|
||||
class Model(val largeBadge: LargeBadge, val shortName: String) : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return newItem.largeBadge.badge.id == largeBadge.badge.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return newItem.largeBadge == largeBadge && newItem.shortName == shortName
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyModel : MappingModel<EmptyModel> {
|
||||
override fun areItemsTheSame(newItem: EmptyModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: EmptyModel): Boolean = true
|
||||
}
|
||||
|
||||
class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
|
||||
override fun bind(model: EmptyModel) {
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val badge: BadgeImageView = itemView.findViewById(R.id.badge)
|
||||
private val name: TextView = itemView.findViewById(R.id.name)
|
||||
private val description: TextView = itemView.findViewById(R.id.description)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
badge.setBadge(model.largeBadge.badge)
|
||||
|
||||
name.text = model.largeBadge.badge.name
|
||||
description.text = model.largeBadge.badge.resolveDescription(model.shortName)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.view_badge_bottom_sheet_dialog_fragment_page))
|
||||
mappingAdapter.registerFactory(EmptyModel::class.java, MappingAdapter.LayoutFactory({ EmptyViewHolder(it) }, R.layout.view_badge_bottom_sheet_dialog_fragment_page))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.thoughtcrime.securesms.badges.self.expired
|
||||
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.ExpiredBadge
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
|
||||
/**
|
||||
* Bottom sheet displaying a fading badge with a notice and action for becoming a subscriber again.
|
||||
*/
|
||||
class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
|
||||
peekHeightPercentage = 1f
|
||||
) {
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
ExpiredBadge.register(adapter)
|
||||
|
||||
adapter.submitList(getConfiguration().toMappingModelList())
|
||||
}
|
||||
|
||||
private fun getConfiguration(): DSLConfiguration {
|
||||
val badge = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments()).badge
|
||||
|
||||
return configure {
|
||||
customPref(ExpiredBadge.Model(badge))
|
||||
|
||||
sectionHeaderPref(R.string.ExpiredBadgeBottomSheetDialogFragment__your_badge_has_expired)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(4f).toInt())
|
||||
|
||||
noPadTextPref(
|
||||
DSLSettingsText.from(
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_s_badge_has_expired, badge.name),
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
noPadTextPref(
|
||||
DSLSettingsText.from(
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__to_continue_supporting,
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(92f).toInt())
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__become_a_subscriber),
|
||||
onClick = {
|
||||
dismiss()
|
||||
findNavController().navigate(R.id.action_directly_to_subscribe)
|
||||
}
|
||||
)
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__not_now),
|
||||
onClick = {
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.thoughtcrime.securesms.badges.self.featured
|
||||
|
||||
enum class SelectFeaturedBadgeEvent {
|
||||
NO_BADGE_SELECTED,
|
||||
FAILED_TO_UPDATE_PROFILE,
|
||||
SAVE_SUCCESSFUL
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package org.thoughtcrime.securesms.badges.self.featured
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.Badges.displayBadges
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.BadgePreview
|
||||
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
|
||||
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
|
||||
/**
|
||||
* Fragment which allows user to select one of their badges to be their "Featured" badge.
|
||||
*/
|
||||
class SelectFeaturedBadgeFragment : DSLSettingsFragment(
|
||||
titleId = R.string.BadgesOverviewFragment__featured_badge,
|
||||
layoutId = R.layout.select_featured_badge_fragment,
|
||||
layoutManagerProducer = Badges::createLayoutManagerForGridWithBadges
|
||||
) {
|
||||
|
||||
private val viewModel: SelectFeaturedBadgeViewModel by viewModels(factoryProducer = { SelectFeaturedBadgeViewModel.Factory(BadgeRepository(requireContext())) })
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private lateinit var scrollShadow: View
|
||||
private lateinit var save: View
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
scrollShadow = view.findViewById(R.id.scroll_shadow)
|
||||
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
save = view.findViewById(R.id.save)
|
||||
save.setOnClickListener {
|
||||
viewModel.save()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getOnScrollAnimationHelper(toolbarShadow: View): OnScrollAnimationHelper {
|
||||
return ToolbarShadowAnimationHelper(scrollShadow)
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
Badge.register(adapter) { badge, isSelected ->
|
||||
if (!isSelected) {
|
||||
viewModel.setSelectedBadge(badge)
|
||||
}
|
||||
}
|
||||
|
||||
val previewView: View = requireView().findViewById(R.id.preview)
|
||||
val previewViewHolder = BadgePreview.ViewHolder<BadgePreview.Model>(previewView)
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
lifecycleDisposable += viewModel.events.subscribe { event: SelectFeaturedBadgeEvent ->
|
||||
when (event) {
|
||||
SelectFeaturedBadgeEvent.NO_BADGE_SELECTED -> Toast.makeText(requireContext(), R.string.SelectFeaturedBadgeFragment__you_must_select_a_badge, Toast.LENGTH_LONG).show()
|
||||
SelectFeaturedBadgeEvent.FAILED_TO_UPDATE_PROFILE -> Toast.makeText(requireContext(), R.string.SelectFeaturedBadgeFragment__failed_to_update_profile, Toast.LENGTH_LONG).show()
|
||||
SelectFeaturedBadgeEvent.SAVE_SUCCESSFUL -> findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
save.isEnabled = state.stage == SelectFeaturedBadgeState.Stage.READY
|
||||
previewViewHolder.bind(BadgePreview.Model(state.selectedBadge))
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: SelectFeaturedBadgeState): DSLConfiguration {
|
||||
return configure {
|
||||
sectionHeaderPref(R.string.SelectFeaturedBadgeFragment__select_a_badge)
|
||||
displayBadges(requireContext(), state.allUnlockedBadges, state.selectedBadge)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.thoughtcrime.securesms.badges.self.featured
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
|
||||
data class SelectFeaturedBadgeState(
|
||||
val stage: Stage = Stage.INIT,
|
||||
val selectedBadge: Badge? = null,
|
||||
val allUnlockedBadges: List<Badge> = listOf()
|
||||
) {
|
||||
enum class Stage {
|
||||
INIT,
|
||||
READY,
|
||||
SAVING
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package org.thoughtcrime.securesms.badges.self.featured
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
private val TAG = Log.tag(SelectFeaturedBadgeViewModel::class.java)
|
||||
|
||||
class SelectFeaturedBadgeViewModel(private val repository: BadgeRepository) : ViewModel() {
|
||||
|
||||
private val store = Store(SelectFeaturedBadgeState())
|
||||
private val eventSubject = PublishSubject.create<SelectFeaturedBadgeEvent>()
|
||||
|
||||
val state: LiveData<SelectFeaturedBadgeState> = store.stateLiveData
|
||||
val events: Observable<SelectFeaturedBadgeEvent> = eventSubject.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
init {
|
||||
store.update(Recipient.live(Recipient.self().id).liveDataResolved) { recipient, state ->
|
||||
val unexpiredBadges = recipient.badges.filterNot { it.isExpired() }
|
||||
state.copy(
|
||||
stage = if (state.stage == SelectFeaturedBadgeState.Stage.INIT) SelectFeaturedBadgeState.Stage.READY else state.stage,
|
||||
selectedBadge = unexpiredBadges.firstOrNull(),
|
||||
allUnlockedBadges = unexpiredBadges
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSelectedBadge(badge: Badge) {
|
||||
store.update { it.copy(selectedBadge = badge) }
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val snapshot = store.state
|
||||
if (snapshot.selectedBadge == null) {
|
||||
eventSubject.onNext(SelectFeaturedBadgeEvent.NO_BADGE_SELECTED)
|
||||
return
|
||||
}
|
||||
|
||||
store.update { it.copy(stage = SelectFeaturedBadgeState.Stage.SAVING) }
|
||||
disposables += repository.setFeaturedBadge(snapshot.selectedBadge).subscribeBy(
|
||||
onComplete = {
|
||||
eventSubject.onNext(SelectFeaturedBadgeEvent.SAVE_SUCCESSFUL)
|
||||
},
|
||||
onError = { error ->
|
||||
Log.e(TAG, "Failed to update profile.", error)
|
||||
eventSubject.onNext(SelectFeaturedBadgeEvent.FAILED_TO_UPDATE_PROFILE)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
class Factory(private val badgeRepository: BadgeRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(SelectFeaturedBadgeViewModel(badgeRepository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.thoughtcrime.securesms.badges.self.overview
|
||||
|
||||
enum class BadgesOverviewEvent {
|
||||
FAILED_TO_UPDATE_PROFILE
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package org.thoughtcrime.securesms.badges.self.overview
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.Badges.displayBadges
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.view.ViewBadgeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
|
||||
/**
|
||||
* Fragment to allow user to manage options related to the badges they've unlocked.
|
||||
*/
|
||||
class BadgesOverviewFragment : DSLSettingsFragment(
|
||||
titleId = R.string.ManageProfileFragment_badges,
|
||||
layoutManagerProducer = Badges::createLayoutManagerForGridWithBadges
|
||||
) {
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
private val viewModel: BadgesOverviewViewModel by viewModels(factoryProducer = { BadgesOverviewViewModel.Factory(BadgeRepository(requireContext())) })
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
Badge.register(adapter) { badge, _ ->
|
||||
if (badge.isExpired()) {
|
||||
findNavController().navigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge))
|
||||
} else {
|
||||
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, Recipient.self().id, badge)
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
|
||||
lifecycleDisposable.add(
|
||||
viewModel.events.subscribe { event: BadgesOverviewEvent ->
|
||||
when (event) {
|
||||
BadgesOverviewEvent.FAILED_TO_UPDATE_PROFILE -> Toast.makeText(requireContext(), R.string.BadgesOverviewFragment__failed_to_update_profile, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: BadgesOverviewState): DSLConfiguration {
|
||||
return configure {
|
||||
sectionHeaderPref(R.string.BadgesOverviewFragment__my_badges)
|
||||
|
||||
displayBadges(requireContext(), state.allUnlockedBadges)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.BadgesOverviewFragment__display_badges_on_profile),
|
||||
isChecked = state.displayBadgesOnProfile,
|
||||
isEnabled = state.stage == BadgesOverviewState.Stage.READY && state.hasUnexpiredBadges,
|
||||
onClick = {
|
||||
viewModel.setDisplayBadgesOnProfile(!state.displayBadgesOnProfile)
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.BadgesOverviewFragment__featured_badge),
|
||||
summary = state.featuredBadge?.name?.let { DSLSettingsText.from(it) },
|
||||
isEnabled = state.stage == BadgesOverviewState.Stage.READY && state.hasUnexpiredBadges,
|
||||
onClick = {
|
||||
findNavController().navigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToFeaturedBadgeFragment())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.thoughtcrime.securesms.badges.self.overview
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
|
||||
data class BadgesOverviewState(
|
||||
val stage: Stage = Stage.INIT,
|
||||
val allUnlockedBadges: List<Badge> = listOf(),
|
||||
val featuredBadge: Badge? = null,
|
||||
val displayBadgesOnProfile: Boolean = false,
|
||||
) {
|
||||
|
||||
val hasUnexpiredBadges = allUnlockedBadges.any { it.expirationTimestamp > System.currentTimeMillis() }
|
||||
|
||||
enum class Stage {
|
||||
INIT,
|
||||
READY,
|
||||
UPDATING
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.thoughtcrime.securesms.badges.self.overview
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
private val TAG = Log.tag(BadgesOverviewViewModel::class.java)
|
||||
|
||||
class BadgesOverviewViewModel(private val badgeRepository: BadgeRepository) : ViewModel() {
|
||||
private val store = Store(BadgesOverviewState())
|
||||
private val eventSubject = PublishSubject.create<BadgesOverviewEvent>()
|
||||
|
||||
val state: LiveData<BadgesOverviewState> = store.stateLiveData
|
||||
val events: Observable<BadgesOverviewEvent> = eventSubject.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
val disposables = CompositeDisposable()
|
||||
|
||||
init {
|
||||
store.update(Recipient.live(Recipient.self().id).liveDataResolved) { recipient, state ->
|
||||
state.copy(
|
||||
stage = if (state.stage == BadgesOverviewState.Stage.INIT) BadgesOverviewState.Stage.READY else state.stage,
|
||||
allUnlockedBadges = recipient.badges,
|
||||
displayBadgesOnProfile = recipient.badges.firstOrNull()?.visible == true,
|
||||
featuredBadge = recipient.featuredBadge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setDisplayBadgesOnProfile(displayBadgesOnProfile: Boolean) {
|
||||
disposables += badgeRepository.setVisibilityForAllBadges(displayBadgesOnProfile)
|
||||
.subscribe(
|
||||
{
|
||||
store.update { it.copy(stage = BadgesOverviewState.Stage.READY) }
|
||||
},
|
||||
{ error ->
|
||||
Log.e(TAG, "Failed to update visibility.", error)
|
||||
store.update { it.copy(stage = BadgesOverviewState.Stage.READY) }
|
||||
eventSubject.onNext(BadgesOverviewEvent.FAILED_TO_UPDATE_PROFILE)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
class Factory(private val badgeRepository: BadgeRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package org.thoughtcrime.securesms.badges.view
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.LargeBadge
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
|
||||
private val viewModel: ViewBadgeViewModel by viewModels(factoryProducer = { ViewBadgeViewModel.Factory(getStartBadge(), getRecipientId(), BadgeRepository(requireContext())) })
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.view_badge_bottom_sheet_dialog_fragment, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
postponeEnterTransition()
|
||||
|
||||
val pager: ViewPager2 = view.findViewById(R.id.pager)
|
||||
val tabs: TabLayout = view.findViewById(R.id.tab_layout)
|
||||
val action: MaterialButton = view.findViewById(R.id.action)
|
||||
|
||||
if (getRecipientId() == Recipient.self().id) {
|
||||
action.visible = false
|
||||
}
|
||||
|
||||
val adapter = MappingAdapter()
|
||||
|
||||
LargeBadge.register(adapter)
|
||||
pager.adapter = adapter
|
||||
adapter.submitList(listOf(LargeBadge.EmptyModel()))
|
||||
|
||||
TabLayoutMediator(tabs, pager) { _, _ ->
|
||||
}.attach()
|
||||
|
||||
pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
if (adapter.getModel(position).map { it is LargeBadge.Model }.orElse(false)) {
|
||||
viewModel.onPageSelected(position)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
if (state.recipient == null || state.badgeLoadState == ViewBadgeState.LoadState.INIT) {
|
||||
return@observe
|
||||
}
|
||||
|
||||
if (state.allBadgesVisibleOnProfile.isEmpty()) {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
tabs.visible = state.allBadgesVisibleOnProfile.size > 1
|
||||
|
||||
adapter.submitList(
|
||||
state.allBadgesVisibleOnProfile.map {
|
||||
LargeBadge.Model(LargeBadge(it), state.recipient.getShortDisplayName(requireContext()))
|
||||
}
|
||||
) {
|
||||
val stateSelectedIndex = state.allBadgesVisibleOnProfile.indexOf(state.selectedBadge)
|
||||
if (state.selectedBadge != null && pager.currentItem != stateSelectedIndex) {
|
||||
pager.currentItem = stateSelectedIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getStartBadge(): Badge? = requireArguments().getParcelable(ARG_START_BADGE)
|
||||
|
||||
private fun getRecipientId(): RecipientId = requireNotNull(requireArguments().getParcelable(ARG_RECIPIENT_ID))
|
||||
|
||||
companion object {
|
||||
|
||||
private const val ARG_START_BADGE = "start_badge"
|
||||
private const val ARG_RECIPIENT_ID = "recipient_id"
|
||||
|
||||
@JvmStatic
|
||||
fun show(
|
||||
fragmentManager: FragmentManager,
|
||||
recipientId: RecipientId,
|
||||
startBadge: Badge? = null
|
||||
) {
|
||||
ViewBadgeBottomSheetDialogFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putParcelable(ARG_START_BADGE, startBadge)
|
||||
putParcelable(ARG_RECIPIENT_ID, recipientId)
|
||||
}
|
||||
|
||||
show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.badges.view
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
data class ViewBadgeState(
|
||||
val allBadgesVisibleOnProfile: List<Badge> = listOf(),
|
||||
val badgeLoadState: LoadState = LoadState.INIT,
|
||||
val selectedBadge: Badge? = null,
|
||||
val recipient: Recipient? = null
|
||||
) {
|
||||
enum class LoadState {
|
||||
INIT,
|
||||
LOADED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package org.thoughtcrime.securesms.badges.view
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class ViewBadgeViewModel(
|
||||
private val startBadge: Badge?,
|
||||
private val recipientId: RecipientId,
|
||||
private val repository: BadgeRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
private val store = Store(ViewBadgeState())
|
||||
|
||||
val state: LiveData<ViewBadgeState> = store.stateLiveData
|
||||
|
||||
init {
|
||||
store.update(Recipient.live(recipientId).liveData) { recipient, state ->
|
||||
state.copy(
|
||||
recipient = recipient,
|
||||
allBadgesVisibleOnProfile = recipient.badges,
|
||||
selectedBadge = startBadge ?: recipient.badges.firstOrNull(),
|
||||
badgeLoadState = ViewBadgeState.LoadState.LOADED
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun onPageSelected(position: Int) {
|
||||
if (position > store.state.allBadgesVisibleOnProfile.size - 1 || position < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
store.update {
|
||||
it.copy(selectedBadge = it.allBadgesVisibleOnProfile[position])
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val startBadge: Badge?,
|
||||
private val recipientId: RecipientId,
|
||||
private val repository: BadgeRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(ViewBadgeViewModel(startBadge, recipientId, repository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import android.graphics.PorterDuffColorFilter;
|
||||
import android.graphics.Rect;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
@@ -207,9 +208,9 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
setBackground(null);
|
||||
}
|
||||
|
||||
public @Nullable Projection getProjection() {
|
||||
public @Nullable Projection getProjection(@NonNull ViewGroup coordinateRoot) {
|
||||
if (getVisibility() == VISIBLE) {
|
||||
return Projection.relativeToViewRoot(this, new Projection.Corners(ViewUtil.dpToPx(11)));
|
||||
return Projection.relativeToParent(coordinateRoot, this, new Projection.Corners(ViewUtil.dpToPx(11)));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.PorterDuffXfermode;
|
||||
import android.graphics.Rect;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewTreeObserver;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class MaskView extends View {
|
||||
|
||||
private MaskTarget maskTarget;
|
||||
private ViewGroup activityContentView;
|
||||
private Paint maskPaint;
|
||||
private Rect drawingRect = new Rect();
|
||||
private float targetParentTranslationY;
|
||||
|
||||
private final ViewTreeObserver.OnDrawListener onDrawListener = this::invalidate;
|
||||
|
||||
public MaskView(@NonNull Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public MaskView(@NonNull Context context, @Nullable AttributeSet attributeSet) {
|
||||
super(context, attributeSet);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onFinishInflate() {
|
||||
super.onFinishInflate();
|
||||
|
||||
maskPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
|
||||
setLayerType(LAYER_TYPE_HARDWARE, maskPaint);
|
||||
|
||||
maskPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onAttachedToWindow() {
|
||||
super.onAttachedToWindow();
|
||||
activityContentView = getRootView().findViewById(android.R.id.content);
|
||||
}
|
||||
|
||||
public void setTarget(@Nullable MaskTarget maskTarget) {
|
||||
if (this.maskTarget != null) {
|
||||
removeOnDrawListener(this.maskTarget, onDrawListener);
|
||||
}
|
||||
|
||||
this.maskTarget = maskTarget;
|
||||
|
||||
if (this.maskTarget != null) {
|
||||
addOnDrawListener(maskTarget, onDrawListener);
|
||||
}
|
||||
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void setTargetParentTranslationY(float targetParentTranslationY) {
|
||||
this.targetParentTranslationY = targetParentTranslationY;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(@NonNull Canvas canvas) {
|
||||
super.onDraw(canvas);
|
||||
|
||||
if (nothingToMask(maskTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
maskTarget.getPrimaryTarget().getDrawingRect(drawingRect);
|
||||
activityContentView.offsetDescendantRectToMyCoords(maskTarget.getPrimaryTarget(), drawingRect);
|
||||
|
||||
drawingRect.top += targetParentTranslationY;
|
||||
drawingRect.bottom += targetParentTranslationY;
|
||||
|
||||
Bitmap mask = Bitmap.createBitmap(maskTarget.getPrimaryTarget().getWidth(), drawingRect.height(), Bitmap.Config.ARGB_8888);
|
||||
Canvas maskCanvas = new Canvas(mask);
|
||||
|
||||
maskTarget.draw(maskCanvas);
|
||||
|
||||
canvas.clipRect(drawingRect.left, Math.max(drawingRect.top, getTop() + getPaddingTop()), drawingRect.right, Math.min(drawingRect.bottom, getBottom() - getPaddingBottom()));
|
||||
|
||||
ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) maskTarget.getPrimaryTarget().getLayoutParams();
|
||||
canvas.drawBitmap(mask, params.leftMargin, drawingRect.top, maskPaint);
|
||||
|
||||
mask.recycle();
|
||||
}
|
||||
|
||||
private static void removeOnDrawListener(@NonNull MaskTarget maskTarget, @NonNull ViewTreeObserver.OnDrawListener onDrawListener) {
|
||||
for (View view : maskTarget.getAllTargets()) {
|
||||
if (view != null) {
|
||||
view.getViewTreeObserver().removeOnDrawListener(onDrawListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void addOnDrawListener(@NonNull MaskTarget maskTarget, @NonNull ViewTreeObserver.OnDrawListener onDrawListener) {
|
||||
for (View view : maskTarget.getAllTargets()) {
|
||||
if (view != null) {
|
||||
view.getViewTreeObserver().addOnDrawListener(onDrawListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean nothingToMask(@Nullable MaskTarget maskTarget) {
|
||||
if (maskTarget == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (View view : maskTarget.getAllTargets()) {
|
||||
if (view == null || !view.isAttachedToWindow()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static class MaskTarget {
|
||||
|
||||
private final View primaryTarget;
|
||||
|
||||
public MaskTarget(@NonNull View primaryTarget) {
|
||||
this.primaryTarget = primaryTarget;
|
||||
}
|
||||
|
||||
final @NonNull View getPrimaryTarget() {
|
||||
return primaryTarget;
|
||||
}
|
||||
|
||||
protected @NonNull List<View> getAllTargets() {
|
||||
return Collections.singletonList(primaryTarget);
|
||||
}
|
||||
|
||||
protected void draw(@NonNull Canvas canvas) {
|
||||
primaryTarget.draw(canvas);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.Projection;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
|
||||
@@ -228,55 +229,63 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
bodyView.setVisibility(GONE);
|
||||
mediaDescriptionText.setVisibility(VISIBLE);
|
||||
|
||||
List<Slide> audioSlides = Stream.of(attachments.getSlides()).filter(Slide::hasAudio).limit(1).toList();
|
||||
List<Slide> documentSlides = Stream.of(attachments.getSlides()).filter(Slide::hasDocument).limit(1).toList();
|
||||
List<Slide> imageSlides = Stream.of(attachments.getSlides()).filter(Slide::hasImage).limit(1).toList();
|
||||
List<Slide> videoSlides = Stream.of(attachments.getSlides()).filter(Slide::hasVideo).limit(1).toList();
|
||||
List<Slide> stickerSlides = Stream.of(attachments.getSlides()).filter(Slide::hasSticker).limit(1).toList();
|
||||
List<Slide> viewOnceSlides = Stream.of(attachments.getSlides()).filter(Slide::hasViewOnce).limit(1).toList();
|
||||
Slide audioSlide = attachments.getSlides().stream().filter(Slide::hasAudio).findFirst().orElse(null);
|
||||
Slide documentSlide = attachments.getSlides().stream().filter(Slide::hasDocument).findFirst().orElse(null);
|
||||
Slide imageSlide = attachments.getSlides().stream().filter(Slide::hasImage).findFirst().orElse(null);
|
||||
Slide videoSlide = attachments.getSlides().stream().filter(Slide::hasVideo).findFirst().orElse(null);
|
||||
Slide stickerSlide = attachments.getSlides().stream().filter(Slide::hasSticker).findFirst().orElse(null);
|
||||
Slide viewOnceSlide = attachments.getSlides().stream().filter(Slide::hasViewOnce).findFirst().orElse(null);
|
||||
|
||||
// Given that most types have images, we specifically check images last
|
||||
if (!viewOnceSlides.isEmpty()) {
|
||||
if (viewOnceSlide != null) {
|
||||
mediaDescriptionText.setText(R.string.QuoteView_view_once_media);
|
||||
} else if (!audioSlides.isEmpty()) {
|
||||
} else if (audioSlide != null) {
|
||||
mediaDescriptionText.setText(R.string.QuoteView_audio);
|
||||
} else if (!documentSlides.isEmpty()) {
|
||||
} else if (documentSlide != null) {
|
||||
mediaDescriptionText.setVisibility(GONE);
|
||||
} else if (!videoSlides.isEmpty()) {
|
||||
mediaDescriptionText.setText(R.string.QuoteView_video);
|
||||
} else if (!stickerSlides.isEmpty()) {
|
||||
} else if (videoSlide != null) {
|
||||
if (videoSlide.isVideoGif()) {
|
||||
mediaDescriptionText.setText(R.string.QuoteView_gif);
|
||||
} else {
|
||||
mediaDescriptionText.setText(R.string.QuoteView_video);
|
||||
}
|
||||
} else if (stickerSlide != null) {
|
||||
mediaDescriptionText.setText(R.string.QuoteView_sticker);
|
||||
} else if (!imageSlides.isEmpty()) {
|
||||
mediaDescriptionText.setText(R.string.QuoteView_photo);
|
||||
} else if (imageSlide != null) {
|
||||
if (MediaUtil.isGif(imageSlide.getContentType())) {
|
||||
mediaDescriptionText.setText(R.string.QuoteView_gif);
|
||||
} else {
|
||||
mediaDescriptionText.setText(R.string.QuoteView_photo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void setQuoteAttachment(@NonNull GlideRequests glideRequests, @NonNull SlideDeck slideDeck) {
|
||||
List<Slide> imageVideoSlides = Stream.of(slideDeck.getSlides()).filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).limit(1).toList();
|
||||
List<Slide> documentSlides = Stream.of(attachments.getSlides()).filter(Slide::hasDocument).limit(1).toList();
|
||||
List<Slide> viewOnceSlides = Stream.of(attachments.getSlides()).filter(Slide::hasViewOnce).limit(1).toList();
|
||||
Slide imageVideoSlide = slideDeck.getSlides().stream().filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).findFirst().orElse(null);
|
||||
Slide documentSlide = slideDeck.getSlides().stream().filter(Slide::hasDocument).findFirst().orElse(null);
|
||||
Slide viewOnceSlide = slideDeck.getSlides().stream().filter(Slide::hasViewOnce).findFirst().orElse(null);
|
||||
|
||||
attachmentVideoOverlayView.setVisibility(GONE);
|
||||
|
||||
if (!viewOnceSlides.isEmpty()) {
|
||||
if (viewOnceSlide != null) {
|
||||
thumbnailView.setVisibility(GONE);
|
||||
attachmentContainerView.setVisibility(GONE);
|
||||
} else if (!imageVideoSlides.isEmpty() && imageVideoSlides.get(0).getUri() != null) {
|
||||
} else if (imageVideoSlide != null && imageVideoSlide.getUri() != null) {
|
||||
thumbnailView.setVisibility(VISIBLE);
|
||||
attachmentContainerView.setVisibility(GONE);
|
||||
dismissView.setBackgroundResource(R.drawable.dismiss_background);
|
||||
if (imageVideoSlides.get(0).hasVideo()) {
|
||||
if (imageVideoSlide.hasVideo() && !imageVideoSlide.isVideoGif()) {
|
||||
attachmentVideoOverlayView.setVisibility(VISIBLE);
|
||||
}
|
||||
glideRequests.load(new DecryptableUri(imageVideoSlides.get(0).getUri()))
|
||||
glideRequests.load(new DecryptableUri(imageVideoSlide.getUri()))
|
||||
.centerCrop()
|
||||
.override(getContext().getResources().getDimensionPixelSize(R.dimen.quote_thumb_size))
|
||||
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||
.into(thumbnailView);
|
||||
} else if (!documentSlides.isEmpty()){
|
||||
} else if (documentSlide != null){
|
||||
thumbnailView.setVisibility(GONE);
|
||||
attachmentContainerView.setVisibility(VISIBLE);
|
||||
attachmentNameView.setText(documentSlides.get(0).getFileName().or(""));
|
||||
attachmentNameView.setText(documentSlide.getFileName().or(""));
|
||||
} else {
|
||||
thumbnailView.setVisibility(GONE);
|
||||
attachmentContainerView.setVisibility(GONE);
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.graphics.PixelFormat;
|
||||
import android.graphics.Point;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.Shader;
|
||||
import android.graphics.Xfermode;
|
||||
import android.graphics.drawable.Drawable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
@@ -100,6 +101,10 @@ public final class RotatableGradientDrawable extends Drawable {
|
||||
fillPaint.setShader(new LinearGradient(fillRect.left, fillRect.top, fillRect.right, fillRect.bottom, colors, positions, Shader.TileMode.CLAMP));
|
||||
}
|
||||
|
||||
public void setXfermode(@NonNull Xfermode xfermode) {
|
||||
fillPaint.setXfermode(xfermode);
|
||||
}
|
||||
|
||||
private static Point cornerPrime(@NonNull Point origin, @NonNull Point corner, float degrees) {
|
||||
return new Point(xPrime(origin, corner, Math.toRadians(degrees)), yPrime(origin, corner, Math.toRadians(degrees)));
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.graphics.drawable.ShapeDrawable;
|
||||
import android.graphics.drawable.shapes.RoundRectShape;
|
||||
import android.graphics.drawable.shapes.Shape;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
@@ -438,8 +439,10 @@ public class ThumbnailView extends FrameLayout {
|
||||
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||
.transition(withCrossFade()), fit);
|
||||
|
||||
if (slide.isInProgress()) return request;
|
||||
else return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture));
|
||||
boolean doNotShowMissingThumbnailImage = Build.VERSION.SDK_INT < 23;
|
||||
|
||||
if (slide.isInProgress() || doNotShowMissingThumbnailImage) return request;
|
||||
else return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture));
|
||||
}
|
||||
|
||||
private RequestBuilder buildPlaceholderGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
|
||||
|
||||
@@ -133,7 +133,7 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
previousTransformationMethod = getTransformationMethod();
|
||||
|
||||
if (useSystemEmoji || candidates == null || candidates.size() == 0) {
|
||||
super.setText(new SpannableStringBuilder(Optional.fromNullable(text).or("")), BufferType.NORMAL);
|
||||
super.setText(new SpannableStringBuilder(Optional.fromNullable(text).or("")), BufferType.SPANNABLE);
|
||||
} else {
|
||||
CharSequence emojified = EmojiProvider.emojify(candidates, text, this);
|
||||
super.setText(new SpannableStringBuilder(emojified), BufferType.SPANNABLE);
|
||||
@@ -165,10 +165,9 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
|
||||
int lines = layout.getLineCount();
|
||||
int start = layout.getLineStart(lines - 1);
|
||||
int count = text.length() - start;
|
||||
|
||||
if ((getLayoutDirection() == LAYOUT_DIRECTION_LTR && textDirection.isRtl(text, start, count)) ||
|
||||
(getLayoutDirection() == LAYOUT_DIRECTION_RTL && !textDirection.isRtl(text, start, count))) {
|
||||
if ((getLayoutDirection() == LAYOUT_DIRECTION_LTR && textDirection.isRtl(text, 0, text.length())) ||
|
||||
(getLayoutDirection() == LAYOUT_DIRECTION_RTL && !textDirection.isRtl(text, 0, text.length()))) {
|
||||
lastLineWidth = getMeasuredWidth();
|
||||
} else {
|
||||
lastLineWidth = (int) getPaint().measureText(text, start, text.length());
|
||||
@@ -220,7 +219,7 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
EmojiParser.CandidateList newCandidates = isInEditMode() ? null : EmojiProvider.getCandidates(newContent);
|
||||
|
||||
if (useSystemEmoji || newCandidates == null || newCandidates.size() == 0) {
|
||||
super.setText(newContent, BufferType.NORMAL);
|
||||
super.setText(newContent, BufferType.SPANNABLE);
|
||||
} else {
|
||||
CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this);
|
||||
super.setText(emojified, BufferType.SPANNABLE);
|
||||
@@ -266,7 +265,7 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
Util.equals(previousBufferType, bufferType) &&
|
||||
useSystemEmoji == useSystemEmoji() &&
|
||||
!sizeChangeInProgress &&
|
||||
previousTransformationMethod != getTransformationMethod();
|
||||
previousTransformationMethod == getTransformationMethod();
|
||||
}
|
||||
|
||||
private boolean useSystemEmoji() {
|
||||
|
||||
@@ -19,12 +19,16 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
|
||||
bufferType = type
|
||||
val candidates = if (isInEditMode) null else EmojiProvider.getCandidates(text)
|
||||
if (SignalStore.settings().isPreferSystemEmoji || candidates == null || candidates.size() == 0) {
|
||||
super.setText(Optional.fromNullable(text).or(""), BufferType.NORMAL)
|
||||
super.setText(Optional.fromNullable(text).or(""), type)
|
||||
} else {
|
||||
val startDrawableSize: Int = compoundDrawables[0]?.let { it.intrinsicWidth + compoundDrawablePadding } ?: 0
|
||||
val endDrawableSize: Int = compoundDrawables[1]?.let { it.intrinsicWidth + compoundDrawablePadding } ?: 0
|
||||
val adjustedWidth: Int = width - startDrawableSize - endDrawableSize
|
||||
|
||||
val newContent = if (width == 0 || maxLines == -1) {
|
||||
text
|
||||
} else {
|
||||
TextUtils.ellipsize(text, paint, (width * maxLines).toFloat(), TextUtils.TruncateAt.END, false, null)
|
||||
TextUtils.ellipsize(text, paint, (adjustedWidth * maxLines).toFloat(), TextUtils.TruncateAt.END, false, null)
|
||||
}
|
||||
|
||||
val newCandidates = if (isInEditMode) null else EmojiProvider.getCandidates(newContent)
|
||||
@@ -41,7 +45,7 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
|
||||
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
|
||||
super.onSizeChanged(width, height, oldWidth, oldHeight)
|
||||
if (width > 0 && oldWidth != width) {
|
||||
setText(text, bufferType ?: BufferType.NORMAL)
|
||||
setText(text, bufferType ?: BufferType.SPANNABLE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@ import androidx.annotation.CallSuper
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.models.Button
|
||||
import org.thoughtcrime.securesms.components.settings.models.Space
|
||||
import org.thoughtcrime.securesms.components.settings.models.Text
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
@@ -30,6 +34,9 @@ class DSLSettingsAdapter : MappingAdapter() {
|
||||
registerFactory(SectionHeaderPreference::class.java, LayoutFactory(::SectionHeaderPreferenceViewHolder, R.layout.dsl_section_header))
|
||||
registerFactory(SwitchPreference::class.java, LayoutFactory(::SwitchPreferenceViewHolder, R.layout.dsl_switch_preference_item))
|
||||
registerFactory(RadioPreference::class.java, LayoutFactory(::RadioPreferenceViewHolder, R.layout.dsl_radio_preference_item))
|
||||
Text.register(this)
|
||||
Space.register(this)
|
||||
Button.register(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,8 +104,13 @@ class RadioListPreferenceViewHolder(itemView: View) : PreferenceViewHolder<Radio
|
||||
override fun bind(model: RadioListPreference) {
|
||||
super.bind(model)
|
||||
|
||||
summaryView.visibility = View.VISIBLE
|
||||
summaryView.text = model.listItems[model.selected]
|
||||
if (model.selected >= 0) {
|
||||
summaryView.visibility = View.VISIBLE
|
||||
summaryView.text = model.listItems[model.selected]
|
||||
} else {
|
||||
summaryView.visibility = View.GONE
|
||||
Log.w(TAG, "Detected a radio list without a default selection: ${model.dialogTitle}")
|
||||
}
|
||||
|
||||
itemView.setOnClickListener {
|
||||
var selection = -1
|
||||
@@ -128,6 +140,10 @@ class RadioListPreferenceViewHolder(itemView: View) : PreferenceViewHolder<Radio
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(RadioListPreference::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
class MultiSelectListPreferenceViewHolder(itemView: View) : PreferenceViewHolder<MultiSelectListPreference>(itemView) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.thoughtcrime.securesms.components.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EdgeEffect
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
|
||||
abstract class DSLSettingsBottomSheetFragment(
|
||||
@LayoutRes private val layoutId: Int = R.layout.dsl_settings_bottom_sheet,
|
||||
val layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) },
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
) : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
|
||||
private lateinit var recyclerView: RecyclerView
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(layoutId, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
recyclerView = view.findViewById(R.id.recycler)
|
||||
recyclerView.edgeEffectFactory = EdgeEffectFactory()
|
||||
val adapter = DSLSettingsAdapter()
|
||||
|
||||
recyclerView.layoutManager = layoutManagerProducer(requireContext())
|
||||
recyclerView.adapter = adapter
|
||||
|
||||
bindAdapter(adapter)
|
||||
}
|
||||
|
||||
abstract fun bindAdapter(adapter: DSLSettingsAdapter)
|
||||
|
||||
private class EdgeEffectFactory : RecyclerView.EdgeEffectFactory() {
|
||||
override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect {
|
||||
return super.createEdgeEffect(view, direction).apply {
|
||||
if (Build.VERSION.SDK_INT > 21) {
|
||||
color =
|
||||
requireNotNull(ContextCompat.getColor(view.context, R.color.settings_ripple_color))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
@@ -10,6 +11,7 @@ import androidx.annotation.StringRes
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
|
||||
@@ -18,7 +20,8 @@ import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimation
|
||||
abstract class DSLSettingsFragment(
|
||||
@StringRes private val titleId: Int = -1,
|
||||
@MenuRes private val menuId: Int = -1,
|
||||
@LayoutRes layoutId: Int = R.layout.dsl_settings_fragment
|
||||
@LayoutRes layoutId: Int = R.layout.dsl_settings_fragment,
|
||||
val layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) }
|
||||
) : Fragment(layoutId) {
|
||||
|
||||
private lateinit var recyclerView: RecyclerView
|
||||
@@ -46,6 +49,7 @@ abstract class DSLSettingsFragment(
|
||||
scrollAnimationHelper = getOnScrollAnimationHelper(toolbarShadow)
|
||||
val adapter = DSLSettingsAdapter()
|
||||
|
||||
recyclerView.layoutManager = layoutManagerProducer(requireContext())
|
||||
recyclerView.adapter = adapter
|
||||
recyclerView.addOnScrollListener(scrollAnimationHelper)
|
||||
|
||||
|
||||
@@ -3,35 +3,71 @@ package org.thoughtcrime.securesms.components.settings
|
||||
import android.content.Context
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.StyleRes
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
|
||||
sealed class DSLSettingsText {
|
||||
|
||||
protected abstract val modifiers: List<Modifier>
|
||||
|
||||
private data class FromResource(
|
||||
@StringRes private val stringId: Int,
|
||||
@ColorInt private val textColor: Int?
|
||||
override val modifiers: List<Modifier>
|
||||
) : DSLSettingsText() {
|
||||
override fun resolve(context: Context): CharSequence {
|
||||
val text = context.getString(stringId)
|
||||
|
||||
return if (textColor == null) {
|
||||
text
|
||||
} else {
|
||||
SpanUtil.color(textColor, text)
|
||||
}
|
||||
override fun getCharSequence(context: Context): CharSequence {
|
||||
return context.getString(stringId)
|
||||
}
|
||||
}
|
||||
|
||||
private data class FromCharSequence(private val charSequence: CharSequence) : DSLSettingsText() {
|
||||
override fun resolve(context: Context): CharSequence = charSequence
|
||||
private data class FromCharSequence(
|
||||
private val charSequence: CharSequence,
|
||||
override val modifiers: List<Modifier>
|
||||
) : DSLSettingsText() {
|
||||
override fun getCharSequence(context: Context): CharSequence = charSequence
|
||||
}
|
||||
|
||||
abstract fun resolve(context: Context): CharSequence
|
||||
protected abstract fun getCharSequence(context: Context): CharSequence
|
||||
|
||||
fun resolve(context: Context): CharSequence {
|
||||
val text: CharSequence = getCharSequence(context)
|
||||
return modifiers.fold(text) { t, m -> m.modify(context, t) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(@StringRes stringId: Int, @ColorInt textColor: Int? = null): DSLSettingsText =
|
||||
FromResource(stringId, textColor)
|
||||
fun from(@StringRes stringId: Int, @ColorInt textColor: Int): DSLSettingsText =
|
||||
FromResource(stringId, listOf(ColorModifier(textColor)))
|
||||
|
||||
fun from(charSequence: CharSequence): DSLSettingsText = FromCharSequence(charSequence)
|
||||
fun from(@StringRes stringId: Int, vararg modifiers: Modifier): DSLSettingsText =
|
||||
FromResource(stringId, modifiers.toList())
|
||||
|
||||
fun from(charSequence: CharSequence, vararg modifiers: Modifier): DSLSettingsText =
|
||||
FromCharSequence(charSequence, modifiers.toList())
|
||||
}
|
||||
|
||||
interface Modifier {
|
||||
fun modify(context: Context, charSequence: CharSequence): CharSequence
|
||||
}
|
||||
|
||||
class ColorModifier(@ColorInt private val textColor: Int) : Modifier {
|
||||
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
|
||||
return SpanUtil.color(textColor, charSequence)
|
||||
}
|
||||
}
|
||||
|
||||
object CenterModifier : Modifier {
|
||||
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
|
||||
return SpanUtil.center(charSequence)
|
||||
}
|
||||
}
|
||||
|
||||
object Title2BoldModifier : TextAppearanceModifier(R.style.TextAppearance_Signal_Title2_Bold)
|
||||
object Body1Modifier : TextAppearanceModifier(R.style.Signal_Text_Body)
|
||||
object Body1BoldModifier : TextAppearanceModifier(R.style.TextAppearance_Signal_Body1_Bold)
|
||||
|
||||
open class TextAppearanceModifier(@StyleRes private val textAppearance: Int) : Modifier {
|
||||
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
|
||||
return SpanUtil.textAppearance(context, textAppearance, charSequence)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,23 @@ package org.thoughtcrime.securesms.components.settings.app
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.navigation.NavDirections
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.BoostRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.BoostViewModel
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.subscribe.SubscribeViewModel
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SettingsValues
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService
|
||||
import org.thoughtcrime.securesms.util.CachedInflater
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
|
||||
private const val START_LOCATION = "app.settings.start.location"
|
||||
private const val NOTIFICATION_CATEGORY = "android.intent.category.NOTIFICATION_PREFERENCES"
|
||||
@@ -22,7 +29,23 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
|
||||
private var wasConfigurationUpdated = false
|
||||
|
||||
private val donationRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
|
||||
private val subscribeViewModel: SubscribeViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
SubscribeViewModel.Factory(SubscriptionsRepository(), donationRepository, FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE)
|
||||
}
|
||||
)
|
||||
|
||||
private val boostViewModel: BoostViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
BoostViewModel.Factory(BoostRepository(), donationRepository, FETCH_BOOST_TOKEN_REQUEST_CODE)
|
||||
}
|
||||
)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
|
||||
warmDonationViewModels()
|
||||
|
||||
if (intent?.hasExtra(ARG_NAV_GRAPH) != true) {
|
||||
intent?.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings)
|
||||
}
|
||||
@@ -79,8 +102,17 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
subscribeViewModel.onActivityResult(requestCode, resultCode, data)
|
||||
boostViewModel.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE = 1000
|
||||
private const val FETCH_BOOST_TOKEN_REQUEST_CODE = 2000
|
||||
|
||||
@JvmStatic
|
||||
fun home(context: Context): Intent = getIntentForStartLocation(context, StartLocation.HOME)
|
||||
|
||||
@@ -109,6 +141,13 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun warmDonationViewModels() {
|
||||
if (FeatureFlags.donorBadges()) {
|
||||
subscribeViewModel
|
||||
boostViewModel
|
||||
}
|
||||
}
|
||||
|
||||
private enum class StartLocation(val code: Int) {
|
||||
HOME(0),
|
||||
BACKUPS(1),
|
||||
|
||||
@@ -4,7 +4,9 @@ import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
@@ -129,11 +131,33 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
}
|
||||
)
|
||||
|
||||
externalLinkPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
linkId = R.string.donate_url
|
||||
)
|
||||
if (FeatureFlags.donorBadges()) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__subscription),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
onClick = {
|
||||
findNavController()
|
||||
.navigate(
|
||||
AppSettingsFragmentDirections.actionAppSettingsFragmentToSubscriptions()
|
||||
.setSkipToSubscribe(true /* TODO [alex] -- Check state to see if user has active subscription or not. */)
|
||||
)
|
||||
}
|
||||
)
|
||||
// TODO [alex] -- clap
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__signal_boost),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
onClick = {
|
||||
findNavController().navigate(R.id.action_appSettingsFragment_to_boostsFragment)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
externalLinkPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
linkId = R.string.donate_url
|
||||
)
|
||||
}
|
||||
|
||||
if (FeatureFlags.internalUser()) {
|
||||
dividerPref()
|
||||
@@ -162,6 +186,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
|
||||
private val avatarView: AvatarImageView = itemView.findViewById(R.id.icon)
|
||||
private val aboutView: TextView = itemView.findViewById(R.id.about)
|
||||
private val badgeView: BadgeImageView = itemView.findViewById(R.id.badge)
|
||||
|
||||
override fun bind(model: BioPreference) {
|
||||
super.bind(model)
|
||||
@@ -171,6 +196,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
titleView.text = model.recipient.profileName.toString()
|
||||
summaryView.text = PhoneNumberFormatter.prettyPrint(model.recipient.requireE164())
|
||||
avatarView.setRecipient(Recipient.self())
|
||||
badgeView.setBadgeFromRecipient(Recipient.self())
|
||||
|
||||
titleView.visibility = View.VISIBLE
|
||||
summaryView.visibility = View.VISIBLE
|
||||
|
||||
@@ -10,9 +10,9 @@ import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNum
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getCaptchaArguments
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.registration.fragments.BaseEnterCodeFragment
|
||||
import org.thoughtcrime.securesms.registration.fragments.BaseEnterSmsCodeFragment
|
||||
|
||||
class ChangeNumberEnterCodeFragment : BaseEnterCodeFragment<ChangeNumberViewModel>(R.layout.fragment_change_number_enter_code) {
|
||||
class ChangeNumberEnterSmsCodeFragment : BaseEnterSmsCodeFragment<ChangeNumberViewModel>(R.layout.fragment_change_number_enter_code) {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
@@ -54,10 +54,10 @@ class ChangeNumberEnterCodeFragment : BaseEnterCodeFragment<ChangeNumberViewMode
|
||||
}
|
||||
|
||||
override fun navigateToRegistrationLock(timeRemaining: Long) {
|
||||
findNavController().navigate(ChangeNumberEnterCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberRegistrationLock(timeRemaining))
|
||||
findNavController().navigate(ChangeNumberEnterSmsCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberRegistrationLock(timeRemaining))
|
||||
}
|
||||
|
||||
override fun navigateToKbsAccountLocked() {
|
||||
findNavController().navigate(ChangeNumberEnterCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberAccountLocked())
|
||||
findNavController().navigate(ChangeNumberEnterSmsCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberAccountLocked())
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.mms.SentMediaQuality
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.webrtc.CallBandwidthMode
|
||||
import kotlin.math.abs
|
||||
@@ -18,6 +19,8 @@ class DataAndStorageSettingsFragment : DSLSettingsFragment(R.string.preferences_
|
||||
private val autoDownloadValues by lazy { resources.getStringArray(R.array.pref_media_download_entries) }
|
||||
private val autoDownloadLabels by lazy { resources.getStringArray(R.array.pref_media_download_values) }
|
||||
|
||||
private val sentMediaQualityLabels by lazy { SentMediaQuality.getLabels(requireContext()) }
|
||||
|
||||
private val callBandwidthLabels by lazy { resources.getStringArray(R.array.pref_data_and_storage_call_bandwidth_values) }
|
||||
|
||||
private lateinit var viewModel: DataAndStorageSettingsViewModel
|
||||
@@ -84,6 +87,21 @@ class DataAndStorageSettingsFragment : DSLSettingsFragment(R.string.preferences_
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.DataAndStorageSettingsFragment__media_quality)
|
||||
|
||||
radioListPref(
|
||||
title = DSLSettingsText.from(R.string.DataAndStorageSettingsFragment__sent_media_quality),
|
||||
listItems = sentMediaQualityLabels,
|
||||
selected = SentMediaQuality.values().indexOf(state.sentMediaQuality),
|
||||
onSelected = { viewModel.setSentMediaQuality(SentMediaQuality.values()[it]) }
|
||||
)
|
||||
|
||||
textPref(
|
||||
summary = DSLSettingsText.from(R.string.DataAndStorageSettingsFragment__sending_high_quality_media_will_use_more_data)
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.DataAndStorageSettingsFragment__calls)
|
||||
|
||||
radioListPref(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.data
|
||||
|
||||
import org.thoughtcrime.securesms.mms.SentMediaQuality
|
||||
import org.thoughtcrime.securesms.webrtc.CallBandwidthMode
|
||||
|
||||
data class DataAndStorageSettingsState(
|
||||
@@ -8,5 +9,6 @@ data class DataAndStorageSettingsState(
|
||||
val wifiAutoDownloadValues: Set<String>,
|
||||
val roamingAutoDownloadValues: Set<String>,
|
||||
val callBandwidthMode: CallBandwidthMode,
|
||||
val isProxyEnabled: Boolean
|
||||
val isProxyEnabled: Boolean,
|
||||
val sentMediaQuality: SentMediaQuality
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.SentMediaQuality
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import org.thoughtcrime.securesms.webrtc.CallBandwidthMode
|
||||
@@ -46,6 +47,11 @@ class DataAndStorageSettingsViewModel(
|
||||
getStateAndCopyStorageUsage()
|
||||
}
|
||||
|
||||
fun setSentMediaQuality(sentMediaQuality: SentMediaQuality) {
|
||||
SignalStore.settings().sentMediaQuality = sentMediaQuality
|
||||
getStateAndCopyStorageUsage()
|
||||
}
|
||||
|
||||
private fun getStateAndCopyStorageUsage() {
|
||||
store.update { getState().copy(totalStorageUse = it.totalStorageUse) }
|
||||
}
|
||||
@@ -62,7 +68,8 @@ class DataAndStorageSettingsViewModel(
|
||||
ApplicationDependencies.getApplication()
|
||||
),
|
||||
callBandwidthMode = SignalStore.settings().callBandwidthMode,
|
||||
isProxyEnabled = SignalStore.proxy().isProxyEnabled
|
||||
isProxyEnabled = SignalStore.proxy().isProxyEnabled,
|
||||
sentMediaQuality = SignalStore.settings().sentMediaQuality
|
||||
)
|
||||
|
||||
class Factory(
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.database.LocalMetricsDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
|
||||
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
|
||||
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob
|
||||
import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob
|
||||
@@ -107,6 +108,15 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
|
||||
sectionHeaderPref(R.string.preferences__internal_storage_service)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__internal_disable_storage_service),
|
||||
summary = DSLSettingsText.from(R.string.preferences__internal_disable_storage_service_description),
|
||||
isChecked = state.disableStorageService,
|
||||
onClick = {
|
||||
viewModel.setDisableStorageService(!state.disableStorageService)
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__internal_force_storage_service_sync),
|
||||
summary = DSLSettingsText.from(R.string.preferences__internal_force_storage_service_sync_description),
|
||||
@@ -225,6 +235,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__internal_force_emoji_download),
|
||||
summary = DSLSettingsText.from(R.string.preferences__internal_force_emoji_download_description),
|
||||
onClick = {
|
||||
ApplicationDependencies.getJobManager().add(DownloadLatestEmojiDataJob(true))
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.preferences__internal_sender_key)
|
||||
@@ -314,12 +332,12 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
.setPositiveButton(
|
||||
"Copy"
|
||||
) { _: DialogInterface?, _: Int ->
|
||||
val context: Context = ApplicationDependencies.getApplication()
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
|
||||
SimpleTask.run<Any?>(
|
||||
SignalExecutors.UNBOUNDED,
|
||||
{
|
||||
val context: Context = ApplicationDependencies.getApplication()
|
||||
val clipboard =
|
||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val tsv = DataExportUtil.createTsv()
|
||||
val clip = ClipData.newPlainText(context.getString(R.string.app_name), tsv)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
|
||||
@@ -17,4 +17,5 @@ data class InternalSettingsState(
|
||||
val emojiVersion: EmojiFiles.Version?,
|
||||
val removeSenderKeyMinimium: Boolean,
|
||||
val delayResends: Boolean,
|
||||
val disableStorageService: Boolean,
|
||||
)
|
||||
|
||||
@@ -30,6 +30,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setDisableStorageService(enabled: Boolean) {
|
||||
preferenceDataStore.putBoolean(InternalValues.DISABLE_STORAGE_SERVICE, enabled)
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setGv2DoNotCreateGv2Groups(enabled: Boolean) {
|
||||
preferenceDataStore.putBoolean(InternalValues.GV2_DO_NOT_CREATE_GV2, enabled)
|
||||
refresh()
|
||||
@@ -103,7 +108,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
useBuiltInEmojiSet = SignalStore.internalValues().forceBuiltInEmoji(),
|
||||
emojiVersion = null,
|
||||
removeSenderKeyMinimium = SignalStore.internalValues().removeSenderKeyMinimum(),
|
||||
delayResends = SignalStore.internalValues().delayResends()
|
||||
delayResends = SignalStore.internalValues().delayResends(),
|
||||
disableStorageService = SignalStore.internalValues().storageServiceDisabled()
|
||||
)
|
||||
|
||||
class Factory(private val repository: InternalSettingsRepository) : ViewModelProvider.Factory {
|
||||
|
||||
@@ -30,6 +30,7 @@ class AdvancedPrivacySettingsViewModel(
|
||||
AdvancedPrivacySettingsRepository.DisablePushMessagesResult.SUCCESS -> {
|
||||
TextSecurePreferences.setPushRegistered(ApplicationDependencies.getApplication(), false)
|
||||
SignalStore.registrationValues().clearRegistrationComplete()
|
||||
SignalStore.registrationValues().clearHasUploadedProfile()
|
||||
}
|
||||
AdvancedPrivacySettingsRepository.DisablePushMessagesResult.NETWORK_ERROR -> {
|
||||
singleEvents.postValue(Event.DISABLE_PUSH_FAILED)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
|
||||
/**
|
||||
* Events that can arise from use of the donations apis.
|
||||
*/
|
||||
sealed class DonationEvent {
|
||||
class GooglePayUnavailableError(val throwable: Throwable) : DonationEvent()
|
||||
object RequestTokenSuccess : DonationEvent()
|
||||
object RequestTokenError : DonationEvent()
|
||||
class PaymentConfirmationError(val throwable: Throwable) : DonationEvent()
|
||||
class PaymentConfirmationSuccess(val badge: Badge) : DonationEvent()
|
||||
object SubscriptionCancelled : DonationEvent()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import com.google.android.gms.wallet.PaymentData
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.GooglePayApi
|
||||
import org.signal.donations.GooglePayPaymentSource
|
||||
import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
|
||||
class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFetcher {
|
||||
|
||||
private val configuration = StripeApi.Configuration(publishableKey = BuildConfig.STRIPE_PUBLISHABLE_KEY)
|
||||
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(configuration))
|
||||
private val stripeApi = StripeApi(configuration, this, ApplicationDependencies.getOkHttpClient())
|
||||
|
||||
fun isGooglePayAvailable(): Completable = googlePayApi.queryIsReadyToPay()
|
||||
|
||||
fun requestTokenFromGooglePay(price: FiatMoney, label: String, requestCode: Int) {
|
||||
googlePayApi.requestPayment(price, label, requestCode)
|
||||
}
|
||||
|
||||
fun onActivityResult(
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
data: Intent?,
|
||||
expectedRequestCode: Int,
|
||||
paymentsRequestCallback: GooglePayApi.PaymentRequestCallback
|
||||
) {
|
||||
googlePayApi.onActivityResult(requestCode, resultCode, data, expectedRequestCode, paymentsRequestCallback)
|
||||
}
|
||||
|
||||
fun continuePayment(price: FiatMoney, paymentData: PaymentData): Completable {
|
||||
return stripeApi.createPaymentIntent(price)
|
||||
.flatMapCompletable { result ->
|
||||
when (result) {
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(Exception("Amount is too small"))
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(Exception("Amount is too large"))
|
||||
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(Exception("Currency is not supported"))
|
||||
is StripeApi.CreatePaymentIntentResult.Success -> stripeApi.confirmPaymentIntent(GooglePayPaymentSource(paymentData), result.paymentIntent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchPaymentIntent(price: FiatMoney, description: String?): Single<StripeApi.PaymentIntent> {
|
||||
return ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode)
|
||||
.map { StripeApi.PaymentIntent(it.result.get().id, it.result.get().clientSecret) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import java.util.Currency
|
||||
|
||||
/**
|
||||
* Repository which can query for the user's active subscription as well as a list of available subscriptions,
|
||||
* in the currency indicated.
|
||||
*/
|
||||
class SubscriptionsRepository {
|
||||
|
||||
fun getActiveSubscription(currency: Currency): Maybe<Subscription> = Maybe.empty()
|
||||
|
||||
fun getSubscriptions(currency: Currency): Single<List<Subscription>> = Single.fromCallable {
|
||||
listOf()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
import android.text.Editable
|
||||
import android.text.Spanned
|
||||
import android.text.TextWatcher
|
||||
import android.text.method.DigitsKeyListener
|
||||
import android.view.View
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import java.lang.Integer.min
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
import java.util.regex.Pattern
|
||||
|
||||
/**
|
||||
* A Signal Boost is a one-time ephemeral show of support. Each boost level
|
||||
* can unlock a corresponding badge for a time determined by the server.
|
||||
*/
|
||||
data class Boost(
|
||||
val badge: Badge,
|
||||
val price: FiatMoney
|
||||
) {
|
||||
|
||||
/**
|
||||
* A heading containing a 96dp rendering of the boost's badge.
|
||||
*/
|
||||
class HeadingModel(
|
||||
val boostBadge: Badge
|
||||
) : PreferenceModel<HeadingModel>() {
|
||||
override fun areItemsTheSame(newItem: HeadingModel): Boolean = true
|
||||
|
||||
override fun areContentsTheSame(newItem: HeadingModel): Boolean {
|
||||
return super.areContentsTheSame(newItem) && newItem.boostBadge == boostBadge
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A widget that allows a user to select from six different amounts, or enter a custom amount.
|
||||
*/
|
||||
class SelectionModel(
|
||||
val boosts: List<Boost>,
|
||||
val selectedBoost: Boost?,
|
||||
val currency: Currency,
|
||||
override val isEnabled: Boolean,
|
||||
val onBoostClick: (Boost) -> Unit,
|
||||
val isCustomAmountFocused: Boolean,
|
||||
val onCustomAmountChanged: (String) -> Unit,
|
||||
val onCustomAmountFocusChanged: (Boolean) -> Unit,
|
||||
) : PreferenceModel<SelectionModel>(isEnabled = isEnabled) {
|
||||
override fun areItemsTheSame(newItem: SelectionModel): Boolean = true
|
||||
|
||||
override fun areContentsTheSame(newItem: SelectionModel): Boolean {
|
||||
return super.areContentsTheSame(newItem) &&
|
||||
newItem.boosts == boosts &&
|
||||
newItem.selectedBoost == selectedBoost &&
|
||||
newItem.currency == currency &&
|
||||
newItem.isCustomAmountFocused == isCustomAmountFocused
|
||||
}
|
||||
}
|
||||
|
||||
private class SelectionViewHolder(itemView: View) : MappingViewHolder<SelectionModel>(itemView) {
|
||||
|
||||
private val boost1: MaterialButton = itemView.findViewById(R.id.boost_1)
|
||||
private val boost2: MaterialButton = itemView.findViewById(R.id.boost_2)
|
||||
private val boost3: MaterialButton = itemView.findViewById(R.id.boost_3)
|
||||
private val boost4: MaterialButton = itemView.findViewById(R.id.boost_4)
|
||||
private val boost5: MaterialButton = itemView.findViewById(R.id.boost_5)
|
||||
private val boost6: MaterialButton = itemView.findViewById(R.id.boost_6)
|
||||
private val custom: AppCompatEditText = itemView.findViewById(R.id.boost_custom)
|
||||
|
||||
private var filter: MoneyFilter? = null
|
||||
|
||||
init {
|
||||
custom.filters = emptyArray()
|
||||
}
|
||||
|
||||
override fun bind(model: SelectionModel) {
|
||||
itemView.isEnabled = model.isEnabled
|
||||
|
||||
model.boosts.zip(listOf(boost1, boost2, boost3, boost4, boost5, boost6)).forEach { (boost, button) ->
|
||||
button.isSelected = boost == model.selectedBoost && !model.isCustomAmountFocused
|
||||
button.text = FiatMoneyUtil.format(
|
||||
context.resources,
|
||||
boost.price,
|
||||
FiatMoneyUtil.formatOptions()
|
||||
)
|
||||
button.setOnClickListener {
|
||||
model.onBoostClick(boost)
|
||||
custom.clearFocus()
|
||||
}
|
||||
}
|
||||
|
||||
if (filter == null || filter?.currency != model.currency) {
|
||||
custom.removeTextChangedListener(filter)
|
||||
|
||||
filter = MoneyFilter(model.currency) {
|
||||
model.onCustomAmountChanged(it)
|
||||
}
|
||||
|
||||
custom.keyListener = filter
|
||||
custom.addTextChangedListener(filter)
|
||||
|
||||
custom.setText("")
|
||||
}
|
||||
|
||||
custom.setOnFocusChangeListener { _, hasFocus ->
|
||||
model.onCustomAmountFocusChanged(hasFocus)
|
||||
}
|
||||
|
||||
if (model.isCustomAmountFocused && !custom.hasFocus()) {
|
||||
ViewUtil.focusAndShowKeyboard(custom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class HeadingViewHolder(itemView: View) : MappingViewHolder<HeadingModel>(itemView) {
|
||||
|
||||
private val badgeImageView: BadgeImageView = itemView as BadgeImageView
|
||||
|
||||
override fun bind(model: HeadingModel) {
|
||||
badgeImageView.setBadge(model.boostBadge)
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
class MoneyFilter(val currency: Currency, private val onCustomAmountChanged: (String) -> Unit = {}) : DigitsKeyListener(), TextWatcher {
|
||||
|
||||
val separatorCount = min(1, currency.defaultFractionDigits)
|
||||
val prefix: String = "${currency.getSymbol(Locale.getDefault())} "
|
||||
val pattern: Pattern = "[0-9]*([.,]){0,$separatorCount}[0-9]{0,${currency.defaultFractionDigits}}".toPattern()
|
||||
|
||||
override fun filter(
|
||||
source: CharSequence,
|
||||
start: Int,
|
||||
end: Int,
|
||||
dest: Spanned,
|
||||
dstart: Int,
|
||||
dend: Int
|
||||
): CharSequence? {
|
||||
|
||||
val result = dest.subSequence(0, dstart).toString() + source.toString() + dest.subSequence(dend, dest.length)
|
||||
val resultWithoutCurrencyPrefix = result.removePrefix(prefix)
|
||||
val matcher = pattern.matcher(resultWithoutCurrencyPrefix)
|
||||
|
||||
if (!matcher.matches()) {
|
||||
return dest.subSequence(dstart, dend)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
if (s.isNullOrEmpty()) return
|
||||
|
||||
val hasPrefix = s.startsWith(prefix)
|
||||
if (hasPrefix && s.length == prefix.length) {
|
||||
s.clear()
|
||||
} else if (!hasPrefix) {
|
||||
s.insert(0, prefix)
|
||||
}
|
||||
|
||||
onCustomAmountChanged(s.removePrefix(prefix).toString())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(SelectionModel::class.java, MappingAdapter.LayoutFactory({ SelectionViewHolder(it) }, R.layout.boost_preference))
|
||||
adapter.registerFactory(HeadingModel::class.java, MappingAdapter.LayoutFactory({ HeadingViewHolder(it) }, R.layout.boost_preview_preference))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
import android.text.SpannableStringBuilder
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.BadgePreview
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
|
||||
/**
|
||||
* UX to allow users to donate ephemerally.
|
||||
*/
|
||||
class BoostFragment : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
private val viewModel: BoostViewModel by viewModels(ownerProducer = { requireActivity() })
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private val sayThanks: CharSequence by lazy {
|
||||
SpannableStringBuilder(requireContext().getString(R.string.BoostFragment__say_thanks_and_earn, 30))
|
||||
.append(" ")
|
||||
.append(
|
||||
SpanUtil.learnMore(requireContext(), ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)) {
|
||||
// TODO [alex] -- Where's this go?
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
CurrencySelection.register(adapter)
|
||||
BadgePreview.register(adapter)
|
||||
Boost.register(adapter)
|
||||
GooglePayButton.register(adapter)
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
lifecycleDisposable += viewModel.events.subscribe { event: DonationEvent ->
|
||||
when (event) {
|
||||
is DonationEvent.GooglePayUnavailableError -> Log.w(TAG, "Google Pay error", event.throwable)
|
||||
is DonationEvent.PaymentConfirmationError -> Log.w(TAG, "Payment confirmation error", event.throwable)
|
||||
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(event.badge)
|
||||
DonationEvent.RequestTokenError -> Log.w(TAG, "Request token could not be fetched")
|
||||
DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay")
|
||||
DonationEvent.SubscriptionCancelled -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: BoostState): DSLConfiguration {
|
||||
return configure {
|
||||
customPref(BadgePreview.SubscriptionModel(state.boostBadge))
|
||||
|
||||
sectionHeaderPref(
|
||||
title = DSLSettingsText.from(
|
||||
R.string.BoostFragment__give_signal_a_boost,
|
||||
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
|
||||
)
|
||||
)
|
||||
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
sayThanks,
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(28f).toInt())
|
||||
|
||||
customPref(
|
||||
CurrencySelection.Model(
|
||||
currencySelection = state.currencySelection,
|
||||
isEnabled = state.stage == BoostState.Stage.READY,
|
||||
onClick = {
|
||||
findNavController().navigate(BoostFragmentDirections.actionBoostFragmentToSetDonationCurrencyFragment())
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
customPref(
|
||||
Boost.SelectionModel(
|
||||
boosts = state.boosts,
|
||||
selectedBoost = state.selectedBoost,
|
||||
currency = state.customAmount.currency,
|
||||
isCustomAmountFocused = state.isCustomAmountFocused,
|
||||
isEnabled = state.stage == BoostState.Stage.READY,
|
||||
onBoostClick = {
|
||||
viewModel.setSelectedBoost(it)
|
||||
},
|
||||
onCustomAmountChanged = {
|
||||
viewModel.setCustomAmount(it)
|
||||
},
|
||||
onCustomAmountFocusChanged = {
|
||||
viewModel.setCustomAmountFocused(it)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if (state.isGooglePayAvailable) {
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
customPref(
|
||||
GooglePayButton.Model(
|
||||
onClick = this@BoostFragment::onGooglePayButtonClicked,
|
||||
isEnabled = state.stage == BoostState.Stage.READY
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__more_payment_options),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_open_20, R.color.signal_accent_primary),
|
||||
onClick = {
|
||||
// TODO
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onGooglePayButtonClicked() {
|
||||
viewModel.requestTokenFromGooglePay(getString(R.string.preferences__signal_boost))
|
||||
}
|
||||
|
||||
private fun onPaymentConfirmed(boostBadge: Badge) {
|
||||
findNavController().navigate(BoostFragmentDirections.actionBoostFragmentToBoostThanksForYourSupportBottomSheetDialog(boostBadge).setIsBoost(true))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(BoostFragment::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
import android.net.Uri
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
class BoostRepository {
|
||||
|
||||
fun getBoosts(currency: Currency): Single<Pair<List<Boost>, Boost?>> {
|
||||
val boosts = testBoosts(currency)
|
||||
|
||||
return Single.just(
|
||||
Pair(
|
||||
boosts,
|
||||
boosts[2]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun getBoostBadge(): Single<Badge> = Single.fromCallable {
|
||||
// Get boost badge from server
|
||||
// throw NotImplementedError()
|
||||
testBadge
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val testBadge = Badge(
|
||||
id = "TEST",
|
||||
category = Badge.Category.Testing,
|
||||
name = "Test Badge",
|
||||
description = "Test Badge",
|
||||
imageUrl = Uri.EMPTY,
|
||||
imageDensity = "xxxhdpi",
|
||||
expirationTimestamp = 0L,
|
||||
visible = false,
|
||||
)
|
||||
|
||||
private fun testBoosts(currency: Currency) = listOf(
|
||||
3L, 5L, 10L, 20L, 50L, 100L
|
||||
).map {
|
||||
Boost(testBadge, FiatMoney(BigDecimal.valueOf(it), currency))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
data class BoostState(
|
||||
val boostBadge: Badge? = null,
|
||||
val currencySelection: CurrencySelection = CurrencySelection("USD"),
|
||||
val isGooglePayAvailable: Boolean = false,
|
||||
val boosts: List<Boost> = listOf(),
|
||||
val selectedBoost: Boost? = null,
|
||||
val customAmount: FiatMoney = FiatMoney(BigDecimal.ZERO, Currency.getInstance(currencySelection.selectedCurrencyCode)),
|
||||
val isCustomAmountFocused: Boolean = false,
|
||||
val stage: Stage = Stage.INIT
|
||||
) {
|
||||
enum class Stage {
|
||||
INIT,
|
||||
READY,
|
||||
PAYMENT_PIPELINE,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.google.android.gms.wallet.PaymentData
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.GooglePayApi
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import java.math.BigDecimal
|
||||
|
||||
class BoostViewModel(
|
||||
private val boostRepository: BoostRepository,
|
||||
private val donationPaymentRepository: DonationPaymentRepository,
|
||||
private val fetchTokenRequestCode: Int
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = Store(BoostState())
|
||||
private val eventPublisher: PublishSubject<DonationEvent> = PublishSubject.create()
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
val state: LiveData<BoostState> = store.stateLiveData
|
||||
val events: Observable<DonationEvent> = eventPublisher
|
||||
|
||||
private var boostToPurchase: Boost? = null
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
init {
|
||||
val currencyObservable = SignalStore.donationsValues().observableCurrency
|
||||
val boosts = currencyObservable.flatMapSingle { boostRepository.getBoosts(it) }
|
||||
val boostBadge = boostRepository.getBoostBadge()
|
||||
|
||||
disposables += Observable.combineLatest(boosts, boostBadge.toObservable()) { (boosts, defaultBoost), badge -> BoostInfo(boosts, defaultBoost, badge) }.subscribe { info ->
|
||||
store.update {
|
||||
it.copy(
|
||||
boosts = info.boosts,
|
||||
selectedBoost = if (it.selectedBoost in info.boosts) it.selectedBoost else info.defaultBoost,
|
||||
boostBadge = it.boostBadge ?: info.boostBadge,
|
||||
stage = if (it.stage == BoostState.Stage.INIT) BoostState.Stage.READY else it.stage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
disposables += donationPaymentRepository.isGooglePayAvailable().subscribeBy(
|
||||
onComplete = { store.update { it.copy(isGooglePayAvailable = true) } },
|
||||
onError = { eventPublisher.onNext(DonationEvent.GooglePayUnavailableError(it)) }
|
||||
)
|
||||
|
||||
disposables += currencyObservable.subscribeBy { currency ->
|
||||
store.update {
|
||||
it.copy(
|
||||
currencySelection = CurrencySelection(currency.currencyCode),
|
||||
isCustomAmountFocused = false,
|
||||
customAmount = FiatMoney(
|
||||
BigDecimal.ZERO, currency
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onActivityResult(
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
data: Intent?
|
||||
) {
|
||||
donationPaymentRepository.onActivityResult(
|
||||
requestCode,
|
||||
resultCode,
|
||||
data,
|
||||
this.fetchTokenRequestCode,
|
||||
object : GooglePayApi.PaymentRequestCallback {
|
||||
override fun onSuccess(paymentData: PaymentData) {
|
||||
val boost = boostToPurchase
|
||||
boostToPurchase = null
|
||||
|
||||
if (boost != null) {
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
|
||||
donationPaymentRepository.continuePayment(boost.price, paymentData).subscribeBy(
|
||||
onError = { eventPublisher.onNext(DonationEvent.PaymentConfirmationError(it)) },
|
||||
onComplete = {
|
||||
// Now we need to do the whole query for a token, submit token rigamarole
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.boostBadge!!))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError() {
|
||||
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenError)
|
||||
}
|
||||
|
||||
override fun onCancelled() {
|
||||
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun requestTokenFromGooglePay(label: String) {
|
||||
val snapshot = store.state
|
||||
if (snapshot.selectedBoost == null) {
|
||||
return
|
||||
}
|
||||
|
||||
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
|
||||
|
||||
// TODO [alex] -- Do we want prevalidation? Stripe will catch us anyway.
|
||||
// TODO [alex] -- Custom boost badge details... how do we determine this?
|
||||
boostToPurchase = if (snapshot.isCustomAmountFocused) {
|
||||
Boost(snapshot.selectedBoost.badge, snapshot.customAmount)
|
||||
} else {
|
||||
snapshot.selectedBoost
|
||||
}
|
||||
|
||||
donationPaymentRepository.requestTokenFromGooglePay(snapshot.selectedBoost.price, label, fetchTokenRequestCode)
|
||||
}
|
||||
|
||||
fun setSelectedBoost(boost: Boost) {
|
||||
store.update {
|
||||
it.copy(
|
||||
isCustomAmountFocused = false,
|
||||
selectedBoost = boost
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setCustomAmount(amount: String) {
|
||||
val bigDecimalAmount = if (amount.isEmpty()) {
|
||||
BigDecimal.ZERO
|
||||
} else {
|
||||
BigDecimal(amount)
|
||||
}
|
||||
|
||||
store.update { it.copy(customAmount = FiatMoney(bigDecimalAmount, it.customAmount.currency)) }
|
||||
}
|
||||
|
||||
fun setCustomAmountFocused(isFocused: Boolean) {
|
||||
store.update { it.copy(isCustomAmountFocused = isFocused) }
|
||||
}
|
||||
|
||||
private data class BoostInfo(val boosts: List<Boost>, val defaultBoost: Boost?, val boostBadge: Badge)
|
||||
|
||||
class Factory(
|
||||
private val boostRepository: BoostRepository,
|
||||
private val donationPaymentRepository: DonationPaymentRepository,
|
||||
private val fetchTokenRequestCode: Int
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(BoostViewModel(boostRepository, donationPaymentRepository, fetchTokenRequestCode))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.currency
|
||||
|
||||
import androidx.fragment.app.viewModels
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Simple fragment for selecting a currency for Donations
|
||||
*/
|
||||
class SetCurrencyFragment : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
private val viewModel: SetCurrencyViewModel by viewModels()
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: SetCurrencyState): DSLConfiguration {
|
||||
return configure {
|
||||
state.currencies.forEach { currency ->
|
||||
radioPref(
|
||||
title = DSLSettingsText.from(currency.getDisplayName(Locale.getDefault())),
|
||||
summary = DSLSettingsText.from(currency.currencyCode),
|
||||
isChecked = currency.currencyCode == state.selectedCurrencyCode,
|
||||
onClick = {
|
||||
viewModel.setSelectedCurrency(currency.currencyCode)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.currency
|
||||
|
||||
import java.util.Currency
|
||||
|
||||
data class SetCurrencyState(
|
||||
val selectedCurrencyCode: String = "",
|
||||
val currencies: List<Currency> = listOf()
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.currency
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
class SetCurrencyViewModel : ViewModel() {
|
||||
|
||||
private val store = Store(SetCurrencyState())
|
||||
|
||||
val state: LiveData<SetCurrencyState> = store.stateLiveData
|
||||
|
||||
init {
|
||||
val defaultCurrency = SignalStore.donationsValues().getCurrency()
|
||||
|
||||
store.update { state ->
|
||||
val platformCurrencies = Currency.getAvailableCurrencies()
|
||||
val stripeCurrencies = platformCurrencies
|
||||
.filter { StripeApi.Validation.supportedCurrencyCodes.contains(it.currencyCode) }
|
||||
.sortedWith(CurrencyComparator(BuildConfig.DEFAULT_CURRENCIES.split(",")))
|
||||
|
||||
state.copy(
|
||||
selectedCurrencyCode = defaultCurrency.currencyCode,
|
||||
currencies = stripeCurrencies
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSelectedCurrency(selectedCurrencyCode: String) {
|
||||
store.update { it.copy(selectedCurrencyCode = selectedCurrencyCode) }
|
||||
SignalStore.donationsValues().setCurrency(Currency.getInstance(selectedCurrencyCode))
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
class CurrencyComparator(private val defaults: List<String>) : Comparator<Currency> {
|
||||
|
||||
companion object {
|
||||
private const val USD = "USD"
|
||||
}
|
||||
|
||||
override fun compare(o1: Currency, o2: Currency): Int {
|
||||
val isO1Default = o1.currencyCode in defaults
|
||||
val isO2Default = o2.currencyCode in defaults
|
||||
|
||||
return if (o1.currencyCode == o2.currencyCode) {
|
||||
0
|
||||
} else if (o1.currencyCode == USD) {
|
||||
-1
|
||||
} else if (o2.currencyCode == USD) {
|
||||
1
|
||||
} else if (isO1Default && isO2Default) {
|
||||
o1.getDisplayName(Locale.getDefault()).compareTo(o2.getDisplayName(Locale.getDefault()))
|
||||
} else if (isO1Default) {
|
||||
-1
|
||||
} else if (isO2Default) {
|
||||
1
|
||||
} else {
|
||||
o1.getDisplayName(Locale.getDefault()).compareTo(o2.getDisplayName(Locale.getDefault()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* DSL renderable item that displays active subscription information on the user's
|
||||
* manage donations page.
|
||||
*/
|
||||
object ActiveSubscriptionPreference {
|
||||
|
||||
class Model(
|
||||
val subscription: Subscription,
|
||||
val onAddBoostClick: () -> Unit
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return subscription.id == newItem.subscription.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && subscription == newItem.subscription
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
val badge: BadgeImageView = itemView.findViewById(R.id.my_support_badge)
|
||||
val title: TextView = itemView.findViewById(R.id.my_support_title)
|
||||
val price: TextView = itemView.findViewById(R.id.my_support_price)
|
||||
val expiry: TextView = itemView.findViewById(R.id.my_support_expiry)
|
||||
val boost: MaterialButton = itemView.findViewById(R.id.my_support_boost)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
badge.setBadge(model.subscription.badge)
|
||||
title.text = model.subscription.title
|
||||
|
||||
price.text = context.getString(
|
||||
R.string.MySupportPreference__s_per_month,
|
||||
FiatMoneyUtil.format(
|
||||
context.resources,
|
||||
model.subscription.price,
|
||||
FiatMoneyUtil.formatOptions()
|
||||
)
|
||||
)
|
||||
|
||||
expiry.text = context.getString(
|
||||
R.string.MySupportPreference__renews_s,
|
||||
DateUtils.formatDate(
|
||||
Locale.getDefault(),
|
||||
model.subscription.renewalTimestamp
|
||||
)
|
||||
)
|
||||
|
||||
boost.setOnClickListener {
|
||||
model.onAddBoostClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.my_support_preference))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
enum class ManageDonationsEvent {
|
||||
NOT_SUBSCRIBED,
|
||||
ERROR_GETTING_SUBSCRIPTION
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
|
||||
/**
|
||||
* Fragment displayed when a user enters "Subscriptions" via app settings but is already
|
||||
* a subscriber. Used to manage their current subscription, view badges, and boost.
|
||||
*/
|
||||
class ManageDonationsFragment : DSLSettingsFragment() {
|
||||
|
||||
private val viewModel: ManageDonationsViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
ManageDonationsViewModel.Factory(SubscriptionsRepository())
|
||||
}
|
||||
)
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val args = ManageDonationsFragmentArgs.fromBundle(requireArguments())
|
||||
if (args.skipToSubscribe) {
|
||||
findNavController().navigate(
|
||||
ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment(),
|
||||
NavOptions.Builder().setPopUpTo(R.id.manageDonationsFragment, true).build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.refresh()
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
val args = ManageDonationsFragmentArgs.fromBundle(requireArguments())
|
||||
if (args.skipToSubscribe) {
|
||||
return
|
||||
}
|
||||
|
||||
ActiveSubscriptionPreference.register(adapter)
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
lifecycleDisposable += viewModel.events.subscribe { event: ManageDonationsEvent ->
|
||||
when (event) {
|
||||
ManageDonationsEvent.NOT_SUBSCRIBED -> handleUserIsNotSubscribed()
|
||||
ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION -> handleErrorGettingSubscription()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: ManageDonationsState): DSLConfiguration {
|
||||
return configure {
|
||||
sectionHeaderPref(
|
||||
title = DSLSettingsText.from(
|
||||
R.string.SubscribeFragment__signal_is_powered_by_people_like_you,
|
||||
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
|
||||
)
|
||||
)
|
||||
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
R.string.ManageDonationsFragment__my_support,
|
||||
DSLSettingsText.Title2BoldModifier
|
||||
)
|
||||
)
|
||||
|
||||
if (state.activeSubscription != null) {
|
||||
customPref(
|
||||
ActiveSubscriptionPreference.Model(
|
||||
subscription = state.activeSubscription,
|
||||
onAddBoostClick = {
|
||||
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts())
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
}
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__manage_subscription),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_person_white_24dp),
|
||||
onClick = {
|
||||
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment())
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__badges),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_badge_24),
|
||||
onClick = {
|
||||
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscriptionBadgeManageFragment())
|
||||
}
|
||||
)
|
||||
|
||||
externalLinkPref(
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__subscription_faq),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
|
||||
linkId = R.string.donate_url
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUserIsNotSubscribed() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
private fun handleErrorGettingSubscription() {
|
||||
Toast.makeText(requireContext(), R.string.ManageDonationsFragment__error_getting_subscription, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
|
||||
data class ManageDonationsState(
|
||||
val featuredBadge: Badge? = null,
|
||||
val activeSubscription: Subscription? = null
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class ManageDonationsViewModel(
|
||||
private val subscriptionsRepository: SubscriptionsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = Store(ManageDonationsState())
|
||||
private val eventPublisher = PublishSubject.create<ManageDonationsEvent>()
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
val state: LiveData<ManageDonationsState> = store.stateLiveData
|
||||
val events: Observable<ManageDonationsEvent> = eventPublisher
|
||||
|
||||
init {
|
||||
store.update(Recipient.self().live().liveDataResolved) { self, state ->
|
||||
state.copy(featuredBadge = self.featuredBadge)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
disposables.clear()
|
||||
disposables += subscriptionsRepository.getActiveSubscription(SignalStore.donationsValues().getCurrency()).subscribeBy(
|
||||
onSuccess = { subscription -> store.update { it.copy(activeSubscription = subscription) } },
|
||||
onComplete = {
|
||||
store.update { it.copy(activeSubscription = null) }
|
||||
eventPublisher.onNext(ManageDonationsEvent.NOT_SUBSCRIBED)
|
||||
},
|
||||
onError = {
|
||||
eventPublisher.onNext(ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val subscriptionsRepository: SubscriptionsRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(ManageDonationsViewModel(subscriptionsRepository))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.models
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
data class CurrencySelection(
|
||||
val selectedCurrencyCode: String,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.subscription_currency_selection))
|
||||
}
|
||||
}
|
||||
|
||||
class Model(
|
||||
val currencySelection: CurrencySelection,
|
||||
override val isEnabled: Boolean,
|
||||
val onClick: () -> Unit
|
||||
) : PreferenceModel<Model>(isEnabled = isEnabled) {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) &&
|
||||
newItem.currencySelection.selectedCurrencyCode == currencySelection.selectedCurrencyCode
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val spinner: TextView = itemView.findViewById(R.id.subscription_currency_selection_spinner)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
spinner.text = model.currencySelection.selectedCurrencyCode
|
||||
itemView.setOnClickListener { model.onClick() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.models
|
||||
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
object GooglePayButton {
|
||||
|
||||
class Model(val onClick: () -> Unit, override val isEnabled: Boolean) : PreferenceModel<Model>(isEnabled = isEnabled) {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean = true
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val googlePayButton: View = findViewById(R.id.googlepay_button)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
googlePayButton.isEnabled = model.isEnabled
|
||||
googlePayButton.setOnClickListener {
|
||||
googlePayButton.isEnabled = false
|
||||
model.onClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.google_pay_button_pref))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
|
||||
|
||||
import android.text.SpannableStringBuilder
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.BadgePreview
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
|
||||
/**
|
||||
* UX for creating and changing a subscription
|
||||
*/
|
||||
class SubscribeFragment : DSLSettingsFragment() {
|
||||
|
||||
private val viewModel: SubscribeViewModel by viewModels(ownerProducer = { requireActivity() })
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private val supportTechSummary: CharSequence by lazy {
|
||||
SpannableStringBuilder(requireContext().getString(R.string.SubscribeFragment__support_technology_that_is_built_for_you))
|
||||
.append(" ")
|
||||
.append(
|
||||
SpanUtil.learnMore(requireContext(), ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)) {
|
||||
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSubscribeLearnMoreBottomSheetDialog())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.refresh()
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
BadgePreview.register(adapter)
|
||||
CurrencySelection.register(adapter)
|
||||
Subscription.register(adapter)
|
||||
GooglePayButton.register(adapter)
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
lifecycleDisposable += viewModel.events.subscribe {
|
||||
when (it) {
|
||||
is DonationEvent.GooglePayUnavailableError -> Log.w(TAG, "Google Pay error", it.throwable)
|
||||
is DonationEvent.PaymentConfirmationError -> Log.w(TAG, "Payment confirmation error", it.throwable)
|
||||
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(it.badge)
|
||||
DonationEvent.RequestTokenError -> Log.w(TAG, "Request token could not be fetched")
|
||||
DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay")
|
||||
DonationEvent.SubscriptionCancelled -> onSubscriptionCancelled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: SubscribeState): DSLConfiguration {
|
||||
return configure {
|
||||
customPref(BadgePreview.SubscriptionModel(state.previewBadge))
|
||||
|
||||
sectionHeaderPref(
|
||||
title = DSLSettingsText.from(
|
||||
R.string.SubscribeFragment__signal_is_powered_by_people_like_you,
|
||||
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
|
||||
)
|
||||
)
|
||||
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(supportTechSummary, DSLSettingsText.CenterModifier)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
customPref(
|
||||
CurrencySelection.Model(
|
||||
currencySelection = state.currencySelection,
|
||||
isEnabled = state.stage == SubscribeState.Stage.READY,
|
||||
onClick = {
|
||||
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSetDonationCurrencyFragment())
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
state.subscriptions.forEach {
|
||||
customPref(
|
||||
Subscription.Model(
|
||||
subscription = it,
|
||||
isSelected = state.selectedSubscription == it,
|
||||
isEnabled = state.stage == SubscribeState.Stage.READY,
|
||||
isActive = state.activeSubscription == it,
|
||||
onClick = { viewModel.setSelectedSubscription(it) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (state.activeSubscription != null) {
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__update_subscription),
|
||||
onClick = {
|
||||
// TODO [alex] -- Dunno what the update process requires.
|
||||
}
|
||||
)
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__cancel_subscription),
|
||||
onClick = {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.SubscribeFragment__confirm_cancellation)
|
||||
.setMessage(R.string.SubscribeFragment__you_wont_be_charged_again)
|
||||
.setPositiveButton(R.string.SubscribeFragment__confirm) { d, _ ->
|
||||
d.dismiss()
|
||||
viewModel.cancel()
|
||||
}
|
||||
.setNegativeButton(R.string.SubscribeFragment__not_now) { d, _ ->
|
||||
d.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
if (state.isGooglePayAvailable) {
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
customPref(
|
||||
GooglePayButton.Model(
|
||||
onClick = this@SubscribeFragment::onGooglePayButtonClicked,
|
||||
isEnabled = state.stage == SubscribeState.Stage.READY
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__more_payment_options),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_open_20, R.color.signal_accent_primary),
|
||||
onClick = {
|
||||
// TODO
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onGooglePayButtonClicked() {
|
||||
viewModel.requestTokenFromGooglePay()
|
||||
}
|
||||
|
||||
private fun onPaymentConfirmed(badge: Badge) {
|
||||
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSubscribeThanksForYourSupportBottomSheetDialog(badge).setIsBoost(false))
|
||||
}
|
||||
|
||||
private fun onSubscriptionCancelled() {
|
||||
Snackbar.make(requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(SubscribeFragment::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
|
||||
class SubscribeLearnMoreBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.subscribe_learn_more_bottom_sheet_dialog_fragment, container, false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
|
||||
data class SubscribeState(
|
||||
val previewBadge: Badge? = null,
|
||||
val currencySelection: CurrencySelection = CurrencySelection("USD"),
|
||||
val subscriptions: List<Subscription> = listOf(),
|
||||
val selectedSubscription: Subscription? = null,
|
||||
val activeSubscription: Subscription? = null,
|
||||
val isGooglePayAvailable: Boolean = false,
|
||||
val stage: Stage = Stage.INIT
|
||||
) {
|
||||
enum class Stage {
|
||||
INIT,
|
||||
READY,
|
||||
PAYMENT_PIPELINE,
|
||||
CANCELLING
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.google.android.gms.wallet.PaymentData
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.donations.GooglePayApi
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
|
||||
class SubscribeViewModel(
|
||||
private val subscriptionsRepository: SubscriptionsRepository,
|
||||
private val donationPaymentRepository: DonationPaymentRepository,
|
||||
private val fetchTokenRequestCode: Int
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = Store(SubscribeState())
|
||||
private val eventPublisher: PublishSubject<DonationEvent> = PublishSubject.create()
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
val state: LiveData<SubscribeState> = store.stateLiveData
|
||||
val events: Observable<DonationEvent> = eventPublisher
|
||||
|
||||
private var subscriptionToPurchase: Subscription? = null
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
disposables.clear()
|
||||
|
||||
val currency = SignalStore.donationsValues().getCurrency()
|
||||
|
||||
val allSubscriptions = subscriptionsRepository.getSubscriptions(currency)
|
||||
val activeSubscription = subscriptionsRepository.getActiveSubscription(currency)
|
||||
.map { Optional.of(it) }
|
||||
.defaultIfEmpty(Optional.absent())
|
||||
|
||||
disposables += allSubscriptions.zipWith(activeSubscription, ::Pair).subscribe { (subs, active) ->
|
||||
store.update {
|
||||
it.copy(
|
||||
subscriptions = subs,
|
||||
selectedSubscription = it.selectedSubscription ?: active.orNull() ?: subs.firstOrNull(),
|
||||
activeSubscription = active.orNull(),
|
||||
stage = SubscribeState.Stage.READY
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
disposables += donationPaymentRepository.isGooglePayAvailable().subscribeBy(
|
||||
onComplete = { store.update { it.copy(isGooglePayAvailable = true) } },
|
||||
onError = { eventPublisher.onNext(DonationEvent.GooglePayUnavailableError(it)) }
|
||||
)
|
||||
|
||||
store.update { it.copy(currencySelection = CurrencySelection(SignalStore.donationsValues().getCurrency().currencyCode)) }
|
||||
}
|
||||
fun cancel() {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.CANCELLING) }
|
||||
// TODO [alex] -- cancel api call
|
||||
}
|
||||
|
||||
fun onActivityResult(
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
data: Intent?
|
||||
) {
|
||||
donationPaymentRepository.onActivityResult(
|
||||
requestCode, resultCode, data, this.fetchTokenRequestCode,
|
||||
object : GooglePayApi.PaymentRequestCallback {
|
||||
override fun onSuccess(paymentData: PaymentData) {
|
||||
val subscription = subscriptionToPurchase
|
||||
subscriptionToPurchase = null
|
||||
|
||||
if (subscription != null) {
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
|
||||
donationPaymentRepository.continuePayment(subscription.price, paymentData).subscribeBy(
|
||||
onError = { eventPublisher.onNext(DonationEvent.PaymentConfirmationError(it)) },
|
||||
onComplete = {
|
||||
// Now we need to do the whole query for a token, submit token rigamarole
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(subscription.badge))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError() {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenError)
|
||||
}
|
||||
|
||||
override fun onCancelled() {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun requestTokenFromGooglePay() {
|
||||
val snapshot = store.state
|
||||
if (snapshot.selectedSubscription == null) {
|
||||
return
|
||||
}
|
||||
|
||||
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
|
||||
|
||||
subscriptionToPurchase = snapshot.selectedSubscription
|
||||
donationPaymentRepository.requestTokenFromGooglePay(snapshot.selectedSubscription.price, snapshot.selectedSubscription.title, fetchTokenRequestCode)
|
||||
}
|
||||
|
||||
fun setSelectedSubscription(subscription: Subscription) {
|
||||
store.update { it.copy(selectedSubscription = subscription) }
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val subscriptionsRepository: SubscriptionsRepository,
|
||||
private val donationPaymentRepository: DonationPaymentRepository,
|
||||
private val fetchTokenRequestCode: Int
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(SubscribeViewModel(subscriptionsRepository, donationPaymentRepository, fetchTokenRequestCode))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.thanks
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
|
||||
class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
private lateinit var displayOnProfileSwitch: SwitchMaterial
|
||||
private lateinit var heading: TextView
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.thanks_for_your_support_bottom_sheet_dialog_fragment, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val badgeView: BadgeImageView = view.findViewById(R.id.thanks_bottom_sheet_badge)
|
||||
val badgeName: TextView = view.findViewById(R.id.thanks_bottom_sheet_badge_name)
|
||||
val done: MaterialButton = view.findViewById(R.id.thanks_bottom_sheet_done)
|
||||
|
||||
heading = view.findViewById(R.id.thanks_bottom_sheet_heading)
|
||||
displayOnProfileSwitch = view.findViewById(R.id.thanks_bottom_sheet_display_on_profile)
|
||||
|
||||
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
|
||||
|
||||
badgeView.setBadge(args.badge)
|
||||
badgeName.text = args.badge.name
|
||||
displayOnProfileSwitch.isChecked = true
|
||||
|
||||
if (args.isBoost) {
|
||||
presentBoostCopy()
|
||||
} else {
|
||||
presentSubscriptionCopy()
|
||||
}
|
||||
|
||||
done.setOnClickListener { dismissAllowingStateLoss() }
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
val isDisplayOnProfile = displayOnProfileSwitch.isChecked
|
||||
// TODO [alex] -- Not sure what state we're in with regards to submitting the token.
|
||||
}
|
||||
|
||||
private fun presentBoostCopy() {
|
||||
heading.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_the_boost)
|
||||
}
|
||||
|
||||
private fun presentSubscriptionCopy() {
|
||||
heading.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_your_support)
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,9 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.doOnPreDraw
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import app.cash.exhaustive.Exhaustive
|
||||
import com.google.android.flexbox.FlexboxLayoutManager
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.thoughtcrime.securesms.AvatarPreviewActivity
|
||||
@@ -30,6 +30,10 @@ import org.thoughtcrime.securesms.MuteDialog
|
||||
import org.thoughtcrime.securesms.PushContactSelectionActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.VerifyIdentityActivity
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.Badges.displayBadges
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.view.ViewBadgeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
|
||||
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper
|
||||
@@ -85,7 +89,8 @@ private const val REQUEST_CODE_RETURN_FROM_MEDIA = 4
|
||||
|
||||
class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
layoutId = R.layout.conversation_settings_fragment,
|
||||
menuId = R.menu.conversation_settings
|
||||
menuId = R.menu.conversation_settings,
|
||||
layoutManagerProducer = Badges::createLayoutManagerForGridWithBadges
|
||||
) {
|
||||
|
||||
private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) }
|
||||
@@ -175,6 +180,8 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments())
|
||||
|
||||
BioTextPreference.register(adapter)
|
||||
AvatarPreference.register(adapter)
|
||||
ButtonStripPreference.register(adapter)
|
||||
@@ -185,6 +192,13 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
GroupDescriptionPreference.register(adapter)
|
||||
LegacyGroupPreference.register(adapter)
|
||||
|
||||
val recipientId = args.recipientId
|
||||
if (recipientId != null) {
|
||||
Badge.register(adapter) { badge, _ ->
|
||||
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, recipientId, badge)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
|
||||
if (state.recipient != Recipient.UNKNOWN) {
|
||||
@@ -245,6 +259,9 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onBadgeClick = { badge ->
|
||||
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, state.recipient.id, badge)
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -297,18 +314,16 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
}
|
||||
}
|
||||
|
||||
state.withRecipientSettingsState { recipientState ->
|
||||
if (recipientState.displayInternalRecipientDetails) {
|
||||
customPref(
|
||||
InternalPreference.Model(
|
||||
recipient = state.recipient,
|
||||
onInternalDetailsClicked = {
|
||||
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToInternalDetailsSettingsFragment(state.recipient.id)
|
||||
navController.navigate(action)
|
||||
}
|
||||
)
|
||||
if (state.displayInternalRecipientDetails) {
|
||||
customPref(
|
||||
InternalPreference.Model(
|
||||
recipient = state.recipient,
|
||||
onInternalDetailsClicked = {
|
||||
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToInternalDetailsSettingsFragment(state.recipient.id)
|
||||
navController.navigate(action)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
customPref(
|
||||
@@ -466,12 +481,20 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
)
|
||||
}
|
||||
|
||||
state.withRecipientSettingsState { groupState ->
|
||||
if (groupState.selfHasGroups) {
|
||||
state.withRecipientSettingsState { recipientSettingsState ->
|
||||
if (state.recipient.badges.isNotEmpty()) {
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.ManageProfileFragment_badges)
|
||||
|
||||
displayBadges(requireContext(), state.recipient.badges)
|
||||
}
|
||||
|
||||
if (recipientSettingsState.selfHasGroups) {
|
||||
|
||||
dividerPref()
|
||||
|
||||
val groupsInCommonCount = groupState.allGroupsInCommon.size
|
||||
val groupsInCommonCount = recipientSettingsState.allGroupsInCommon.size
|
||||
sectionHeaderPref(
|
||||
DSLSettingsText.from(
|
||||
if (groupsInCommonCount == 0) {
|
||||
@@ -496,7 +519,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
)
|
||||
)
|
||||
|
||||
for (group in groupState.groupsInCommon) {
|
||||
for (group in recipientSettingsState.groupsInCommon) {
|
||||
customPref(
|
||||
RecipientPreference.Model(
|
||||
recipient = group,
|
||||
@@ -508,7 +531,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
)
|
||||
}
|
||||
|
||||
if (groupState.canShowMoreGroupsInCommon) {
|
||||
if (recipientSettingsState.canShowMoreGroupsInCommon) {
|
||||
customPref(
|
||||
LargeIconClickPreference.Model(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__see_all),
|
||||
@@ -637,7 +660,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
val blockUnblockIcon = if (isBlocked) unblockIcon else blockIcon
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(title, titleTint),
|
||||
title = if (titleTint != null) DSLSettingsText.from(title, titleTint) else DSLSettingsText.from(title),
|
||||
icon = DSLSettingsIcon.from(blockUnblockIcon),
|
||||
onClick = {
|
||||
if (state.recipient.isBlocked) {
|
||||
@@ -718,7 +741,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
private val rect = Rect()
|
||||
|
||||
override fun getAnimationState(recyclerView: RecyclerView): AnimationState {
|
||||
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
|
||||
val layoutManager = recyclerView.layoutManager as FlexboxLayoutManager
|
||||
|
||||
return if (layoutManager.findFirstVisibleItemPosition() == 0) {
|
||||
val firstChild = requireNotNull(layoutManager.getChildAt(0))
|
||||
|
||||
@@ -178,7 +178,11 @@ class ConversationSettingsRepository(
|
||||
fun block(recipientId: RecipientId) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
RecipientUtil.blockNonGroup(context, recipient)
|
||||
if (recipient.isGroup) {
|
||||
RecipientUtil.block(context, recipient)
|
||||
} else {
|
||||
RecipientUtil.blockNonGroup(context, recipient)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ data class ConversationSettingsState(
|
||||
val canModifyBlockedState: Boolean = false,
|
||||
val sharedMedia: Cursor? = null,
|
||||
val sharedMediaIds: List<Long> = listOf(),
|
||||
val displayInternalRecipientDetails: Boolean = false,
|
||||
private val sharedMediaLoaded: Boolean = false,
|
||||
private val specificSettingsState: SpecificSettingsState,
|
||||
) {
|
||||
@@ -49,8 +50,7 @@ sealed class SpecificSettingsState {
|
||||
val selfHasGroups: Boolean = false,
|
||||
val canShowMoreGroupsInCommon: Boolean = false,
|
||||
val groupsInCommonExpanded: Boolean = false,
|
||||
val contactLinkState: ContactLinkState = ContactLinkState.NONE,
|
||||
val displayInternalRecipientDetails: Boolean
|
||||
val contactLinkState: ContactLinkState = ContactLinkState.NONE
|
||||
) : SpecificSettingsState() {
|
||||
|
||||
override val isLoaded: Boolean = true
|
||||
|
||||
@@ -71,7 +71,8 @@ sealed class ConversationSettingsViewModel(
|
||||
state.copy(
|
||||
sharedMedia = cursor.orNull(),
|
||||
sharedMediaIds = ids,
|
||||
sharedMediaLoaded = true
|
||||
sharedMediaLoaded = true,
|
||||
displayInternalRecipientDetails = repository.isInternalRecipientDetailsEnabled()
|
||||
)
|
||||
} else {
|
||||
cursor.orNull().ensureClosed()
|
||||
@@ -121,9 +122,7 @@ sealed class ConversationSettingsViewModel(
|
||||
private val repository: ConversationSettingsRepository
|
||||
) : ConversationSettingsViewModel(
|
||||
repository,
|
||||
SpecificSettingsState.RecipientSettingsState(
|
||||
displayInternalRecipientDetails = repository.isInternalRecipientDetailsEnabled()
|
||||
)
|
||||
SpecificSettingsState.RecipientSettingsState()
|
||||
) {
|
||||
|
||||
private val liveRecipient = Recipient.live(recipientId)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.text.TextUtils
|
||||
import android.widget.Toast
|
||||
@@ -7,6 +8,7 @@ import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
@@ -14,6 +16,8 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@@ -55,79 +59,103 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
|
||||
summary = DSLSettingsText.from(recipient.id.serialize())
|
||||
)
|
||||
|
||||
val uuid = recipient.uuid.transform(UUID::toString).or("null")
|
||||
if (!recipient.isGroup) {
|
||||
val uuid = recipient.uuid.transform(UUID::toString).or("null")
|
||||
longClickPref(
|
||||
title = DSLSettingsText.from("UUID"),
|
||||
summary = DSLSettingsText.from(uuid),
|
||||
onLongClick = { copyToClipboard(uuid) }
|
||||
)
|
||||
}
|
||||
|
||||
if (state.groupId != null) {
|
||||
val groupId: String = state.groupId.toString()
|
||||
longClickPref(
|
||||
title = DSLSettingsText.from("GroupId"),
|
||||
summary = DSLSettingsText.from(groupId),
|
||||
onLongClick = { copyToClipboard(groupId) }
|
||||
)
|
||||
}
|
||||
|
||||
val threadId: String = if (state.threadId != null) state.threadId.toString() else "N/A"
|
||||
longClickPref(
|
||||
title = DSLSettingsText.from("UUID"),
|
||||
summary = DSLSettingsText.from(uuid),
|
||||
onLongClick = { copyToClipboard(uuid) }
|
||||
title = DSLSettingsText.from("ThreadId"),
|
||||
summary = DSLSettingsText.from(threadId),
|
||||
onLongClick = { copyToClipboard(threadId) }
|
||||
)
|
||||
|
||||
textPref(
|
||||
title = DSLSettingsText.from("Profile Name"),
|
||||
summary = DSLSettingsText.from("[${recipient.profileName.givenName}] [${state.recipient.profileName.familyName}]")
|
||||
)
|
||||
if (!recipient.isGroup) {
|
||||
textPref(
|
||||
title = DSLSettingsText.from("Profile Name"),
|
||||
summary = DSLSettingsText.from("[${recipient.profileName.givenName}] [${state.recipient.profileName.familyName}]")
|
||||
)
|
||||
|
||||
val profileKeyBase64 = recipient.profileKey?.let(Base64::encodeBytes) ?: "None"
|
||||
longClickPref(
|
||||
title = DSLSettingsText.from("Profile Key (Base64)"),
|
||||
summary = DSLSettingsText.from(profileKeyBase64),
|
||||
onLongClick = { copyToClipboard(profileKeyBase64) }
|
||||
)
|
||||
val profileKeyBase64 = recipient.profileKey?.let(Base64::encodeBytes) ?: "None"
|
||||
longClickPref(
|
||||
title = DSLSettingsText.from("Profile Key (Base64)"),
|
||||
summary = DSLSettingsText.from(profileKeyBase64),
|
||||
onLongClick = { copyToClipboard(profileKeyBase64) }
|
||||
)
|
||||
|
||||
val profileKeyHex = recipient.profileKey?.let(Hex::toStringCondensed) ?: ""
|
||||
longClickPref(
|
||||
title = DSLSettingsText.from("Profile Key (Hex)"),
|
||||
summary = DSLSettingsText.from(profileKeyHex),
|
||||
onLongClick = { copyToClipboard(profileKeyHex) }
|
||||
)
|
||||
val profileKeyHex = recipient.profileKey?.let(Hex::toStringCondensed) ?: ""
|
||||
longClickPref(
|
||||
title = DSLSettingsText.from("Profile Key (Hex)"),
|
||||
summary = DSLSettingsText.from(profileKeyHex),
|
||||
onLongClick = { copyToClipboard(profileKeyHex) }
|
||||
)
|
||||
|
||||
textPref(
|
||||
title = DSLSettingsText.from("Sealed Sender Mode"),
|
||||
summary = DSLSettingsText.from(recipient.unidentifiedAccessMode.toString())
|
||||
)
|
||||
textPref(
|
||||
title = DSLSettingsText.from("Sealed Sender Mode"),
|
||||
summary = DSLSettingsText.from(recipient.unidentifiedAccessMode.toString())
|
||||
)
|
||||
}
|
||||
|
||||
textPref(
|
||||
title = DSLSettingsText.from("Profile Sharing (AKA \"Whitelisted\")"),
|
||||
summary = DSLSettingsText.from(recipient.isProfileSharing.toString())
|
||||
)
|
||||
|
||||
textPref(
|
||||
title = DSLSettingsText.from("Capabilities"),
|
||||
summary = DSLSettingsText.from(buildCapabilitySpan(recipient))
|
||||
)
|
||||
if (!recipient.isGroup) {
|
||||
textPref(
|
||||
title = DSLSettingsText.from("Capabilities"),
|
||||
summary = DSLSettingsText.from(buildCapabilitySpan(recipient))
|
||||
)
|
||||
}
|
||||
|
||||
sectionHeaderPref(DSLSettingsText.from("Actions"))
|
||||
if (!recipient.isGroup) {
|
||||
sectionHeaderPref(DSLSettingsText.from("Actions"))
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Disable Profile Sharing"),
|
||||
summary = DSLSettingsText.from("Clears profile sharing/whitelisted status, which should cause the Message Request UI to show."),
|
||||
onClick = {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Are you sure?")
|
||||
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> DatabaseFactory.getRecipientDatabase(requireContext()).setProfileSharing(recipient.id, false) }
|
||||
.show()
|
||||
}
|
||||
)
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Disable Profile Sharing"),
|
||||
summary = DSLSettingsText.from("Clears profile sharing/whitelisted status, which should cause the Message Request UI to show."),
|
||||
onClick = {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Are you sure?")
|
||||
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> DatabaseFactory.getRecipientDatabase(requireContext()).setProfileSharing(recipient.id, false) }
|
||||
.show()
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Delete Session"),
|
||||
summary = DSLSettingsText.from("Deletes the session, essentially guaranteeing an encryption error if they send you a message."),
|
||||
onClick = {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Are you sure?")
|
||||
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
if (recipient.hasUuid()) {
|
||||
DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipient.requireUuid().toString())
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Delete Session"),
|
||||
summary = DSLSettingsText.from("Deletes the session, essentially guaranteeing an encryption error if they send you a message."),
|
||||
onClick = {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Are you sure?")
|
||||
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
if (recipient.hasUuid()) {
|
||||
DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipient.requireUuid().toString())
|
||||
}
|
||||
if (recipient.hasE164()) {
|
||||
DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipient.requireE164())
|
||||
}
|
||||
}
|
||||
if (recipient.hasE164()) {
|
||||
DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipient.requireE164())
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
)
|
||||
.show()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,17 +190,30 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
|
||||
val recipientId: RecipientId
|
||||
) : ViewModel(), RecipientForeverObserver {
|
||||
|
||||
private val store = Store(InternalState(Recipient.resolved(recipientId)))
|
||||
private val store = Store(
|
||||
InternalState(
|
||||
recipient = Recipient.resolved(recipientId),
|
||||
threadId = null,
|
||||
groupId = null
|
||||
)
|
||||
)
|
||||
|
||||
val state = store.stateLiveData
|
||||
val liveRecipient = Recipient.live(recipientId)
|
||||
|
||||
init {
|
||||
liveRecipient.observeForever(this)
|
||||
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val context: Context = ApplicationDependencies.getApplication()
|
||||
val threadId: Long? = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipientId)
|
||||
val groupId: GroupId? = DatabaseFactory.getGroupDatabase(context).getGroup(recipientId).transform { it.id }.orNull()
|
||||
store.update { state -> state.copy(threadId = threadId, groupId = groupId) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRecipientChanged(recipient: Recipient) {
|
||||
store.update { InternalState(recipient) }
|
||||
store.update { state -> state.copy(recipient = recipient) }
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
@@ -187,6 +228,8 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
|
||||
}
|
||||
|
||||
data class InternalState(
|
||||
val recipient: Recipient
|
||||
val recipient: Recipient,
|
||||
val threadId: Long?,
|
||||
val groupId: GroupId?
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.components.settings.conversation.preferences
|
||||
import android.view.View
|
||||
import androidx.core.view.ViewCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto
|
||||
@@ -23,7 +25,8 @@ object AvatarPreference {
|
||||
|
||||
class Model(
|
||||
val recipient: Recipient,
|
||||
val onAvatarClick: (View) -> Unit
|
||||
val onAvatarClick: (View) -> Unit,
|
||||
val onBadgeClick: (Badge) -> Unit
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return recipient == newItem.recipient
|
||||
@@ -36,11 +39,23 @@ object AvatarPreference {
|
||||
|
||||
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
private val avatar: AvatarImageView = itemView.findViewById<AvatarImageView>(R.id.bio_preference_avatar).apply {
|
||||
ViewCompat.setTransitionName(this, "avatar")
|
||||
setFallbackPhotoProvider(AvatarPreferenceFallbackPhotoProvider())
|
||||
}
|
||||
|
||||
private val badge: BadgeImageView = itemView.findViewById(R.id.bio_preference_badge)
|
||||
|
||||
init {
|
||||
ViewCompat.setTransitionName(avatar.parent as View, "avatar")
|
||||
}
|
||||
|
||||
override fun bind(model: Model) {
|
||||
badge.setBadgeFromRecipient(model.recipient)
|
||||
badge.setOnClickListener {
|
||||
val badge = model.recipient.badges.firstOrNull()
|
||||
if (badge != null) {
|
||||
model.onBadgeClick(badge)
|
||||
}
|
||||
}
|
||||
avatar.setAvatar(model.recipient)
|
||||
avatar.disableQuickContact()
|
||||
avatar.setOnClickListener { model.onAvatarClick(avatar) }
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.fragment.app.viewModels
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
@@ -19,6 +20,9 @@ import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.util.RingtoneUtil
|
||||
import java.lang.NullPointerException
|
||||
|
||||
private val TAG = Log.tag(CustomNotificationsSettingsFragment::class.java)
|
||||
|
||||
class CustomNotificationsSettingsFragment : DSLSettingsFragment(R.string.CustomNotificationsDialogFragment__custom_notifications) {
|
||||
|
||||
@@ -135,7 +139,12 @@ class CustomNotificationsSettingsFragment : DSLSettingsFragment(R.string.CustomN
|
||||
} else {
|
||||
val tone = RingtoneUtil.getRingtone(requireContext(), ringtone)
|
||||
if (tone != null) {
|
||||
return tone.getTitle(context)
|
||||
return try {
|
||||
tone.getTitle(context)
|
||||
} catch (e: NullPointerException) {
|
||||
Log.w(TAG, "Could not get correct title for ringtone.", e)
|
||||
context.getString(R.string.CustomNotificationsDialogFragment__unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
return context.getString(R.string.CustomNotificationsDialogFragment__default)
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class CustomNotificationsSettingsViewModel(
|
||||
@@ -42,7 +43,7 @@ class CustomNotificationsSettingsViewModel(
|
||||
RecipientDatabase.VibrateState.ENABLED -> true
|
||||
RecipientDatabase.VibrateState.DISABLED -> false
|
||||
},
|
||||
showCallingOptions = !recipient.isGroup && recipient.isRegistered,
|
||||
showCallingOptions = recipient.isRegistered && (!recipient.isGroup || FeatureFlags.groupCallRinging()),
|
||||
callSound = recipient.callRingtone,
|
||||
callVibrateState = recipient.callVibrate
|
||||
)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package org.thoughtcrime.securesms.components.settings
|
||||
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.annotation.Px
|
||||
import androidx.annotation.StringRes
|
||||
import org.thoughtcrime.securesms.components.settings.models.Button
|
||||
import org.thoughtcrime.securesms.components.settings.models.Space
|
||||
import org.thoughtcrime.securesms.components.settings.models.Text
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
import org.thoughtcrime.securesms.util.MappingModelList
|
||||
|
||||
@@ -121,6 +125,35 @@ class DSLConfiguration {
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
fun noPadTextPref(title: DSLSettingsText) {
|
||||
val preference = Text(title)
|
||||
children.add(Text.Model(preference))
|
||||
}
|
||||
|
||||
fun space(@Px pixels: Int) {
|
||||
val preference = Space(pixels)
|
||||
children.add(Space.Model(preference))
|
||||
}
|
||||
|
||||
fun primaryButton(
|
||||
text: DSLSettingsText,
|
||||
isEnabled: Boolean = true,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val preference = Button.Model.Primary(text, null, isEnabled, onClick)
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
fun secondaryButtonNoOutline(
|
||||
text: DSLSettingsText,
|
||||
icon: DSLSettingsIcon? = null,
|
||||
isEnabled: Boolean = true,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val preference = Button.Model.SecondaryNoOutline(text, icon, isEnabled, onClick)
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
fun textPref(
|
||||
title: DSLSettingsText? = null,
|
||||
summary: DSLSettingsText? = null
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.thoughtcrime.securesms.components.settings.models
|
||||
|
||||
import android.view.View
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
object Button {
|
||||
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model.Primary::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) as MappingViewHolder<Model.Primary> }, R.layout.dsl_button_primary))
|
||||
mappingAdapter.registerFactory(Model.SecondaryNoOutline::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) as MappingViewHolder<Model.SecondaryNoOutline> }, R.layout.dsl_button_secondary))
|
||||
}
|
||||
|
||||
sealed class Model<T : Model<T>>(
|
||||
title: DSLSettingsText?,
|
||||
icon: DSLSettingsIcon?,
|
||||
isEnabled: Boolean,
|
||||
val onClick: () -> Unit
|
||||
) : PreferenceModel<T>(
|
||||
title = title,
|
||||
icon = icon,
|
||||
isEnabled = isEnabled
|
||||
) {
|
||||
class Primary(
|
||||
title: DSLSettingsText?,
|
||||
icon: DSLSettingsIcon?,
|
||||
isEnabled: Boolean,
|
||||
onClick: () -> Unit
|
||||
) : Model<Primary>(title, icon, isEnabled, onClick)
|
||||
|
||||
class SecondaryNoOutline(
|
||||
title: DSLSettingsText?,
|
||||
icon: DSLSettingsIcon?,
|
||||
isEnabled: Boolean,
|
||||
onClick: () -> Unit
|
||||
) : Model<SecondaryNoOutline>(title, icon, isEnabled, onClick)
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model<*>>(itemView) {
|
||||
|
||||
private val button: MaterialButton = itemView as MaterialButton
|
||||
|
||||
override fun bind(model: Model<*>) {
|
||||
button.text = model.title?.resolve(context)
|
||||
button.setOnClickListener {
|
||||
model.onClick()
|
||||
}
|
||||
button.icon = model.icon?.resolve(context)
|
||||
button.isEnabled = model.isEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.thoughtcrime.securesms.components.settings.models
|
||||
|
||||
import android.view.View
|
||||
import androidx.annotation.Px
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
/**
|
||||
* Adds extra space between elements in a DSL fragment
|
||||
*/
|
||||
data class Space(
|
||||
@Px val pixels: Int
|
||||
) {
|
||||
|
||||
companion object {
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.dsl_space_preference))
|
||||
}
|
||||
}
|
||||
|
||||
class Model(val space: Space) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && newItem.space == space
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
override fun bind(model: Model) {
|
||||
itemView.updateLayoutParams {
|
||||
height = model.space.pixels
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.thoughtcrime.securesms.components.settings.models
|
||||
|
||||
import android.text.Spanned
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.text.style.ClickableSpan
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
/**
|
||||
* A Text without any padding, allowing for exact padding to be handed in at runtime.
|
||||
*/
|
||||
data class Text(
|
||||
val text: DSLSettingsText,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.dsl_text_preference))
|
||||
}
|
||||
}
|
||||
|
||||
class Model(val paddableText: Text) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && newItem.paddableText == paddableText
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val text: TextView = itemView.findViewById(R.id.title)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
text.text = model.paddableText.text.resolve(context)
|
||||
|
||||
val clickableSpans = (text.text as? Spanned)?.getSpans(0, text.text.length, ClickableSpan::class.java)
|
||||
if (clickableSpans?.isEmpty() == false) {
|
||||
text.movementMethod = LinkMovementMethod.getInstance()
|
||||
} else {
|
||||
text.movementMethod = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,20 +104,15 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
return voiceNotePlayerViewState;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart(@NonNull LifecycleOwner owner) {
|
||||
if (!mediaBrowser.isConnected()) {
|
||||
mediaBrowser.connect();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume(@NonNull LifecycleOwner owner) {
|
||||
mediaBrowser.disconnect();
|
||||
mediaBrowser.connect();
|
||||
activity.setVolumeControlStream(AudioManager.STREAM_MUSIC);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStop(@NonNull LifecycleOwner owner) {
|
||||
public void onPause(@NonNull LifecycleOwner owner) {
|
||||
clearProgressEventHandler();
|
||||
|
||||
if (MediaControllerCompat.getMediaController(activity) != null) {
|
||||
|
||||
@@ -5,12 +5,12 @@ import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.DefaultLoadControl
|
||||
import com.google.android.exoplayer2.ForwardingPlayer
|
||||
import com.google.android.exoplayer2.SimpleExoPlayer
|
||||
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory
|
||||
import org.thoughtcrime.securesms.video.exo.SignalMediaSourceFactory
|
||||
|
||||
class VoiceNotePlayer @JvmOverloads constructor(
|
||||
context: Context,
|
||||
val internalPlayer: SimpleExoPlayer = SimpleExoPlayer.Builder(context)
|
||||
.setMediaSourceFactory(AttachmentMediaSourceFactory(context))
|
||||
.setMediaSourceFactory(SignalMediaSourceFactory(context))
|
||||
.setLoadControl(
|
||||
DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE)
|
||||
|
||||
@@ -35,6 +35,7 @@ class VoiceNotePlayerView @JvmOverloads constructor(
|
||||
|
||||
private val playPauseToggleView: LottieAnimationView
|
||||
private val infoView: TextView
|
||||
private val durationView: TextView
|
||||
private val speedView: PlaybackSpeedToggleTextView
|
||||
private val closeButton: View
|
||||
|
||||
@@ -49,6 +50,7 @@ class VoiceNotePlayerView @JvmOverloads constructor(
|
||||
|
||||
playPauseToggleView = findViewById(R.id.voice_note_player_play_pause_toggle)
|
||||
infoView = findViewById(R.id.voice_note_player_info)
|
||||
durationView = findViewById(R.id.voice_note_player_duration)
|
||||
speedView = findViewById(R.id.voice_note_player_speed)
|
||||
closeButton = findViewById(R.id.voice_note_player_close)
|
||||
|
||||
@@ -116,7 +118,11 @@ class VoiceNotePlayerView @JvmOverloads constructor(
|
||||
animateToggleToPause()
|
||||
}
|
||||
|
||||
infoView.text = context.getString(R.string.VoiceNotePlayerView__s_dot_s, state.name, formatDuration(state.playbackDuration - state.playbackPosition))
|
||||
if (infoView.text != state.name) {
|
||||
infoView.text = state.name
|
||||
}
|
||||
|
||||
durationView.text = context.getString(R.string.VoiceNotePlayerView__dot_s, formatDuration(state.playbackDuration - state.playbackPosition))
|
||||
speedView.setCurrentSpeed(state.playbackSpeed)
|
||||
}
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ data class CallParticipantsState(
|
||||
localRenderState = localRenderState,
|
||||
showVideoForOutgoing = newShowVideoForOutgoing,
|
||||
remoteDevicesCount = webRtcViewModel.remoteDevicesCount,
|
||||
ringGroup = webRtcViewModel.shouldRingGroup(),
|
||||
ringGroup = webRtcViewModel.ringGroup,
|
||||
isInOutgoingRingingMode = isInOutgoingRingingMode,
|
||||
ringerRecipient = webRtcViewModel.ringerRecipient
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
import android.content.Context;
|
||||
import android.media.AudioManager;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.WorkerThread;
|
||||
@@ -10,11 +9,9 @@ import androidx.core.util.Consumer;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase;
|
||||
import org.thoughtcrime.securesms.database.identity.IdentityRecordList;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -22,21 +19,9 @@ import java.util.List;
|
||||
class WebRtcCallRepository {
|
||||
|
||||
private final Context context;
|
||||
private final AudioManager audioManager;
|
||||
|
||||
WebRtcCallRepository(@NonNull Context context) {
|
||||
this.context = context;
|
||||
this.audioManager = ServiceUtil.getAudioManager(ApplicationDependencies.getApplication());
|
||||
}
|
||||
|
||||
@NonNull WebRtcAudioOutput getAudioOutput() {
|
||||
if (audioManager.isBluetoothScoOn()) {
|
||||
return WebRtcAudioOutput.HEADSET;
|
||||
} else if (audioManager.isSpeakerphoneOn()) {
|
||||
return WebRtcAudioOutput.SPEAKER;
|
||||
} else {
|
||||
return WebRtcAudioOutput.HANDSET;
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
||||
@@ -19,7 +19,6 @@ import com.annimon.stream.Stream;
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
|
||||
import org.thoughtcrime.securesms.components.sensors.Orientation;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.events.CallParticipant;
|
||||
@@ -35,11 +34,13 @@ import org.thoughtcrime.securesms.util.DefaultValueLiveData;
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
public class WebRtcCallViewModel extends ViewModel {
|
||||
|
||||
@@ -252,9 +253,9 @@ public class WebRtcCallViewModel extends ViewModel {
|
||||
webRtcViewModel.isRemoteVideoEnabled(),
|
||||
webRtcViewModel.isRemoteVideoOffer(),
|
||||
localParticipant.isMoreThanOneCameraAvailable(),
|
||||
webRtcViewModel.isBluetoothAvailable(),
|
||||
Util.hasItems(webRtcViewModel.getRemoteParticipants()),
|
||||
repository.getAudioOutput(),
|
||||
webRtcViewModel.getActiveDevice(),
|
||||
webRtcViewModel.getAvailableDevices(),
|
||||
webRtcViewModel.getRemoteDevicesCount().orElse(0),
|
||||
webRtcViewModel.getParticipantLimit());
|
||||
|
||||
@@ -314,9 +315,9 @@ public class WebRtcCallViewModel extends ViewModel {
|
||||
boolean isRemoteVideoEnabled,
|
||||
boolean isRemoteVideoOffer,
|
||||
boolean isMoreThanOneCameraAvailable,
|
||||
boolean isBluetoothAvailable,
|
||||
boolean hasAtLeastOneRemote,
|
||||
@NonNull WebRtcAudioOutput audioOutput,
|
||||
@NonNull SignalAudioManager.AudioDevice activeDevice,
|
||||
@NonNull Set<SignalAudioManager.AudioDevice> availableDevices,
|
||||
long remoteDevicesCount,
|
||||
@Nullable Long participantLimit)
|
||||
{
|
||||
@@ -373,14 +374,14 @@ public class WebRtcCallViewModel extends ViewModel {
|
||||
webRtcControls.setValue(new WebRtcControls(isLocalVideoEnabled,
|
||||
isRemoteVideoEnabled || isRemoteVideoOffer,
|
||||
isMoreThanOneCameraAvailable,
|
||||
isBluetoothAvailable,
|
||||
Boolean.TRUE.equals(isInPipMode.getValue()),
|
||||
hasAtLeastOneRemote,
|
||||
callState,
|
||||
groupCallState,
|
||||
audioOutput,
|
||||
participantLimit,
|
||||
WebRtcControls.FoldableState.flat()));
|
||||
WebRtcControls.FoldableState.flat(),
|
||||
activeDevice,
|
||||
availableDevices));
|
||||
}
|
||||
|
||||
private @NonNull WebRtcControls updateControlsFoldableState(@NonNull WebRtcControls.FoldableState foldableState, @NonNull WebRtcControls controls) {
|
||||
|
||||
@@ -9,65 +9,90 @@ import androidx.annotation.StringRes;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import static java.util.Collections.emptySet;
|
||||
|
||||
public final class WebRtcControls {
|
||||
|
||||
public static final WebRtcControls NONE = new WebRtcControls();
|
||||
public static final WebRtcControls PIP = new WebRtcControls(false, false, false, false, true, false, CallState.NONE, GroupCallState.NONE, WebRtcAudioOutput.HANDSET, null, FoldableState.flat());
|
||||
public static final WebRtcControls PIP = new WebRtcControls(false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
CallState.NONE,
|
||||
GroupCallState.NONE,
|
||||
null,
|
||||
FoldableState.flat(),
|
||||
SignalAudioManager.AudioDevice.NONE,
|
||||
emptySet());
|
||||
|
||||
private final boolean isRemoteVideoEnabled;
|
||||
private final boolean isLocalVideoEnabled;
|
||||
private final boolean isMoreThanOneCameraAvailable;
|
||||
private final boolean isBluetoothAvailable;
|
||||
private final boolean isInPipMode;
|
||||
private final boolean hasAtLeastOneRemote;
|
||||
private final CallState callState;
|
||||
private final GroupCallState groupCallState;
|
||||
private final WebRtcAudioOutput audioOutput;
|
||||
private final Long participantLimit;
|
||||
private final FoldableState foldableState;
|
||||
private final boolean isRemoteVideoEnabled;
|
||||
private final boolean isLocalVideoEnabled;
|
||||
private final boolean isMoreThanOneCameraAvailable;
|
||||
private final boolean isInPipMode;
|
||||
private final boolean hasAtLeastOneRemote;
|
||||
private final CallState callState;
|
||||
private final GroupCallState groupCallState;
|
||||
private final Long participantLimit;
|
||||
private final FoldableState foldableState;
|
||||
private final SignalAudioManager.AudioDevice activeDevice;
|
||||
private final Set<SignalAudioManager.AudioDevice> availableDevices;
|
||||
|
||||
private WebRtcControls() {
|
||||
this(false, false, false, false, false, false, CallState.NONE, GroupCallState.NONE, WebRtcAudioOutput.HANDSET, null, FoldableState.flat());
|
||||
this(false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
CallState.NONE,
|
||||
GroupCallState.NONE,
|
||||
null,
|
||||
FoldableState.flat(),
|
||||
SignalAudioManager.AudioDevice.NONE,
|
||||
emptySet());
|
||||
}
|
||||
|
||||
WebRtcControls(boolean isLocalVideoEnabled,
|
||||
boolean isRemoteVideoEnabled,
|
||||
boolean isMoreThanOneCameraAvailable,
|
||||
boolean isBluetoothAvailable,
|
||||
boolean isInPipMode,
|
||||
boolean hasAtLeastOneRemote,
|
||||
@NonNull CallState callState,
|
||||
@NonNull GroupCallState groupCallState,
|
||||
@NonNull WebRtcAudioOutput audioOutput,
|
||||
@Nullable Long participantLimit,
|
||||
@NonNull FoldableState foldableState)
|
||||
@NonNull FoldableState foldableState,
|
||||
@NonNull SignalAudioManager.AudioDevice activeDevice,
|
||||
@NonNull Set<SignalAudioManager.AudioDevice> availableDevices)
|
||||
{
|
||||
this.isLocalVideoEnabled = isLocalVideoEnabled;
|
||||
this.isRemoteVideoEnabled = isRemoteVideoEnabled;
|
||||
this.isBluetoothAvailable = isBluetoothAvailable;
|
||||
this.isMoreThanOneCameraAvailable = isMoreThanOneCameraAvailable;
|
||||
this.isInPipMode = isInPipMode;
|
||||
this.hasAtLeastOneRemote = hasAtLeastOneRemote;
|
||||
this.callState = callState;
|
||||
this.groupCallState = groupCallState;
|
||||
this.audioOutput = audioOutput;
|
||||
this.participantLimit = participantLimit;
|
||||
this.foldableState = foldableState;
|
||||
this.activeDevice = activeDevice;
|
||||
this.availableDevices = availableDevices;
|
||||
}
|
||||
|
||||
public @NonNull WebRtcControls withFoldableState(FoldableState foldableState) {
|
||||
return new WebRtcControls(isLocalVideoEnabled,
|
||||
isRemoteVideoEnabled,
|
||||
isMoreThanOneCameraAvailable,
|
||||
isBluetoothAvailable,
|
||||
isInPipMode,
|
||||
hasAtLeastOneRemote,
|
||||
callState,
|
||||
groupCallState,
|
||||
audioOutput,
|
||||
participantLimit,
|
||||
foldableState);
|
||||
foldableState,
|
||||
activeDevice,
|
||||
availableDevices);
|
||||
}
|
||||
|
||||
boolean displayErrorControls() {
|
||||
@@ -129,7 +154,7 @@ public final class WebRtcControls {
|
||||
}
|
||||
|
||||
boolean displayAudioToggle() {
|
||||
return (isPreJoin() || isAtLeastOutgoing()) && (!isLocalVideoEnabled || isBluetoothAvailable);
|
||||
return (isPreJoin() || isAtLeastOutgoing()) && (!isLocalVideoEnabled || enableHeadsetInAudioToggle());
|
||||
}
|
||||
|
||||
boolean displayCameraToggle() {
|
||||
@@ -153,7 +178,7 @@ public final class WebRtcControls {
|
||||
}
|
||||
|
||||
boolean enableHeadsetInAudioToggle() {
|
||||
return isBluetoothAvailable;
|
||||
return availableDevices.contains(SignalAudioManager.AudioDevice.BLUETOOTH);
|
||||
}
|
||||
|
||||
boolean isFadeOutEnabled() {
|
||||
@@ -173,7 +198,14 @@ public final class WebRtcControls {
|
||||
}
|
||||
|
||||
@NonNull WebRtcAudioOutput getAudioOutput() {
|
||||
return audioOutput;
|
||||
switch (activeDevice) {
|
||||
case SPEAKER_PHONE:
|
||||
return WebRtcAudioOutput.SPEAKER;
|
||||
case BLUETOOTH:
|
||||
return WebRtcAudioOutput.HEADSET;
|
||||
default:
|
||||
return WebRtcAudioOutput.HANDSET;
|
||||
}
|
||||
}
|
||||
|
||||
boolean showSmallHeader() {
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
@@ -14,6 +13,7 @@ import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView;
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||
import org.thoughtcrime.securesms.components.FromTextView;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
@@ -37,16 +37,17 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
|
||||
private TextView labelView;
|
||||
private CheckBox checkBox;
|
||||
private View smsTag;
|
||||
private BadgeImageView badge;
|
||||
|
||||
private String number;
|
||||
private String chipName;
|
||||
private int contactType;
|
||||
private String contactName;
|
||||
private String contactNumber;
|
||||
private String contactLabel;
|
||||
private String contactAbout;
|
||||
private LiveRecipient recipient;
|
||||
private GlideRequests glideRequests;
|
||||
private String number;
|
||||
private String chipName;
|
||||
private int contactType;
|
||||
private String contactName;
|
||||
private String contactNumber;
|
||||
private String contactLabel;
|
||||
private String contactAbout;
|
||||
private LiveRecipient recipient;
|
||||
private GlideRequests glideRequests;
|
||||
|
||||
public ContactSelectionListItem(Context context) {
|
||||
super(context);
|
||||
@@ -65,6 +66,7 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
|
||||
this.nameView = findViewById(R.id.name);
|
||||
this.checkBox = findViewById(R.id.check_box);
|
||||
this.smsTag = findViewById(R.id.sms_tag);
|
||||
this.badge = findViewById(R.id.contact_badge);
|
||||
|
||||
ViewUtil.setTextViewGravityStart(this.nameView, getContext());
|
||||
}
|
||||
@@ -118,6 +120,8 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
|
||||
}
|
||||
|
||||
this.checkBox.setVisibility(checkboxVisible ? View.VISIBLE : View.GONE);
|
||||
|
||||
badge.setBadgeFromRecipient(recipientSnapshot);
|
||||
}
|
||||
|
||||
public void setChecked(boolean selected, boolean animate) {
|
||||
@@ -229,6 +233,7 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
|
||||
contactPhotoImage.setAvatar(glideRequests, recipient, false);
|
||||
setText(recipient, contactType, contactName, contactNumber, contactLabel, contactAbout);
|
||||
smsTag.setVisibility(recipient.isRegistered() ? GONE : VISIBLE);
|
||||
badge.setBadgeFromRecipient(recipient);
|
||||
} else {
|
||||
Log.w(TAG, "Bad change! Local recipient doesn't match. Ignoring. Local: " + (this.recipient == null ? "null" : this.recipient.getId()) + ", Changed: " + recipient.getId());
|
||||
}
|
||||
|
||||
@@ -66,8 +66,8 @@ class ContactDiscoveryV2 {
|
||||
KeyStore iasKeyStore = getIasKeyStore(context);
|
||||
|
||||
try {
|
||||
Map<String, UUID> results = accountManager.getRegisteredUsers(iasKeyStore, sanitizedNumbers, BuildConfig.CDS_MRENCLAVE);
|
||||
FuzzyPhoneNumberHelper.OutputResultV2 outputResult = FuzzyPhoneNumberHelper.generateOutputV2(results, inputResult);
|
||||
Map<String, UUID> results = accountManager.getRegisteredUsers(iasKeyStore, sanitizedNumbers, BuildConfig.CDS_MRENCLAVE);
|
||||
FuzzyPhoneNumberHelper.OutputResult outputResult = FuzzyPhoneNumberHelper.generateOutput(results, inputResult);
|
||||
|
||||
return new DirectoryResult(outputResult.getNumbers(), outputResult.getRewrites(), ignoredNumbers);
|
||||
} catch (SignatureException | UnauthenticatedQuoteException | UnauthenticatedResponseException | Quote.InvalidQuoteFormatException |InvalidKeyException e) {
|
||||
|
||||