[SES-469] Update push notifications

- Utilise new push endpoint
- Add huawei support
This commit is contained in:
Andrew 2023-08-23 11:42:19 +09:30 committed by GitHub
commit f6345c86ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 1786 additions and 761 deletions

2
.gitignore vendored
View File

@ -15,4 +15,4 @@ signing.properties
ffpr
*.sh
pkcs11.password
play
app/play

View File

@ -34,6 +34,12 @@ Setting up a development environment and building from Android Studio
6. Project initialization and building should proceed.
7. Clone submodules with `git submodule update --init --recursive`
If you would like to build the Huawei Flavor with Huawei HMS push notifications you will need to pass 'huawei' as a command line arg to include the required dependencies.
e.g. `./gradlew assembleHuaweiDebug -Phuawei`
If you are building in Android Studio then add `-Phuawei` to `Preferences > Build, Execution, Deployment > Gradle-Android Compiler > Command-line Options`
Contributing code
-----------------

View File

@ -24,15 +24,181 @@ apply plugin: 'kotlin-android'
apply plugin: 'witness'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-parcelize'
apply plugin: 'com.google.gms.google-services'
apply plugin: 'kotlinx-serialization'
apply plugin: 'dagger.hilt.android.plugin'
configurations.all {
exclude module: "commons-logging"
}
def canonicalVersionCode = 354
def canonicalVersionName = "1.17.0"
def postFixSize = 10
def abiPostFix = ['armeabi-v7a' : 1,
'arm64-v8a' : 2,
'x86' : 3,
'x86_64' : 4,
'universal' : 5]
android {
compileSdkVersion androidCompileSdkVersion
namespace 'network.loki.messenger'
useLibrary 'org.apache.http.legacy'
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
packagingOptions {
exclude 'LICENSE.txt'
exclude 'LICENSE'
exclude 'NOTICE'
exclude 'asm-license.txt'
exclude 'META-INF/LICENSE'
exclude 'META-INF/NOTICE'
exclude 'META-INF/proguard/androidx-annotations.pro'
}
splits {
abi {
enable true
reset()
include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
universalApk true
}
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.4.7'
}
defaultConfig {
versionCode canonicalVersionCode * postFixSize
versionName canonicalVersionName
minSdkVersion androidMinimumSdkVersion
targetSdkVersion androidTargetSdkVersion
multiDexEnabled = true
vectorDrawables.useSupportLibrary = true
project.ext.set("archivesBaseName", "session")
buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L"
buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\""
buildConfigField "int", "CONTENT_PROXY_PORT", "443"
buildConfigField "String", "USER_AGENT", "\"OWA\""
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
resConfigs autoResConfig()
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// The following argument makes the Android Test Orchestrator run its
// "pm clear" command after each test invocation. This command ensures
// that the app's state is completely cleared between tests.
testInstrumentationRunnerArguments clearPackageData: 'true'
testOptions {
execution 'ANDROIDX_TEST_ORCHESTRATOR'
}
}
sourceSets {
String sharedTestDir = 'src/sharedTest/java'
test.java.srcDirs += sharedTestDir
androidTest.java.srcDirs += sharedTestDir
}
buildTypes {
release {
minifyEnabled false
}
debug {
minifyEnabled false
}
}
flavorDimensions "distribution"
productFlavors {
play {
dimension "distribution"
apply plugin: 'com.google.gms.google-services'
ext.websiteUpdateUrl = "null"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
buildConfigField "org.session.libsession.utilities.Device", "DEVICE", "org.session.libsession.utilities.Device.ANDROID"
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
}
huawei {
dimension "distribution"
ext.websiteUpdateUrl = "null"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "true"
buildConfigField "org.session.libsession.utilities.Device", "DEVICE", "org.session.libsession.utilities.Device.HUAWEI"
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
}
website {
dimension "distribution"
ext.websiteUpdateUrl = "https://github.com/oxen-io/session-android/releases"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "true"
buildConfigField "org.session.libsession.utilities.Device", "DEVICE", "org.session.libsession.utilities.Device.ANDROID"
buildConfigField "String", "NOPLAY_UPDATE_URL", "\"$ext.websiteUpdateUrl\""
}
}
applicationVariants.all { variant ->
variant.outputs.each { output ->
def abiName = output.getFilter("ABI") ?: 'universal'
def postFix = abiPostFix.get(abiName, 0)
if (postFix >= postFixSize) throw new AssertionError("postFix is too large")
output.outputFileName = output.outputFileName = "session-${variant.versionName}-${abiName}.apk"
output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix
}
}
lintOptions {
abortOnError true
baseline file("lint-baseline.xml")
}
testOptions {
unitTests {
includeAndroidResources = true
}
}
buildFeatures {
dataBinding true
viewBinding true
}
def huaweiEnabled = project.properties['huawei'] != null
applicationVariants.configureEach { variant ->
if (variant.flavorName == 'huawei') {
variant.getPreBuildProvider().configure { task ->
task.doFirst {
if (!huaweiEnabled) {
def message = 'Huawei is not enabled. Please add -Phuawei command line arg. See BUILDING.md'
logger.error(message)
throw new GradleException(message)
}
}
}
}
}
}
dependencies {
implementation("com.google.dagger:hilt-android:2.46.1")
@ -59,11 +225,12 @@ dependencies {
implementation 'androidx.fragment:fragment-ktx:1.5.3'
implementation "androidx.core:core-ktx:$coreVersion"
implementation "androidx.work:work-runtime-ktx:2.7.1"
implementation ("com.google.firebase:firebase-messaging:18.0.0") {
playImplementation ("com.google.firebase:firebase-messaging:18.0.0") {
exclude group: 'com.google.firebase', module: 'firebase-core'
exclude group: 'com.google.firebase', module: 'firebase-analytics'
exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'
}
if (project.hasProperty('huawei')) huaweiImplementation 'com.huawei.hms:push:6.7.0.300'
implementation 'com.google.android.exoplayer:exoplayer-core:2.9.1'
implementation 'com.google.android.exoplayer:exoplayer-ui:2.9.1'
implementation 'org.conscrypt:conscrypt-android:2.0.0'
@ -176,144 +343,6 @@ dependencies {
implementation 'androidx.compose.material:material:1.5.0-alpha02'
}
def canonicalVersionCode = 354
def canonicalVersionName = "1.17.0"
def postFixSize = 10
def abiPostFix = ['armeabi-v7a' : 1,
'arm64-v8a' : 2,
'x86' : 3,
'x86_64' : 4,
'universal' : 5]
android {
compileSdkVersion androidCompileSdkVersion
namespace 'network.loki.messenger'
useLibrary 'org.apache.http.legacy'
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
packagingOptions {
exclude 'LICENSE.txt'
exclude 'LICENSE'
exclude 'NOTICE'
exclude 'asm-license.txt'
exclude 'META-INF/LICENSE'
exclude 'META-INF/NOTICE'
exclude 'META-INF/proguard/androidx-annotations.pro'
}
splits {
abi {
enable true
reset()
include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
universalApk true
}
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.4.7'
}
defaultConfig {
versionCode canonicalVersionCode * postFixSize
versionName canonicalVersionName
minSdkVersion androidMinimumSdkVersion
targetSdkVersion androidTargetSdkVersion
multiDexEnabled = true
vectorDrawables.useSupportLibrary = true
project.ext.set("archivesBaseName", "session")
buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L"
buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\""
buildConfigField "int", "CONTENT_PROXY_PORT", "443"
buildConfigField "String", "USER_AGENT", "\"OWA\""
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
resConfigs autoResConfig()
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// The following argument makes the Android Test Orchestrator run its
// "pm clear" command after each test invocation. This command ensures
// that the app's state is completely cleared between tests.
testInstrumentationRunnerArguments clearPackageData: 'true'
testOptions {
execution 'ANDROIDX_TEST_ORCHESTRATOR'
}
}
sourceSets {
String sharedTestDir = 'src/sharedTest/java'
test.java.srcDirs += sharedTestDir
androidTest.java.srcDirs += sharedTestDir
}
buildTypes {
release {
minifyEnabled false
}
debug {
minifyEnabled false
}
}
flavorDimensions "distribution"
productFlavors {
play {
ext.websiteUpdateUrl = "null"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
}
website {
ext.websiteUpdateUrl = "https://github.com/oxen-io/session-android/releases"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "true"
buildConfigField "String", "NOPLAY_UPDATE_URL", "\"$ext.websiteUpdateUrl\""
}
}
applicationVariants.all { variant ->
variant.outputs.each { output ->
def abiName = output.getFilter("ABI") ?: 'universal'
def postFix = abiPostFix.get(abiName, 0)
if (postFix >= postFixSize) throw new AssertionError("postFix is too large")
output.outputFileName = output.outputFileName = "session-${variant.versionName}-${abiName}.apk"
output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix
}
}
lintOptions {
abortOnError true
baseline file("lint-baseline.xml")
}
testOptions {
unitTests {
includeAndroidResources = true
}
}
buildFeatures {
dataBinding true
viewBinding true
}
}
static def getLastCommitTimestamp() {
new ByteArrayOutputStream().withStream { os ->
return os.toString() + "000"

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application tools:node="merge">
<meta-data
android:name="com.huawei.hms.client.appid"
android:value="appid=107205081">
</meta-data>
<meta-data
android:name="com.huawei.hms.client.cpid"
android:value="cpid=30061000024605000">
</meta-data>
<service
android:name="org.thoughtcrime.securesms.notifications.HuaweiPushService"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.huawei.push.action.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>

View File

@ -0,0 +1,96 @@
{
"agcgw":{
"backurl":"connect-dre.hispace.hicloud.com",
"url":"connect-dre.dbankcloud.cn",
"websocketbackurl":"connect-ws-dre.hispace.dbankcloud.com",
"websocketurl":"connect-ws-dre.hispace.dbankcloud.cn"
},
"agcgw_all":{
"CN":"connect-drcn.dbankcloud.cn",
"CN_back":"connect-drcn.hispace.hicloud.com",
"DE":"connect-dre.dbankcloud.cn",
"DE_back":"connect-dre.hispace.hicloud.com",
"RU":"connect-drru.hispace.dbankcloud.ru",
"RU_back":"connect-drru.hispace.dbankcloud.cn",
"SG":"connect-dra.dbankcloud.cn",
"SG_back":"connect-dra.hispace.hicloud.com"
},
"websocketgw_all":{
"CN":"connect-ws-drcn.hispace.dbankcloud.cn",
"CN_back":"connect-ws-drcn.hispace.dbankcloud.com",
"DE":"connect-ws-dre.hispace.dbankcloud.cn",
"DE_back":"connect-ws-dre.hispace.dbankcloud.com",
"RU":"connect-ws-drru.hispace.dbankcloud.ru",
"RU_back":"connect-ws-drru.hispace.dbankcloud.cn",
"SG":"connect-ws-dra.hispace.dbankcloud.cn",
"SG_back":"connect-ws-dra.hispace.dbankcloud.com"
},
"client":{
"cp_id":"890061000023000573",
"product_id":"99536292102532562",
"client_id":"954244311350791232",
"client_secret":"555999202D718B6744DAD2E923B386DC17F3F4E29F5105CE0D061EED328DADEE",
"project_id":"99536292102532562",
"app_id":"107205081",
"api_key":"DAEDABeddLEqUy0QRwa1THLwRA0OqrSuyci/HjNvVSmsdWsXRM2U2hRaCyqfvGYH1IFOKrauArssz/WPMLRHCYxliWf+DTj9bDwlWA==",
"package_name":"network.loki.messenger"
},
"oauth_client":{
"client_id":"107205081",
"client_type":1
},
"app_info":{
"app_id":"107205081",
"package_name":"network.loki.messenger"
},
"service":{
"analytics":{
"collector_url":"datacollector-dre.dt.hicloud.com,datacollector-dre.dt.dbankcloud.cn",
"collector_url_ru":"datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com",
"collector_url_sg":"datacollector-dra.dt.hicloud.com,datacollector-dra.dt.dbankcloud.cn",
"collector_url_de":"datacollector-dre.dt.hicloud.com,datacollector-dre.dt.dbankcloud.cn",
"collector_url_cn":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn",
"resource_id":"p1",
"channel_id":""
},
"edukit":{
"edu_url":"edukit.edu.cloud.huawei.com.cn",
"dh_url":"edukit.edu.cloud.huawei.com.cn"
},
"search":{
"url":"https://search-dre.cloud.huawei.com"
},
"cloudstorage":{
"storage_url_sg_back":"https://agc-storage-dra.cloud.huawei.asia",
"storage_url_ru_back":"https://agc-storage-drru.cloud.huawei.ru",
"storage_url_ru":"https://agc-storage-drru.cloud.huawei.ru",
"storage_url_de_back":"https://agc-storage-dre.cloud.huawei.eu",
"storage_url_de":"https://ops-dre.agcstorage.link",
"storage_url":"https://agc-storage-drcn.platform.dbankcloud.cn",
"storage_url_sg":"https://ops-dra.agcstorage.link",
"storage_url_cn_back":"https://agc-storage-drcn.cloud.huawei.com.cn",
"storage_url_cn":"https://agc-storage-drcn.platform.dbankcloud.cn"
},
"ml":{
"mlservice_url":"ml-api-dre.ai.dbankcloud.com,ml-api-dre.ai.dbankcloud.cn"
}
},
"region":"DE",
"configuration_version":"3.0",
"appInfos":[
{
"package_name":"network.loki.messenger",
"client":{
"app_id":"107205081"
},
"app_info":{
"package_name":"network.loki.messenger",
"app_id":"107205081"
},
"oauth_client":{
"client_type":1,
"client_id":"107205081"
}
}
]
}

View File

@ -0,0 +1,13 @@
package org.thoughtcrime.securesms.notifications
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
abstract class HuaweiBindingModule {
@Binds
abstract fun bindTokenFetcher(tokenFetcher: HuaweiTokenFetcher): TokenFetcher
}

View File

@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.notifications
import android.os.Bundle
import com.huawei.hms.push.HmsMessageService
import com.huawei.hms.push.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
import org.json.JSONException
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Log
import java.lang.Exception
import javax.inject.Inject
private val TAG = HuaweiPushService::class.java.simpleName
@AndroidEntryPoint
class HuaweiPushService: HmsMessageService() {
@Inject lateinit var pushRegistry: PushRegistry
@Inject lateinit var pushReceiver: PushReceiver
override fun onMessageReceived(message: RemoteMessage?) {
Log.d(TAG, "onMessageReceived")
message?.dataOfMap?.takeIf { it.isNotEmpty() }?.let(pushReceiver::onPush) ?:
pushReceiver.onPush(message?.data?.let(Base64::decode))
}
override fun onNewToken(token: String?) {
pushRegistry.register(token)
}
override fun onNewToken(token: String?, bundle: Bundle?) {
Log.d(TAG, "New HCM token: $token.")
pushRegistry.register(token)
}
override fun onDeletedMessages() {
Log.d(TAG, "onDeletedMessages")
pushRegistry.refresh(false)
}
}

View File

@ -0,0 +1,29 @@
package org.thoughtcrime.securesms.notifications
import android.content.Context
import com.huawei.hms.aaid.HmsInstanceId
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.session.libsignal.utilities.Log
import javax.inject.Inject
import javax.inject.Singleton
private const val APP_ID = "107205081"
private const val TOKEN_SCOPE = "HCM"
@Singleton
class HuaweiTokenFetcher @Inject constructor(
@ApplicationContext private val context: Context,
private val pushRegistry: Lazy<PushRegistry>,
): TokenFetcher {
override suspend fun fetch(): String? = HmsInstanceId.getInstance(context).run {
// https://developer.huawei.com/consumer/en/doc/development/HMS-Guides/push-basic-capability#h2-1576218800370
// getToken may return an empty string, if so HuaweiPushService#onNewToken will be called.
withContext(Dispatchers.IO) { getToken(APP_ID, TOKEN_SCOPE) }
}
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="preferences_notifications_strategy_category_fast_mode_summary">You\'ll be notified of new messages reliably and immediately using Huaweis notification servers.</string>
<string name="activity_pn_mode_fast_mode_explanation">You\'ll be notified of new messages reliably and immediately using Huaweis notification servers.</string>
</resources>

View File

@ -310,14 +310,6 @@
android:name="android.support.PARENT_ACTIVITY"
android:value="org.thoughtcrime.securesms.home.HomeActivity" />
</activity>
<service
android:name="org.thoughtcrime.securesms.notifications.PushNotificationService"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<service android:enabled="true" android:name="org.thoughtcrime.securesms.service.WebRtcCallService"
android:exported="false" />
<service

View File

@ -41,6 +41,7 @@ import org.session.libsession.messaging.sending_receiving.pollers.Poller;
import org.session.libsession.snode.SnodeModule;
import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.ConfigFactoryUpdateListener;
import org.session.libsession.utilities.Device;
import org.session.libsession.utilities.ProfilePictureUtilities;
import org.session.libsession.utilities.SSKEnvironment;
import org.session.libsession.utilities.TextSecurePreferences;
@ -73,10 +74,9 @@ import org.thoughtcrime.securesms.logging.PersistentLogger;
import org.thoughtcrime.securesms.logging.UncaughtExceptionLogger;
import org.thoughtcrime.securesms.notifications.BackgroundPollWorker;
import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier;
import org.thoughtcrime.securesms.notifications.FcmUtils;
import org.thoughtcrime.securesms.notifications.LokiPushNotificationManager;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier;
import org.thoughtcrime.securesms.notifications.PushRegistry;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.service.KeyCachingService;
@ -143,8 +143,10 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
@Inject LokiAPIDatabase lokiAPIDatabase;
@Inject public Storage storage;
@Inject Device device;
@Inject MessageDataProvider messageDataProvider;
@Inject TextSecurePreferences textSecurePreferences;
@Inject PushRegistry pushRegistry;
@Inject ConfigFactory configFactory;
CallMessageProcessor callMessageProcessor;
MessagingModuleConfiguration messagingModuleConfiguration;
@ -207,8 +209,10 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
DatabaseModule.init(this);
MessagingModuleConfiguration.configure(this);
super.onCreate();
messagingModuleConfiguration = new MessagingModuleConfiguration(this,
messagingModuleConfiguration = new MessagingModuleConfiguration(
this,
storage,
device,
messageDataProvider,
()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this),
configFactory
@ -226,10 +230,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
broadcaster = new Broadcaster(this);
LokiAPIDatabase apiDB = getDatabaseComponent().lokiAPIDatabase();
SnodeModule.Companion.configure(apiDB, broadcaster);
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
if (userPublicKey != null) {
registerForFCMIfNeeded(false);
}
initializeExpiringMessageManager();
initializeTypingStatusRepository();
initializeTypingStatusSender();
@ -427,33 +427,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
}
private static class ProviderInitializationException extends RuntimeException { }
public void registerForFCMIfNeeded(final Boolean force) {
if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive() && !force) return;
if (force && firebaseInstanceIdJob != null) {
firebaseInstanceIdJob.cancel(null);
}
firebaseInstanceIdJob = FcmUtils.getFcmInstanceId(task->{
if (!task.isSuccessful()) {
Log.w("Loki", "FirebaseInstanceId.getInstance().getInstanceId() failed." + task.getException());
return Unit.INSTANCE;
}
String token = task.getResult().getToken();
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
if (userPublicKey == null) return Unit.INSTANCE;
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
if (TextSecurePreferences.isUsingFCM(this)) {
LokiPushNotificationManager.register(token, userPublicKey, this, force);
} else {
LokiPushNotificationManager.unregister(token, this);
}
});
return Unit.INSTANCE;
});
}
private void setUpPollingIfNeeded() {
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
if (userPublicKey == null) return;
@ -524,18 +497,14 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
}
public void clearAllData(boolean isMigratingToV2KeyPair) {
String token = TextSecurePreferences.getFCMToken(this);
if (token != null && !token.isEmpty()) {
LokiPushNotificationManager.unregister(token, this);
}
if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive()) {
firebaseInstanceIdJob.cancel(null);
}
String displayName = TextSecurePreferences.getProfileName(this);
boolean isUsingFCM = TextSecurePreferences.isUsingFCM(this);
boolean isUsingFCM = TextSecurePreferences.isPushEnabled(this);
TextSecurePreferences.clearAll(this);
if (isMigratingToV2KeyPair) {
TextSecurePreferences.setIsUsingFCM(this, isUsingFCM);
TextSecurePreferences.setPushEnabled(this, isUsingFCM);
TextSecurePreferences.setProfileName(this, displayName);
}
getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit();

View File

@ -0,0 +1,16 @@
package org.thoughtcrime.securesms
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import network.loki.messenger.BuildConfig
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DeviceModule {
@Provides
@Singleton
fun provides() = BuildConfig.DEVICE
}

View File

@ -52,6 +52,7 @@ public class IdentityKeyUtil {
public static final String IDENTITY_PRIVATE_KEY_PREF = "pref_identity_private_v3";
public static final String ED25519_PUBLIC_KEY = "pref_ed25519_public_key";
public static final String ED25519_SECRET_KEY = "pref_ed25519_secret_key";
public static final String NOTIFICATION_KEY = "pref_notification_key";
public static final String LOKI_SEED = "loki_seed";
public static final String HAS_MIGRATED_KEY = "has_migrated_keys";

View File

@ -50,7 +50,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.messaging.utilities.SessionId
@ -591,7 +591,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
val expireTimer = group.disappearingTimer
setExpirationTimer(groupId, expireTimer.toInt())
// Notify the PN server
PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Subscribe, group.sessionId, localUserPublicKey)
PushRegistryV1.subscribeGroup(group.sessionId, publicKey = localUserPublicKey)
// Notify the user
val threadID = getOrCreateThreadIdFor(Address.fromSerialized(groupId))
threadDb.setDate(threadID, formationTimestamp)

View File

@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.groups
import android.content.Context
import network.loki.messenger.libsession_util.ConfigBase
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupRecord
@ -24,7 +24,7 @@ object ClosedGroupManager {
storage.removeAllClosedGroupEncryptionKeyPairs(groupPublicKey)
storage.removeMember(groupID, Address.fromSerialized(userPublicKey))
// Notify the PN server
PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Unsubscribe, groupPublicKey, userPublicKey)
PushRegistryV1.unsubscribeGroup(closedGroupPublicKey = groupPublicKey, publicKey = userPublicKey)
// Stop polling
ClosedGroupPollerV2.shared.stopPolling(groupPublicKey)
storage.cancelPendingMessageSendJobs(threadId)

View File

@ -21,6 +21,7 @@ import nl.komponents.kovenant.ui.successUi
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.groupSizeLimit
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Device
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.contacts.SelectContactsAdapter
@ -31,10 +32,14 @@ import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.fadeIn
import org.thoughtcrime.securesms.util.fadeOut
import javax.inject.Inject
@AndroidEntryPoint
class CreateGroupFragment : Fragment() {
@Inject
lateinit var device: Device
private lateinit var binding: FragmentCreateGroupBinding
private val viewModel: CreateGroupViewModel by viewModels()
@ -86,7 +91,7 @@ class CreateGroupFragment : Fragment() {
val userPublicKey = TextSecurePreferences.getLocalNumber(requireContext())!!
isLoading = true
binding.loaderContainer.fadeIn()
MessageSender.createClosedGroup(name.toString(), selectedMembers + setOf( userPublicKey )).successUi { groupID ->
MessageSender.createClosedGroup(device, name.toString(), selectedMembers + setOf( userPublicKey )).successUi { groupID ->
binding.loaderContainer.fadeOut()
isLoading = false
val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getOrCreateThreadIdFor(Recipient.from(requireContext(), Address.fromSerialized(groupID), false))

View File

@ -67,6 +67,7 @@ import org.thoughtcrime.securesms.home.search.GlobalSearchViewModel
import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.notifications.PushRegistry
import org.thoughtcrime.securesms.onboarding.SeedActivity
import org.thoughtcrime.securesms.onboarding.SeedReminderViewDelegate
import org.thoughtcrime.securesms.permissions.Permissions
@ -106,6 +107,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
@Inject lateinit var groupDatabase: GroupDatabase
@Inject lateinit var textSecurePreferences: TextSecurePreferences
@Inject lateinit var configFactory: ConfigFactory
@Inject lateinit var pushRegistry: PushRegistry
private val globalSearchViewModel by viewModels<GlobalSearchViewModel>()
private val homeViewModel by viewModels<HomeViewModel>()
@ -230,8 +232,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
(applicationContext as ApplicationContext).startPollingIfNeeded()
// update things based on TextSecurePrefs (profile info etc)
// Set up remaining components if needed
val application = ApplicationContext.getInstance(this@HomeActivity)
application.registerForFCMIfNeeded(false)
pushRegistry.refresh(false)
if (textSecurePreferences.getLocalNumber() != null) {
OpenGroupManager.startPolling()
JobQueue.shared.resumePendingJobs()

View File

@ -1,5 +1,7 @@
package org.thoughtcrime.securesms.linkpreview;
import static org.session.libsession.utilities.Util.readFully;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
@ -8,8 +10,6 @@ import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.gms.common.util.IOUtils;
import org.session.libsession.messaging.sending_receiving.attachments.Attachment;
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress;
import org.session.libsession.messaging.sending_receiving.attachments.UriAttachment;
@ -148,7 +148,7 @@ public class LinkPreviewRepository {
InputStream bodyStream = response.body().byteStream();
controller.setStream(bodyStream);
byte[] data = IOUtils.readInputStreamFully(bodyStream);
byte[] data = readFully(bodyStream);
Bitmap bitmap = BitmapFactory.decodeByteArray(data, 0, data.length);
Optional<Attachment> thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.JPEG, MediaTypes.IMAGE_JPEG);

View File

@ -1,19 +0,0 @@
@file:JvmName("FcmUtils")
package org.thoughtcrime.securesms.notifications
import com.google.android.gms.tasks.Task
import com.google.firebase.iid.FirebaseInstanceId
import com.google.firebase.iid.InstanceIdResult
import kotlinx.coroutines.*
fun getFcmInstanceId(body: (Task<InstanceIdResult>)->Unit): Job = MainScope().launch(Dispatchers.IO) {
val task = FirebaseInstanceId.getInstance().instanceId
while (!task.isComplete && isActive) {
// wait for task to complete while we are active
}
if (!isActive) return@launch // don't 'complete' task if we were canceled
withContext(Dispatchers.Main) {
body(task)
}
}

View File

@ -1,121 +0,0 @@
package org.thoughtcrime.securesms.notifications
import android.content.Context
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.map
import okhttp3.MediaType
import okhttp3.Request
import okhttp3.RequestBody
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.snode.Version
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.retryIfNeeded
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
object LokiPushNotificationManager {
private val maxRetryCount = 4
private val tokenExpirationInterval = 12 * 60 * 60 * 1000
private val server by lazy {
PushNotificationAPI.server
}
private val pnServerPublicKey by lazy {
PushNotificationAPI.serverPublicKey
}
enum class ClosedGroupOperation {
Subscribe, Unsubscribe;
val rawValue: String
get() {
return when (this) {
Subscribe -> "subscribe_closed_group"
Unsubscribe -> "unsubscribe_closed_group"
}
}
}
@JvmStatic
fun unregister(token: String, context: Context) {
val parameters = mapOf( "token" to token )
val url = "$server/unregister"
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
val request = Request.Builder().url(url).post(body)
retryIfNeeded(maxRetryCount) {
getResponseBody(request.build()).map { json ->
val code = json["code"] as? Int
if (code != null && code != 0) {
TextSecurePreferences.setIsUsingFCM(context, false)
} else {
Log.d("Loki", "Couldn't disable FCM due to error: ${json["message"] as? String ?: "null"}.")
}
}.fail { exception ->
Log.d("Loki", "Couldn't disable FCM due to error: ${exception}.")
}
}
// Unsubscribe from all closed groups
val allClosedGroupPublicKeys = DatabaseComponent.get(context).lokiAPIDatabase().getAllClosedGroupPublicKeys()
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
allClosedGroupPublicKeys.iterator().forEach { closedGroup ->
performOperation(context, ClosedGroupOperation.Unsubscribe, closedGroup, userPublicKey)
}
}
@JvmStatic
fun register(token: String, publicKey: String, context: Context, force: Boolean) {
val oldToken = TextSecurePreferences.getFCMToken(context)
val lastUploadDate = TextSecurePreferences.getLastFCMUploadTime(context)
if (!force && token == oldToken && System.currentTimeMillis() - lastUploadDate < tokenExpirationInterval) { return }
val parameters = mapOf( "token" to token, "pubKey" to publicKey )
val url = "$server/register"
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
val request = Request.Builder().url(url).post(body)
retryIfNeeded(maxRetryCount) {
getResponseBody(request.build()).map { json ->
val code = json["code"] as? Int
if (code != null && code != 0) {
TextSecurePreferences.setIsUsingFCM(context, true)
TextSecurePreferences.setFCMToken(context, token)
TextSecurePreferences.setLastFCMUploadTime(context, System.currentTimeMillis())
} else {
Log.d("Loki", "Couldn't register for FCM due to error: ${json["message"] as? String ?: "null"}.")
}
}.fail { exception ->
Log.d("Loki", "Couldn't register for FCM due to error: ${exception}.")
}
}
// Subscribe to all closed groups
val allClosedGroupPublicKeys = DatabaseComponent.get(context).lokiAPIDatabase().getAllClosedGroupPublicKeys()
allClosedGroupPublicKeys.iterator().forEach { closedGroup ->
performOperation(context, ClosedGroupOperation.Subscribe, closedGroup, publicKey)
}
}
@JvmStatic
fun performOperation(context: Context, operation: ClosedGroupOperation, closedGroupPublicKey: String, publicKey: String) {
if (!TextSecurePreferences.isUsingFCM(context)) { return }
val parameters = mapOf( "closedGroupPublicKey" to closedGroupPublicKey, "pubKey" to publicKey )
val url = "$server/${operation.rawValue}"
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
val request = Request.Builder().url(url).post(body)
retryIfNeeded(maxRetryCount) {
getResponseBody(request.build()).map { json ->
val code = json["code"] as? Int
if (code == null || code == 0) {
Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${json["message"] as? String ?: "null"}.")
}
}.fail { exception ->
Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${exception}.")
}
}
}
private fun getResponseBody(request: Request): Promise<Map<*, *>, Exception> {
return OnionRequestAPI.sendOnionRequest(request, server, pnServerPublicKey, Version.V2).map { response ->
JsonUtil.fromJson(response.body, Map::class.java)
}
}
}

View File

@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.notifications
interface PushManager {
fun refresh(force: Boolean)
}

View File

@ -1,58 +0,0 @@
package org.thoughtcrime.securesms.notifications
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.MessageReceiveParameters
import org.session.libsession.messaging.utilities.MessageWrapper
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Log
class PushNotificationService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
Log.d("Loki", "New FCM token: $token.")
val userPublicKey = TextSecurePreferences.getLocalNumber(this) ?: return
LokiPushNotificationManager.register(token, userPublicKey, this, false)
}
override fun onMessageReceived(message: RemoteMessage) {
Log.d("Loki", "Received a push notification.")
val base64EncodedData = message.data?.get("ENCRYPTED_DATA")
val data = base64EncodedData?.let { Base64.decode(it) }
if (data != null) {
try {
val envelopeAsData = MessageWrapper.unwrap(data).toByteArray()
val job = BatchMessageReceiveJob(listOf(MessageReceiveParameters(envelopeAsData)), null)
JobQueue.shared.add(job)
} catch (e: Exception) {
Log.d("Loki", "Failed to unwrap data for message due to error: $e.")
}
} else {
Log.d("Loki", "Failed to decode data for message.")
val builder = NotificationCompat.Builder(this, NotificationChannels.OTHER)
.setSmallIcon(network.loki.messenger.R.drawable.ic_notification)
.setColor(this.getResources().getColor(network.loki.messenger.R.color.textsecure_primary))
.setContentTitle("Session")
.setContentText("You've got a new message.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
with(NotificationManagerCompat.from(this)) {
notify(11111, builder.build())
}
}
}
override fun onDeletedMessages() {
Log.d("Loki", "Called onDeletedMessages.")
super.onDeletedMessages()
val token = TextSecurePreferences.getFCMToken(this)!!
val userPublicKey = TextSecurePreferences.getLocalNumber(this) ?: return
LokiPushNotificationManager.register(token, userPublicKey, this, true)
}
}

View File

@ -0,0 +1,114 @@
package org.thoughtcrime.securesms.notifications
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.goterl.lazysodium.LazySodiumAndroid
import com.goterl.lazysodium.SodiumAndroid
import com.goterl.lazysodium.interfaces.AEAD
import com.goterl.lazysodium.utils.Key
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.MessageReceiveParameters
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationMetadata
import org.session.libsession.messaging.utilities.MessageWrapper
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.bencode.Bencode
import org.session.libsession.utilities.bencode.BencodeList
import org.session.libsession.utilities.bencode.BencodeString
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import javax.inject.Inject
private const val TAG = "PushHandler"
class PushReceiver @Inject constructor(@ApplicationContext val context: Context) {
private val sodium = LazySodiumAndroid(SodiumAndroid())
fun onPush(dataMap: Map<String, String>?) {
onPush(dataMap?.asByteArray())
}
fun onPush(data: ByteArray?) {
if (data == null) {
onPush()
return
}
try {
val envelopeAsData = MessageWrapper.unwrap(data).toByteArray()
val job = BatchMessageReceiveJob(listOf(MessageReceiveParameters(envelopeAsData)), null)
JobQueue.shared.add(job)
} catch (e: Exception) {
Log.d(TAG, "Failed to unwrap data for message due to error.", e)
}
}
private fun onPush() {
Log.d(TAG, "Failed to decode data for message.")
val builder = NotificationCompat.Builder(context, NotificationChannels.OTHER)
.setSmallIcon(network.loki.messenger.R.drawable.ic_notification)
.setColor(context.getColor(network.loki.messenger.R.color.textsecure_primary))
.setContentTitle("Session")
.setContentText("You've got a new message.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
NotificationManagerCompat.from(context).notify(11111, builder.build())
}
private fun Map<String, String>.asByteArray() =
when {
// this is a v2 push notification
containsKey("spns") -> {
try {
decrypt(Base64.decode(this["enc_payload"]))
} catch (e: Exception) {
Log.e(TAG, "Invalid push notification", e)
null
}
}
// old v1 push notification; we still need this for receiving legacy closed group notifications
else -> this["ENCRYPTED_DATA"]?.let(Base64::decode)
}
private fun decrypt(encPayload: ByteArray): ByteArray? {
Log.d(TAG, "decrypt() called")
val encKey = getOrCreateNotificationKey()
val nonce = encPayload.take(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray()
val payload = encPayload.drop(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray()
val padded = SodiumUtilities.decrypt(payload, encKey.asBytes, nonce)
?: error("Failed to decrypt push notification")
val decrypted = padded.dropLastWhile { it.toInt() == 0 }.toByteArray()
val bencoded = Bencode.Decoder(decrypted)
val expectedList = (bencoded.decode() as? BencodeList)?.values
?: error("Failed to decode bencoded list from payload")
val metadataJson = (expectedList[0] as? BencodeString)?.value ?: error("no metadata")
val metadata: PushNotificationMetadata = Json.decodeFromString(String(metadataJson))
return (expectedList.getOrNull(1) as? BencodeString)?.value.also {
// null content is valid only if we got a "data_too_long" flag
it?.let { check(metadata.data_len == it.size) { "wrong message data size" } }
?: check(metadata.data_too_long) { "missing message data, but no too-long flag" }
}
}
fun getOrCreateNotificationKey(): Key {
if (IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY) == null) {
// generate the key and store it
val key = sodium.keygen(AEAD.Method.XCHACHA20_POLY1305_IETF)
IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.asHexString)
}
return Key.fromHexString(
IdentityKeyUtil.retrieve(
context,
IdentityKeyUtil.NOTIFICATION_KEY
)
)
}
}

View File

@ -0,0 +1,111 @@
package org.thoughtcrime.securesms.notifications
import android.content.Context
import com.goterl.lazysodium.utils.KeyPair
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.combine.and
import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1
import org.session.libsession.utilities.Device
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Namespace
import org.session.libsignal.utilities.emptyPromise
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import javax.inject.Inject
import javax.inject.Singleton
private val TAG = PushRegistry::class.java.name
@Singleton
class PushRegistry @Inject constructor(
@ApplicationContext private val context: Context,
private val device: Device,
private val tokenManager: TokenManager,
private val pushRegistryV2: PushRegistryV2,
private val prefs: TextSecurePreferences,
private val tokenFetcher: TokenFetcher,
) {
private var pushRegistrationJob: Job? = null
fun refresh(force: Boolean): Job {
Log.d(TAG, "refresh() called with: force = $force")
pushRegistrationJob?.apply {
if (force) cancel() else if (isActive) return MainScope().launch {}
}
return MainScope().launch(Dispatchers.IO) {
try {
register(tokenFetcher.fetch()).get()
} catch (e: Exception) {
Log.e(TAG, "register failed", e)
}
}.also { pushRegistrationJob = it }
}
fun register(token: String?): Promise<*, Exception> {
Log.d(TAG, "refresh() called")
if (token?.isNotEmpty() != true) return emptyPromise()
prefs.setPushToken(token)
val userPublicKey = prefs.getLocalNumber() ?: return emptyPromise()
val userEdKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return emptyPromise()
return when {
prefs.isPushEnabled() -> register(token, userPublicKey, userEdKey)
tokenManager.isRegistered -> unregister(token, userPublicKey, userEdKey)
else -> emptyPromise()
}
}
/**
* Register for push notifications.
*/
private fun register(
token: String,
publicKey: String,
userEd25519Key: KeyPair,
namespaces: List<Int> = listOf(Namespace.DEFAULT)
): Promise<*, Exception> {
Log.d(TAG, "register() called")
val v1 = PushRegistryV1.register(
device = device,
token = token,
publicKey = publicKey
) fail {
Log.e(TAG, "register v1 failed", it)
}
val v2 = pushRegistryV2.register(
device, token, publicKey, userEd25519Key, namespaces
) fail {
Log.e(TAG, "register v2 failed", it)
}
return v1 and v2 success {
Log.d(TAG, "register v1 & v2 success")
tokenManager.register()
}
}
private fun unregister(
token: String,
userPublicKey: String,
userEdKey: KeyPair
): Promise<*, Exception> = PushRegistryV1.unregister() and pushRegistryV2.unregister(
device, token, userPublicKey, userEdKey
) fail {
Log.e(TAG, "unregisterBoth failed", it)
} success {
tokenManager.unregister()
}
}

View File

@ -0,0 +1,116 @@
package org.thoughtcrime.securesms.notifications
import com.goterl.lazysodium.LazySodiumAndroid
import com.goterl.lazysodium.SodiumAndroid
import com.goterl.lazysodium.interfaces.Sign
import com.goterl.lazysodium.utils.KeyPair
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.map
import okhttp3.MediaType
import okhttp3.Request
import okhttp3.RequestBody
import org.session.libsession.messaging.sending_receiving.notifications.Response
import org.session.libsession.messaging.sending_receiving.notifications.Server
import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionRequest
import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionResponse
import org.session.libsession.messaging.sending_receiving.notifications.UnsubscribeResponse
import org.session.libsession.messaging.sending_receiving.notifications.UnsubscriptionRequest
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.snode.Version
import org.session.libsession.utilities.Device
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Namespace
import org.session.libsignal.utilities.retryIfNeeded
import javax.inject.Inject
import javax.inject.Singleton
private val TAG = PushRegistryV2::class.java.name
private const val maxRetryCount = 4
@Singleton
class PushRegistryV2 @Inject constructor(private val pushReceiver: PushReceiver) {
private val sodium = LazySodiumAndroid(SodiumAndroid())
fun register(
device: Device,
token: String,
publicKey: String,
userEd25519Key: KeyPair,
namespaces: List<Int>
): Promise<SubscriptionResponse, Exception> {
val pnKey = pushReceiver.getOrCreateNotificationKey()
val timestamp = SnodeAPI.nowWithOffset / 1000 // get timestamp in ms -> s
// if we want to support passing namespace list, here is the place to do it
val sigData = "MONITOR${publicKey}${timestamp}1${namespaces.joinToString(separator = ",")}".encodeToByteArray()
val signature = ByteArray(Sign.BYTES)
sodium.cryptoSignDetached(signature, sigData, sigData.size.toLong(), userEd25519Key.secretKey.asBytes)
val requestParameters = SubscriptionRequest(
pubkey = publicKey,
session_ed25519 = userEd25519Key.publicKey.asHexString,
namespaces = listOf(Namespace.DEFAULT),
data = true, // only permit data subscription for now (?)
service = device.service,
sig_ts = timestamp,
signature = Base64.encodeBytes(signature),
service_info = mapOf("token" to token),
enc_key = pnKey.asHexString,
).let(Json::encodeToString)
return retryResponseBody<SubscriptionResponse>("subscribe", requestParameters) success {
Log.d(TAG, "registerV2 success")
}
}
fun unregister(
device: Device,
token: String,
userPublicKey: String,
userEdKey: KeyPair
): Promise<UnsubscribeResponse, Exception> {
val timestamp = SnodeAPI.nowWithOffset / 1000 // get timestamp in ms -> s
// if we want to support passing namespace list, here is the place to do it
val sigData = "UNSUBSCRIBE${userPublicKey}${timestamp}".encodeToByteArray()
val signature = ByteArray(Sign.BYTES)
sodium.cryptoSignDetached(signature, sigData, sigData.size.toLong(), userEdKey.secretKey.asBytes)
val requestParameters = UnsubscriptionRequest(
pubkey = userPublicKey,
session_ed25519 = userEdKey.publicKey.asHexString,
service = device.service,
sig_ts = timestamp,
signature = Base64.encodeBytes(signature),
service_info = mapOf("token" to token),
).let(Json::encodeToString)
return retryResponseBody<UnsubscribeResponse>("unsubscribe", requestParameters) success {
Log.d(TAG, "unregisterV2 success")
}
}
private inline fun <reified T: Response> retryResponseBody(path: String, requestParameters: String): Promise<T, Exception> =
retryIfNeeded(maxRetryCount) { getResponseBody(path, requestParameters) }
private inline fun <reified T: Response> getResponseBody(path: String, requestParameters: String): Promise<T, Exception> {
val server = Server.LATEST
val url = "${server.url}/$path"
val body = RequestBody.create(MediaType.get("application/json"), requestParameters)
val request = Request.Builder().url(url).post(body).build()
return OnionRequestAPI.sendOnionRequest(
request,
server.url,
server.publicKey,
Version.V4
).map { response ->
response.body!!.inputStream()
.let { Json.decodeFromStream<T>(it) }
.also { if (it.isFailure()) throw Exception("error: ${it.message}.") }
}
}
}

View File

@ -99,7 +99,7 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil
.get();
setLargeIcon(iconBitmap);
} catch (InterruptedException | ExecutionException e) {
Log.w(TAG, e);
Log.w(TAG, "get iconBitmap in getThread failed", e);
setLargeIcon(getPlaceholderDrawable(context, recipient));
}
} else {
@ -298,7 +298,7 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil
.submit(64, 64)
.get();
} catch (InterruptedException | ExecutionException e) {
Log.w(TAG, e);
Log.w(TAG, "getBigPicture failed", e);
return Bitmap.createBitmap(64, 64, Bitmap.Config.RGB_565);
}
}

View File

@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.notifications
interface TokenFetcher {
suspend fun fetch(): String?
}

View File

@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.notifications
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import org.session.libsession.utilities.TextSecurePreferences
import javax.inject.Inject
import javax.inject.Singleton
private const val INTERVAL: Int = 12 * 60 * 60 * 1000
@Singleton
class TokenManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
val hasValidRegistration get() = isRegistered && !isExpired
val isRegistered get() = time > 0
private val isExpired get() = currentTime() > time + INTERVAL
fun register() {
time = currentTime()
}
fun unregister() {
time = 0
}
private var time
get() = TextSecurePreferences.getPushRegisterTime(context)
set(value) = TextSecurePreferences.setPushRegisterTime(context, value)
private fun currentTime() = System.currentTimeMillis()
}

View File

@ -12,6 +12,7 @@ import android.view.View
import android.widget.Toast
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityPnModeBinding
import org.session.libsession.utilities.TextSecurePreferences
@ -19,6 +20,8 @@ import org.session.libsession.utilities.ThemeUtil
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.home.HomeActivity
import org.thoughtcrime.securesms.notifications.PushManager
import org.thoughtcrime.securesms.notifications.PushRegistry
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.GlowViewUtilities
import org.thoughtcrime.securesms.util.PNModeView
@ -27,8 +30,13 @@ import org.thoughtcrime.securesms.util.getAccentColor
import org.thoughtcrime.securesms.util.getColorWithID
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import org.thoughtcrime.securesms.util.show
import javax.inject.Inject
@AndroidEntryPoint
class PNModeActivity : BaseActionBarActivity() {
@Inject lateinit var pushRegistry: PushRegistry
private lateinit var binding: ActivityPnModeBinding
private var selectedOptionView: PNModeView? = null
@ -158,10 +166,10 @@ class PNModeActivity : BaseActionBarActivity() {
return
}
TextSecurePreferences.setIsUsingFCM(this, (selectedOptionView == binding.fcmOptionView))
TextSecurePreferences.setPushEnabled(this, (selectedOptionView == binding.fcmOptionView))
val application = ApplicationContext.getInstance(this)
application.startPollingIfNeeded()
application.registerForFCMIfNeeded(true)
pushRegistry.refresh(true)
val intent = Intent(this, HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
intent.putExtra(HomeActivity.FROM_ONBOARDING, true)

View File

@ -1,10 +1,12 @@
package org.thoughtcrime.securesms.preferences
import android.os.Bundle
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.preferences.NotificationsPreferenceFragment
@AndroidEntryPoint
class NotificationSettingsActivity : PassphraseRequiredActionBarActivity() {
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {

View File

@ -1,182 +0,0 @@
package org.thoughtcrime.securesms.preferences;
import static android.app.Activity.RESULT_OK;
import static org.thoughtcrime.securesms.preferences.ListPreferenceDialogKt.listPreferenceDialog;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.media.Ringtone;
import android.media.RingtoneManager;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.provider.Settings;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import org.session.libsession.utilities.TextSecurePreferences;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.components.SwitchPreferenceCompat;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import network.loki.messenger.R;
public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragment {
@SuppressWarnings("unused")
private static final String TAG = NotificationsPreferenceFragment.class.getSimpleName();
@Override
public void onCreate(Bundle paramBundle) {
super.onCreate(paramBundle);
// Set up FCM toggle
String fcmKey = "pref_key_use_fcm";
((SwitchPreferenceCompat)findPreference(fcmKey)).setChecked(TextSecurePreferences.isUsingFCM(getContext()));
this.findPreference(fcmKey)
.setOnPreferenceChangeListener((preference, newValue) -> {
TextSecurePreferences.setIsUsingFCM(getContext(), (boolean) newValue);
ApplicationContext.getInstance(getContext()).registerForFCMIfNeeded(true);
return true;
});
if (NotificationChannels.supported()) {
TextSecurePreferences.setNotificationRingtone(getContext(), NotificationChannels.getMessageRingtone(getContext()).toString());
TextSecurePreferences.setNotificationVibrateEnabled(getContext(), NotificationChannels.getMessageVibrate(getContext()));
}
this.findPreference(TextSecurePreferences.RINGTONE_PREF)
.setOnPreferenceChangeListener(new RingtoneSummaryListener());
this.findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)
.setOnPreferenceChangeListener(new NotificationPrivacyListener());
this.findPreference(TextSecurePreferences.VIBRATE_PREF)
.setOnPreferenceChangeListener((preference, newValue) -> {
NotificationChannels.updateMessageVibrate(getContext(), (boolean) newValue);
return true;
});
this.findPreference(TextSecurePreferences.RINGTONE_PREF)
.setOnPreferenceClickListener(preference -> {
Uri current = TextSecurePreferences.getNotificationRingtone(getContext());
Intent intent = new Intent(RingtoneManager.ACTION_RINGTONE_PICKER);
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true);
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true);
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION);
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, Settings.System.DEFAULT_NOTIFICATION_URI);
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current);
startActivityForResult(intent, 1);
return true;
});
this.findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)
.setOnPreferenceClickListener(preference -> {
ListPreference listPreference = (ListPreference) preference;
listPreference.setDialogMessage(R.string.preferences_notifications__content_message);
listPreferenceDialog(getContext(), listPreference, () -> {
initializeListSummary(findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF));
return null;
});
return true;
});
initializeListSummary((ListPreference) findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF));
if (NotificationChannels.supported()) {
this.findPreference(TextSecurePreferences.NOTIFICATION_PRIORITY_PREF)
.setOnPreferenceClickListener(preference -> {
Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS);
intent.putExtra(Settings.EXTRA_CHANNEL_ID, NotificationChannels.getMessagesChannel(getContext()));
intent.putExtra(Settings.EXTRA_APP_PACKAGE, getContext().getPackageName());
startActivity(intent);
return true;
});
}
initializeRingtoneSummary(findPreference(TextSecurePreferences.RINGTONE_PREF));
initializeMessageVibrateSummary((SwitchPreferenceCompat)findPreference(TextSecurePreferences.VIBRATE_PREF));
}
@Override
public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) {
addPreferencesFromResource(R.xml.preferences_notifications);
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == 1 && resultCode == RESULT_OK && data != null) {
Uri uri = data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI);
if (Settings.System.DEFAULT_NOTIFICATION_URI.equals(uri)) {
NotificationChannels.updateMessageRingtone(getContext(), uri);
TextSecurePreferences.removeNotificationRingtone(getContext());
} else {
uri = uri == null ? Uri.EMPTY : uri;
NotificationChannels.updateMessageRingtone(getContext(), uri);
TextSecurePreferences.setNotificationRingtone(getContext(), uri.toString());
}
initializeRingtoneSummary(findPreference(TextSecurePreferences.RINGTONE_PREF));
}
}
private class RingtoneSummaryListener implements Preference.OnPreferenceChangeListener {
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
Uri value = (Uri) newValue;
if (value == null || TextUtils.isEmpty(value.toString())) {
preference.setSummary(R.string.preferences__silent);
} else {
Ringtone tone = RingtoneManager.getRingtone(getActivity(), value);
if (tone != null) {
preference.setSummary(tone.getTitle(getActivity()));
}
}
return true;
}
}
private void initializeRingtoneSummary(Preference pref) {
RingtoneSummaryListener listener = (RingtoneSummaryListener) pref.getOnPreferenceChangeListener();
Uri uri = TextSecurePreferences.getNotificationRingtone(getContext());
listener.onPreferenceChange(pref, uri);
}
private void initializeMessageVibrateSummary(SwitchPreferenceCompat pref) {
pref.setChecked(TextSecurePreferences.isNotificationVibrateEnabled(getContext()));
}
public static CharSequence getSummary(Context context) {
final int onCapsResId = R.string.ApplicationPreferencesActivity_On;
final int offCapsResId = R.string.ApplicationPreferencesActivity_Off;
return context.getString(TextSecurePreferences.isNotificationsEnabled(context) ? onCapsResId : offCapsResId);
}
private class NotificationPrivacyListener extends ListSummaryListener {
@SuppressLint("StaticFieldLeak")
@Override
public boolean onPreferenceChange(Preference preference, Object value) {
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
ApplicationContext.getInstance(getActivity()).messageNotifier.updateNotification(getActivity());
return null;
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
return super.onPreferenceChange(preference, value);
}
}
}

View File

@ -0,0 +1,183 @@
package org.thoughtcrime.securesms.preferences
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.os.AsyncTask
import android.os.Bundle
import android.provider.Settings
import android.text.TextUtils
import androidx.lifecycle.lifecycleScope
import androidx.preference.ListPreference
import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotificationsEnabled
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.components.SwitchPreferenceCompat
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.PushRegistry
import javax.inject.Inject
@AndroidEntryPoint
class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() {
@Inject
lateinit var pushRegistry: PushRegistry
@Inject
lateinit var prefs: TextSecurePreferences
override fun onCreate(paramBundle: Bundle?) {
super.onCreate(paramBundle)
// Set up FCM toggle
val fcmKey = "pref_key_use_fcm"
val fcmPreference: SwitchPreferenceCompat = findPreference(fcmKey)!!
fcmPreference.isChecked = prefs.isPushEnabled()
fcmPreference.setOnPreferenceChangeListener { _: Preference, newValue: Any ->
prefs.setPushEnabled(newValue as Boolean)
val job = pushRegistry.refresh(true)
fcmPreference.isEnabled = false
lifecycleScope.launch(Dispatchers.IO) {
job.join()
withContext(Dispatchers.Main) {
fcmPreference.isEnabled = true
}
}
true
}
if (NotificationChannels.supported()) {
prefs.setNotificationRingtone(
NotificationChannels.getMessageRingtone(requireContext()).toString()
)
prefs.setNotificationVibrateEnabled(
NotificationChannels.getMessageVibrate(requireContext())
)
}
findPreference<Preference>(TextSecurePreferences.RINGTONE_PREF)!!.onPreferenceChangeListener = RingtoneSummaryListener()
findPreference<Preference>(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)!!.onPreferenceChangeListener = NotificationPrivacyListener()
findPreference<Preference>(TextSecurePreferences.VIBRATE_PREF)!!.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _: Preference?, newValue: Any ->
NotificationChannels.updateMessageVibrate(requireContext(), newValue as Boolean)
true
}
findPreference<Preference>(TextSecurePreferences.RINGTONE_PREF)!!.onPreferenceClickListener =
Preference.OnPreferenceClickListener {
val current = prefs.getNotificationRingtone()
val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
intent.putExtra(
RingtoneManager.EXTRA_RINGTONE_TYPE,
RingtoneManager.TYPE_NOTIFICATION
)
intent.putExtra(
RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI,
Settings.System.DEFAULT_NOTIFICATION_URI
)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current)
startActivityForResult(intent, 1)
true
}
findPreference<Preference>(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)!!.onPreferenceClickListener =
Preference.OnPreferenceClickListener { preference: Preference ->
val listPreference = preference as ListPreference
listPreference.setDialogMessage(R.string.preferences_notifications__content_message)
listPreferenceDialog(requireContext(), listPreference) {
initializeListSummary(findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF))
}
true
}
initializeListSummary(findPreference<Preference>(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF) as ListPreference?)
if (NotificationChannels.supported()) {
findPreference<Preference>(TextSecurePreferences.NOTIFICATION_PRIORITY_PREF)!!.onPreferenceClickListener =
Preference.OnPreferenceClickListener {
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
intent.putExtra(
Settings.EXTRA_CHANNEL_ID, NotificationChannels.getMessagesChannel(requireContext())
)
intent.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName)
startActivity(intent)
true
}
}
initializeRingtoneSummary(findPreference(TextSecurePreferences.RINGTONE_PREF))
initializeMessageVibrateSummary(findPreference<Preference>(TextSecurePreferences.VIBRATE_PREF) as SwitchPreferenceCompat?)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.preferences_notifications)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == 1 && resultCode == Activity.RESULT_OK && data != null) {
var uri = data.getParcelableExtra<Uri>(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
if (Settings.System.DEFAULT_NOTIFICATION_URI == uri) {
NotificationChannels.updateMessageRingtone(requireContext(), uri)
prefs.removeNotificationRingtone()
} else {
uri = uri ?: Uri.EMPTY
NotificationChannels.updateMessageRingtone(requireContext(), uri)
prefs.setNotificationRingtone(uri.toString())
}
initializeRingtoneSummary(findPreference(TextSecurePreferences.RINGTONE_PREF))
}
}
private inner class RingtoneSummaryListener : Preference.OnPreferenceChangeListener {
override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean {
val value = newValue as? Uri
if (value == null || TextUtils.isEmpty(value.toString())) {
preference.setSummary(R.string.preferences__silent)
} else {
RingtoneManager.getRingtone(activity, value)
?.getTitle(activity)
?.let { preference.summary = it }
}
return true
}
}
private fun initializeRingtoneSummary(pref: Preference?) {
val listener = pref!!.onPreferenceChangeListener as RingtoneSummaryListener?
val uri = prefs.getNotificationRingtone()
listener!!.onPreferenceChange(pref, uri)
}
private fun initializeMessageVibrateSummary(pref: SwitchPreferenceCompat?) {
pref!!.isChecked = prefs.isNotificationVibrateEnabled()
}
private inner class NotificationPrivacyListener : ListSummaryListener() {
@SuppressLint("StaticFieldLeak")
override fun onPreferenceChange(preference: Preference, value: Any): Boolean {
object : AsyncTask<Void?, Void?, Void?>() {
override fun doInBackground(vararg params: Void?): Void? {
ApplicationContext.getInstance(activity).messageNotifier.updateNotification(activity!!)
return null
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR)
return super.onPreferenceChange(preference, value)
}
}
companion object {
@Suppress("unused")
private val TAG = NotificationsPreferenceFragment::class.java.simpleName
fun getSummary(context: Context): CharSequence = when (isNotificationsEnabled(context)) {
true -> R.string.ApplicationPreferencesActivity_On
false -> R.string.ApplicationPreferencesActivity_Off
}.let(context::getString)
}
}

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application tools:node="merge">
<service
android:name="org.thoughtcrime.securesms.notifications.FirebasePushService"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>

View File

@ -0,0 +1,13 @@
package org.thoughtcrime.securesms.notifications
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
abstract class FirebaseBindingModule {
@Binds
abstract fun bindTokenFetcher(tokenFetcher: FirebaseTokenFetcher): TokenFetcher
}

View File

@ -0,0 +1,33 @@
package org.thoughtcrime.securesms.notifications
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.Log
import javax.inject.Inject
private const val TAG = "FirebasePushNotificationService"
@AndroidEntryPoint
class FirebasePushService : FirebaseMessagingService() {
@Inject lateinit var prefs: TextSecurePreferences
@Inject lateinit var pushReceiver: PushReceiver
@Inject lateinit var pushRegistry: PushRegistry
override fun onNewToken(token: String) {
if (token == prefs.getPushToken()) return
pushRegistry.register(token)
}
override fun onMessageReceived(message: RemoteMessage) {
Log.d(TAG, "Received a push notification.")
pushReceiver.onPush(message.data)
}
override fun onDeletedMessages() {
Log.d(TAG, "Called onDeletedMessages.")
pushRegistry.refresh(true)
}
}

View File

@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.notifications
import com.google.android.gms.tasks.Tasks
import com.google.firebase.iid.FirebaseInstanceId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FirebaseTokenFetcher @Inject constructor(): TokenFetcher {
override suspend fun fetch() = withContext(Dispatchers.IO) {
FirebaseInstanceId.getInstance().instanceId
.also(Tasks::await)
.takeIf { isActive } // don't 'complete' task if we were canceled
?.run { result?.token ?: throw exception!! }
}
}

View File

@ -0,0 +1,15 @@
package org.thoughtcrime.securesms.notifications
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class NoOpPushModule {
@Binds
abstract fun bindTokenFetcher(tokenFetcher: NoOpTokenFetcher): TokenFetcher
}

View File

@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.notifications
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NoOpTokenFetcher @Inject constructor() : TokenFetcher {
override suspend fun fetch(): String? = null
}

View File

@ -2,13 +2,24 @@ buildscript {
repositories {
google()
mavenCentral()
if (project.hasProperty('huawei')) maven {
url 'https://developer.huawei.com/repo/'
content {
includeGroup 'com.huawei.agconnect'
}
}
}
dependencies {
classpath "com.android.tools.build:gradle:$gradlePluginVersion"
classpath files('libs/gradle-witness.jar')
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlinVersion"
classpath "com.google.gms:google-services:$googleServicesVersion"
classpath files('libs/gradle-witness.jar')
classpath "com.squareup:javapoet:1.13.0"
classpath "com.google.dagger:hilt-android-gradle-plugin:$daggerVersion"
if (project.hasProperty('huawei')) classpath 'com.huawei.agconnect:agcp:1.9.1.300'
}
}
@ -52,6 +63,15 @@ allprojects {
}
jcenter()
maven { url "https://jitpack.io" }
if (project.hasProperty('huawei')) maven {
url 'https://developer.huawei.com/repo/'
content {
includeGroup 'com.huawei.android.hms'
includeGroup 'com.huawei.agconnect'
includeGroup 'com.huawei.hmf'
includeGroup 'com.huawei.hms'
}
}
}
project.ext {

View File

@ -1,6 +1,7 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
id 'kotlinx-serialization'
}
android {
@ -41,6 +42,7 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion"
implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
testImplementation "junit:junit:$junitVersion"
testImplementation 'org.assertj:assertj-core:3.11.1'

View File

@ -5,10 +5,12 @@ import com.goterl.lazysodium.utils.KeyPair
import org.session.libsession.database.MessageDataProvider
import org.session.libsession.database.StorageProtocol
import org.session.libsession.utilities.ConfigFactoryProtocol
import org.session.libsession.utilities.Device
class MessagingModuleConfiguration(
val context: Context,
val storage: StorageProtocol,
val device: Device,
val messageDataProvider: MessageDataProvider,
val getUserED25519KeyPair: () -> KeyPair?,
val configFactory: ConfigFactoryProtocol

View File

@ -3,12 +3,11 @@ package org.session.libsession.messaging.jobs
import com.esotericsoftware.kryo.Kryo
import com.esotericsoftware.kryo.io.Input
import com.esotericsoftware.kryo.io.Output
import nl.komponents.kovenant.functional.map
import okhttp3.MediaType
import okhttp3.Request
import okhttp3.RequestBody
import org.session.libsession.messaging.jobs.Job.Companion.MAX_BUFFER_SIZE
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
import org.session.libsession.messaging.sending_receiving.notifications.Server
import org.session.libsession.messaging.utilities.Data
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.snode.SnodeMessage
@ -31,23 +30,27 @@ class NotifyPNServerJob(val message: SnodeMessage) : Job {
}
override suspend fun execute(dispatcherName: String) {
val server = PushNotificationAPI.server
val server = Server.LEGACY
val parameters = mapOf( "data" to message.data, "send_to" to message.recipient )
val url = "${server}/notify"
val url = "${server.url}/notify"
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
val request = Request.Builder().url(url).post(body)
val request = Request.Builder().url(url).post(body).build()
retryIfNeeded(4) {
OnionRequestAPI.sendOnionRequest(request.build(), server, PushNotificationAPI.serverPublicKey, Version.V2).map { response ->
val code = response.info["code"] as? Int
if (code == null || code == 0) {
Log.d("Loki", "Couldn't notify PN server due to error: ${response.info["message"] as? String ?: "null"}.")
OnionRequestAPI.sendOnionRequest(
request,
server.url,
server.publicKey,
Version.V2
) success { response ->
when (response.code) {
null, 0 -> Log.d("NotifyPNServerJob", "Couldn't notify PN server due to error: ${response.message}.")
}
}.fail { exception ->
Log.d("Loki", "Couldn't notify PN server due to error: $exception.")
} fail { exception ->
Log.d("NotifyPNServerJob", "Couldn't notify PN server due to error: $exception.")
}
}.success {
} success {
handleSuccess(dispatcherName)
}. fail {
} fail {
handleFailure(dispatcherName, it)
}
}

View File

@ -29,6 +29,7 @@ import org.session.libsession.snode.SnodeAPI
import org.session.libsession.snode.SnodeMessage
import org.session.libsession.snode.SnodeModule
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Device
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.SSKEnvironment
import org.session.libsignal.crypto.PushTransportDetails
@ -454,8 +455,8 @@ object MessageSender {
}
// Closed groups
fun createClosedGroup(name: String, members: Collection<String>): Promise<String, Exception> {
return create(name, members)
fun createClosedGroup(device: Device, name: String, members: Collection<String>): Promise<String, Exception> {
return create(device, name, members)
}
fun explicitNameChange(groupPublicKey: String, newName: String) {

View File

@ -8,11 +8,12 @@ import nl.komponents.kovenant.deferred
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage
import org.session.libsession.messaging.sending_receiving.MessageSender.Error
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.Device
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
@ -33,7 +34,11 @@ const val groupSizeLimit = 100
val pendingKeyPairs = ConcurrentHashMap<String, Optional<ECKeyPair>>()
fun MessageSender.create(name: String, members: Collection<String>): Promise<String, Exception> {
fun MessageSender.create(
device: Device,
name: String,
members: Collection<String>
): Promise<String, Exception> {
val deferred = deferred<String, Exception>()
ThreadUtils.queue {
// Prepare
@ -89,7 +94,7 @@ fun MessageSender.create(name: String, members: Collection<String>): Promise<Str
// Add the group to the config now that it was successfully created
storage.createInitialConfigGroup(groupPublicKey, name, GroupUtil.createConfigMemberMap(members, admins), sentTime, encryptionKeyPair)
// Notify the PN server
PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Subscribe, groupPublicKey, userPublicKey)
PushRegistryV1.register(device = device, publicKey = userPublicKey)
// Start polling
ClosedGroupPollerV2.shared.startPolling(groupPublicKey)
// Fulfill the promise

View File

@ -23,7 +23,7 @@ import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.messaging.utilities.SessionId
@ -556,10 +556,14 @@ private fun handleNewClosedGroup(sender: String, sentTimestamp: Long, groupPubli
// Set expiration timer
storage.setExpirationTimer(groupID, expireTimer)
// Notify the PN server
PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Subscribe, groupPublicKey, userPublicKey)
// Create thread
val threadId = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID))
storage.setThreadDate(threadId, formationTimestamp)
PushRegistryV1.register(device = MessagingModuleConfiguration.shared.device, publicKey = userPublicKey)
// Notify the user
if (userPublicKey == sender && !groupExists) {
val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID))
storage.insertOutgoingInfoMessage(context, groupID, SignalServiceGroup.Type.CREATION, name, members, admins, threadID, sentTimestamp)
} else if (userPublicKey != sender) {
storage.insertIncomingInfoMessage(context, sender, groupID, SignalServiceGroup.Type.CREATION, name, members, admins, sentTimestamp)
}
// Start polling
ClosedGroupPollerV2.shared.startPolling(groupPublicKey)
}
@ -865,7 +869,7 @@ fun MessageReceiver.disableLocalGroupAndUnsubscribe(groupPublicKey: String, grou
storage.setActive(groupID, false)
storage.removeMember(groupID, Address.fromSerialized(userPublicKey))
// Notify the PN server
PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Unsubscribe, groupPublicKey, userPublicKey)
PushRegistryV1.unsubscribeGroup(groupPublicKey, publicKey = userPublicKey)
// Stop polling
ClosedGroupPollerV2.shared.stopPolling(groupPublicKey)

View File

@ -14,4 +14,4 @@ interface MessageNotifier {
fun updateNotification(context: Context, threadId: Long, signal: Boolean)
fun updateNotification(context: Context, signal: Boolean, reminderCount: Int)
fun clearReminder(context: Context)
}
}

View File

@ -0,0 +1,126 @@
package org.session.libsession.messaging.sending_receiving.notifications
import com.goterl.lazysodium.utils.Key
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* N.B. all of these variable names will be named the same as the actual JSON utf-8 request/responses expected from the server.
* Changing the variable names will break how data is serialized/deserialized.
* If it's less than ideally named we can use [SerialName], such as for the push metadata which uses
* single-letter keys to be as compact as possible.
*/
@Serializable
data class SubscriptionRequest(
/** the 33-byte account being subscribed to; typically a session ID */
val pubkey: String,
/** when the pubkey starts with 05 (i.e. a session ID) this is the ed25519 32-byte pubkey associated with the session ID */
val session_ed25519: String?,
/** 32-byte swarm authentication subkey; omitted (or null) when not using subkey auth (new closed groups) */
val subkey_tag: String? = null,
/** array of integer namespaces to subscribe to, **must be sorted in ascending order** */
val namespaces: List<Int>,
/** if provided and true then notifications will include the body of the message (as long as it isn't too large) */
val data: Boolean,
/** the signature unix timestamp in seconds, not ms */
val sig_ts: Long,
/** the 64-byte ed25519 signature */
val signature: String,
/** the string identifying the notification service, "firebase" for android (currently) */
val service: String,
/** dict of service-specific data, currently just "token" field with device-specific token but different services might have other requirements */
val service_info: Map<String, String>,
/** 32-byte encryption key; notification payloads sent to the device will be encrypted with XChaCha20-Poly1305 via libsodium using this key.
* persist it on device */
val enc_key: String
)
@Serializable
data class UnsubscriptionRequest(
/** the 33-byte account being subscribed to; typically a session ID */
val pubkey: String,
/** when the pubkey starts with 05 (i.e. a session ID) this is the ed25519 32-byte pubkey associated with the session ID */
val session_ed25519: String?,
/** 32-byte swarm authentication subkey; omitted (or null) when not using subkey auth (new closed groups) */
val subkey_tag: String? = null,
/** the signature unix timestamp in seconds, not ms */
val sig_ts: Long,
/** the 64-byte ed25519 signature */
val signature: String,
/** the string identifying the notification service, "firebase" for android (currently) */
val service: String,
/** dict of service-specific data, currently just "token" field with device-specific token but different services might have other requirements */
val service_info: Map<String, String>,
)
/** invalid values, missing reuqired arguments etc, details in message */
private const val UNPARSEABLE_ERROR = 1
/** the "service" value is not active / valid */
private const val SERVICE_NOT_AVAILABLE = 2
/** something getting wrong internally talking to the backend */
private const val SERVICE_TIMEOUT = 3
/** other error processing the subscription (details in the message) */
private const val GENERIC_ERROR = 4
@Serializable
data class SubscriptionResponse(
override val error: Int? = null,
override val message: String? = null,
override val success: Boolean? = null,
val added: Boolean? = null,
val updated: Boolean? = null,
): Response
@Serializable
data class UnsubscribeResponse(
override val error: Int? = null,
override val message: String? = null,
override val success: Boolean? = null,
val removed: Boolean? = null,
): Response
interface Response {
val error: Int?
val message: String?
val success: Boolean?
fun isSuccess() = success == true && error == null
fun isFailure() = !isSuccess()
}
@Serializable
data class PushNotificationMetadata(
/** Account ID (such as Session ID or closed group ID) where the message arrived **/
@SerialName("@")
val account: String,
/** The hash of the message in the swarm. */
@SerialName("#")
val msg_hash: String,
/** The swarm namespace in which this message arrived. */
@SerialName("n")
val namespace: Int,
/** The length of the message data. This is always included, even if the message content
* itself was too large to fit into the push notification. */
@SerialName("l")
val data_len: Int,
/** This will be true if the data was omitted because it was too long to fit in a push
* notification (around 2.5kB of raw data), in which case the push notification includes
* only this metadata but not the message content itself. */
@SerialName("B")
val data_too_long : Boolean = false
)
@Serializable
data class PushNotificationServerObject(
val enc_payload: String,
val spns: Int,
) {
fun decryptPayload(key: Key): Any {
TODO()
}
}

View File

@ -1,107 +0,0 @@
package org.session.libsession.messaging.sending_receiving.notifications
import android.annotation.SuppressLint
import nl.komponents.kovenant.functional.map
import okhttp3.MediaType
import okhttp3.Request
import okhttp3.RequestBody
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.snode.Version
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.retryIfNeeded
import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.utilities.Log
@SuppressLint("StaticFieldLeak")
object PushNotificationAPI {
val context = MessagingModuleConfiguration.shared.context
val server = "https://live.apns.getsession.org"
val serverPublicKey = "642a6585919742e5a2d4dc51244964fbcd8bcab2b75612407de58b810740d049"
private val maxRetryCount = 4
private val tokenExpirationInterval = 12 * 60 * 60 * 1000
enum class ClosedGroupOperation {
Subscribe, Unsubscribe;
val rawValue: String
get() {
return when (this) {
Subscribe -> "subscribe_closed_group"
Unsubscribe -> "unsubscribe_closed_group"
}
}
}
fun unregister(token: String) {
val parameters = mapOf( "token" to token )
val url = "$server/unregister"
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
val request = Request.Builder().url(url).post(body)
retryIfNeeded(maxRetryCount) {
OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, Version.V2).map { response ->
val code = response.info["code"] as? Int
if (code != null && code != 0) {
TextSecurePreferences.setIsUsingFCM(context, false)
} else {
Log.d("Loki", "Couldn't disable FCM due to error: ${response.info["message"] as? String ?: "null"}.")
}
}.fail { exception ->
Log.d("Loki", "Couldn't disable FCM due to error: ${exception}.")
}
}
// Unsubscribe from all closed groups
val allClosedGroupPublicKeys = MessagingModuleConfiguration.shared.storage.getAllClosedGroupPublicKeys()
val userPublicKey = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!!
allClosedGroupPublicKeys.iterator().forEach { closedGroup ->
performOperation(ClosedGroupOperation.Unsubscribe, closedGroup, userPublicKey)
}
}
fun register(token: String, publicKey: String, force: Boolean) {
val oldToken = TextSecurePreferences.getFCMToken(context)
val lastUploadDate = TextSecurePreferences.getLastFCMUploadTime(context)
if (!force && token == oldToken && System.currentTimeMillis() - lastUploadDate < tokenExpirationInterval) { return }
val parameters = mapOf( "token" to token, "pubKey" to publicKey )
val url = "$server/register"
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
val request = Request.Builder().url(url).post(body)
retryIfNeeded(maxRetryCount) {
OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, Version.V2).map { response ->
val code = response.info["code"] as? Int
if (code != null && code != 0) {
TextSecurePreferences.setIsUsingFCM(context, true)
TextSecurePreferences.setFCMToken(context, token)
TextSecurePreferences.setLastFCMUploadTime(context, System.currentTimeMillis())
} else {
Log.d("Loki", "Couldn't register for FCM due to error: ${response.info["message"] as? String ?: "null"}.")
}
}.fail { exception ->
Log.d("Loki", "Couldn't register for FCM due to error: ${exception}.")
}
}
// Subscribe to all closed groups
val allClosedGroupPublicKeys = MessagingModuleConfiguration.shared.storage.getAllClosedGroupPublicKeys()
allClosedGroupPublicKeys.iterator().forEach { closedGroup ->
performOperation(ClosedGroupOperation.Subscribe, closedGroup, publicKey)
}
}
fun performOperation(operation: ClosedGroupOperation, closedGroupPublicKey: String, publicKey: String) {
if (!TextSecurePreferences.isUsingFCM(context)) { return }
val parameters = mapOf( "closedGroupPublicKey" to closedGroupPublicKey, "pubKey" to publicKey )
val url = "$server/${operation.rawValue}"
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
val request = Request.Builder().url(url).post(body)
retryIfNeeded(maxRetryCount) {
OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, Version.V2).map { response ->
val code = response.info["code"] as? Int
if (code == null || code == 0) {
Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${response.info["message"] as? String ?: "null"}.")
}
}.fail { exception ->
Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${exception}.")
}
}
}
}

View File

@ -0,0 +1,141 @@
package org.session.libsession.messaging.sending_receiving.notifications
import android.annotation.SuppressLint
import nl.komponents.kovenant.Promise
import okhttp3.MediaType
import okhttp3.Request
import okhttp3.RequestBody
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.snode.OnionResponse
import org.session.libsession.snode.Version
import org.session.libsession.utilities.Device
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.emptyPromise
import org.session.libsignal.utilities.retryIfNeeded
import org.session.libsignal.utilities.sideEffect
@SuppressLint("StaticFieldLeak")
object PushRegistryV1 {
private val TAG = PushRegistryV1::class.java.name
val context = MessagingModuleConfiguration.shared.context
private const val maxRetryCount = 4
private val server = Server.LEGACY
fun register(
device: Device,
isPushEnabled: Boolean = TextSecurePreferences.isPushEnabled(context),
token: String? = TextSecurePreferences.getPushToken(context),
publicKey: String? = TextSecurePreferences.getLocalNumber(context),
legacyGroupPublicKeys: Collection<String> = MessagingModuleConfiguration.shared.storage.getAllClosedGroupPublicKeys()
): Promise<*, Exception> = when {
isPushEnabled -> retryIfNeeded(maxRetryCount) {
Log.d(TAG, "register() called")
doRegister(token, publicKey, device, legacyGroupPublicKeys)
} fail { exception ->
Log.d(TAG, "Couldn't register for FCM due to error", exception)
}
else -> emptyPromise()
}
private fun doRegister(token: String?, publicKey: String?, device: Device, legacyGroupPublicKeys: Collection<String>): Promise<*, Exception> {
Log.d(TAG, "doRegister() called")
token ?: return emptyPromise()
publicKey ?: return emptyPromise()
val parameters = mapOf(
"token" to token,
"pubKey" to publicKey,
"device" to device.value,
"legacyGroupPublicKeys" to legacyGroupPublicKeys
)
val url = "${server.url}/register_legacy_groups_only"
val body = RequestBody.create(
MediaType.get("application/json"),
JsonUtil.toJson(parameters)
)
val request = Request.Builder().url(url).post(body).build()
return sendOnionRequest(request) sideEffect { response ->
when (response.code) {
null, 0 -> throw Exception("error: ${response.message}.")
}
} success {
Log.d(TAG, "registerV1 success")
}
}
/**
* Unregister push notifications for 1-1 conversations as this is now done in FirebasePushManager.
*/
fun unregister(): Promise<*, Exception> {
Log.d(TAG, "unregisterV1 requested")
val token = TextSecurePreferences.getPushToken(context) ?: emptyPromise()
return retryIfNeeded(maxRetryCount) {
val parameters = mapOf("token" to token)
val url = "${server.url}/unregister"
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
val request = Request.Builder().url(url).post(body).build()
sendOnionRequest(request) success {
when (it.code) {
null, 0 -> Log.d(TAG, "error: ${it.message}.")
else -> Log.d(TAG, "unregisterV1 success")
}
}
}
}
// Legacy Closed Groups
fun subscribeGroup(
closedGroupPublicKey: String,
isPushEnabled: Boolean = TextSecurePreferences.isPushEnabled(context),
publicKey: String = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!!
) = if (isPushEnabled) {
performGroupOperation("subscribe_closed_group", closedGroupPublicKey, publicKey)
} else emptyPromise()
fun unsubscribeGroup(
closedGroupPublicKey: String,
isPushEnabled: Boolean = TextSecurePreferences.isPushEnabled(context),
publicKey: String = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!!
) = if (isPushEnabled) {
performGroupOperation("unsubscribe_closed_group", closedGroupPublicKey, publicKey)
} else emptyPromise()
private fun performGroupOperation(
operation: String,
closedGroupPublicKey: String,
publicKey: String
): Promise<*, Exception> {
val parameters = mapOf("closedGroupPublicKey" to closedGroupPublicKey, "pubKey" to publicKey)
val url = "${server.url}/$operation"
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
val request = Request.Builder().url(url).post(body).build()
return retryIfNeeded(maxRetryCount) {
sendOnionRequest(request) sideEffect {
when (it.code) {
0, null -> throw Exception(it.message)
}
}
}
}
private fun sendOnionRequest(request: Request): Promise<OnionResponse, Exception> = OnionRequestAPI.sendOnionRequest(
request,
server.url,
server.publicKey,
Version.V2
)
}

View File

@ -0,0 +1,6 @@
package org.session.libsession.messaging.sending_receiving.notifications
enum class Server(val url: String, val publicKey: String) {
LATEST("https://push.getsession.org", "d7557fe563e2610de876c0ac7341b62f3c82d5eea4b62c702392ea4368f51b3b"),
LEGACY("https://live.apns.getsession.org", "642a6585919742e5a2d4dc51244964fbcd8bcab2b75612407de58b810740d049")
}

View File

@ -205,7 +205,7 @@ object SodiumUtilities {
}
fun decrypt(ciphertext: ByteArray, decryptionKey: ByteArray, nonce: ByteArray): ByteArray? {
val plaintextSize = ciphertext.size - AEAD.CHACHA20POLY1305_ABYTES
val plaintextSize = ciphertext.size - AEAD.XCHACHA20POLY1305_IETF_ABYTES
val plaintext = ByteArray(plaintextSize)
return if (sodium.cryptoAeadXChaCha20Poly1305IetfDecrypt(
plaintext,

View File

@ -686,4 +686,7 @@ enum class Version(val value: String) {
data class OnionResponse(
val info: Map<*, *>,
val body: ByteArray? = null
)
) {
val code: Int? get() = info["code"] as? Int
val message: String? get() = info["message"] as? String
}

View File

@ -0,0 +1,6 @@
package org.session.libsession.utilities
enum class Device(val value: String, val service: String = value) {
ANDROID("android", "firebase"),
HUAWEI("huawei");
}

View File

@ -37,12 +37,12 @@ interface TextSecurePreferences {
fun setLastConfigurationSyncTime(value: Long)
fun getConfigurationMessageSynced(): Boolean
fun setConfigurationMessageSynced(value: Boolean)
fun isUsingFCM(): Boolean
fun setIsUsingFCM(value: Boolean)
fun getFCMToken(): String?
fun setFCMToken(value: String)
fun getLastFCMUploadTime(): Long
fun setLastFCMUploadTime(value: Long)
fun isPushEnabled(): Boolean
fun setPushEnabled(value: Boolean)
fun getPushToken(): String?
fun setPushToken(value: String)
fun getPushRegisterTime(): Long
fun setPushRegisterTime(value: Long)
fun isScreenLockEnabled(): Boolean
fun setScreenLockEnabled(value: Boolean)
fun getScreenLockTimeout(): Long
@ -251,9 +251,9 @@ interface TextSecurePreferences {
const val LINK_PREVIEWS = "pref_link_previews"
const val GIF_METADATA_WARNING = "has_seen_gif_metadata_warning"
const val GIF_GRID_LAYOUT = "pref_gif_grid_layout"
const val IS_USING_FCM = "pref_is_using_fcm"
const val FCM_TOKEN = "pref_fcm_token"
const val LAST_FCM_TOKEN_UPLOAD_TIME = "pref_last_fcm_token_upload_time_2"
const val IS_PUSH_ENABLED = "pref_is_using_fcm"
const val PUSH_TOKEN = "pref_fcm_token_2"
const val PUSH_REGISTER_TIME = "pref_last_fcm_token_upload_time_2"
const val LAST_CONFIGURATION_SYNC_TIME = "pref_last_configuration_sync_time"
const val CONFIGURATION_SYNCED = "pref_configuration_synced"
const val LAST_PROFILE_UPDATE_TIME = "pref_last_profile_update_time"
@ -309,31 +309,31 @@ interface TextSecurePreferences {
}
@JvmStatic
fun isUsingFCM(context: Context): Boolean {
return getBooleanPreference(context, IS_USING_FCM, false)
fun isPushEnabled(context: Context): Boolean {
return getBooleanPreference(context, IS_PUSH_ENABLED, false)
}
@JvmStatic
fun setIsUsingFCM(context: Context, value: Boolean) {
setBooleanPreference(context, IS_USING_FCM, value)
fun setPushEnabled(context: Context, value: Boolean) {
setBooleanPreference(context, IS_PUSH_ENABLED, value)
}
@JvmStatic
fun getFCMToken(context: Context): String? {
return getStringPreference(context, FCM_TOKEN, "")
fun getPushToken(context: Context): String? {
return getStringPreference(context, PUSH_TOKEN, "")
}
@JvmStatic
fun setFCMToken(context: Context, value: String) {
setStringPreference(context, FCM_TOKEN, value)
fun setPushToken(context: Context, value: String?) {
setStringPreference(context, PUSH_TOKEN, value)
}
fun getLastFCMUploadTime(context: Context): Long {
return getLongPreference(context, LAST_FCM_TOKEN_UPLOAD_TIME, 0)
fun getPushRegisterTime(context: Context): Long {
return getLongPreference(context, PUSH_REGISTER_TIME, 0)
}
fun setLastFCMUploadTime(context: Context, value: Long) {
setLongPreference(context, LAST_FCM_TOKEN_UPLOAD_TIME, value)
fun setPushRegisterTime(context: Context, value: Long) {
setLongPreference(context, PUSH_REGISTER_TIME, value)
}
// endregion
@ -1008,7 +1008,6 @@ interface TextSecurePreferences {
fun clearAll(context: Context) {
getDefaultSharedPreferences(context).edit().clear().commit()
}
}
}
@ -1033,28 +1032,28 @@ class AppTextSecurePreferences @Inject constructor(
TextSecurePreferences._events.tryEmit(TextSecurePreferences.CONFIGURATION_SYNCED)
}
override fun isUsingFCM(): Boolean {
return getBooleanPreference(TextSecurePreferences.IS_USING_FCM, false)
override fun isPushEnabled(): Boolean {
return getBooleanPreference(TextSecurePreferences.IS_PUSH_ENABLED, false)
}
override fun setIsUsingFCM(value: Boolean) {
setBooleanPreference(TextSecurePreferences.IS_USING_FCM, value)
override fun setPushEnabled(value: Boolean) {
setBooleanPreference(TextSecurePreferences.IS_PUSH_ENABLED, value)
}
override fun getFCMToken(): String? {
return getStringPreference(TextSecurePreferences.FCM_TOKEN, "")
override fun getPushToken(): String? {
return getStringPreference(TextSecurePreferences.PUSH_TOKEN, "")
}
override fun setFCMToken(value: String) {
setStringPreference(TextSecurePreferences.FCM_TOKEN, value)
override fun setPushToken(value: String) {
setStringPreference(TextSecurePreferences.PUSH_TOKEN, value)
}
override fun getLastFCMUploadTime(): Long {
return getLongPreference(TextSecurePreferences.LAST_FCM_TOKEN_UPLOAD_TIME, 0)
override fun getPushRegisterTime(): Long {
return getLongPreference(TextSecurePreferences.PUSH_REGISTER_TIME, 0)
}
override fun setLastFCMUploadTime(value: Long) {
setLongPreference(TextSecurePreferences.LAST_FCM_TOKEN_UPLOAD_TIME, value)
override fun setPushRegisterTime(value: Long) {
setLongPreference(TextSecurePreferences.PUSH_REGISTER_TIME, value)
}
override fun isScreenLockEnabled(): Boolean {

View File

@ -0,0 +1,169 @@
package org.session.libsession.utilities.bencode
import java.util.LinkedList
object Bencode {
class Decoder(source: ByteArray) {
private val iterator = LinkedList<Byte>().apply {
addAll(source.asIterable())
}
/**
* Decode an element based on next marker assumed to be string/int/list/dict or return null
*/
fun decode(): BencodeElement? {
val result = when (iterator.peek()?.toInt()?.toChar()) {
in NUMBERS -> decodeString()
INT_INDICATOR -> decodeInt()
LIST_INDICATOR -> decodeList()
DICT_INDICATOR -> decodeDict()
else -> {
null
}
}
return result
}
/**
* Decode a string element from iterator assumed to have structure `{length}:{data}`
*/
private fun decodeString(): BencodeString? {
val lengthStrings = buildString {
while (iterator.isNotEmpty() && iterator.peek()?.toInt()?.toChar() != SEPARATOR) {
append(iterator.pop().toInt().toChar())
}
}
iterator.pop() // drop `:`
val length = lengthStrings.toIntOrNull(10) ?: return null
val remaining = (0 until length).map { iterator.pop() }.toByteArray()
return BencodeString(remaining)
}
/**
* Decode an int element from iterator assumed to have structure `i{int}e`
*/
private fun decodeInt(): BencodeElement? {
iterator.pop() // drop `i`
val intString = buildString {
while (iterator.isNotEmpty() && iterator.peek()?.toInt()?.toChar() != END_INDICATOR) {
append(iterator.pop().toInt().toChar())
}
}
val asInt = intString.toIntOrNull(10) ?: return null
iterator.pop() // drop `e`
return BencodeInteger(asInt)
}
/**
* Decode a list element from iterator assumed to have structure `l{data}e`
*/
private fun decodeList(): BencodeElement {
iterator.pop() // drop `l`
val listElements = mutableListOf<BencodeElement>()
while (iterator.isNotEmpty() && iterator.peek()?.toInt()?.toChar() != END_INDICATOR) {
decode()?.let { nextElement ->
listElements += nextElement
}
}
iterator.pop() // drop `e`
return BencodeList(listElements)
}
/**
* Decode a dict element from iterator assumed to have structure `d{data}e`
*/
private fun decodeDict(): BencodeElement? {
iterator.pop() // drop `d`
val dictElements = mutableMapOf<String,BencodeElement>()
while (iterator.isNotEmpty() && iterator.peek()?.toInt()?.toChar() != END_INDICATOR) {
val key = decodeString() ?: return null
val value = decode() ?: return null
dictElements += key.value.decodeToString() to value
}
iterator.pop() // drop `e`
return BencodeDict(dictElements)
}
companion object {
private val NUMBERS = arrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9')
private const val INT_INDICATOR = 'i'
private const val LIST_INDICATOR = 'l'
private const val DICT_INDICATOR = 'd'
private const val END_INDICATOR = 'e'
private const val SEPARATOR = ':'
}
}
}
sealed class BencodeElement {
abstract fun encode(): ByteArray
}
fun String.bencode() = BencodeString(this.encodeToByteArray())
fun Int.bencode() = BencodeInteger(this)
data class BencodeString(val value: ByteArray): BencodeElement() {
override fun encode(): ByteArray = buildString {
append(value.size.toString())
append(':')
}.toByteArray() + value
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as BencodeString
if (!value.contentEquals(other.value)) return false
return true
}
override fun hashCode(): Int {
return value.contentHashCode()
}
}
data class BencodeInteger(val value: Int): BencodeElement() {
override fun encode(): ByteArray = buildString {
append('i')
append(value.toString())
append('e')
}.toByteArray()
}
data class BencodeList(val values: List<BencodeElement>): BencodeElement() {
constructor(vararg values: BencodeElement) : this(values.toList())
override fun encode(): ByteArray = "l".toByteArray() +
values.fold(byteArrayOf()) { array, element -> array + element.encode() } +
"e".toByteArray()
}
data class BencodeDict(val values: Map<String, BencodeElement>): BencodeElement() {
constructor(vararg values: Pair<String, BencodeElement>) : this(values.toMap())
override fun encode(): ByteArray = "d".toByteArray() +
values.entries.fold(byteArrayOf()) { array, (key, value) ->
array + key.bencode().encode() + value.encode()
} + "e".toByteArray()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as BencodeDict
if (values != other.values) return false
return true
}
override fun hashCode(): Int {
return values.hashCode()
}
}

View File

@ -0,0 +1,107 @@
package org.session.libsession.utilities
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Test
import org.session.libsession.utilities.bencode.Bencode
import org.session.libsession.utilities.bencode.BencodeDict
import org.session.libsession.utilities.bencode.BencodeInteger
import org.session.libsession.utilities.bencode.BencodeList
import org.session.libsession.utilities.bencode.bencode
class BencoderTest {
@Test
fun `it should decode a basic string`() {
val basicString = "5:howdy".toByteArray()
val bencoder = Bencode.Decoder(basicString)
val result = bencoder.decode()
assertEquals("howdy".bencode(), result)
}
@Test
fun `it should decode a basic integer`() {
val basicInteger = "i3e".toByteArray()
val bencoder = Bencode.Decoder(basicInteger)
val result = bencoder.decode()
assertEquals(BencodeInteger(3), result)
}
@Test
fun `it should decode a list of integers`() {
val basicIntList = "li1ei2ee".toByteArray()
val bencoder = Bencode.Decoder(basicIntList)
val result = bencoder.decode()
assertEquals(
BencodeList(
1.bencode(),
2.bencode()
),
result
)
}
@Test
fun `it should decode a basic dict`() {
val basicDict = "d4:spaml1:a1:bee".toByteArray()
val bencoder = Bencode.Decoder(basicDict)
val result = bencoder.decode()
assertEquals(
BencodeDict(
"spam" to BencodeList(
"a".bencode(),
"b".bencode()
)
),
result
)
}
@Test
fun `it should encode a basic string`() {
val basicString = "5:howdy".toByteArray()
val element = "howdy".bencode()
assertArrayEquals(basicString, element.encode())
}
@Test
fun `it should encode a basic int`() {
val basicInt = "i3e".toByteArray()
val element = 3.bencode()
assertArrayEquals(basicInt, element.encode())
}
@Test
fun `it should encode a basic list`() {
val basicList = "li1ei2ee".toByteArray()
val element = BencodeList(1.bencode(),2.bencode())
assertArrayEquals(basicList, element.encode())
}
@Test
fun `it should encode a basic dict`() {
val basicDict = "d4:spaml1:a1:bee".toByteArray()
val element = BencodeDict(
"spam" to BencodeList(
"a".bencode(),
"b".bencode()
)
)
assertArrayEquals(basicDict, element.encode())
}
@Test
fun `it should encode a more complex real world case`() {
val source = "d15:lastReadMessaged66:031122334455667788990011223344556677889900112233445566778899001122i1234568790e66:051122334455667788990011223344556677889900112233445566778899001122i1234568790ee5:seqNoi1ee".toByteArray()
val result = Bencode.Decoder(source).decode()
val expected = BencodeDict(
"lastReadMessage" to BencodeDict(
"051122334455667788990011223344556677889900112233445566778899001122" to 1234568790.bencode(),
"031122334455667788990011223344556677889900112233445566778899001122" to 1234568790.bencode()
),
"seqNo" to BencodeInteger(1)
)
assertEquals(expected, result)
}
}

View File

@ -15,7 +15,7 @@ object ExternalStorageUtil {
@Throws(NoExternalStorageException::class)
fun getDir(context: Context, type: String?): File {
return context.getExternalFilesDir(type)
?: throw NoExternalStorageException("External storage dir is currently unavailable: $type")
?: throw NoExternalStorageException("External storage dir is currently unavailable: $type")
}
@Throws(NoExternalStorageException::class)
@ -73,10 +73,7 @@ object ExternalStorageUtil {
}
@JvmStatic
fun getCleanFileName(fileName: String?): String? {
var fileName = fileName ?: return null
fileName = fileName.replace('\u202D', '\uFFFD')
fileName = fileName.replace('\u202E', '\uFFFD')
return fileName
}
}
fun getCleanFileName(fileName: String?): String? =
fileName?.replace('\u202D', '\uFFFD')
?.replace('\u202E', '\uFFFD')
}

View File

@ -3,8 +3,13 @@ package org.session.libsignal.utilities
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred
import nl.komponents.kovenant.functional.map
import nl.komponents.kovenant.task
import java.util.concurrent.TimeoutException
fun emptyPromise() = EMPTY_PROMISE
private val EMPTY_PROMISE: Promise<*, java.lang.Exception> = task {}
fun <V, E : Throwable> Promise<V, E>.get(defaultValue: V): V {
return try {
get()
@ -54,4 +59,11 @@ fun <V> Promise<V, Exception>.timeout(millis: Long): Promise<V, Exception> {
if (!deferred.promise.isDone()) { deferred.reject(it) }
}
return deferred.promise
}
}
infix fun <V, E: Exception> Promise<V, E>.sideEffect(
callback: (value: V) -> Unit
) = map {
callback(it)
it
}