diff --git a/build.gradle b/build.gradle
index fcee3f3e7..9d15c22f4 100644
--- a/build.gradle
+++ b/build.gradle
@@ -32,7 +32,7 @@ buildscript {
versions.coroutinesAndroid = '1.5.2'
versions.gson = '2.9.0'
versions.legacyV4 = '1.0.0'
- versions.tooltip = '1.0.0-alpha06-SNAPSHOT'
+ versions.tooltip = '0.2.0'
versions.retrofit2KotliCoroutinesAdapter = '0.9.2'
versions.desugar = '1.1.5'
versions.multidex = '2.0.1'
@@ -102,6 +102,7 @@ buildscript {
classpath "com.google.firebase:firebase-crashlytics-gradle:$versions.crashlyticsGradle"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$versions.kotlin"
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.50'
+ classpath "org.jetbrains.kotlin:kotlin-serialization:$versions.kotlin"
}
}
diff --git a/mobile/build.gradle b/mobile/build.gradle
index d09f3d3a4..3c51ba238 100644
--- a/mobile/build.gradle
+++ b/mobile/build.gradle
@@ -5,6 +5,7 @@ plugins {
id 'dagger.hilt.android.plugin'
id 'com.google.firebase.crashlytics'
id 'com.google.gms.google-services'
+ id 'org.jetbrains.kotlin.plugin.serialization'
}
android {
@@ -14,8 +15,8 @@ android {
minSdkVersion versions.minSdk
compileSdkVersion versions.compileSdk
targetSdkVersion versions.targetSdk
- versionCode 211
- versionName "2.17.0"
+ versionCode 212
+ versionName "3.0.0"
multiDexEnabled true
ndk {
@@ -88,7 +89,7 @@ android {
}
packagingOptions {
exclude 'META-INF/DEPENDENCIES'
-
+ exclude 'META-INF/versions/9/OSGI-INF/MANIFEST.MF'
}
namespace 'org.horizontal.tella.mobile'
}
@@ -233,6 +234,21 @@ dependencies {
implementation "com.github.nextcloud-deps.hwsecurity:hwsecurity-fido:$versions.fidoVersion"
implementation "com.github.nextcloud-deps.hwsecurity:hwsecurity-fido2:$versions.fidoVersion"
+ //QR CODE SCAN/GENERATE LIBRARY
+ implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
+
+ //ktor imp
+ implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.9")
+ implementation("io.ktor:ktor-server-core:2.3.9")
+ implementation("io.ktor:ktor-server-netty:2.3.9")
+ implementation("io.ktor:ktor-server-content-negotiation:2.3.9")
+ implementation("io.ktor:ktor-server-cio:2.3.9")
+ implementation "io.ktor:ktor-server-call-logging:2.3.9" // or your current version
+
+
+ implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3' // Use the latest version
+
+
}
def getLocalProperties() {
diff --git a/mobile/proguard-rules.pro b/mobile/proguard-rules.pro
index d114ab925..acb937b3a 100644
--- a/mobile/proguard-rules.pro
+++ b/mobile/proguard-rules.pro
@@ -246,4 +246,7 @@
-if class androidx.credentials.CredentialManager
-keep class androidx.credentials.playservices.** {
*;
-}
\ No newline at end of file
+}
+
+-keep class org.bouncycastle.** { *; }
+-dontwarn org.bouncycastle.**
\ No newline at end of file
diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml
index cc549d1b0..47eaf1b53 100644
--- a/mobile/src/main/AndroidManifest.xml
+++ b/mobile/src/main/AndroidManifest.xml
@@ -34,6 +34,7 @@
+
@@ -49,9 +50,9 @@
android:icon="@mipmap/tella_icon"
android:label="@string/app_name"
android:largeHeap="true"
+ android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/configure_localhost_media_file_http_server"
android:roundIcon="@mipmap/tella_icon_round"
- android:localeConfig="@xml/locales_config"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
@@ -502,6 +503,7 @@
+
+
\ No newline at end of file
diff --git a/mobile/src/main/assets/testDemo.txt b/mobile/src/main/assets/testDemo.txt
new file mode 100644
index 000000000..f6983e619
--- /dev/null
+++ b/mobile/src/main/assets/testDemo.txt
@@ -0,0 +1 @@
+ This is a test yo
\ No newline at end of file
diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/MyApplication.java b/mobile/src/main/java/org/horizontal/tella/mobile/MyApplication.java
index e4f238503..1ca39ffd3 100644
--- a/mobile/src/main/java/org/horizontal/tella/mobile/MyApplication.java
+++ b/mobile/src/main/java/org/horizontal/tella/mobile/MyApplication.java
@@ -8,9 +8,11 @@
import android.content.Intent;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
+import android.net.http.HttpResponseCache;
import android.os.Build;
import android.os.StrictMode;
import android.os.Bundle;
+import android.util.Log;
import android.widget.Toast;
import androidx.annotation.NonNull;
@@ -19,6 +21,7 @@
import androidx.appcompat.app.AppCompatDelegate;
import androidx.hilt.work.HiltWorkerFactory;
import androidx.lifecycle.ProcessLifecycleOwner;
+import androidx.multidex.MultiDex;
import androidx.multidex.MultiDexApplication;
import androidx.work.Configuration;
@@ -36,6 +39,7 @@
import com.owncloud.android.lib.resources.status.OwnCloudVersion;
import org.horizontal.tella.mobile.data.KeyRxVault;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.hzontal.shared_ui.data.CommonPrefs;
import org.conscrypt.Conscrypt;
@@ -53,6 +57,7 @@
import java.io.File;
import java.lang.ref.WeakReference;
import java.security.NoSuchAlgorithmException;
+import java.security.Provider;
import java.security.Security;
import java.util.Arrays;
@@ -101,8 +106,6 @@ public class MyApplication extends MultiDexApplication implements IUnlockRegistr
Vault.Config vaultConfig;
private static final String TAG = MyApplication.class.getSimpleName();
public static final String DOT = ".";
- public static final OwnCloudVersion MINIMUM_SUPPORTED_SERVER_VERSION = OwnCloudVersion.nextcloud_17;
- private static WeakReference appContext;
private long startTime;
private long totalTimeSpent = 0; // Store total time spent in the app
private int activityReferences = 0;
@@ -168,6 +171,7 @@ public static void resetKeys() {
@Override
protected void attachBaseContext(Context newBase) {
+ MultiDex.install(this);
CommonPrefs.getInstance().init(newBase);
SharedPrefs.getInstance().init(newBase);
super.attachBaseContext(LocaleManager.getInstance().getLocalizedContext(newBase));
@@ -240,6 +244,7 @@ public void accept(Throwable throwable) {
TellaKeysUI.initialize(mainKeyStore, mainKeyHolder, unlockRegistry, this, Preferences.getFailedUnlockOption(), Preferences.getUnlockRemainingAttempts(), Preferences.isShowUnlockRemainingAttempts());
insertConscrypt();
enableStrictMode();
+ setupBouncyCastleProvider();
}
private void configureCrashlytics() {
@@ -443,4 +448,15 @@ private void enableStrictMode() {
}
+ private void setupBouncyCastleProvider() {
+ try {
+ if (Security.getProvider("BC") == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ Timber.i("BouncyCastle provider registered");
+ }
+ } catch (Exception e) {
+ Timber.e(e, "Failed to register BouncyCastle provider");
+ }
+ }
+
}
diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/certificate/CertificateGenerator.kt b/mobile/src/main/java/org/horizontal/tella/mobile/certificate/CertificateGenerator.kt
new file mode 100644
index 000000000..0f8dc74ca
--- /dev/null
+++ b/mobile/src/main/java/org/horizontal/tella/mobile/certificate/CertificateGenerator.kt
@@ -0,0 +1,69 @@
+package org.horizontal.tella.mobile.certificate
+
+import org.bouncycastle.asn1.x500.X500Name
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder
+import org.bouncycastle.jce.provider.BouncyCastleProvider
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
+import java.security.KeyPair
+import java.security.KeyPairGenerator
+import java.security.Security
+import java.security.cert.X509Certificate
+import java.util.Date
+
+object CertificateGenerator {
+
+ init {
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(BouncyCastleProvider())
+ }
+ }
+
+ fun generateCertificate(
+ commonName: String = "Tella Android",
+ organization: String = "Tella",
+ validityDays: Int = 7,
+ ipAddress: String
+ ): Pair {
+ require(ipAddress.isNotBlank()) { "IP address must not be empty when generating a certificate." }
+
+ val keyGen = KeyPairGenerator.getInstance("RSA")
+ keyGen.initialize(2048)
+ val keyPair = keyGen.generateKeyPair()
+
+ val now = Date()
+ val validTo = Date(now.time + validityDays * 86400000L)
+
+ val issuer = X500Name("CN=$commonName,O=$organization")
+ val subject = issuer
+
+ val serialNumber = CertificateUtils.generateSerialNumber()
+
+ val certBuilder = JcaX509v3CertificateBuilder(
+ issuer, serialNumber, now, validTo, subject, keyPair.public
+ )
+
+ val san = org.bouncycastle.asn1.x509.GeneralNames(
+ org.bouncycastle.asn1.x509.GeneralName(
+ org.bouncycastle.asn1.x509.GeneralName.iPAddress, ipAddress
+ )
+ )
+ certBuilder.addExtension(
+ org.bouncycastle.asn1.x509.Extension.subjectAlternativeName,
+ false,
+ san
+ )
+
+ val signer = JcaContentSignerBuilder("SHA256withRSA")
+ .build(keyPair.private)
+
+ val holder = certBuilder.build(signer)
+
+ val certificate = JcaX509CertificateConverter()
+ .getCertificate(holder)
+
+ return Pair(keyPair, certificate)
+ }
+
+
+}
\ No newline at end of file
diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/certificate/CertificateUtils.kt b/mobile/src/main/java/org/horizontal/tella/mobile/certificate/CertificateUtils.kt
new file mode 100644
index 000000000..3d0f67f9b
--- /dev/null
+++ b/mobile/src/main/java/org/horizontal/tella/mobile/certificate/CertificateUtils.kt
@@ -0,0 +1,75 @@
+package org.horizontal.tella.mobile.certificate
+
+import android.util.Base64
+import org.bouncycastle.asn1.x500.X500Name
+import org.bouncycastle.asn1.x509.Extension
+import org.bouncycastle.asn1.x509.GeneralName
+import org.bouncycastle.asn1.x509.GeneralNames
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
+import java.math.BigInteger
+import java.security.KeyPair
+import java.security.SecureRandom
+import java.security.cert.CertificateFactory
+import java.security.cert.X509Certificate
+import java.security.KeyStore
+import java.util.Date
+import java.util.UUID
+import javax.net.ssl.KeyManagerFactory
+import javax.net.ssl.SSLContext
+import javax.net.ssl.TrustManagerFactory
+import javax.net.ssl.X509TrustManager
+
+object CertificateUtils {
+
+
+ fun generateKeyPair(): KeyPair {
+ val keyPairGenerator = java.security.KeyPairGenerator.getInstance("RSA")
+ keyPairGenerator.initialize(2048)
+ return keyPairGenerator.generateKeyPair()
+ }
+
+ fun generateSelfSignedCertificate(keyPair: KeyPair, ipAddress: String): X509Certificate {
+ val now = Date()
+ val until = Date(now.time + 365L * 24 * 60 * 60 * 1000) // Valid for 1 year
+
+ val serial = BigInteger(128, SecureRandom())
+ val dn = X500Name("CN=Tella P2P, O=Tella, C=US")
+
+ val certBuilder = JcaX509v3CertificateBuilder(
+ dn, serial, now, until, dn, keyPair.public
+ )
+
+ val san = GeneralNames(
+ GeneralName(GeneralName.iPAddress, ipAddress)
+ )
+
+ certBuilder.addExtension(
+ Extension.subjectAlternativeName, false, san
+ )
+
+ val signer = JcaContentSignerBuilder("SHA256WithRSAEncryption").build(keyPair.private)
+
+ val certHolder = certBuilder.build(signer)
+ return JcaX509CertificateConverter().setProvider(org.bouncycastle.jce.provider.BouncyCastleProvider())
+ .getCertificate(certHolder)
+ }
+
+
+ fun generateSerialNumber(): BigInteger {
+ return BigInteger(128, SecureRandom())
+ }
+
+ fun certificateToPem(certificate: X509Certificate): String {
+ val encoded = Base64.encodeToString(certificate.encoded, Base64.NO_WRAP)
+ return "-----BEGIN CERTIFICATE-----\n$encoded\n-----END CERTIFICATE-----"
+ }
+
+ fun getPublicKeyHash(certificate: X509Certificate): String {
+ val digest = java.security.MessageDigest.getInstance("SHA-256")
+ val hash = digest.digest(certificate.encoded)
+ return hash.joinToString("") { "%02x".format(it) } // <-- fix here
+ }
+
+}
\ No newline at end of file
diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/database/DataSource.java b/mobile/src/main/java/org/horizontal/tella/mobile/data/database/DataSource.java
index f671bb596..c53a996e8 100644
--- a/mobile/src/main/java/org/horizontal/tella/mobile/data/database/DataSource.java
+++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/database/DataSource.java
@@ -5,7 +5,6 @@
import android.content.Context;
import android.database.Cursor;
import android.text.TextUtils;
-
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/FingerprintFetcher.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/FingerprintFetcher.kt
new file mode 100644
index 000000000..8db0ca444
--- /dev/null
+++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/FingerprintFetcher.kt
@@ -0,0 +1,328 @@
+package org.horizontal.tella.mobile.data.peertopeer
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.os.Build
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+import okhttp3.CertificatePinner
+import okhttp3.Dns
+import okhttp3.OkHttpClient
+import timber.log.Timber
+import java.io.IOException
+import java.net.InetSocketAddress
+import java.net.Socket
+import java.net.SocketTimeoutException
+import java.security.MessageDigest
+import java.security.SecureRandom
+import java.security.cert.X509Certificate
+import java.util.concurrent.TimeUnit
+import javax.net.ssl.SSLContext
+import javax.net.ssl.SSLHandshakeException
+import javax.net.ssl.SSLSocket
+import javax.net.ssl.SSLSocketFactory
+import javax.net.ssl.TrustManager
+import javax.net.ssl.X509TrustManager
+import kotlin.coroutines.resume
+
+/**
+ * Fingerprint payloads:
+ * - spkiHex : SHA-256(SPKI) in lowercase hex (optional, useful for logs)
+ * - okHttpPin: OkHttp SPKI pin string "sha256/" (optional, if you want SPKI pinning)
+ * - certHex : SHA-256(leaf certificate DER) in lowercase hex (matches iOS behavior)
+ */
+data class FingerprintResult(
+ val spkiHex: String,
+ val okHttpPin: String,
+ val certHex: String
+)
+
+object FingerprintFetcher {
+
+ // ---------------------------------------------------------------------
+ // Public: PRE-PIN handshake to read cert (no HTTP), then compute hashes
+ // ---------------------------------------------------------------------
+ suspend fun fetch(context: Context, ip: String, port: Int): kotlin.Result =
+ withContext(Dispatchers.IO) {
+ try {
+ val wifi = getWifiNetworkPreferringValidated(context)
+
+ // 1) Quick TCP probe (fail fast if nothing listening)
+ probeTcp(ip, port, wifi)
+
+ // 2) TLS handshake (trust-all) to read leaf cert
+ createBoundTlsSocket(ip, port, wifi).use { tls ->
+ val cert = tls.session.peerCertificates.first() as X509Certificate
+ return@withContext kotlin.Result.success(fingerprintFromCert(cert))
+ }
+ } catch (e: SSLHandshakeException) {
+ val msg = e.message.orEmpty()
+ return@withContext if (
+ msg.contains("unexpected_message", true) ||
+ msg.contains("unrecognized_name", true) ||
+ msg.contains("EOF", true) ||
+ msg.contains("received fatal alert", true)
+ ) {
+ kotlin.Result.failure(
+ IllegalStateException("Server on $port appears to be plain HTTP or misconfigured TLS. ${e.message}")
+ )
+ } else {
+ kotlin.Result.failure(e)
+ }
+ } catch (e: SocketTimeoutException) {
+ kotlin.Result.failure(RuntimeException("Connection timed out to $ip:$port", e))
+ } catch (e: IOException) {
+ kotlin.Result.failure(IOException("I/O error to $ip:$port: ${e.message}", e))
+ } catch (e: Throwable) {
+ kotlin.Result.failure(e)
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // Public: Build clients that ENFORCE identity
+ // A) By certificate DER hash (matches iOS) -> Interceptor based
+ // B) By SPKI pin (OkHttp native) -> Optional
+ // ---------------------------------------------------------------------
+
+ fun buildClientPinnedByCertHash(
+ expectedCertSha256Hex: String,
+ hostForRequests: String,
+ network: Network? = null
+ ): OkHttpClient {
+ val trustAll: X509TrustManager = TrustAllCerts()
+ val sslContext = SSLContext.getInstance("TLS").apply {
+ init(null, arrayOf(trustAll), SecureRandom())
+ }
+
+ val builder = OkHttpClient.Builder()
+ .sslSocketFactory(sslContext.socketFactory, trustAll)
+ .hostnameVerifier { _, _ -> true } // connect by IP; identity enforced by our hash check
+ .addNetworkInterceptor(LeafCertHashInterceptor(expectedCertSha256Hex))
+ .connectTimeout(7, TimeUnit.SECONDS)
+ .readTimeout(7, TimeUnit.SECONDS)
+ .writeTimeout(7, TimeUnit.SECONDS)
+
+ if (network != null) {
+ builder.socketFactory(network.socketFactory)
+ builder.dns(Dns { hostname -> network.getAllByName(hostname).toList() })
+ }
+ return builder.build()
+ }
+
+ suspend fun buildClientPinnedByCertHash(
+ context: Context,
+ expectedCertSha256Hex: String,
+ hostForRequests: String
+ ): OkHttpClient {
+ val wifi = getWifiNetworkPreferringValidated(context)
+ return buildClientPinnedByCertHash(expectedCertSha256Hex, hostForRequests, wifi)
+ }
+
+ // Optional: SPKI pinning (OkHttp-native). Keep if you want SPKI instead of DER hash.
+ fun buildPinnedClientWithOkPin(
+ okHttpPin: String, // must be "sha256/"
+ hostForPin: String, // same host used in the URL (IP or DNS)
+ network: Network? = null
+ ): OkHttpClient {
+ require(okHttpPin.startsWith("sha256/")) { "Pin must start with 'sha256/'" }
+
+ val pinner = CertificatePinner.Builder()
+ .add(hostForPin, okHttpPin)
+ .build()
+
+ val trustAll: X509TrustManager = TrustAllCerts()
+ val sslContext = SSLContext.getInstance("TLS").apply {
+ init(null, arrayOf(trustAll), SecureRandom())
+ }
+
+ val builder = OkHttpClient.Builder()
+ .sslSocketFactory(sslContext.socketFactory, trustAll)
+ .hostnameVerifier { _, _ -> true } // connect by IP; identity enforced by pin
+ .certificatePinner(pinner)
+ .connectTimeout(7, TimeUnit.SECONDS)
+ .readTimeout(7, TimeUnit.SECONDS)
+ .writeTimeout(7, TimeUnit.SECONDS)
+
+ if (network != null) {
+ builder.socketFactory(network.socketFactory)
+ builder.dns(Dns { hostname -> network.getAllByName(hostname).toList() })
+ }
+ return builder.build()
+ }
+
+ suspend fun buildPinnedClientWithOkPin(
+ context: Context,
+ okHttpPin: String,
+ hostForPin: String
+ ): OkHttpClient {
+ val wifi = getWifiNetworkPreferringValidated(context)
+ return buildPinnedClientWithOkPin(okHttpPin, hostForPin, wifi)
+ }
+
+ // ---------------------------------------------------------------------
+ // Public helpers
+ // ---------------------------------------------------------------------
+
+ suspend fun pickWifiNetwork(context: Context): Network? =
+ getWifiNetworkPreferringValidated(context)
+
+ fun fingerprintFromCert(cert: X509Certificate): FingerprintResult {
+ val okHttpPin = CertificatePinner.pin(cert) // "sha256/" of SPKI
+ val spkiHex = sha256Hex(cert.publicKey.encoded) // SPKI hex (optional)
+ val certHex = sha256Hex(cert.encoded) // ✅ CERT DER hex (matches iOS)
+ return FingerprintResult(spkiHex = spkiHex, okHttpPin = okHttpPin, certHex = certHex)
+ }
+
+ // ---------------------------------------------------------------------
+ // Internals used by fetch()
+ // ---------------------------------------------------------------------
+
+ private fun createBoundTlsSocket(ip: String, port: Int, wifi: Network?): SSLSocket {
+ val sslContext = SSLContext.getInstance("TLS").apply {
+ // Trust-all only to read the cert; real requests will be pinned
+ init(null, arrayOf(TrustAllCerts()), SecureRandom())
+ }
+ val factory = sslContext.socketFactory as SSLSocketFactory
+
+ val s = factory.createSocket() as SSLSocket
+ s.soTimeout = 7000
+ s.enabledProtocols = arrayOf("TLSv1.3", "TLSv1.2")
+
+ try {
+ wifi?.bindSocket(s)
+ } catch (bindErr: Exception) {
+ Timber.w(bindErr, "bindSocket failed, continuing on default route")
+ }
+
+ s.connect(InetSocketAddress(ip, port), 7000)
+ s.startHandshake()
+ return s
+ }
+
+ private fun probeTcp(ip: String, port: Int, wifi: Network?) {
+ val sock = Socket()
+ try { wifi?.bindSocket(sock) } catch (_: Exception) {}
+ try { sock.connect(InetSocketAddress(ip, port), 2500) } finally {
+ try { sock.close() } catch (_: Exception) {}
+ }
+ }
+
+ class TrustAllCerts : X509TrustManager {
+ override fun checkClientTrusted(chain: Array, authType: String) = Unit
+ override fun checkServerTrusted(chain: Array, authType: String) = Unit
+ override fun getAcceptedIssuers(): Array = emptyArray()
+ }
+
+ @Suppress("DEPRECATION")
+ private suspend fun getWifiNetworkPreferringValidated(ctx: Context): Network? =
+ withContext(Dispatchers.IO) {
+ val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+
+ // Fast path: active VALIDATED Wi-Fi
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ cm.activeNetwork?.let { n ->
+ cm.getNetworkCapabilities(n)?.let { c ->
+ if (c.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
+ c.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
+ c.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
+ ) return@withContext n
+ }
+ }
+ } else {
+ cm.allNetworks.firstOrNull { n ->
+ val info = cm.getNetworkInfo(n)
+ val caps = cm.getNetworkCapabilities(n)
+ info?.isConnected == true &&
+ caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true &&
+ caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ }?.let { return@withContext it }
+ }
+
+ // Request a validated Wi-Fi (time-bound)
+ val request = NetworkRequest.Builder()
+ .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ .build()
+
+ val validated: Network? = try {
+ withTimeout(4_000) {
+ suspendCancellableCoroutine { cont ->
+ val cb = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ if (cont.isActive) {
+ cm.unregisterNetworkCallback(this)
+ cont.resume(network)
+ }
+ }
+ }
+ override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
+ caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
+ caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
+ ) {
+ if (cont.isActive) {
+ cm.unregisterNetworkCallback(this)
+ cont.resume(network)
+ }
+ }
+ }
+ override fun onUnavailable() {
+ if (cont.isActive) {
+ cm.unregisterNetworkCallback(this)
+ cont.resume(null)
+ }
+ }
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ cm.requestNetwork(request, cb, 4_000)
+ } else {
+ cm.requestNetwork(request, cb)
+ }
+ cont.invokeOnCancellation { runCatching { cm.unregisterNetworkCallback(cb) } }
+ }
+ }
+ } catch (_: Exception) { null }
+
+ if (validated != null) return@withContext validated
+
+ // Last resort: any connected Wi-Fi with INTERNET
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ cm.allNetworks.firstOrNull { n ->
+ cm.getNetworkCapabilities(n)?.let { c ->
+ c.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
+ c.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ } == true
+ }
+ } else null
+ }
+
+ private fun sha256Hex(bytes: ByteArray): String =
+ MessageDigest.getInstance("SHA-256").digest(bytes)
+ .joinToString("") { "%02x".format(it) }
+
+ // Interceptor to enforce SHA-256 over the LEAF CERTIFICATE (DER), iOS-compatible
+ private class LeafCertHashInterceptor(
+ private val expectedHexLower: String
+ ) : okhttp3.Interceptor {
+ override fun intercept(chain: okhttp3.Interceptor.Chain): okhttp3.Response {
+ val resp = chain.proceed(chain.request())
+ val cert = (resp.handshake?.peerCertificates?.firstOrNull() as? X509Certificate)
+ ?: throw javax.net.ssl.SSLPeerUnverifiedException("No peer certificate")
+ val actual = sha256Hex(cert.encoded)
+ if (!actual.equals(expectedHexLower, ignoreCase = true)) {
+ resp.close()
+ throw javax.net.ssl.SSLPeerUnverifiedException(
+ "Certificate DER hash mismatch. expected=$expectedHexLower actual=$actual"
+ )
+ }
+ return resp
+ }
+ }
+}
diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/P2PSecurity.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/P2PSecurity.kt
new file mode 100644
index 000000000..c3fa007d6
--- /dev/null
+++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/P2PSecurity.kt
@@ -0,0 +1,6 @@
+package org.horizontal.tella.mobile.data.peertopeer
+
+object P2PSecurity {
+ /** Allow trust-all TLS only for the *manual connect handshake* (e.g., /ping). */
+ @JvmStatic var allowInsecureManualHandshake: Boolean = false
+}
\ No newline at end of file
diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerKeyProvider.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerKeyProvider.kt
new file mode 100644
index 000000000..8ab752fe7
--- /dev/null
+++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerKeyProvider.kt
@@ -0,0 +1,30 @@
+package org.horizontal.tella.mobile.data.peertopeer
+
+import org.horizontal.tella.mobile.certificate.CertificateUtils
+import java.security.KeyPair
+import java.security.cert.X509Certificate
+
+object PeerKeyProvider {
+ private var keyPair: KeyPair? = null
+ private var certificate: X509Certificate? = null
+
+ fun getKeyPair(): KeyPair {
+ if (keyPair == null) {
+ keyPair = CertificateUtils.generateKeyPair()
+ }
+ return keyPair!!
+ }
+
+ fun getCertificate(ipAddress: String): X509Certificate {
+ if (certificate == null) {
+ certificate = CertificateUtils.generateSelfSignedCertificate(getKeyPair(), ipAddress)
+ }
+ return certificate!!
+ }
+
+ fun reset() {
+ keyPair = null
+ certificate = null
+ }
+}
+
diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerToPeerConstants.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerToPeerConstants.kt
new file mode 100644
index 000000000..fd4b8629e
--- /dev/null
+++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerToPeerConstants.kt
@@ -0,0 +1,11 @@
+package org.horizontal.tella.mobile.data.peertopeer
+
+/**
+ * Created by wafa on 3/7/2025.
+ */
+object PeerToPeerConstants {
+ const val TRANSMISSION_ID_KEY = "transmissionId"
+ const val CONTENT_TYPE_JSON = "application/json"
+ const val CONTENT_TYPE = "Content-Type"
+ const val CONTENT_TYPE_OCTET = "application/octet-stream"
+}
\ No newline at end of file
diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/ServerPinger.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/ServerPinger.kt
new file mode 100644
index 000000000..4c9c30293
--- /dev/null
+++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/ServerPinger.kt
@@ -0,0 +1,113 @@
+package org.horizontal.tella.mobile.data.peertopeer
+
+import android.content.Context
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.Dns
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody
+import java.security.SecureRandom
+import java.security.cert.X509Certificate
+import java.util.concurrent.TimeUnit
+import javax.net.ssl.SSLContext
+import javax.net.ssl.X509TrustManager
+
+object ServerPinger {
+
+ suspend fun pingAndExtractFingerprint(
+ context: Context,
+ ip: String,
+ port: Int
+ ): Result = withContext(Dispatchers.IO) {
+ try {
+ val network = FingerprintFetcher.pickWifiNetwork(context)
+
+ val trustAll: X509TrustManager = FingerprintFetcher.TrustAllCerts()
+ val sslContext = SSLContext.getInstance("TLS").apply {
+ init(null, arrayOf(trustAll), SecureRandom())
+ }
+
+ val builder = OkHttpClient.Builder()
+ .sslSocketFactory(sslContext.socketFactory, trustAll)
+ .hostnameVerifier { _, _ -> true } // connect by IP
+ .connectTimeout(7, TimeUnit.SECONDS)
+ .readTimeout(7, TimeUnit.SECONDS)
+ .writeTimeout(7, TimeUnit.SECONDS)
+
+ if (network != null) {
+ builder.socketFactory(network.socketFactory)
+ builder.dns(Dns { hostname -> network.getAllByName(hostname).toList() })
+ }
+
+ val client = builder.build()
+
+ val req = Request.Builder()
+ .url("https://$ip:$port/api/v1/ping")
+ .post(RequestBody.create(null, ByteArray(0)))
+ .build()
+
+ client.newCall(req).execute().use { resp ->
+ val cert = (resp.handshake?.peerCertificates?.firstOrNull()
+ ?: return@withContext Result.failure(IllegalStateException("No peer certificate in handshake"))
+ ) as X509Certificate
+
+ val fp = FingerprintFetcher.fingerprintFromCert(cert)
+ // Even if HTTP code isn’t 2xx, we already got the cert.
+ return@withContext Result.success(fp)
+ }
+ } catch (t: Throwable) {
+ Result.failure(t)
+ }
+ }
+
+ /**
+ * Pinned ping using CERT DER hash (matches iOS).
+ */
+ suspend fun notifyServerPinnedByCert(
+ context: Context,
+ ip: String,
+ port: Int,
+ expectedCertSha256Hex: String
+ ) = withContext(Dispatchers.IO) {
+ val client = FingerprintFetcher.buildClientPinnedByCertHash(
+ context = context,
+ expectedCertSha256Hex = expectedCertSha256Hex,
+ hostForRequests = ip
+ )
+
+ val req = Request.Builder()
+ .url("https://$ip:$port/api/v1/ping")
+ .post(RequestBody.create(null, ByteArray(0)))
+ .build()
+
+ client.newCall(req).execute().use { resp ->
+ if (!resp.isSuccessful) error("Ping failed: HTTP ${resp.code}")
+ }
+ }
+
+ /**
+ * Optional: pinned ping using SPKI pin (OkHttp-native).
+ */
+ suspend fun notifyServerPinnedWithOkPin(
+ context: Context,
+ ip: String,
+ port: Int,
+ okHttpPin: String
+ ) = withContext(Dispatchers.IO) {
+ val client = FingerprintFetcher.buildPinnedClientWithOkPin(
+ context = context,
+ okHttpPin = okHttpPin,
+ hostForPin = ip
+ )
+
+ val req = Request.Builder()
+ .url("https://$ip:$port/api/v1/ping")
+ .post(RequestBody.create(null, ByteArray(0)))
+ .build()
+
+ client.newCall(req).execute().use { resp ->
+ if (!resp.isSuccessful) error("Ping failed: HTTP ${resp.code}")
+ }
+ }
+}
diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/TellaPeerToPeerClient.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/TellaPeerToPeerClient.kt
new file mode 100644
index 000000000..c7ea295d3
--- /dev/null
+++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/TellaPeerToPeerClient.kt
@@ -0,0 +1,408 @@
+package org.horizontal.tella.mobile.data.peertopeer
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.os.Build
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+
+import com.hzontal.tella_vault.VaultFile
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import okhttp3.ConnectionSpec
+import okhttp3.OkHttpClient
+import okhttp3.Protocol
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.TlsVersion
+import org.horizontal.tella.mobile.certificate.CertificateUtils
+import org.horizontal.tella.mobile.data.peertopeer.PeerToPeerConstants.CONTENT_TYPE
+import org.horizontal.tella.mobile.data.peertopeer.PeerToPeerConstants.CONTENT_TYPE_JSON
+import org.horizontal.tella.mobile.data.peertopeer.PeerToPeerConstants.CONTENT_TYPE_OCTET
+import org.horizontal.tella.mobile.data.peertopeer.network.ProgressRequestBody
+import org.horizontal.tella.mobile.data.peertopeer.remote.PeerApiRoutes
+import org.horizontal.tella.mobile.data.peertopeer.remote.PrepareUploadRequest
+import org.horizontal.tella.mobile.data.peertopeer.remote.PrepareUploadResult
+import org.horizontal.tella.mobile.data.peertopeer.remote.RegisterPeerResult
+import org.horizontal.tella.mobile.domain.peertopeer.P2PFile
+import org.horizontal.tella.mobile.domain.peertopeer.PeerPrepareUploadResponse
+import org.horizontal.tella.mobile.domain.peertopeer.PeerRegisterPayload
+import org.json.JSONObject
+import timber.log.Timber
+import java.io.InputStream
+import java.security.SecureRandom
+import java.security.cert.CertificateException
+import java.security.cert.X509Certificate
+import java.util.Collections
+import java.util.UUID
+import java.util.concurrent.TimeUnit
+import javax.net.ssl.SSLContext
+import javax.net.ssl.TrustManager
+import javax.net.ssl.X509TrustManager
+
+class TellaPeerToPeerClient @Inject constructor(
+ @ApplicationContext private val appContext: Context
+) {
+ suspend fun registerPeerDevice(
+ ip: String,
+ port: String,
+ expectedFingerprint: String,
+ pin: String,
+ ): RegisterPeerResult = withContext(Dispatchers.IO) {
+ val url = PeerApiRoutes.buildUrl(ip, port, PeerApiRoutes.REGISTER)
+ Timber.d("Connecting to: $url")
+
+ val payload = PeerRegisterPayload(
+ pin = pin.trim(),
+ nonce = UUID.randomUUID().toString()
+ )
+
+ val jsonPayload = Json.encodeToString(payload)
+ val requestBody = jsonPayload.toRequestBody()
+ val client = getClientWithFingerprintValidation(ip, expectedFingerprint)
+
+ val request = Request.Builder()
+ .url(url)
+ .post(requestBody)
+ .addHeader(CONTENT_TYPE, CONTENT_TYPE_JSON)
+ // Encourage short-lived TLS sessions to avoid half-closed sockets across platforms.
+ .addHeader("Connection", "close")
+ .build()
+
+ return@withContext try {
+ client.newCall(request).execute().use { response ->
+ val body = response.body?.string().orEmpty()
+ Timber.d("registerPeerDevice: code=%d headers=%s body=%s",
+ response.code, response.headers, body.take(600))
+
+ if (response.isSuccessful) {
+ when (val parsed = parseSessionIdFromResponse(body)) {
+ is RegisterPeerResult.Success -> parsed
+ is RegisterPeerResult.Failure -> parsed
+ else -> RegisterPeerResult.Failure(Exception("Unexpected success response shape"))
+ }
+ } else {
+ when (response.code) {
+ 400 -> RegisterPeerResult.InvalidFormat
+ 401 -> RegisterPeerResult.InvalidPin
+ 403 -> RegisterPeerResult.RejectedByReceiver
+ 409 -> RegisterPeerResult.Conflict
+ 429 -> RegisterPeerResult.TooManyRequests
+ 500 -> RegisterPeerResult.ServerError
+ else -> RegisterPeerResult.Failure(Exception("Unhandled error ${response.code}: $body"))
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "registerPeerDevice request failed")
+ RegisterPeerResult.Failure(e)
+ }
+ }
+
+ suspend fun prepareUpload(
+ ip: String,
+ port: String,
+ expectedFingerprint: String, // SPKI SHA-256 hex
+ title: String,
+ files: List,
+ sessionId: String
+ ): PrepareUploadResult = withContext(Dispatchers.IO) {
+ val url = PeerApiRoutes.buildUrl(ip, port, PeerApiRoutes.PREPARE_UPLOAD)
+
+ val fileItems = files.map {
+ val mimeType = it.mimeType ?: CONTENT_TYPE_OCTET
+ P2PFile(
+ id = it.id,
+ fileName = it.name,
+ size = it.size,
+ fileType = mimeType,
+ // sha256 = it.hash,
+ thumbnail = it.thumb
+ )
+ }
+
+ val requestPayload = PrepareUploadRequest(title, sessionId, fileItems)
+ val jsonPayload = Json.encodeToString(requestPayload)
+ val requestBody = jsonPayload.toRequestBody()
+ val client = getClientWithFingerprintValidation(ip, expectedFingerprint)
+
+ try {
+ val request = Request.Builder()
+ .url(url)
+ .post(requestBody)
+ .addHeader(CONTENT_TYPE, CONTENT_TYPE_JSON)
+ .addHeader("Connection", "close")
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ val responseBody = response.body?.string().orEmpty()
+ Timber.d("prepareUpload: code=%d body=%s", response.code, responseBody.take(600))
+ if (response.isSuccessful) {
+ parseTransmissionId(responseBody)
+ } else {
+ Timber.e("Server error ${response.code}: ${response.message}")
+ handleServerError(response.code, responseBody)
+ }
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Exception during prepareUpload")
+ PrepareUploadResult.Failure(e)
+ }
+ }
+
+ suspend fun uploadFileWithProgress(
+ ip: String,
+ port: String,
+ expectedFingerprint: String, // SPKI SHA-256 hex
+ sessionId: String,
+ fileId: String,
+ transmissionId: String,
+ inputStream: InputStream,
+ fileSize: Long,
+ fileName: String,
+ onProgress: (bytesWritten: Long, totalBytes: Long) -> Unit
+ ): Boolean = withContext(Dispatchers.IO) {
+ Timber.d("session id from the client = %s", sessionId)
+ val url = PeerApiRoutes.buildUploadUrl(ip, port, sessionId, fileId, transmissionId)
+
+ val client = getClientWithFingerprintValidation(ip, expectedFingerprint)
+ val requestBody = ProgressRequestBody(inputStream, fileSize, onProgress)
+
+ val request = Request.Builder()
+ .url(url)
+ .put(requestBody)
+ .addHeader(CONTENT_TYPE, CONTENT_TYPE_OCTET)
+ .addHeader("Connection", "close")
+ .build()
+
+ return@withContext try {
+ client.newCall(request).execute().use { response ->
+ val ok = response.isSuccessful
+ Timber.d("uploadFileWithProgress(%s): %s", fileId, if (ok) "OK" else "FAIL ${response.code}")
+ ok
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Exception while uploading %s", fileId)
+ false
+ }
+ }
+
+ suspend fun closeConnection(
+ ip: String,
+ port: String,
+ expectedFingerprint: String,
+ sessionId: String
+ ): Boolean = withContext(Dispatchers.IO) {
+ val url = PeerApiRoutes.buildUrl(ip, port, PeerApiRoutes.CLOSE)
+
+ val payload = Json.encodeToString(mapOf("sessionId" to sessionId))
+ val requestBody = payload.toRequestBody()
+ val client = getClientWithFingerprintValidation(ip, expectedFingerprint)
+
+ val request = Request.Builder()
+ .url(url)
+ .post(requestBody)
+ .addHeader(CONTENT_TYPE, CONTENT_TYPE_JSON)
+ .addHeader("Connection", "close")
+ .build()
+
+ return@withContext try {
+ client.newCall(request)
+
+ .execute().use { response ->
+ Timber.d("closeConnection: code=%d", response.code)
+ response.isSuccessful
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to close connection")
+ false
+ }
+ }
+
+ // ---------------- Internals ----------------
+
+ private fun parseSessionIdFromResponse(body: String): RegisterPeerResult {
+ return try {
+ val json = JSONObject(body)
+
+ // Optional: some servers send { success: true/false }
+ val successFlag = json.optBoolean("success", true)
+
+ // Required: non-empty sessionId
+ val sessionId = json.optString("sessionId", "").trim()
+
+ when {
+ !successFlag -> {
+ val msg = json.optString("message", json.optString("error", "Registration rejected"))
+ RegisterPeerResult.Failure(Exception(msg))
+ }
+ sessionId.isEmpty() -> {
+ RegisterPeerResult.Failure(Exception("Missing or empty sessionId"))
+ }
+ else -> RegisterPeerResult.Success(sessionId)
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Malformed JSON response: %s", body)
+ RegisterPeerResult.Failure(Exception("Malformed JSON: ${e.message}"))
+ }
+ }
+
+ private fun parseTransmissionId(body: String): PrepareUploadResult =
+ try {
+ val response = Json.decodeFromString(body)
+ PrepareUploadResult.Success(response.files)
+ } catch (e: Exception) {
+ Timber.e(e, "Invalid JSON response: %s", body)
+ PrepareUploadResult.Failure(Exception("Malformed server response"))
+ }
+
+ private fun handleServerError(code: Int, body: String): PrepareUploadResult =
+ when (code) {
+ 400 -> PrepareUploadResult.BadRequest
+ 403 -> PrepareUploadResult.Forbidden
+ 409 -> PrepareUploadResult.Conflict
+ 500 -> PrepareUploadResult.ServerError
+ else -> PrepareUploadResult.Failure(Exception("Unhandled server error $code: $body"))
+ }
+
+ /**
+ * OkHttp client that:
+ * - Pins the server by SPKI SHA-256 hex (your CertificateUtils.getPublicKeyHash).
+ * - Optionally binds sockets to a Wi-Fi Network if one is active/validated.
+ * - Relaxes hostname verification (IP literal + hard pin).
+ * - Uses TLS 1.2/1.3 spec to ease cross-platform handshakes (iOS -9816).
+ */
+ private fun getClientWithFingerprintValidation(
+ ip: String,
+ expectedFingerprintHex: String
+ ): OkHttpClient {
+ val expected = normalizeHex(expectedFingerprintHex)
+
+ val trustManager = object : X509TrustManager {
+ override fun getAcceptedIssuers(): Array = emptyArray()
+ override fun checkClientTrusted(chain: Array?, authType: String?) {}
+ override fun checkServerTrusted(chain: Array?, authType: String?) {
+ val serverCert = chain?.firstOrNull()
+ ?: throw CertificateException("Empty certificate chain")
+ val actualHex = normalizeHex(CertificateUtils.getPublicKeyHash(serverCert))
+ if (actualHex != expected) {
+ throw CertificateException("Certificate DER hash mismatch. Expected: $expected, Got: $actualHex")
+ }
+ }
+ }
+
+ val sslContext = SSLContext.getInstance("TLS").apply {
+ init(null, arrayOf(trustManager), SecureRandom())
+ }
+
+ val tlsSpec = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
+ .tlsVersions(TlsVersion.TLS_1_3, TlsVersion.TLS_1_2)
+ .allEnabledCipherSuites()
+ .build()
+
+ val builder = OkHttpClient.Builder()
+ .sslSocketFactory(sslContext.socketFactory, trustManager)
+ .hostnameVerifier { _, _ -> true }
+ .connectionSpecs(listOf(tlsSpec, ConnectionSpec.CLEARTEXT))
+ .protocols(listOf(Protocol.HTTP_1_1))
+ .connectTimeout(5, TimeUnit.SECONDS)
+ .readTimeout(20, TimeUnit.SECONDS)
+ .writeTimeout(20, TimeUnit.SECONDS)
+
+ pickWifiNetwork(appContext)?.let { network ->
+ builder.socketFactory(network.socketFactory)
+ }
+
+ return builder.build()
+ }
+
+
+ @Suppress("DEPRECATION")
+ private fun pickWifiNetwork(context: Context): Network? {
+ val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+
+ // Prefer active validated Wi-Fi on API 23+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ cm.activeNetwork?.let { n ->
+ cm.getNetworkCapabilities(n)?.let { caps ->
+ if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
+ caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
+ caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
+ ) return n
+ }
+ }
+ }
+
+ // Otherwise, any Wi-Fi with INTERNET capability
+ return cm.allNetworks.firstOrNull { n ->
+ cm.getNetworkCapabilities(n)?.let { caps ->
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
+ caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ } == true
+ }
+ }
+
+ private fun normalizeHex(hexLike: String): String =
+ hexLike.trim().replace(":", "").replace("\\s".toRegex(), "").lowercase()
+
+
+
+ /** Trust-all, *only* for manual handshake like /ping before we have a pin. */
+ private fun newInsecureClientForHandshake(network: Network?): OkHttpClient {
+ val trustAll = object : X509TrustManager {
+ override fun getAcceptedIssuers(): Array = emptyArray()
+ override fun checkClientTrusted(chain: Array?, authType: String?) {}
+ override fun checkServerTrusted(chain: Array?, authType: String?) {}
+ }
+
+ val ssl = SSLContext.getInstance("TLS").apply {
+ init(null, arrayOf(trustAll), SecureRandom())
+ }
+
+ val tlsSpec = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
+ .tlsVersions(TlsVersion.TLS_1_3, TlsVersion.TLS_1_2)
+ .allEnabledCipherSuites()
+ .build()
+
+ return OkHttpClient.Builder()
+ .sslSocketFactory(ssl.socketFactory, trustAll)
+ .hostnameVerifier { _, _ -> true }
+ .connectionSpecs(listOf(tlsSpec))
+ .protocols(listOf(Protocol.HTTP_1_1))
+ .apply { network?.let { socketFactory(it.socketFactory) } }
+ .connectTimeout(3, TimeUnit.SECONDS)
+ .readTimeout(5, TimeUnit.SECONDS)
+ .build()
+ }
+
+ suspend fun pingBeforeRegister(ip: String, port: String): Boolean = withContext(Dispatchers.IO) {
+ val network = pickWifiNetwork(appContext)
+ val client = newInsecureClientForHandshake(network)
+
+ // Use the real path your server exposes; many backends use /api/v1/ping
+ val url = PeerApiRoutes.buildUrl(ip, port, "/api/v1/ping", secure = true)
+
+ val req = Request.Builder()
+ .url("https://$ip:$port/api/v1/ping")
+ .post(okhttp3.RequestBody.create(null, ByteArray(0))) // or "".toRequestBody(null)
+ .build()
+
+ runCatching {
+ client.newCall(req).execute().use { resp ->
+ // consider any HTTP code as “host reachable”
+ Timber.d("pingBeforeRegister $url -> HTTP %d", resp.code)
+ resp.code in 100..599
+ }
+ }.getOrElse {
+ Timber.w(it, "Ping failed for $url")
+ false
+ }
+ }
+
+
+
+}
diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/TellaPeerToPeerServer.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/TellaPeerToPeerServer.kt
new file mode 100644
index 000000000..32cfc8a38
--- /dev/null
+++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/TellaPeerToPeerServer.kt
@@ -0,0 +1,349 @@
+package org.horizontal.tella.mobile.data.peertopeer
+
+import io.ktor.http.HttpStatusCode
+import io.ktor.serialization.kotlinx.json.json
+import io.ktor.server.application.call
+import io.ktor.server.application.install
+import io.ktor.server.engine.ApplicationEngine
+import io.ktor.server.engine.applicationEngineEnvironment
+import io.ktor.server.engine.embeddedServer
+import io.ktor.server.engine.sslConnector
+import io.ktor.server.netty.Netty
+import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.server.request.receive
+import io.ktor.server.request.receiveStream
+import io.ktor.server.response.respond
+import io.ktor.server.response.respondText
+import io.ktor.server.routing.get
+import io.ktor.server.routing.post
+import io.ktor.server.routing.put
+import io.ktor.server.routing.routing
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import org.horizontal.tella.mobile.certificate.CertificateUtils
+import org.horizontal.tella.mobile.data.peertopeer.managers.PeerToPeerManager
+import org.horizontal.tella.mobile.data.peertopeer.model.P2PFileStatus
+import org.horizontal.tella.mobile.data.peertopeer.model.P2PSession
+import org.horizontal.tella.mobile.data.peertopeer.model.P2PSharedState
+import org.horizontal.tella.mobile.data.peertopeer.model.ProgressFile
+import org.horizontal.tella.mobile.data.peertopeer.model.SessionStatus
+import org.horizontal.tella.mobile.data.peertopeer.remote.PeerApiRoutes
+import org.horizontal.tella.mobile.data.peertopeer.remote.PrepareUploadRequest
+import org.horizontal.tella.mobile.domain.peertopeer.FileInfo
+import org.horizontal.tella.mobile.domain.peertopeer.KeyStoreConfig
+import org.horizontal.tella.mobile.domain.peertopeer.PeerEventManager
+import org.horizontal.tella.mobile.domain.peertopeer.PeerPrepareUploadResponse
+import org.horizontal.tella.mobile.domain.peertopeer.PeerRegisterPayload
+import org.horizontal.tella.mobile.domain.peertopeer.PeerResponse
+import org.horizontal.tella.mobile.domain.peertopeer.TellaServer
+import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.state.UploadProgressState
+import timber.log.Timber
+import java.io.File
+import java.security.KeyPair
+import java.security.KeyStore
+import java.security.cert.X509Certificate
+import java.util.UUID
+
+const val port = 53317
+
+class TellaPeerToPeerServer(
+ private val ip: String,
+ private val serverPort: Int = port,
+ private val pin: String,
+ private val keyPair: KeyPair,
+ private val certificate: X509Certificate,
+ private val keyStoreConfig: KeyStoreConfig,
+ private val peerToPeerManager: PeerToPeerManager,
+ private val p2PSharedState: P2PSharedState
+) : TellaServer {
+
+ private var serverSession: PeerResponse? = null
+ private var engine: ApplicationEngine? = null
+
+ override val certificatePem: String
+ get() = CertificateUtils.certificateToPem(certificate)
+
+ override fun start() {
+ val keyStore = KeyStore.getInstance("PKCS12").apply {
+ load(null, null)
+ setKeyEntry(
+ keyStoreConfig.alias, keyPair.private, keyStoreConfig.password, arrayOf(certificate)
+ )
+ }
+
+ engine = embeddedServer(Netty, environment = applicationEngineEnvironment {
+ sslConnector(keyStore = keyStore,
+ keyAlias = keyStoreConfig.alias,
+ keyStorePassword = { keyStoreConfig.password },
+ privateKeyPassword = { keyStoreConfig.password }) {
+ this.host = ip
+ this.port = serverPort
+ }
+
+ module {
+ install(ContentNegotiation) {
+ json(kotlinx.serialization.json.Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ })
+ }
+
+ routing {
+ // Sanity probe
+ get("/") { call.respondText("The server is running securely over HTTPS.") }
+
+ // Client presence hint (optional)
+ post(PeerApiRoutes.PING) {
+ CoroutineScope(Dispatchers.IO).launch {
+ peerToPeerManager.notifyClientConnected(p2PSharedState.hash)
+ }
+ call.respondText("ping", status = HttpStatusCode.OK)
+ }
+
+ // 1) Register a session
+ post(PeerApiRoutes.REGISTER) {
+ val request = try {
+ call.receive()
+ } catch (e: Exception) {
+ call.respond(HttpStatusCode.BadRequest, "Invalid request format")
+ return@post
+ }
+
+ if (!isValidPin(request.pin) || pin != request.pin) {
+ call.respond(HttpStatusCode.Unauthorized, "Invalid PIN")
+ return@post
+ }
+
+ if (serverSession != null) {
+ call.respond(HttpStatusCode.Conflict, "Active session already exists")
+ return@post
+ }
+
+ val sessionId = UUID.randomUUID().toString()
+ val session = PeerResponse(sessionId)
+ p2PSharedState.session?.sessionId = sessionId
+ serverSession = session
+
+ val accepted = try {
+ PeerEventManager.emitIncomingRegistrationRequest(sessionId, request)
+ } catch (e: Exception) {
+ call.respond(HttpStatusCode.InternalServerError, "Internal error")
+ return@post
+ }
+
+ if (!accepted) {
+ call.respond(
+ HttpStatusCode.Forbidden, "Receiver rejected the registration"
+ )
+ return@post
+ }
+
+ launch { PeerEventManager.emitRegistrationSuccess() }
+ call.respond(HttpStatusCode.OK, session)
+ }
+
+ // 2) Prepare upload → create receiving session, STATUS = SENDING
+ post(PeerApiRoutes.PREPARE_UPLOAD) {
+ val request = try {
+ call.receive()
+ } catch (e: Exception) {
+ call.respond(HttpStatusCode.BadRequest, "Invalid body: ${e.message}")
+ return@post
+ }
+
+ if (request.title.isBlank() || request.sessionId.isBlank() || request.files.isEmpty()) {
+ call.respond(HttpStatusCode.BadRequest, "Missing required fields")
+ return@post
+ }
+
+ if (request.sessionId != serverSession?.sessionId) {
+ call.respond(HttpStatusCode.Unauthorized, "Invalid session ID")
+ return@post
+ }
+
+ val accepted = PeerEventManager.emitPrepareUploadRequest(request)
+ if (!accepted) {
+ call.respond(HttpStatusCode.Forbidden, "Transfer rejected by receiver")
+ return@post
+ }
+
+ val session = P2PSession(
+ title = request.title, sessionId = request.sessionId
+ ).also { it.status = SessionStatus.SENDING }
+
+ val responseFiles = request.files.map { file ->
+ val transmissionId = UUID.randomUUID().toString()
+ val receivingFile =
+ ProgressFile(file = file, transmissionId = transmissionId)
+ session.files[transmissionId] = receivingFile
+ FileInfo(id = file.id, transmissionId = transmissionId)
+ }
+
+ p2PSharedState.session = session
+ call.respond(
+ HttpStatusCode.OK, PeerPrepareUploadResponse(files = responseFiles)
+ )
+ }
+
+ // 3) Upload each file (transport-only; recipient will SAVE later)
+ put(PeerApiRoutes.UPLOAD) {
+ val sessionId = call.parameters["sessionId"]
+ val fileId = call.parameters["fileId"]
+ val transmissionId = call.parameters["transmissionId"]
+
+ Timber.d("UPLOAD: session=$sessionId, fileId=$fileId, tx=$transmissionId")
+
+
+
+ if (sessionId == null || fileId == null || transmissionId == null) {
+ call.respond(HttpStatusCode.BadRequest, "Missing path parameters")
+ return@put
+ }
+
+ val session = p2PSharedState.session
+ if (sessionId != session?.sessionId) {
+ call.respond(HttpStatusCode.Unauthorized, "Invalid session ID")
+ return@put
+ }
+
+ val progressFile = session.files[transmissionId]
+
+ if (transmissionId != progressFile?.transmissionId) {
+ call.respond(HttpStatusCode.Forbidden, "Invalid transmission ID")
+ return@put
+ }
+
+ if (progressFile.file.id != fileId) {
+ call.respond(HttpStatusCode.NotFound, "File not found in session")
+ return@put
+ }
+
+ if (progressFile.status == P2PFileStatus.FINISHED) {
+ call.respond(HttpStatusCode.Conflict, "Transfer already completed")
+ return@put
+ }
+
+ // temp file for the recipient VM to persist into Vault later
+ val tmpFile = File.createTempFile(fileId, ".tmp")
+ val output = tmpFile.outputStream().buffered()
+
+ try {
+ val input = call.receiveStream().buffered()
+ val totalSize = session.files.values.sumOf { it.file.size }
+ var bytesRead = 0L
+ val buffer = ByteArray(8192)
+
+ while (true) {
+ val read = input.read(buffer)
+ if (read == -1) break
+ output.write(buffer, 0, read)
+ bytesRead += read
+ progressFile.bytesTransferred = bytesRead.toInt()
+
+ // Broadcast “transport” progress; keep status = SENDING
+ val totalTransferred =
+ session.files.values.sumOf { it.bytesTransferred }
+ val percent =
+ if (totalSize > 0) ((totalTransferred * 100) / totalSize).toInt() else 0
+
+ PeerEventManager.onUploadProgressState(
+ UploadProgressState(
+ title = session.title.orEmpty(),
+ percent = percent,
+ sessionStatus = session.status, // SENDING
+ files = session.files.values.toList()
+ )
+ )
+ }
+
+ progressFile.status = P2PFileStatus.FINISHED // network finished
+ progressFile.path =
+ tmpFile.path // handoff path to recipient
+
+ // Emit one last transport-progress tick (still SENDING)
+ val totalTransferred2 =
+ session.files.values.sumOf { it.bytesTransferred }
+ val totalSize2 = session.files.values.sumOf { it.file.size }
+ val percent2 =
+ if (totalSize2 > 0) ((totalTransferred2 * 100) / totalSize2).toInt() else 0
+
+ PeerEventManager.onUploadProgressState(
+ UploadProgressState(
+ title = session.title.orEmpty(),
+ percent = percent2,
+ sessionStatus = session.status, // still SENDING
+ files = session.files.values.toList()
+ )
+ )
+
+ call.respond(HttpStatusCode.OK, "Upload complete")
+ } catch (e: Exception) {
+ progressFile.status = P2PFileStatus.FAILED
+
+ PeerEventManager.onUploadProgressState(
+ UploadProgressState(
+ title = session.title.orEmpty(),
+ percent = 0,
+ sessionStatus = session.status, // SENDING
+ files = session.files.values.toList()
+ )
+ )
+
+ call.respond(
+ HttpStatusCode.InternalServerError, "Upload failed: ${e.message}"
+ )
+ } finally {
+ output.close()
+ }
+ }
+
+ // 4) Close session (transport finished/cancelled by sender)
+ post(PeerApiRoutes.CLOSE) {
+ val payload = try {
+ call.receive