diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 023ec6b660..cfdc16e06c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -86,6 +86,7 @@ import org.thoughtcrime.securesms.sskenvironment.ProfileManager; import org.thoughtcrime.securesms.sskenvironment.ReadReceiptManager; import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository; import org.thoughtcrime.securesms.util.Broadcaster; +import org.thoughtcrime.securesms.util.VersionUtil; import org.thoughtcrime.securesms.util.dynamiclanguage.LocaleParseHelper; import org.thoughtcrime.securesms.webrtc.CallMessageProcessor; import org.webrtc.PeerConnectionFactory; @@ -142,6 +143,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO private HandlerThread conversationListHandlerThread; private Handler conversationListHandler; private PersistentLogger persistentLogger; + private VersionUtil versionUtil; @Inject LokiAPIDatabase lokiAPIDatabase; @Inject public Storage storage; @@ -248,6 +250,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO resubmitProfilePictureIfNeeded(); loadEmojiSearchIndexIfNeeded(); EmojiSource.refresh(); + versionUtil = new VersionUtil(textSecurePreferences); NetworkConstraint networkConstraint = new NetworkConstraint.Factory(this).create(); HTTP.INSTANCE.setConnectedToNetwork(networkConstraint::isMet); @@ -274,6 +277,9 @@ public class ApplicationContext extends Application implements DefaultLifecycleO OpenGroupManager.INSTANCE.startPolling(); }); + + // fetch last version data + versionUtil.startTimedVersionCheck(); } @Override @@ -286,12 +292,14 @@ public class ApplicationContext extends Application implements DefaultLifecycleO poller.stopIfNeeded(); } ClosedGroupPollerV2.getShared().stopAll(); + versionUtil.stopTimedVersionCheck(); } @Override public void onTerminate() { stopKovenant(); // Loki OpenGroupManager.INSTANCE.stopPolling(); + versionUtil.clear(); super.onTerminate(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt index f294e387ff..071da43311 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt @@ -18,7 +18,7 @@ fun showMuteDialog( private enum class Option(@StringRes val stringRes: Int, val getTime: () -> Long) { ONE_HOUR(R.string.arrays__mute_for_one_hour, duration = TimeUnit.HOURS.toMillis(1)), - TWO_HOURS(R.string.arrays__mute_for_two_hours, duration = TimeUnit.DAYS.toMillis(2)), + TWO_HOURS(R.string.arrays__mute_for_two_hours, duration = TimeUnit.HOURS.toMillis(2)), ONE_DAY(R.string.arrays__mute_for_one_day, duration = TimeUnit.DAYS.toMillis(1)), SEVEN_DAYS(R.string.arrays__mute_for_seven_days, duration = TimeUnit.DAYS.toMillis(7)), FOREVER(R.string.arrays__mute_forever, getTime = { Long.MAX_VALUE }); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java index 4ee43e1e9c..f193827efd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java @@ -22,7 +22,7 @@ public class MemoryFileUtil { int fd = field.getInt(fileDescriptor); - return ParcelFileDescriptor.adoptFd(fd); + return ParcelFileDescriptor.fromFd(fd); } catch (IllegalAccessException e) { throw new IOException(e); } catch (InvocationTargetException e) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/VersionUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/VersionUtil.kt new file mode 100644 index 0000000000..76075b4ac1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/VersionUtil.kt @@ -0,0 +1,68 @@ +package org.thoughtcrime.securesms.util + +import android.os.Handler +import android.os.Looper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.session.libsession.messaging.file_server.FileServerApi +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import java.util.concurrent.TimeUnit + +class VersionUtil( + private val prefs: TextSecurePreferences +) { + private val TAG: String = VersionUtil::class.java.simpleName + private val FOUR_HOURS: Long = TimeUnit.HOURS.toMillis(4) + + private val handler = Handler(Looper.getMainLooper()) + private val runnable: Runnable + + private val scope = CoroutineScope(Dispatchers.Default) + private var job: Job? = null + + init { + runnable = Runnable { + fetchAndScheduleNextVersionCheck() + } + } + + fun startTimedVersionCheck() { + handler.post(runnable) + } + + fun stopTimedVersionCheck() { + handler.removeCallbacks(runnable) + } + + fun clear() { + job?.cancel() + stopTimedVersionCheck() + } + + private fun fetchAndScheduleNextVersionCheck() { + fetchVersionData() + handler.postDelayed(runnable, FOUR_HOURS) + } + + private fun fetchVersionData() { + // only perform this if at least 4h has elapsed since th last successful check + val lastCheck = System.currentTimeMillis() - prefs.getLastVersionCheck() + if (lastCheck < FOUR_HOURS) return + + job?.cancel() + job = scope.launch { + try { + // perform the version check + val clientVersion = FileServerApi.getClientVersion() + Log.i(TAG, "Fetched version data: $clientVersion") + prefs.setLastVersionCheck() + } catch (e: Exception) { + // we can silently ignore the error + Log.e(TAG, "Error fetching version data: $e") + } + } + } +} \ No newline at end of file diff --git a/libsession-util/src/main/cpp/CMakeLists.txt b/libsession-util/src/main/cpp/CMakeLists.txt index 47fee4803c..f65667bcb5 100644 --- a/libsession-util/src/main/cpp/CMakeLists.txt +++ b/libsession-util/src/main/cpp/CMakeLists.txt @@ -30,6 +30,7 @@ set(SOURCES config_base.cpp contacts.cpp conversation.cpp + blinded_key.cpp util.cpp) add_library( # Sets the name of the library. diff --git a/libsession-util/src/main/cpp/blinded_key.cpp b/libsession-util/src/main/cpp/blinded_key.cpp new file mode 100644 index 0000000000..1ed1cfd554 --- /dev/null +++ b/libsession-util/src/main/cpp/blinded_key.cpp @@ -0,0 +1,34 @@ +#include <jni.h> +#include <session/blinding.hpp> + +#include "util.h" +#include "jni_utils.h" + +// +// Created by Thomas Ruffie on 29/7/2024. +// + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_util_BlindKeyAPI_blindVersionKeyPair(JNIEnv *env, + jobject thiz, + jbyteArray ed25519_secret_key) { + return jni_utils::run_catching_cxx_exception_or_throws<jobject>(env, [=] { + const auto [pk, sk] = session::blind_version_key_pair(util::ustring_from_bytes(env, ed25519_secret_key)); + + jclass kp_class = env->FindClass("network/loki/messenger/libsession_util/util/KeyPair"); + jmethodID kp_constructor = env->GetMethodID(kp_class, "<init>", "([B[B)V"); + return env->NewObject(kp_class, kp_constructor, util::bytes_from_ustring(env, {pk.data(), pk.size()}), util::bytes_from_ustring(env, {sk.data(), sk.size()})); + }); +} +extern "C" +JNIEXPORT jbyteArray JNICALL +Java_network_loki_messenger_libsession_1util_util_BlindKeyAPI_blindVersionSign(JNIEnv *env, + jobject thiz, + jbyteArray ed25519_secret_key, + jlong timestamp) { + return jni_utils::run_catching_cxx_exception_or_throws<jbyteArray>(env, [=] { + auto bytes = session::blind_version_sign(util::ustring_from_bytes(env, ed25519_secret_key), session::Platform::android, timestamp); + return util::bytes_from_ustring(env, bytes); + }); +} \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/BlindKeyAPI.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/BlindKeyAPI.kt new file mode 100644 index 0000000000..cd3dac3af2 --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/BlindKeyAPI.kt @@ -0,0 +1,15 @@ +package network.loki.messenger.libsession_util.util + +object BlindKeyAPI { + private val loadLibrary by lazy { + System.loadLibrary("session_util") + } + + init { + // Ensure the library is loaded at initialization + loadLibrary + } + + external fun blindVersionKeyPair(ed25519SecretKey: ByteArray): KeyPair + external fun blindVersionSign(ed25519SecretKey: ByteArray, timestamp: Long): ByteArray +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt b/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt index 5bffed57ee..fb9c014a15 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt @@ -1,18 +1,22 @@ package org.session.libsession.messaging.file_server +import android.util.Base64 +import network.loki.messenger.libsession_util.util.BlindKeyAPI import nl.komponents.kovenant.Promise import nl.komponents.kovenant.functional.map -import okhttp3.Headers import okhttp3.Headers.Companion.toHeaders import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.utilities.await import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.toHexString +import java.util.concurrent.TimeUnit object FileServerApi { @@ -23,6 +27,7 @@ object FileServerApi { sealed class Error(message: String) : Exception(message) { object ParsingFailed : Error("Invalid response.") object InvalidURL : Error("Invalid URL.") + object NoEd25519KeyPair : Error("Couldn't find ed25519 key pair.") } data class Request( @@ -105,4 +110,53 @@ object FileServerApi { val request = Request(verb = HTTP.Verb.GET, endpoint = "file/$file") return send(request) } + + /** + * Returns the current version of session + * This is effectively proxying (and caching) the response from the github release + * page. + * + * Note that the value is cached and can be up to 30 minutes out of date normally, and up to 24 + * hours out of date if we cannot reach the Github API for some reason. + * + * https://github.com/oxen-io/session-file-server/blob/dev/doc/api.yaml#L119 + */ + suspend fun getClientVersion(): VersionData { + // Generate the auth signature + val secretKey = MessagingModuleConfiguration.shared.getUserED25519KeyPair()?.secretKey?.asBytes + ?: throw (Error.NoEd25519KeyPair) + + val blindedKeys = BlindKeyAPI.blindVersionKeyPair(secretKey) + val timestamp = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()) // The current timestamp in seconds + val signature = BlindKeyAPI.blindVersionSign(secretKey, timestamp) + + // The hex encoded version-blinded public key with a 07 prefix + val blindedPkHex = buildString { + append("07") + append(blindedKeys.pubKey.toHexString()) + } + + val request = Request( + verb = HTTP.Verb.GET, + endpoint = "session_version", + queryParameters = mapOf("platform" to "android"), + headers = mapOf( + "X-FS-Pubkey" to blindedPkHex, + "X-FS-Timestamp" to timestamp.toString(), + "X-FS-Signature" to Base64.encodeToString(signature, Base64.NO_WRAP) + ) + ) + + // transform the promise into a coroutine + val result = send(request).await() + + // map out the result + return JsonUtil.fromJson(result, Map::class.java).let { + VersionData( + statusCode = it["status_code"] as? Int ?: 0, + version = it["result"] as? String ?: "", + updated = it["updated"] as? Double ?: 0.0 + ) + } + } } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/file_server/VersionData.kt b/libsession/src/main/java/org/session/libsession/messaging/file_server/VersionData.kt new file mode 100644 index 0000000000..b7c28020e7 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/file_server/VersionData.kt @@ -0,0 +1,7 @@ +package org.session.libsession.messaging.file_server + +data class VersionData( + val statusCode: Int, // The value 200. Included for backwards compatibility, and may be removed someday. + val version: String, // The Session version. + val updated: Double // The unix timestamp when this version was retrieved from Github; this can be up to 24 hours ago in case of consistent fetch errors, though normally will be within the last 30 minutes. +) \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/snode/utilities/PromiseUtil.kt b/libsession/src/main/java/org/session/libsession/snode/utilities/PromiseUtil.kt new file mode 100644 index 0000000000..9c55a2282a --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/snode/utilities/PromiseUtil.kt @@ -0,0 +1,13 @@ +package org.session.libsession.snode.utilities + +import nl.komponents.kovenant.Promise +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +suspend fun <T, E: Throwable> Promise<T, E>.await(): T { + return suspendCoroutine { cont -> + success(cont::resume) + fail(cont::resumeWithException) + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt index 12fdd4ceaf..5bf109843d 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import org.session.libsession.R +import org.session.libsession.utilities.TextSecurePreferences.Companion import org.session.libsession.utilities.TextSecurePreferences.Companion.AUTOPLAY_AUDIO_MESSAGES import org.session.libsession.utilities.TextSecurePreferences.Companion.CALL_NOTIFICATIONS_ENABLED import org.session.libsession.utilities.TextSecurePreferences.Companion.CLASSIC_DARK @@ -20,6 +21,7 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.CLASSIC_ import org.session.libsession.utilities.TextSecurePreferences.Companion.FOLLOW_SYSTEM_SETTINGS import org.session.libsession.utilities.TextSecurePreferences.Companion.HIDE_PASSWORD import org.session.libsession.utilities.TextSecurePreferences.Companion.LAST_VACUUM_TIME +import org.session.libsession.utilities.TextSecurePreferences.Companion.LAST_VERSION_CHECK import org.session.libsession.utilities.TextSecurePreferences.Companion.LEGACY_PREF_KEY_SELECTED_UI_MODE import org.session.libsession.utilities.TextSecurePreferences.Companion.OCEAN_DARK import org.session.libsession.utilities.TextSecurePreferences.Companion.OCEAN_LIGHT @@ -186,6 +188,8 @@ interface TextSecurePreferences { fun clearAll() fun getHidePassword(): Boolean fun setHidePassword(value: Boolean) + fun getLastVersionCheck(): Long + fun setLastVersionCheck() companion object { val TAG = TextSecurePreferences::class.simpleName @@ -272,6 +276,7 @@ interface TextSecurePreferences { const val AUTOPLAY_AUDIO_MESSAGES = "pref_autoplay_audio" const val FINGERPRINT_KEY_GENERATED = "fingerprint_key_generated" const val SELECTED_ACCENT_COLOR = "selected_accent_color" + const val LAST_VERSION_CHECK = "pref_last_version_check" const val HAS_RECEIVED_LEGACY_CONFIG = "has_received_legacy_config" const val HAS_FORCED_NEW_CONFIG = "has_forced_new_config" @@ -1541,6 +1546,14 @@ class AppTextSecurePreferences @Inject constructor( setLongPreference(LAST_VACUUM_TIME, System.currentTimeMillis()) } + override fun getLastVersionCheck(): Long { + return getLongPreference(LAST_VERSION_CHECK, 0) + } + + override fun setLastVersionCheck() { + setLongPreference(LAST_VERSION_CHECK, System.currentTimeMillis()) + } + override fun setShownCallNotification(): Boolean { val previousValue = getBooleanPreference(SHOWN_CALL_NOTIFICATION, false) if (previousValue) return false