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>() + } catch (e: Exception) { + call.respond( + HttpStatusCode.BadRequest, "Missing or invalid JSON payload" + ) + return@post + } + + val sessionId = payload["sessionId"] + if (sessionId.isNullOrBlank()) { + call.respond(HttpStatusCode.BadRequest, "Invalid request format") + return@post + } + + val current = serverSession + if (current == null || current.sessionId != sessionId) { + call.respond(HttpStatusCode.Unauthorized, "Invalid session ID") + return@post + } + + if (p2PSharedState.session?.status == SessionStatus.CLOSED) { + call.respond(HttpStatusCode.Forbidden, "Session already closed") + return@post + } + + try { + p2PSharedState.session?.status = SessionStatus.CLOSED + serverSession = null + launch { PeerEventManager.emitCloseConnection() } + call.respond(HttpStatusCode.OK, mapOf("success" to true)) + } catch (e: Exception) { + Timber.e(e, "Error while closing session") + call.respond(HttpStatusCode.InternalServerError, "Server error") + } + } + } + } + }).start(wait = false) + } + + override fun stop() { + engine?.stop(1000, 5000) + } + + private fun isValidPin(pin: String) = pin.length == 6 +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/managers/PeerServerStarterManager.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/managers/PeerServerStarterManager.kt new file mode 100644 index 000000000..89794d12c --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/managers/PeerServerStarterManager.kt @@ -0,0 +1,50 @@ +package org.horizontal.tella.mobile.data.peertopeer.managers + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.horizontal.tella.mobile.data.peertopeer.TellaPeerToPeerServer +import org.horizontal.tella.mobile.data.peertopeer.model.P2PSharedState +import org.horizontal.tella.mobile.domain.peertopeer.KeyStoreConfig +import java.security.KeyPair +import java.security.cert.X509Certificate +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PeerServerStarterManager @Inject constructor( + private val peerToPeerManager: PeerToPeerManager +) { + private var server: TellaPeerToPeerServer? = null + + fun startServer( + ip: String, + keyPair: KeyPair, + pin: String, + cert: X509Certificate, + config: KeyStoreConfig, + p2PSharedState: P2PSharedState, + ) { + if (server == null) { + server = TellaPeerToPeerServer( + ip = ip, + keyPair = keyPair, + pin = pin, + certificate = cert, + keyStoreConfig = config, + peerToPeerManager = peerToPeerManager, + p2PSharedState = p2PSharedState + ) + server?.start() + } + } + + fun stopServer() { + CoroutineScope(Dispatchers.IO).launch { + server?.stop() + server = null + } + } + + fun isRunning(): Boolean = server != null +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/managers/PeerToPeerManager.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/managers/PeerToPeerManager.kt new file mode 100644 index 000000000..f0ccbc4ae --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/managers/PeerToPeerManager.kt @@ -0,0 +1,13 @@ +package org.horizontal.tella.mobile.data.peertopeer.managers + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class PeerToPeerManager { + private val _clientConnected = MutableSharedFlow(replay = 0) + val clientConnected = _clientConnected.asSharedFlow() + + suspend fun notifyClientConnected(hash : String) { + _clientConnected.emit(hash) + } +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PFileStatus.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PFileStatus.kt new file mode 100644 index 000000000..766205915 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PFileStatus.kt @@ -0,0 +1,5 @@ +package org.horizontal.tella.mobile.data.peertopeer.model + +enum class P2PFileStatus { + QUEUE, SENDING, FINISHED, SAVED, FAILED +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PSession.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PSession.kt new file mode 100644 index 000000000..c14abecd6 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PSession.kt @@ -0,0 +1,8 @@ +package org.horizontal.tella.mobile.data.peertopeer.model + +data class P2PSession( + var sessionId: String = "", + var status: SessionStatus = SessionStatus.WAITING, + var files: MutableMap = mutableMapOf(), + var title: String? = null +) \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PSharedState.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PSharedState.kt new file mode 100644 index 000000000..f3b0a621a --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PSharedState.kt @@ -0,0 +1,34 @@ +package org.horizontal.tella.mobile.data.peertopeer.model + +class P2PSharedState( + var ip: String = "", + var port: String = "", + var hash: String = "", + var pin: String? = null, + var session: P2PSession? = null, + private var failedAttempts: Int = 0, + var isUsingManualConnection: Boolean = false, +) { + + companion object { + fun Companion.createNewSession(): P2PSession { + return P2PSession( + sessionId = "", + title = "", + files = mutableMapOf(), + status = SessionStatus.SENDING + ) + } + } + + + fun clear() { + ip = "" + port = "" + hash = "" + pin = null + session = null + failedAttempts = 0 + isUsingManualConnection = false + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/ProgressFile.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/ProgressFile.kt new file mode 100644 index 000000000..6c6a83f67 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/ProgressFile.kt @@ -0,0 +1,13 @@ +package org.horizontal.tella.mobile.data.peertopeer.model + +import com.hzontal.tella_vault.VaultFile +import org.horizontal.tella.mobile.domain.peertopeer.P2PFile + +data class ProgressFile( + var file: P2PFile, + var vaultFile : VaultFile? = null, + var status: P2PFileStatus = P2PFileStatus.QUEUE, + var transmissionId: String? = null, + var path: String? = null, + var bytesTransferred: Int = 0 +) diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/SessionStatus.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/SessionStatus.kt new file mode 100644 index 000000000..f745c5398 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/SessionStatus.kt @@ -0,0 +1,10 @@ +package org.horizontal.tella.mobile.data.peertopeer.model + +enum class SessionStatus { + WAITING, + SENDING, + SAVING, + FINISHED, + FINISHED_WITH_ERRORS, + CLOSED +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/network/ProgressRequestBody.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/network/ProgressRequestBody.kt new file mode 100644 index 000000000..f2e53d5e0 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/network/ProgressRequestBody.kt @@ -0,0 +1,30 @@ +package org.horizontal.tella.mobile.data.peertopeer.network + +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okio.BufferedSink +import java.io.InputStream + +class ProgressRequestBody( + private val inputStream: InputStream, + private val contentLength: Long, + private val onProgress: (Long, Long) -> Unit +) : RequestBody() { + + override fun contentType(): MediaType? = "application/octet-stream".toMediaTypeOrNull() + + override fun contentLength(): Long = contentLength + + override fun writeTo(sink: BufferedSink) { + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var uploaded = 0L + var read: Int + + while (inputStream.read(buffer).also { read = it } != -1) { + sink.write(buffer, 0, read) + uploaded += read + onProgress(uploaded, contentLength) + } + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PeerApiRoutes.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PeerApiRoutes.kt new file mode 100644 index 000000000..21eafcaa9 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PeerApiRoutes.kt @@ -0,0 +1,24 @@ +package org.horizontal.tella.mobile.data.peertopeer.remote + +object PeerApiRoutes { + const val REGISTER = "/api/v1/register" + const val PREPARE_UPLOAD = "/api/v1/prepare-upload" + const val UPLOAD = "/api/v1/upload" + const val PING = "/api/v1/ping" + const val CLOSE = "/api/v1/close-connection" + + + fun buildUrl(ip: String, port: String, endpoint: String, secure: Boolean = true): String { + val scheme = if (secure) "https" else "http" + // Ensure endpoint starts with "/" + val normalized = if (endpoint.startsWith("/")) endpoint else "/$endpoint" + return "$scheme://$ip:$port$normalized" + } + + + fun buildUploadUrl(ip: String, port: String, sessionId: String, fileId: String, transmissionId: String): String { + val baseUrl = buildUrl(ip, port, UPLOAD) + return "$baseUrl?sessionId=$sessionId&fileId=$fileId&transmissionId=$transmissionId" + } + +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PrepareUploadRequest.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PrepareUploadRequest.kt new file mode 100644 index 000000000..803f30c08 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PrepareUploadRequest.kt @@ -0,0 +1,12 @@ +package org.horizontal.tella.mobile.data.peertopeer.remote + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.horizontal.tella.mobile.domain.peertopeer.P2PFile + +@Serializable +data class PrepareUploadRequest( + val title: String, + @SerialName("sessionId") val sessionId: String, + val files: List +) \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PrepareUploadResult.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PrepareUploadResult.kt new file mode 100644 index 000000000..8a39af8ec --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PrepareUploadResult.kt @@ -0,0 +1,16 @@ +package org.horizontal.tella.mobile.data.peertopeer.remote + +import org.horizontal.tella.mobile.domain.peertopeer.FileInfo + + +/** + * Created by wafa on 2/7/2025. + */ +sealed class PrepareUploadResult { + data class Success(val transmissions: List) : PrepareUploadResult() + data object Forbidden : PrepareUploadResult() + data object BadRequest : PrepareUploadResult() + data object Conflict : PrepareUploadResult() + data object ServerError : PrepareUploadResult() + data class Failure(val exception: Throwable) : PrepareUploadResult() +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/RegisterPeerResult.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/RegisterPeerResult.kt new file mode 100644 index 000000000..204056f2f --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/RegisterPeerResult.kt @@ -0,0 +1,12 @@ +package org.horizontal.tella.mobile.data.peertopeer.remote + +sealed class RegisterPeerResult { + data class Success(val sessionId: String) : RegisterPeerResult() + data object InvalidFormat : RegisterPeerResult() // 400 + data object InvalidPin : RegisterPeerResult() // 401 + data object Conflict : RegisterPeerResult() // 409 + data object TooManyRequests : RegisterPeerResult() // 429 + data object ServerError : RegisterPeerResult() // 500 + data object RejectedByReceiver : RegisterPeerResult() // 403 + data class Failure(val exception: Throwable) : RegisterPeerResult() +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/sharedpref/Preferences.java b/mobile/src/main/java/org/horizontal/tella/mobile/data/sharedpref/Preferences.java index 8f9e2de86..c95a87fc0 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/data/sharedpref/Preferences.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/sharedpref/Preferences.java @@ -126,6 +126,14 @@ public static void setEraseForms(boolean value) { setBoolean(SharedPrefs.ERASE_FORMS, value); } + public static boolean isEnableHomeNearby() { + return getBoolean(SharedPrefs.ENABLE_HOME_NEARBY_SHARING, true); + } + + public static void setEnableHomeNearby(boolean value) { + setBoolean(SharedPrefs.ENABLE_HOME_NEARBY_SHARING, value); + } + public static boolean isPanicGeolocationActive() { return getBoolean(SharedPrefs.PANIC_GEOLOCATION, true); } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/sharedpref/SharedPrefs.java b/mobile/src/main/java/org/horizontal/tella/mobile/data/sharedpref/SharedPrefs.java index 391f92715..3a5c8072f 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/data/sharedpref/SharedPrefs.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/sharedpref/SharedPrefs.java @@ -32,6 +32,8 @@ public class SharedPrefs { static final String REMAINING_UNLOCK_ATTEMPTS = "remaining_unlock_attempts"; static final String ERASE_FORMS = "erase_forms"; + + static final String ENABLE_HOME_NEARBY_SHARING = "enable_home_nearby_sharing"; //private static final String AUTO_SAVE_DRAFT_FORM = "auto_save_draft_form"; private static final String LANGUAGE = "language"; static final String SECRET_MODE_ENABLED = "secret_password_enabled"; diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/ServerType.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/ServerType.kt index 31466c64b..99ff73a43 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/ServerType.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/ServerType.kt @@ -1,5 +1,5 @@ package org.horizontal.tella.mobile.domain.entity enum class ServerType { - UNKNOWN, ODK_COLLECT, TELLA_UPLOAD, TELLA_RESORCES, UWAZI, GOOGLE_DRIVE, DROP_BOX, NEXTCLOUD, + UNKNOWN, ODK_COLLECT, TELLA_UPLOAD, TELLA_RESORCES, UWAZI, GOOGLE_DRIVE, DROP_BOX, NEXTCLOUD, PEERTOPEER, ADD_BUTTON } \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/collect/FormMediaFile.java b/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/collect/FormMediaFile.java index 2db77b42f..1c51dc4ac 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/collect/FormMediaFile.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/collect/FormMediaFile.java @@ -9,12 +9,15 @@ public class FormMediaFile extends VaultFile { public FormMediaFileStatus status; // break away from getters/setters :) public boolean uploading; public long uploadedSize; + public String transmissionId; + public FormMediaFile() { super(); status = FormMediaFileStatus.UNKNOWN; uploading = true; uploadedSize = 0; + transmissionId = ""; } public static FormMediaFile fromMediaFile(@NonNull VaultFile vaultFile) { diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/peertopeer/PeerToPeerInstance.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/peertopeer/PeerToPeerInstance.kt new file mode 100644 index 000000000..18d539ed8 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/peertopeer/PeerToPeerInstance.kt @@ -0,0 +1,13 @@ +package org.horizontal.tella.mobile.domain.entity.peertopeer + +import org.horizontal.tella.mobile.domain.entity.EntityStatus +import org.horizontal.tella.mobile.domain.entity.collect.FormMediaFile +import org.horizontal.tella.mobile.domain.entity.collect.FormMediaFileStatus + +class PeerToPeerInstance( + var status: EntityStatus = EntityStatus.UNKNOWN, + var widgetMediaFiles: List = mutableListOf(), + var formPartStatus: FormMediaFileStatus = FormMediaFileStatus.UNKNOWN, + var title: String = "", + var sessionID: String = "" +) \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/peertopeer/QRCodeInfos.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/peertopeer/QRCodeInfos.kt new file mode 100644 index 000000000..2a85a6eb7 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/peertopeer/QRCodeInfos.kt @@ -0,0 +1,15 @@ +package org.horizontal.tella.mobile.domain.entity.peertopeer + +data class QRCodeInfos( + val ipAddress: String, + val pin: String, + val hash: String +) { + override fun equals(other: Any?): Boolean { + return (other as? QRCodeInfos)?.ipAddress == ipAddress + } + + override fun hashCode(): Int { + return ipAddress.hashCode() + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/ByteArrayFlexibleSerializer.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/ByteArrayFlexibleSerializer.kt new file mode 100644 index 000000000..a2264763f --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/ByteArrayFlexibleSerializer.kt @@ -0,0 +1,57 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +import android.os.Build +import androidx.annotation.RequiresApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* +import kotlinx.serialization.json.* +import java.util.Base64 // On Android <26, switch to android.util.Base64 + +object ByteArrayFlexibleSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("ByteArrayFlexible", PrimitiveKind.STRING) + + @RequiresApi(Build.VERSION_CODES.O) + override fun serialize(encoder: Encoder, value: ByteArray) { + // Always serialize as Base64 string to keep responses compact/consistent + val b64 = Base64.getEncoder().encodeToString(value) + encoder.encodeString(b64) + } + + @RequiresApi(Build.VERSION_CODES.O) + override fun deserialize(decoder: Decoder): ByteArray { + if (decoder !is JsonDecoder) + throw SerializationException("Expected JsonDecoder") + + val el = decoder.decodeJsonElement() + return when (el) { + is JsonPrimitive -> { + // Base64 string + val s = el.content + try { + Base64.getDecoder().decode(s) + } catch (e: Exception) { + throw SerializationException("Invalid Base64 in thumbnail", e) + } + } + is JsonArray -> { + // [0..255, 0..255, ...] + val out = ByteArray(el.size) + el.forEachIndexed { i, jp -> + val n = jp.jsonPrimitive.int + // clamp to 0..255 and convert to signed byte + val b = (n.coerceIn(0, 255) and 0xFF).toByte() + out[i] = b + } + out + } + is JsonNull -> { + // shouldn't reach here for non-null type; handled by .nullable wrapper + throw SerializationException("thumbnail was null") + } + else -> throw SerializationException("thumbnail must be Base64 string or int[]") + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/IncomingRegistration.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/IncomingRegistration.kt new file mode 100644 index 000000000..bc983a3f3 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/IncomingRegistration.kt @@ -0,0 +1,6 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +data class IncomingRegistration( + val registrationId: String, + val payload: PeerRegisterPayload +) \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/KeyStoreConfig.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/KeyStoreConfig.kt new file mode 100644 index 000000000..8d067c115 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/KeyStoreConfig.kt @@ -0,0 +1,26 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +import java.util.UUID + +data class KeyStoreConfig( + val alias: String = "tella-alias", + val password: CharArray = UUID.randomUUID().toString().toCharArray() +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as KeyStoreConfig + + if (alias != other.alias) return false + if (!password.contentEquals(other.password)) return false + + return true + } + + override fun hashCode(): Int { + var result = alias.hashCode() + result = 31 * result + password.contentHashCode() + return result + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/P2PFile.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/P2PFile.kt new file mode 100644 index 000000000..15f92e029 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/P2PFile.kt @@ -0,0 +1,42 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +import kotlinx.serialization.Serializable + +@Serializable +data class P2PFile( + val id: String, + val fileName: String, + val size: Long, + val fileType: String, + @Serializable(with = ByteArrayFlexibleSerializer::class) + val thumbnail: ByteArray? = null +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as P2PFile + + if (id != other.id) return false + if (fileName != other.fileName) return false + if (size != other.size) return false + if (fileType != other.fileType) return false + // if (sha256 != other.sha256) return false + if (thumbnail != null) { + if (other.thumbnail == null) return false + if (!thumbnail.contentEquals(other.thumbnail)) return false + } else if (other.thumbnail != null) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + fileName.hashCode() + result = 31 * result + size.hashCode() + result = 31 * result + fileType.hashCode() + // result = 31 * result + sha256.hashCode() + result = 31 * result + (thumbnail?.contentHashCode() ?: 0) + return result + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerConnectionPayload.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerConnectionPayload.kt new file mode 100644 index 000000000..c708ed611 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerConnectionPayload.kt @@ -0,0 +1,18 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +import com.google.gson.annotations.SerializedName + +data class PeerConnectionPayload( + @SerializedName("ip_address") + val ipAddress : String, + + @SerializedName("port") + val port: Int, + + @SerializedName("certificate_hash") + val certificateHash: String, + + @SerializedName("pin") + val pin: String +) + diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerEventManager.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerEventManager.kt new file mode 100644 index 000000000..a93549c11 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerEventManager.kt @@ -0,0 +1,91 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import org.horizontal.tella.mobile.data.peertopeer.model.P2PSharedState +import org.horizontal.tella.mobile.data.peertopeer.remote.PrepareUploadRequest +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.state.UploadProgressState + +/** + * Created by wafa on 3/6/2025. + */ +object PeerEventManager { + + // Used to signal clearing of registration replayed value + private val EMPTY_REGISTRATION_REQUEST = "" to PeerRegisterPayload.EMPTY + + val registrationEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1 + ) + + val closeConnectionEvent = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1 + ) + + // Replays the last actual registration request to new collectors + private val _registrationRequests = MutableSharedFlow>( + replay = 0, + extraBufferCapacity = 1 + ) + val registrationRequests = _registrationRequests.asSharedFlow() + + + private val _prepareUploadRequests = + MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val prepareUploadRequests = _prepareUploadRequests.asSharedFlow() + + private val decisionMap = mutableMapOf>() + private val registrationDecisionMap = mutableMapOf>() + + private val _uploadProgressStateFlow = MutableSharedFlow(replay = 0) + val uploadProgressStateFlow = _uploadProgressStateFlow.asSharedFlow() + + // Upload progress + suspend fun onUploadProgressState(state: UploadProgressState) { + _uploadProgressStateFlow.emit(state) + } + + // Trigger one-time event for registration success + suspend fun emitRegistrationSuccess() { + registrationEvents.emit(true) + } + + suspend fun emitCloseConnection() { + closeConnectionEvent.emit(true) + } + + suspend fun emitPrepareUploadRequest(request: PrepareUploadRequest): Boolean { + val deferred = CompletableDeferred() + decisionMap[request.sessionId] = deferred + _prepareUploadRequests.emit(request) // emit actual payload; late subscribers will receive it + return deferred.await() // await user decision on recipient + } + + + fun resolveUserDecision(sessionId: String, accepted: Boolean) { + decisionMap.remove(sessionId)?.complete(accepted) + } + + // Registration request with real payload + suspend fun emitIncomingRegistrationRequest( + registrationId: String, + payload: PeerRegisterPayload + ): Boolean { + val deferred = CompletableDeferred() + registrationDecisionMap[registrationId] = deferred + _registrationRequests.emit(registrationId to payload) + return deferred.await() + } + + // Clears the replayed registration request by emitting a dummy one + suspend fun clearRegistrationRequest() { + _registrationRequests.emit(EMPTY_REGISTRATION_REQUEST) + } + + fun confirmRegistration(registrationId: String, accepted: Boolean) { + registrationDecisionMap.remove(registrationId)?.complete(accepted) + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerPrepareUploadResponse.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerPrepareUploadResponse.kt new file mode 100644 index 000000000..549628dd8 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerPrepareUploadResponse.kt @@ -0,0 +1,13 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +import kotlinx.serialization.Serializable + +@Serializable +data class FileInfo( + val id: String, + val transmissionId: String +) +@Serializable +data class PeerPrepareUploadResponse( + val files: List +) \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerRegisterPayload.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerRegisterPayload.kt new file mode 100644 index 000000000..2495a7fb2 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerRegisterPayload.kt @@ -0,0 +1,14 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class PeerRegisterPayload( + val pin: String, + val nonce: String = UUID.randomUUID().toString(), +) { + companion object { + val EMPTY = PeerRegisterPayload(pin = "") + } +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerResponse.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerResponse.kt new file mode 100644 index 000000000..9891126b7 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerResponse.kt @@ -0,0 +1,8 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +import kotlinx.serialization.Serializable + +@Serializable +data class PeerResponse( + val sessionId: String +) \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/TellaServer.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/TellaServer.kt new file mode 100644 index 000000000..655edf426 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/TellaServer.kt @@ -0,0 +1,7 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +interface TellaServer { + fun start() + fun stop() + val certificatePem: String +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/UploadProgressEvent.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/UploadProgressEvent.kt new file mode 100644 index 000000000..15d888c50 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/UploadProgressEvent.kt @@ -0,0 +1,7 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +data class UploadProgressEvent( + val fileId: String, + val bytesWritten: Long, + val totalBytes: Long +) \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/media/MediaFileHandler.java b/mobile/src/main/java/org/horizontal/tella/mobile/media/MediaFileHandler.java index ed3902dc4..4e26cff8f 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/media/MediaFileHandler.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/media/MediaFileHandler.java @@ -431,6 +431,7 @@ public static Single importOthersUri(Context context, Uri uri, String InputStream is = context.getContentResolver().openInputStream(uri); RxVault rxVault = MyApplication.keyRxVault.getRxVault().blockingFirst(); + assert DocumentFile.fromSingleUri(context, uri) != null; return rxVault .builder(is) .setMimeType(mimeType) diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/util/ConnectionType.kt b/mobile/src/main/java/org/horizontal/tella/mobile/util/ConnectionType.kt new file mode 100644 index 000000000..0fdf08bfb --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/util/ConnectionType.kt @@ -0,0 +1,107 @@ +package org.horizontal.tella.mobile.util + +import android.annotation.SuppressLint +import android.content.Context +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import java.net.Inet4Address +import java.net.NetworkInterface + +enum class ConnectionType { + WIFI, CELLULAR, HOTSPOT, NONE +} + +data class NetworkInfo( + val connectionType: ConnectionType, + val ipAddress: String?, + var port: String = "53317" +) + +class NetworkInfoManager(private val context: Context) { + + private val _networkInfo = MutableLiveData() + val networkInfo: LiveData = _networkInfo + + private var currentNetworkInfo: NetworkInfo = NetworkInfo(ConnectionType.NONE, null) + + @RequiresApi(Build.VERSION_CODES.M) + @SuppressLint("MissingPermission") + fun fetchCurrentNetworkInfo() { + val cm = ContextCompat.getSystemService(context, ConnectivityManager::class.java) + val network: Network? = cm?.activeNetwork + val caps: NetworkCapabilities? = cm?.getNetworkCapabilities(network) + + if (caps == null) { + post(ConnectionType.NONE, null) + return + } + + val ip = getActiveIpv4(cm, network) ?: getFallbackIpv4() + + when { + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> { + post(ConnectionType.WIFI, ip) + } + + caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> { + if (isDeviceHotspotEnabled(context)) { + post(ConnectionType.HOTSPOT, ip) // treat hotspot + cellular as HOTSPOT + } else { + post(ConnectionType.NONE, null) // cellular alone → NONE + } + } + + else -> { + post(ConnectionType.NONE, null) + } + } + } + + private fun post(type: ConnectionType, ip: String?) { + val info = NetworkInfo(type, ip) + _networkInfo.postValue(info) + currentNetworkInfo = info + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun getActiveIpv4(cm: ConnectivityManager?, network: Network?): String? { + if (cm == null || network == null) return null + val lp: LinkProperties = cm.getLinkProperties(network) ?: return null + return lp.linkAddresses + .mapNotNull { it.address } + .firstOrNull { it is Inet4Address && !it.isLoopbackAddress } + ?.hostAddress + } + + private fun getFallbackIpv4(): String? { + return try { + NetworkInterface.getNetworkInterfaces() + .toList() + .flatMap { it.inetAddresses.toList() } + .firstOrNull { it is Inet4Address && !it.isLoopbackAddress } + ?.hostAddress + } catch (_: Exception) { + null + } + } + + private fun isDeviceHotspotEnabled(context: Context): Boolean { + val wifiManager = + context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + return try { + val method = wifiManager.javaClass.getDeclaredMethod("isWifiApEnabled") + method.isAccessible = true + method.invoke(wifiManager) as? Boolean ?: false + } catch (e: Exception) { + false + } + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/util/Event.kt b/mobile/src/main/java/org/horizontal/tella/mobile/util/Event.kt new file mode 100644 index 000000000..d3649b743 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/util/Event.kt @@ -0,0 +1,20 @@ +package org.horizontal.tella.mobile.util + +/** + * Created by wafa on 23/6/2025. + */ +class Event(private val content: T) { + private var hasBeenHandled = false + + /** Returns the content if not handled, else null */ + fun getContentIfNotHandled(): T? { + return if (hasBeenHandled) null + else { + hasBeenHandled = true + content + } + } + + /** Always returns the content, even if already handled */ + fun peekContent(): T = content +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/util/Extensions.kt b/mobile/src/main/java/org/horizontal/tella/mobile/util/Extensions.kt index a4fbf95cd..30fdaab0e 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/util/Extensions.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/util/Extensions.kt @@ -13,15 +13,13 @@ import android.view.WindowManager import android.view.accessibility.AccessibilityManager import android.widget.ImageView import androidx.annotation.ColorRes +import androidx.annotation.IdRes import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentManager import androidx.navigation.NavController import com.google.gson.Gson import com.google.gson.JsonParseException import com.google.gson.reflect.TypeToken -/*import org. cleaninsights.sdk.Campaign -import org.cleaninsights.sdk.CleanInsights -import org.cleaninsights.sdk.CleanInsightsConfiguration*/ import timber.log.Timber @@ -148,7 +146,32 @@ fun Context.isScreenReaderOn(): Boolean { return false } -fun NavController.navigateSafe(destinationId: Int, bundle: Bundle? = null) { - navigate(destinationId, bundle,) +fun NavController.navigateSafe(@IdRes destinationId: Int, args: Bundle? = null) { + val currentNode = currentDestination + val action = currentNode?.getAction(destinationId) + + if (action != null) { + navigate(destinationId, args) + } else { + val resources = currentNode?.navigatorName?.let { + context.resources + } + + val destName = try { + resources?.getResourceEntryName(destinationId) ?: destinationId.toString() + } catch (e: Exception) { + destinationId.toString() + } + Timber.w("[NavigationSafe] Skipping navigation to '$destName' from '${currentNode?.label}' – action not found") + } +} + +fun String.formatHash(): String { + return this + .take(64) // take only the first 64 characters + .chunked(4) // split into groups of 4 + .chunked(4) // make 4 lines + .joinToString("\n") { it.joinToString(" ") } + } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/util/FileUtil.java b/mobile/src/main/java/org/horizontal/tella/mobile/util/FileUtil.java index 80201e071..54a0c4930 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/util/FileUtil.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/util/FileUtil.java @@ -62,9 +62,9 @@ public static String getPrimaryMime(String mimeType) { @Nullable public static String getMimeType(@NonNull String filename) { - return MimeTypeMap.getSingleton().getMimeTypeFromExtension( - MimeTypeMap.getFileExtensionFromUrl(filename.toLowerCase()) - ); + String extension = MimeTypeMap.getFileExtensionFromUrl(filename.toLowerCase()); + // Return the corresponding MIME type or null if unknown + return extension != null ? MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) : null; } /* public static MediaFile.Type getMediaFileType(@NonNull String filename) { diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/util/NavigationManager.kt b/mobile/src/main/java/org/horizontal/tella/mobile/util/NavigationManager.kt index 9530f77b6..e77139b38 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/util/NavigationManager.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/util/NavigationManager.kt @@ -1,8 +1,8 @@ package org.horizontal.tella.mobile.util import android.os.Bundle +import androidx.navigation.navOptions import org.horizontal.tella.mobile.R -import org.horizontal.tella.mobile.domain.entity.uwazi.UwaziTemplate import org.horizontal.tella.mobile.views.fragment.reports.di.NavControllerProvider class NavigationManager( @@ -56,9 +56,11 @@ class NavigationManager( fun navigateFromGoogleDriveEntryScreenToGoogleDriveSendScreen() { navigateToWithBundle(R.id.action_newGoogleDriveScreen_to_googleDriveSendScreen) } + fun navigateFromNextCloudEntryScreenToNextCloudSendScreen() { navigateToWithBundle(R.id.action_newNextCloudScreen_to_nextCloudSendScreen) } + fun navigateFromGoogleDriveMainScreenToGoogleDriveSendScreen() { navigateToWithBundle(R.id.action_googleDriveScreen_to_googleDriveSendScreen) } @@ -66,6 +68,7 @@ class NavigationManager( fun navigateFromNextCloudMainScreenToNextCloudSendScreen() { navigateToWithBundle(R.id.action_nextCloudScreen_to_nextCloudSendScreen) } + fun navigateFromGoogleDriveScreenToGoogleDriveSubmittedScreen() { navigateToWithBundle(R.id.action_googleDriveScreen_to_googleDriveSubmittedScreen) } @@ -73,6 +76,7 @@ class NavigationManager( fun navigateFromNextCloudScreenToNextCloudSubmittedScreen() { navigateToWithBundle(R.id.action_nextCloudScreen_to_nextCloudSubmittedScreen) } + fun navigateFromDropBoxScreenToDropBoxSubmittedScreen() { navigateToWithBundle(R.id.action_dropBoxScreen_to_dropBoxSubmittedScreen) } @@ -141,7 +145,8 @@ class NavigationManager( fun navigateToNextCloudCreateFolderScreen() { navigateToWithBundle(R.id.action_loginNextCloudScreen_to_nextCloudNewFolderScreen) } - fun actionNextCloudNewFolderScreenToSuccessfulScreen(){ + + fun actionNextCloudNewFolderScreenToSuccessfulScreen() { navigateToWithBundle(R.id.action_nextCloudNewFolderScreen_to_successfulSetServerFragment) } @@ -165,4 +170,87 @@ class NavigationManager( navigateToWithBundle(R.id.action_selectSharedDriveFragment_to_googleDriveConnectedServerFragment) } + fun navigateFromStartNearBySharingFragmentToConnectHotspotFragment() { + navigateToWithBundle(R.id.action_startNearBySharingFragment_to_connectHotspotFragment) + } + + fun navigateFromActionConnectHotspotScreenToQrCodeScreen() { + navigateToWithBundle(R.id.action_connectHotspotScreen_to_qrCodeScreen) + } + + fun navigateFromActionConnectHotspotScreenToScanQrCodeScreen() { + navigateToWithBundle(R.id.action_connectHotspotScreen_to_scanQrCodeScreen) + } + + fun navigateFromScanQrCodeToDeviceInfo() { + navigateToWithBundle(R.id.action_qrCodeScreen_to_deviceInfoScreen) + } + + fun navigateFromScanQrCodeToPrepareUploadFragment() { + navigateToWithBundle(R.id.action_scanQrCodeScreen_to_prepareUploadFragment) + } + + fun navigateFromScanQrCodeToSenderManualConnectionScreen() { + navigateToWithBundle(R.id.action_scanQrCodeScreen_to_senderManualConnectionScreen) + } + + fun navigateFromSenderManualConnectionToConnectManuallyVerification() { + navigateToWithBundle(R.id.action_senderManualConnectionScreen_to_connectManuallyVerificationScreen) + } + + fun navigateFromDeviceInfoScreenTRecipientVerificationScreen() { + navigateToWithBundle(R.id.action_deviceInfoScreen_to_recipientVerificationScreen) + } + + fun navigateFromQrCodeScreenToWaitingReceiverFragment() { + navigateToWithBundle(R.id.action_qrCodeScreen_to_waitingReceiverFragment) + } + + fun navigateFromPrepareUploadFragmentToWaitingSenderFragment() { + navigateToWithBundle(R.id.action_prepareUploadFragment_to_waitingSenderFragment) + } + + fun navigateConnectManuallyVerificationFragmentToprepareUploadFragment() { + navigateToWithBundle(R.id.action_connectManuallyVerificationFragment_to_prepareUploadFragment) + } + + fun navigateFromRecipientVerificationScreenToWaitingReceiverFragment() { + navigateToWithBundle(R.id.action_recipientVerificationScreen_to_waitingReceiverFragment) + } + + fun navigateFromWaitingReceiverFragmentToRecipientSuccessFragment() { + navigateToWithBundle(R.id.action_waitingReceiverFragment_to_recipientSuccessFragment) + } + + fun navigateFromWaitingSenderFragmentToUploadFilesFragment() { + navigateToWithBundle(R.id.action_waitingSenderFragment_to_uploadFilesFragment) + } + + fun navigateFromUploadSenderFragmentToPeerToPeerResultFragment() { + navigateToWithBundle(R.id.action_uploadSenderFragment_to_peerToPeerResultFragment) + } + + fun navigateFromRecipientUploadFilesFragmentToPeerToPeerResultFragment() { + navigateToWithBundle(R.id.action_recipientUploadFilesFragment_to_peerToPeerResultFragment) + } + + fun navigateFromRecipientSuccessFragmentToRecipientUploadFilesFragment() { + navigateToWithBundle(R.id.action_recipientSuccessFragment_to_recipientUploadFilesFragment) + } + + fun navigateFromConnectHotspotScreenToTipsToConnectFragment() { + navigateToWithBundle(R.id.action_connectHotspotScreen_to_tipsToConnectFragment) + } + + fun navigateBackToStartNearBySharingFragmentAndClearBackStack() { + navControllerProvider.navController.navigate( + R.id.startNearBySharingFragment, + bundle, + navOptions { + popUpTo(R.id.startNearBySharingFragment) { + inclusive = true + } + } + ) + } } \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/MainActivity.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/MainActivity.kt index 2faeffb82..957cc873c 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/MainActivity.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/MainActivity.kt @@ -15,6 +15,7 @@ import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels import androidx.navigation.NavController import androidx.navigation.NavDestination +import androidx.navigation.findNavController import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.NavigationUI.setupWithNavController import com.google.android.material.bottomnavigation.BottomNavigationView @@ -41,6 +42,7 @@ import org.horizontal.tella.mobile.views.fragment.main_connexions.base.BaseRepor import org.horizontal.tella.mobile.views.fragment.main_connexions.base.BaseReportsEntryFragment import org.horizontal.tella.mobile.views.fragment.main_connexions.base.BaseReportsSendFragment import org.horizontal.tella.mobile.views.fragment.main_connexions.base.MainReportFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow.PeerToPeerFlags import org.horizontal.tella.mobile.views.fragment.recorder.MicFragment import org.horizontal.tella.mobile.views.fragment.reports.send.ReportsSendFragment import org.horizontal.tella.mobile.views.fragment.uwazi.SubmittedPreviewFragment @@ -252,6 +254,7 @@ class MainActivity : MetadataActivity(), IMetadataAttachPresenterContract.IView, supportFragmentManager.primaryNavigationFragment?.childFragmentManager?.fragments?.forEach { it.onActivityResult(requestCode, resultCode, data) } + } private fun isLocationSettingsRequestCode(requestCode: Int): Boolean { @@ -367,6 +370,10 @@ class MainActivity : MetadataActivity(), IMetadataAttachPresenterContract.IView, super.onResume() startLocationMetadataListening() mOrientationEventListener!!.enable() + if (PeerToPeerFlags.cancelled) { + PeerToPeerFlags.cancelled = false + DialogUtils.showBottomMessage(this, "Nearby sharing was cancelled.", false) + } } override fun onPause() { @@ -464,4 +471,19 @@ class MainActivity : MetadataActivity(), IMetadataAttachPresenterContract.IView, .e(getString(R.string.could_not_find_uwazientryfragment)) } } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + + intent?.getStringExtra("navigateTo")?.let { destination -> + if (destination == "attachments_screen") { + // Avoid navigating if already on the screen + if (navController.currentDestination?.id != R.id.attachments_screen) { + navController.navigate(R.id.attachments_screen) + } + } + } + } + + } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/ServersSettingsActivity.java b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/ServersSettingsActivity.java index a0c5f2c9b..82470b8a8 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/ServersSettingsActivity.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/ServersSettingsActivity.java @@ -141,6 +141,7 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { initDropBoxEvents(); initNextCloudEvents(); initListeners(); + initNearbySharingView(); } private void initObservers() { @@ -188,6 +189,19 @@ private void initObservers() { nextCloudServersViewModel.getServerRemoved().observe(this, this::onRemovedNextCloudServer); } + private void initNearbySharingView() { + binding.nearbySharingSwitch.mSwitch.setChecked(Preferences.isEnableHomeNearby()); + binding.nearbySharingSwitch.setTextAndAction(R.string.action_learn_more, () -> { + maybeChangeTemporaryTimeout(() -> { + Util.startBrowserIntent(getApplicationContext(), getString(R.string.config_nearby_sharing_url)); + return null; + }); + return Unit.INSTANCE; + }); + + binding.nearbySharingSwitch.mSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> Preferences.setEnableHomeNearby(isChecked)); + } + private void initDropBoxEvents() { INSTANCE.getCreateDropBoxServer().observe(this, server -> { if (server != null) { @@ -399,8 +413,7 @@ public void onRefreshBlankFormsError(Throwable error) { private void showChooseServerTypeDialog() { BottomSheetUtils.showBinaryTypeSheet(this.getSupportFragmentManager(), this, getString(R.string.settings_add_server_selection_dialog_title), getString(R.string.settings_add_server_selection_dialog_title), getString(R.string.Connections_description_selection), getString(R.string.Connections_description), this::browseIntent, getString(R.string.action_cancel), //TODO CHECk THIS - getString(R.string.action_ok), - getString(R.string.settings_docu_add_server_dialog_select_odk), getString(R.string.settings_docu_add_server_dialog_select_tella_web), getString(R.string.settings_docu_add_server_dialog_select_tella_uwazi), getString(R.string.settings_docu_add_server_dialog_select_tella_google_drive), getString(R.string.settings_docu_add_server_dialog_select_tella_dropbox), getString(R.string.settings_docu_add_server_dialog_select_next_cloud), getString(R.string.unavailable_connections), getString(R.string.unavailable_connections_desc), servers.stream().anyMatch(server -> server instanceof GoogleDriveServer), servers.stream().anyMatch(server -> server instanceof DropBoxServer), servers.stream().anyMatch(server -> server instanceof NextCloudServer), new BottomSheetUtils.IServerChoiceActions() { + getString(R.string.action_ok), getString(R.string.settings_docu_add_server_dialog_select_odk), getString(R.string.settings_docu_add_server_dialog_select_tella_web), getString(R.string.settings_docu_add_server_dialog_select_tella_uwazi), getString(R.string.settings_docu_add_server_dialog_select_tella_google_drive), getString(R.string.settings_docu_add_server_dialog_select_tella_dropbox), getString(R.string.settings_docu_add_server_dialog_select_next_cloud), getString(R.string.unavailable_connections), getString(R.string.unavailable_connections_desc), servers.stream().anyMatch(server -> server instanceof GoogleDriveServer), servers.stream().anyMatch(server -> server instanceof DropBoxServer), servers.stream().anyMatch(server -> server instanceof NextCloudServer), new BottomSheetUtils.IServerChoiceActions() { @Override public void addDropBoxServer() { diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/SettingsActivity.java b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/SettingsActivity.java index 115e151b5..9429116ec 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/SettingsActivity.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/SettingsActivity.java @@ -72,14 +72,14 @@ protected void onCreate(Bundle savedInstanceState) { } disposables = MyApplication.bus().createCompositeDisposable(); - disposables.wire(LocaleChangedEvent.class, new EventObserver() { + disposables.wire(LocaleChangedEvent.class, new EventObserver<>() { @Override public void onNext(@NotNull LocaleChangedEvent event) { recreate(); } }); - disposables.wire(CloseSettingsActivityEvent.class, new EventObserver() { + disposables.wire(CloseSettingsActivityEvent.class, new EventObserver<>() { @Override public void onNext(@NotNull CloseSettingsActivityEvent event) { finish(); diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/camera/SharedCameraViewModel.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/camera/SharedCameraViewModel.kt index df9749e1d..65cb9f849 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/camera/SharedCameraViewModel.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/camera/SharedCameraViewModel.kt @@ -62,7 +62,8 @@ class SharedCameraViewModel @Inject constructor() : ViewModel() { thumb = null ) _addingInProgress.postValue(true) - MyApplication.bus().post(RecentBackgroundActivitiesEvent(mutableListOf(backgroundVideoFile))) + MyApplication.bus() + .post(RecentBackgroundActivitiesEvent(mutableListOf(backgroundVideoFile))) } .observeOn(AndroidSchedulers.mainThread()) .doFinally { _addingInProgress.postValue(false) } @@ -75,7 +76,6 @@ class SharedCameraViewModel @Inject constructor() : ViewModel() { ) } - fun addMp4Video(file: File, parent: String?) { disposables.add(Observable.fromCallable { MediaFileHandler.saveMp4Video(file, parent) } .subscribeOn(Schedulers.io()).doOnSubscribe { @@ -124,11 +124,13 @@ class SharedCameraViewModel @Inject constructor() : ViewModel() { disposables.add( MediaFileHandler.getLastVaultFileFromDb().subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ vaultFiles -> if (!vaultFiles.isNullOrEmpty()) { - _lastMediaFileSuccess.postValue(vaultFiles[0]) - } else { - _lastMediaFileError.postValue(Throwable("No media files found")) - } }, + .subscribe({ vaultFiles -> + if (!vaultFiles.isNullOrEmpty()) { + _lastMediaFileSuccess.postValue(vaultFiles[0]) + } else { + _lastMediaFileError.postValue(Throwable("No media files found")) + } + }, { throwable -> _lastMediaFileError.postValue(throwable) }) ) } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/onboarding/OnBoardNearbySharingFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/onboarding/OnBoardNearbySharingFragment.kt new file mode 100644 index 000000000..c0fec77a6 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/onboarding/OnBoardNearbySharingFragment.kt @@ -0,0 +1,34 @@ +package org.horizontal.tella.mobile.views.activity.onboarding + +import android.os.Bundle +import android.view.View +import org.horizontal.tella.mobile.databinding.OnboardNearbySharingFragmentBinding +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment + +class OnBoardNearbySharingFragment : + BaseBindingFragment(OnboardNearbySharingFragmentBinding::inflate) { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initView() + } + + override fun onResume() { + super.onResume() + (baseActivity as OnBoardActivityInterface).enableSwipe( + isSwipeable = true, isTabLayoutVisible = true + ) + (baseActivity as OnBoardActivityInterface).showButtons( + isNextButtonVisible = true, isBackButtonVisible = true + ) + } + + private fun initView() { + binding.backBtn.setOnClickListener { + baseActivity.onBackPressed() + } + binding.nextBtn.setOnClickListener { + (baseActivity as OnBoardingActivity).onNextPressed() + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/onboarding/OnBoardingActivity.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/onboarding/OnBoardingActivity.kt index 4d2a95c32..95a560307 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/onboarding/OnBoardingActivity.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/onboarding/OnBoardingActivity.kt @@ -39,7 +39,9 @@ private const val ONBOARDING_CAMERA_VIEW_INDEX = 1 private const val ONBOARDING_RECORDER_VIEW_INDEX = 2 private const val ONBOARDING_FILES_VIEW_INDEX = 3 private const val ONBOARDING_COLLECT_DATA_VIEW = 4 -private const val ONBOARDING_LOCK_VIEW_INDEX = 5 +private const val ONBOARDING_NEARBY_SHARING_VIEW_INDEX = 5 +private const val ONBOARDING_LOCK_VIEW_INDEX = 6 + @AndroidEntryPoint class OnBoardingActivity : BaseActivity(), OnBoardActivityInterface, @@ -62,7 +64,7 @@ class OnBoardingActivity : BaseActivity(), OnBoardActivityInterface, setContentView(binding.root) applyEdgeToEdge(binding.root) // Instantiate a ViewPager and a Tablayout - if (!isOnboardLockSet && !isFromSettings) initViewPager(6) + if (!isOnboardLockSet && !isFromSettings) initViewPager(7) // Instantiate next and back buttons initButtons() @@ -349,6 +351,7 @@ class OnBoardingActivity : BaseActivity(), OnBoardActivityInterface, ONBOARDING_RECORDER_VIEW_INDEX -> OnBoardRecorderFragment() ONBOARDING_FILES_VIEW_INDEX -> OnBoardFilesFragment() ONBOARDING_COLLECT_DATA_VIEW -> OnboardCollectDataFragment() + ONBOARDING_NEARBY_SHARING_VIEW_INDEX -> OnBoardNearbySharingFragment() ONBOARDING_LOCK_VIEW_INDEX -> OnBoardLockFragment() else -> OnBoardIntroFragment() } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/adapters/reports/ReportsFilesRecyclerViewAdapter.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/adapters/reports/ReportsFilesRecyclerViewAdapter.kt index 933892b0c..c60a28152 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/adapters/reports/ReportsFilesRecyclerViewAdapter.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/adapters/reports/ReportsFilesRecyclerViewAdapter.kt @@ -129,12 +129,14 @@ open class ReportsFilesRecyclerViewAdapter( filePreviewImg.loadImage(vaultFile.thumb) } - fun ImageView.loadImage(thumb: ByteArray) { - Glide.with(this) - .load(thumb) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .skipMemoryCache(true) - .into(this) + fun ImageView.loadImage(thumb: ByteArray?) { + if (thumb != null) { + Glide.with(this) + .load(thumb) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .into(this) + } } private fun showAddLink() { diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseActivity.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseActivity.kt index c005a9afd..256c82474 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseActivity.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseActivity.kt @@ -32,7 +32,8 @@ abstract class BaseActivity : AppCompatActivity() { var isManualOrientation = false private lateinit var container: ViewGroup private lateinit var loading: View - @Inject lateinit var divviupUtils : DivviupUtils + @Inject + lateinit var divviupUtils: DivviupUtils override fun onCreate(savedInstanceState: Bundle?) { // Let content draw behind system bars WindowCompat.setDecorFitsSystemWindows(window, false) @@ -146,7 +147,7 @@ abstract class BaseActivity : AppCompatActivity() { .commitAllowingStateLoss() } - fun addFragment(container : Int, fragment: Fragment, tag : String ){ + fun addFragment(container: Int, fragment: Fragment, tag: String) { val existingFragment = supportFragmentManager.findFragmentByTag(tag) if (existingFragment == null) { supportFragmentManager.beginTransaction() diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseBindingDialogFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseBindingDialogFragment.kt index 2f42a0398..19f38a8dd 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseBindingDialogFragment.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseBindingDialogFragment.kt @@ -76,7 +76,7 @@ abstract class BaseBindingDialogFragment( .setPadding(24) .setDismissOnClick(true) .setCancelable(true) - .show() + .show() } open fun back() { diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseBindingFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseBindingFragment.kt index afcfc9186..2fb39e19c 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseBindingFragment.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseBindingFragment.kt @@ -78,7 +78,7 @@ abstract class BaseBindingFragment( .setPadding(24) .setDismissOnClick(true) .setCancelable(true) - .show() + .show() } open fun back() { @@ -90,5 +90,4 @@ abstract class BaseBindingFragment( _binding = null isViewInitialized = false } - } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseFragment.kt index 13ebf5708..1ab079270 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseFragment.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseFragment.kt @@ -75,7 +75,7 @@ abstract class BaseFragment : Fragment() { .setPadding(24) .setDismissOnClick(true) .setCancelable(true) - .show() + .show() } open fun back() { diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/CameraFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/CameraFragment.kt deleted file mode 100644 index 7aa74f02c..000000000 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/CameraFragment.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.horizontal.tella.mobile.views.fragment - -import android.os.Bundle -import androidx.fragment.app.Fragment -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import org.horizontal.tella.mobile.R - -// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER -private const val ARG_PARAM1 = "param1" -private const val ARG_PARAM2 = "param2" - -class CameraFragment : Fragment() { - private var param1: String? = null - private var param2: String? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - arguments?.let { - param1 = it.getString(ARG_PARAM1) - param2 = it.getString(ARG_PARAM2) - } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?): View? { - // Inflate the layout for this fragment - return inflater.inflate(R.layout.fragment_camera, container, false) - } - - companion object { - @JvmStatic - fun newInstance(param1: String, param2: String) = - CameraFragment().apply { - arguments = Bundle().apply { - putString(ARG_PARAM1, param1) - putString(ARG_PARAM2, param2) - } - } - } -} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/ReportsFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/ReportsFragment.kt deleted file mode 100644 index b604058d5..000000000 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/ReportsFragment.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.horizontal.tella.mobile.views.fragment - -import android.os.Bundle -import androidx.fragment.app.Fragment -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import org.horizontal.tella.mobile.R - -// TODO: Rename parameter arguments, choose names that match -// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER -private const val ARG_PARAM1 = "param1" -private const val ARG_PARAM2 = "param2" - -/** - * A simple [Fragment] subclass. - * Use the [ReportsFragment.newInstance] factory method to - * create an instance of this fragment. - */ -class ReportsFragment : Fragment() { - // TODO: Rename and change types of parameters - private var param1: String? = null - private var param2: String? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - arguments?.let { - param1 = it.getString(ARG_PARAM1) - param2 = it.getString(ARG_PARAM2) - } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?): View? { - // Inflate the layout for this fragment - return inflater.inflate(R.layout.fragment_reports, container, false) - } - - companion object { - /** - * Use this factory method to create a new instance of - * this fragment using the provided parameters. - * - * @param param1 Parameter 1. - * @param param2 Parameter 2. - * @return A new instance of fragment ReportsFragment. - */ - // TODO: Rename and change types and number of parameters - @JvmStatic - fun newInstance(param1: String, param2: String) = - ReportsFragment().apply { - arguments = Bundle().apply { - putString(ARG_PARAM1, param1) - putString(ARG_PARAM2, param2) - } - } - } -} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/StartNearBySharingFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/StartNearBySharingFragment.kt new file mode 100644 index 000000000..b9c9fd015 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/StartNearBySharingFragment.kt @@ -0,0 +1,67 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import com.hzontal.tella_locking_ui.ui.pin.pinview.ResourceUtils.getColor +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.databinding.StartNearBySharingFragmentBinding +import org.horizontal.tella.mobile.util.Util +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow.PeerToPeerParticipant +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel + +class StartNearBySharingFragment : BaseBindingFragment( + StartNearBySharingFragmentBinding::inflate +) { + private val viewModel: PeerToPeerViewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initViews() + } + + private fun initViews() { + binding.apply { + nextBtn.setTextColor(getColor(baseActivity, R.color.wa_white_40)) + toolbar.backClickListener = { baseActivity.onBackPressed() } + learnMoreTextView.setOnClickListener { + baseActivity.maybeChangeTemporaryTimeout() + Util.startBrowserIntent(context, getString(R.string.peerToPeer_documentation_url)) + } + + sendFilesBtn.setOnClickListener { selectOption(true) } + receiveFilesBtn.setOnClickListener { selectOption(false) } + nextBtn.setOnClickListener { } + } + } + + private fun selectOption(isSend: Boolean) { + binding.apply { + sendFilesBtn.isChecked = isSend + receiveFilesBtn.isChecked = !isSend + nextBtn.setOnClickListener { onNextClicked() } + nextBtn.setTextColor(getColor(baseActivity, R.color.wa_white)) + } + } + + private fun onNextClicked() { + with(binding) { + when { + sendFilesBtn.isChecked -> { + viewModel.peerToPeerParticipant = PeerToPeerParticipant.SENDER + navManager().navigateFromStartNearBySharingFragmentToConnectHotspotFragment() + } + + receiveFilesBtn.isChecked -> { + viewModel.peerToPeerParticipant = PeerToPeerParticipant.RECIPIENT + navManager().navigateFromStartNearBySharingFragmentToConnectHotspotFragment() + } + + else -> {} + } + } + } +} + + diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/activity/PeerToPeerActivity.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/activity/PeerToPeerActivity.kt new file mode 100644 index 000000000..0be7d6bec --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/activity/PeerToPeerActivity.kt @@ -0,0 +1,97 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.activity + +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.google.gson.Gson +import com.hzontal.tella_vault.VaultFile +import dagger.hilt.android.AndroidEntryPoint +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerServerStarterManager +import org.horizontal.tella.mobile.databinding.ActivityPeerToPeerBinding +import org.horizontal.tella.mobile.mvvm.media.MediaImportViewModel +import org.horizontal.tella.mobile.util.C +import org.horizontal.tella.mobile.views.base_ui.BaseLockActivity +import org.horizontal.tella.mobile.views.fragment.uwazi.attachments.VAULT_FILE_KEY +import timber.log.Timber +import javax.inject.Inject + +@AndroidEntryPoint +class PeerToPeerActivity : BaseLockActivity() { + + private lateinit var binding: ActivityPeerToPeerBinding + private val mediaImportViewModel: MediaImportViewModel by viewModels() + + @Inject + lateinit var peerServerStarterManager: PeerServerStarterManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityPeerToPeerBinding.inflate(layoutInflater) + setContentView(binding.getRoot()) + initObservers() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + // Handle import results + if (requestCode == C.IMPORT_VIDEO || requestCode == C.IMPORT_IMAGE || requestCode == C.IMPORT_FILE) { + handleImportResult(requestCode, data) + return + } + + // Delegate onActivityResult to child fragments + supportFragmentManager.primaryNavigationFragment?.childFragmentManager?.fragments?.forEach { + it.onActivityResult(requestCode, resultCode, data) + } + + } + + private fun handleImportResult(requestCode: Int, data: Intent?) { + try { + if (data != null) { + val uri = data.data + if (uri != null) { + divviupUtils.runFileImportEvent() + when (requestCode) { + C.IMPORT_FILE -> mediaImportViewModel.importFile(uri) + } + } + } + } catch (e: NullPointerException) { + // Handle null pointer exception + showToast(R.string.gallery_toast_fail_importing_file) + FirebaseCrashlytics.getInstance().recordException(e) + Timber.e(e, "NullPointerException occurred: ${e.message}") + } catch (e: Exception) { + // Handle other exceptions + FirebaseCrashlytics.getInstance().recordException(e) + Timber.e(e, "NullPointerException occurred: ${e.message}") + } + } + + private fun onMediaFileImported(vaultFile: VaultFile) { + val list: MutableList = ArrayList() + list.add(vaultFile.id) + onActivityResult( + C.MEDIA_FILE_ID, RESULT_OK, Intent().putExtra(VAULT_FILE_KEY, Gson().toJson(list)) + ) + } + + private fun onImportError(throwable: Throwable?) { + Timber.d(throwable) + } + + private fun initObservers() { + mediaImportViewModel.mediaFileLiveData.observe(this, ::onMediaFileImported) + mediaImportViewModel.importError.observe(this, ::onImportError) + } + + override fun onDestroy() { + super.onDestroy() + peerServerStarterManager.stopServer() + } + +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/common/TipsConnectFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/common/TipsConnectFragment.kt new file mode 100644 index 000000000..dc3dd5dad --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/common/TipsConnectFragment.kt @@ -0,0 +1,22 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.common + +import android.os.Bundle +import android.view.View +import androidx.activity.addCallback +import org.horizontal.tella.mobile.databinding.FragmentTipsConnectBinding +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment + +class TipsConnectFragment : + BaseBindingFragment(FragmentTipsConnectBinding::inflate) { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + + binding.toolbar.backClickListener = + { requireActivity().onBackPressedDispatcher.onBackPressed() } + + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + isEnabled = false + requireActivity().onBackPressedDispatcher.onBackPressed() + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/di/PeerModule.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/di/PeerModule.kt new file mode 100644 index 000000000..4d971ae93 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/di/PeerModule.kt @@ -0,0 +1,44 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import org.horizontal.tella.mobile.data.peertopeer.TellaPeerToPeerClient +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerToPeerManager +import org.horizontal.tella.mobile.data.peertopeer.model.P2PSession +import org.horizontal.tella.mobile.data.peertopeer.model.P2PSharedState +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object PeerModule { + + @Provides + @Singleton + fun providePeerClient(@ApplicationContext context: Context): TellaPeerToPeerClient { + return TellaPeerToPeerClient(context) + } + + @Provides + @Singleton + fun providePeerToPeerManager(): PeerToPeerManager { + return PeerToPeerManager() + } + + @Provides + @Singleton + fun provideSession(): P2PSession { + return P2PSession() + } + + @Provides + @Singleton + fun provideP2PServerState(p2PSession: P2PSession): P2PSharedState { + val p2PSharedState = P2PSharedState() + p2PSharedState.session = p2PSession + return p2PSharedState + } +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/ConnectHotspotFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/ConnectHotspotFragment.kt new file mode 100644 index 000000000..08d72989f --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/ConnectHotspotFragment.kt @@ -0,0 +1,91 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow + +import android.os.Build +import android.os.Bundle +import android.view.View +import androidx.annotation.RequiresApi +import androidx.fragment.app.activityViewModels +import com.hzontal.tella_locking_ui.ui.pin.pinview.ResourceUtils.getColor +import dagger.hilt.android.AndroidEntryPoint +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.databinding.ConnectHotspotLayoutBinding +import org.horizontal.tella.mobile.util.ConnectionType +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow.PeerToPeerParticipant +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel + +@AndroidEntryPoint +class ConnectHotspotFragment : + BaseBindingFragment(ConnectHotspotLayoutBinding::inflate) { + + private val viewModel: PeerToPeerViewModel by activityViewModels() + private var isCheckboxChecked = false + + + @RequiresApi(Build.VERSION_CODES.M) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.updateNetworkInfo() + initObservers() + initListeners() + } + + private fun initObservers() { + viewModel.networkInfo.observe(viewLifecycleOwner) { info -> + viewModel.currentNetworkInfo = info + when (info.connectionType) { + ConnectionType.HOTSPOT, ConnectionType.WIFI, ConnectionType.CELLULAR -> { + updateNextButtonState(info.connectionType) + } + + ConnectionType.NONE -> { + updateNextButtonState(ConnectionType.NONE) + } + } + } + } + + private fun initListeners() { + binding.currentWifi.setOnCheckedChangeListener { isChecked -> + isCheckboxChecked = isChecked + val currentType = viewModel.networkInfo.value?.connectionType ?: ConnectionType.NONE + updateNextButtonState(currentType) + } + + binding.toolbar.backClickListener = { baseActivity.onBackPressed() } + binding.tipsCard.setOnTipsClick { + navManager().navigateFromConnectHotspotScreenToTipsToConnectFragment() + } + } + + private fun updateNextButtonState(connectionType: ConnectionType?) { + val isEligibleConnection = + connectionType == ConnectionType.WIFI || connectionType == ConnectionType.HOTSPOT + + binding.currentWifi.setCheckboxEnabled(isEligibleConnection) + + val shouldEnable = isEligibleConnection && isCheckboxChecked + + binding.nextBtn.isEnabled = shouldEnable + binding.nextBtn.isClickable = shouldEnable + + binding.nextBtn.alpha = if (shouldEnable) 1f else 0.5f + + binding.nextBtn.setOnClickListener( + if (shouldEnable) { { onNextClicked() } } else { { /* no-op */ } } + ) + + binding.nextBtn.setTextColor( + getColor(baseActivity, if (shouldEnable) R.color.wa_white else R.color.wa_white_40) + ) + } + + + private fun onNextClicked() { + if (viewModel.peerToPeerParticipant == PeerToPeerParticipant.RECIPIENT) + navManager().navigateFromActionConnectHotspotScreenToQrCodeScreen() + else navManager().navigateFromActionConnectHotspotScreenToScanQrCodeScreen() + + } + +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/PeerToPeerFlags.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/PeerToPeerFlags.kt new file mode 100644 index 000000000..3f95c004b --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/PeerToPeerFlags.kt @@ -0,0 +1,8 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow + +/** + * Created by wafa on 16/7/2025. + */ +object PeerToPeerFlags { + var cancelled = false +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/QRCodeFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/QRCodeFragment.kt new file mode 100644 index 000000000..dfa44db63 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/QRCodeFragment.kt @@ -0,0 +1,144 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow + +import android.graphics.Bitmap +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import com.google.gson.Gson +import com.google.zxing.BarcodeFormat +import com.google.zxing.WriterException +import com.journeyapps.barcodescanner.BarcodeEncoder +import dagger.hilt.android.AndroidEntryPoint +import org.horizontal.tella.mobile.certificate.CertificateUtils +import org.horizontal.tella.mobile.data.peertopeer.PeerKeyProvider +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerServerStarterManager +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerToPeerManager +import org.horizontal.tella.mobile.data.peertopeer.model.P2PSharedState +import org.horizontal.tella.mobile.data.peertopeer.port +import org.horizontal.tella.mobile.databinding.FragmentQrCodeBinding +import org.horizontal.tella.mobile.domain.peertopeer.KeyStoreConfig +import org.horizontal.tella.mobile.domain.peertopeer.PeerConnectionPayload +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel +import javax.inject.Inject + +@AndroidEntryPoint +class QRCodeFragment : BaseBindingFragment(FragmentQrCodeBinding::inflate) { + + private val viewModel: PeerToPeerViewModel by activityViewModels() + private var payload: PeerConnectionPayload? = null + private lateinit var qrPayload: String + + @Inject + lateinit var peerServerStarterManager: PeerServerStarterManager + + @Inject + lateinit var peerToPeerManager: PeerToPeerManager + + @Inject + lateinit var p2PSharedState: P2PSharedState + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val ip = viewModel.currentNetworkInfo?.ipAddress + if (!ip.isNullOrEmpty()) { + setupServerAndQr(ip) + } + handleBack() + handleConnectManually() + + viewModel.isManualConnection = false + + viewModel.registrationServerSuccess.observe(viewLifecycleOwner) { success -> + if (success) { + // Navigate to the next screen + navManager().navigateFromQrCodeScreenToWaitingReceiverFragment() + // reset the LiveData state if we want to consume event once + viewModel.resetRegistrationState() + } else { + } + } + initObservers() + } + + private fun setupServerAndQr(ip: String) { + val keyPair = PeerKeyProvider.getKeyPair() + val certificate = PeerKeyProvider.getCertificate(ip) + val config = KeyStoreConfig() + + val certHash = CertificateUtils.getPublicKeyHash(certificate) + val pin = (100000..999999).random() + val port = port + + p2PSharedState.pin = pin.toString() + p2PSharedState.port = port.toString() + p2PSharedState.hash = certHash + p2PSharedState.ip = ip + + peerServerStarterManager.startServer( + ip, + keyPair, + pin.toString(), + certificate, + config, + p2PSharedState + ) + + payload = PeerConnectionPayload( + ipAddress = ip, + port = port, + certificateHash = certHash, + pin = pin.toString() + ) + + qrPayload = Gson().toJson(payload) + generateQrCode(qrPayload) + } + + + private fun generateQrCode(content: String) { + try { + val barcodeEncoder = BarcodeEncoder() + val bitmap: Bitmap = barcodeEncoder.encodeBitmap( + content, + BarcodeFormat.QR_CODE, + 600, + 600 + ) + binding.qrCodeImageView.setImageBitmap(bitmap) + } catch (e: WriterException) { + e.printStackTrace() + } + } + + private fun handleBack() { + binding.toolbar.backClickListener = { nav().popBackStack() } + binding.backBtn.setOnClickListener { + peerServerStarterManager.stopServer() + nav().popBackStack() + } + } + + private fun handleConnectManually() { + binding.connectManuallyButton.setOnClickListener { + connectManually() + } + } + + private fun connectManually() { + payload?.let { + bundle.putString("payload", qrPayload) + navManager().navigateFromScanQrCodeToDeviceInfo() + } + } + + private fun initObservers() { + viewModel.registrationSuccess.observe(viewLifecycleOwner) { success -> + if (success) { + navManager().navigateFromQrCodeScreenToWaitingReceiverFragment() + } else { + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/RecipientSuccessFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/RecipientSuccessFragment.kt new file mode 100644 index 000000000..19285a140 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/RecipientSuccessFragment.kt @@ -0,0 +1,56 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.databinding.FragmentRecipientSuccessBinding +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel + +/** + * Created by wafa on 3/6/2025. + */ +class RecipientSuccessFragment : BaseBindingFragment(FragmentRecipientSuccessBinding::inflate){ + private val viewModel: PeerToPeerViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + initView() + } + private fun initView() { + val fileCount = arguments?.getInt("fileCount") ?: 0 + val sessionId = arguments?.getString("sessionId").orEmpty() + + with(binding) { + // Set the dynamic message + waitingText.text = getString(R.string.prepare_upload_message, fileCount) + + // Handle Accept/Reject buttons + acceptBtn.setOnClickListener { + + viewModel.confirmPrepareUpload(sessionId, true) + + navManager().navigateFromRecipientSuccessFragmentToRecipientUploadFilesFragment() + } + + rejectBtn.setOnClickListener { + //TODO WE MOVE THIS TO THE NAV MANAGER + viewModel.confirmPrepareUpload(sessionId, false) + // Set result safely via SavedStateHandle + findNavController().previousBackStackEntry + ?.savedStateHandle + ?.set("receiverDeclined", true) + + // Pop back + findNavController().popBackStack() + } + } + } + + +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/RecipientUploadFilesFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/RecipientUploadFilesFragment.kt new file mode 100644 index 000000000..ea3b120bc --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/RecipientUploadFilesFragment.kt @@ -0,0 +1,121 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.MutableLiveData +import dagger.hilt.android.AndroidEntryPoint +import org.horizontal.tella.mobile.MyApplication +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerServerStarterManager +import org.horizontal.tella.mobile.data.peertopeer.model.P2PFileStatus +import org.horizontal.tella.mobile.data.peertopeer.model.SessionStatus +import org.horizontal.tella.mobile.databinding.FragmentUploadFilesBinding +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow.PeerToPeerParticipant +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel +import org.horizontal.tella.mobile.views.fragment.uwazi.widgets.PeerToPeerEndView +import org.hzontal.shared_ui.bottomsheet.BottomSheetUtils.showProgressImportSheet +import org.hzontal.shared_ui.bottomsheet.BottomSheetUtils.showStandardSheet +import timber.log.Timber +import javax.inject.Inject + +@AndroidEntryPoint +class RecipientUploadFilesFragment : + BaseBindingFragment(FragmentUploadFilesBinding::inflate) { + + private val viewModel: PeerToPeerViewModel by activityViewModels() + private lateinit var endView: PeerToPeerEndView + private val progressPercentLiveData = MutableLiveData() + private var sheetShown = false + + @Inject lateinit var peerServerStarterManager: PeerServerStarterManager + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initializeUI() + setupCancelButton() + } + + private fun initializeUI() { + showFormEndView() + observeUploadProgress() + observeBottomSheetProgress() + viewModel.peerToPeerParticipant = PeerToPeerParticipant.RECIPIENT + } + + private fun setupCancelButton() { + binding.cancel.setOnClickListener { showStopSharingConfirmation() } + } + + private fun showStopSharingConfirmation() { + showStandardSheet( + baseActivity.supportFragmentManager, + getString(R.string.stop_sharing_files), + getString(R.string.nearby_sharing_will_be_stopped_the_recipient_will_not_have_access_to_files_that_were_not_fully_transferred), + getString(R.string.action_continue).uppercase(), + getString(R.string.stop).uppercase(), + onConfirmClick = {}, + onCancelClick = { stopServerAndNavigate() } + ) + } + + private fun stopServerAndNavigate() { + peerServerStarterManager.stopServer() + viewModel.peerToPeerParticipant = PeerToPeerParticipant.RECIPIENT + navManager().navigateFromRecipientUploadFilesFragmentToPeerToPeerResultFragment() + } + + private fun showFormEndView() { + val session = viewModel.p2PState.session ?: return + val files = session.files.values.toList() + + endView = PeerToPeerEndView(baseActivity, session.title) + endView.setFiles(files, MyApplication.isConnectedToInternet(baseActivity), false) + + binding.endViewContainer.removeAllViews() + binding.endViewContainer.addView(endView) + } + + private fun observeUploadProgress() { + viewModel.uploadProgress.observe(viewLifecycleOwner) { state -> + if (state == null) return@observe + + val files = state.files + val percent = state.percent.coerceIn(0, 100) + endView.setUploadProgress(files, percent / 100f) + + if (!sheetShown) { + sheetShown = true + showProgressImportSheet( + baseActivity.supportFragmentManager, + getString(R.string.Vault_Importing_SheetTitle), + files.size, + resources.getQuantityString(R.plurals.Vault_Importing_SheetProgress, files.size), + progressStatus = progressPercentLiveData, + getString(R.string.action_cancel).uppercase(), + viewLifecycleOwner + ) { /* no-op */ } + } + + val isTerminal = when (state.sessionStatus) { + SessionStatus.FINISHED, + SessionStatus.FINISHED_WITH_ERRORS, + SessionStatus.CLOSED -> true + else -> false // WAITING, SENDING, SAVING → not yet + } + + if (isTerminal) { + stopServerAndNavigate() + } + } + } + + private fun observeBottomSheetProgress() { + viewModel.bottomSheetProgress.observe(viewLifecycleOwner) { progress -> + if (!sheetShown) return@observe + // The sheet dismisses itself when current == total + progressPercentLiveData.postValue(progress.current) + } + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/RecipientVerificationFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/RecipientVerificationFragment.kt new file mode 100644 index 000000000..c19974a07 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/RecipientVerificationFragment.kt @@ -0,0 +1,98 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import com.google.gson.Gson +import dagger.hilt.android.AndroidEntryPoint +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerServerStarterManager +import org.horizontal.tella.mobile.databinding.ConnectManuallyVerificationBinding +import org.horizontal.tella.mobile.domain.peertopeer.PeerConnectionPayload +import org.horizontal.tella.mobile.util.formatHash +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel +import javax.inject.Inject + +@AndroidEntryPoint +class RecipientVerificationFragment : + BaseBindingFragment(ConnectManuallyVerificationBinding::inflate) { + + private val viewModel: PeerToPeerViewModel by activityViewModels() + private var payload: PeerConnectionPayload? = null + + @Inject + lateinit var peerServerStarterManager: PeerServerStarterManager + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + arguments?.getString("payload")?.let { payloadJson -> + payload = Gson().fromJson(payloadJson, PeerConnectionPayload::class.java) + } + + initUI() + initListeners() + initObservers() + } + + private fun initUI() = with(binding) { + // Simpler instruction text (update your string as needed) + sequenceDescTextView.text = getString(R.string.nearbySharing_verifyConnection_recipient) + hashContentTextView.text = viewModel.p2PState.hash.formatHash() + + // IMPORTANT: button is enabled immediately + confirmAndConnectBtn.isEnabled = true + confirmAndConnectBtn.setText(getString(R.string.confirm_and_connect)) + } + + private fun initListeners() = with(binding) { + toolbar.backClickListener = { navigateBackAndStopServer() } + discardBtn.setOnClickListener { navigateBackAndStopServer() } + + // Tap immediately — even if no incoming request yet + confirmAndConnectBtn.setOnClickListener { + confirmAndConnectBtn.isEnabled = false + confirmAndConnectBtn.setText(getString(R.string.waiting_for_the_sender)) + viewModel.onRecipientConfirmTapped() + } + } + + private fun initObservers() = with(binding) { + // Manual mode so we don't auto-accept + viewModel.p2PState.isUsingManualConnection = true + + + // Navigate ONLY after server confirms (both sides done) + viewModel.registrationServerSuccess.observe(viewLifecycleOwner) { success -> + if (success) { + navManager().navigateFromRecipientVerificationScreenToWaitingReceiverFragment() + } + } + + viewModel.closeConnection.observe(viewLifecycleOwner) { closeConnection -> + if (closeConnection) navigateBackAndStopServer() + } + + // Optional: reflect VM UI flags if you want the button text/state to be VM-driven + viewModel.waitingForOtherSide.observe(viewLifecycleOwner) { waiting -> + if (waiting) { + confirmAndConnectBtn.isEnabled = false + confirmAndConnectBtn.setText(getString(R.string.waiting_for_the_sender)) + } + } + viewModel.canTapConfirm.observe(viewLifecycleOwner) { canTap -> + if (canTap) { + confirmAndConnectBtn.isEnabled = true + confirmAndConnectBtn.setText(getString(R.string.confirm_and_connect)) + } + } + } + + private fun navigateBackAndStopServer() { + peerServerStarterManager.stopServer() + navManager().navigateBackToStartNearBySharingFragmentAndClearBackStack() + } + + +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/ShowDeviceInfoFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/ShowDeviceInfoFragment.kt new file mode 100644 index 000000000..e9e3798c2 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/ShowDeviceInfoFragment.kt @@ -0,0 +1,64 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import com.google.gson.Gson +import kotlinx.coroutines.launch +import org.horizontal.tella.mobile.databinding.ShowDeviceInfoLayoutBinding +import org.horizontal.tella.mobile.domain.peertopeer.PeerConnectionPayload +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel + +class ShowDeviceInfoFragment : + BaseBindingFragment(ShowDeviceInfoLayoutBinding::inflate) { + private val viewModel: PeerToPeerViewModel by activityViewModels() + + private var payload: PeerConnectionPayload? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + arguments?.getString("payload")?.let { payloadJson -> + payload = Gson().fromJson(payloadJson, PeerConnectionPayload::class.java) + } + initListeners() + initView() + initObservers() + } + + private fun initView() { + binding.connectCode.setRightText(viewModel.p2PState.ip) + binding.pin.setRightText(viewModel.p2PState.pin) + binding.port.setRightText(viewModel.p2PState.port) + } + + private fun initListeners() { + binding.backBtn.setOnClickListener { back() } + binding.toolbar.backClickListener = { nav().popBackStack() } + } + + private fun initObservers() { + lifecycleScope.launch { + viewModel.clientHash.collect { clientHash -> + with(viewModel.p2PState) { + ip = payload?.ipAddress.toString() + port = payload?.port.toString() + pin = payload?.pin + hash = clientHash + } + navManager().navigateFromDeviceInfoScreenTRecipientVerificationScreen() + } + } + + viewModel.registrationServerSuccess.observe(viewLifecycleOwner) { success -> + if (success) { + // Navigate to the next screen + navManager().navigateFromWaitingReceiverFragmentToRecipientSuccessFragment() + // reset the LiveData state if we want to consume event once + viewModel.resetRegistrationState() + } else { + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/WaitingReceiverFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/WaitingReceiverFragment.kt new file mode 100644 index 000000000..72c4a860f --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/WaitingReceiverFragment.kt @@ -0,0 +1,79 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.databinding.FragmentWaitingBinding +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel +import org.hzontal.shared_ui.utils.DialogUtils + +/** + * Created by wafa on 3/6/2025. + */ + +@AndroidEntryPoint +class WaitingReceiverFragment : + BaseBindingFragment(FragmentWaitingBinding::inflate) { + + private val viewModel: PeerToPeerViewModel by activityViewModels() + private var navigated = false + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupToolbar() + observeIncomingPrepareRequest() + observeReceiverRejection() + } + + private fun setupToolbar() { + binding.toolbar.apply { + setStartTextTitle(getString(R.string.receive_files)) + backClickListener = { + navManager().navigateBackToStartNearBySharingFragmentAndClearBackStack() + } + } + binding.waitingText.text = getString(R.string.waiting_for_the_sender_to_share_files) + } + + private fun observeIncomingPrepareRequest() { + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.RESUMED) { + viewModel.incomingPrepareRequest.collect { request -> + if (!navigated && + isAdded && + findNavController().currentDestination?.id == R.id.waitingReceiverFragment + ) { + navigated = true + + bundle.putInt("fileCount", request.files.size) + bundle.putString("sessionId", request.sessionId) + + navManager().navigateFromWaitingReceiverFragmentToRecipientSuccessFragment() + } + } + } + } + } + + private fun observeReceiverRejection() { + val navBackStackEntry = findNavController().currentBackStackEntry + navBackStackEntry?.savedStateHandle + ?.getLiveData("receiverDeclined") + ?.observe(viewLifecycleOwner) { wasRejected -> + if (wasRejected) { + DialogUtils.showBottomMessage( + baseActivity, + getString(R.string.sender_files_rejected), + isError = true + ) + } + } + } + +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/PeerToPeerParticipant.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/PeerToPeerParticipant.kt new file mode 100644 index 000000000..18bbfdc23 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/PeerToPeerParticipant.kt @@ -0,0 +1,9 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow + +/** + * Created by wafa on 16/7/2025. + */ +enum class PeerToPeerParticipant { + SENDER, + RECIPIENT +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/PeerToPeerResultFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/PeerToPeerResultFragment.kt new file mode 100644 index 000000000..8c81e9fb7 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/PeerToPeerResultFragment.kt @@ -0,0 +1,121 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow + +import android.content.Intent +import android.graphics.PorterDuff +import android.os.Bundle +import android.view.View +import androidx.activity.addCallback +import androidx.core.content.ContextCompat +import androidx.fragment.app.activityViewModels +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.data.peertopeer.model.P2PFileStatus +import org.horizontal.tella.mobile.data.peertopeer.model.ProgressFile +import org.horizontal.tella.mobile.databinding.FragmentPeerToPeerResultBinding +import org.horizontal.tella.mobile.views.activity.MainActivity +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel + +class PeerToPeerResultFragment : + BaseBindingFragment(FragmentPeerToPeerResultBinding::inflate) { + + private val viewModel: PeerToPeerViewModel by activityViewModels() + private var transferredFiles: List? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Get values from shared state + transferredFiles = viewModel.p2PState.session?.files?.values?.toList() + + setupImage() + setupTexts() + setupButton() + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { baseActivity.finish() } + } + + + private fun setupImage() { + binding.setupImgV.run { + val isSuccess = allFilesTransferred + setImageResource(if (isSuccess) R.drawable.checked_circle else R.drawable.ic_warning_orange) + + if (!isSuccess) { + val whiteColor = ContextCompat.getColor(context, R.color.wa_white) + setColorFilter(whiteColor, PorterDuff.Mode.SRC_IN) + } else { + clearColorFilter() + } + } + } + + private fun setupTexts() { + binding.toolbar.setStartTextTitle( getString(if (allFilesTransferred) R.string.success_title else R.string.result)) + binding.tileTv.text = + getString(if (allFilesTransferred) R.string.success_title else R.string.failure_title) + binding.descriptionTv.text = computeSubtitle() + } + + private fun setupButton() { + binding.toolbar.backClickListener = { baseActivity.finish() } + + binding.viewFilesBtn.apply { + val isRecipient = viewModel.peerToPeerParticipant == PeerToPeerParticipant.RECIPIENT + + if (!noFilesTransferred && isRecipient) { + setText(getString(R.string.view_files_action)) + visibility = View.VISIBLE + setOnClickListener { + val intent = Intent(requireContext(), MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra("navigateTo", "attachments_screen") + } + startActivity(intent) + requireActivity().finish() // finish PeerToPeerActivity + } + } else { + visibility = View.GONE + } + } + } + + private fun computeSubtitle(): String { + val total = transferredFiles?.size ?: 0 + val successCount = transferredFiles?.count { it.status == successStatus } ?: 0 + val failureCount = total - successCount + + return when { + // Case 1: All successful + successCount == total -> { + val resId = if (viewModel.peerToPeerParticipant == PeerToPeerParticipant.RECIPIENT) + R.plurals.success_file_received_from_sender + else + R.plurals.success_file_sent_to_recipient + + resources.getQuantityString(resId, total, total) + } + + // Case 2: All failed + failureCount == total -> { + resources.getQuantityString(R.plurals.failure_file_received_expl, failureCount, failureCount) + } + + //Case 3: Partial success + else -> { + getString(R.string.partial_success_summary, successCount, failureCount) + } + } + } + + private val successStatus: P2PFileStatus + get() = if (viewModel.peerToPeerParticipant == PeerToPeerParticipant.RECIPIENT) + P2PFileStatus.SAVED + else + P2PFileStatus.FINISHED + + private val allFilesTransferred: Boolean + get() = transferredFiles?.all { it.status == successStatus } == true + + private val noFilesTransferred: Boolean + get() = transferredFiles?.none { it.status == successStatus } == true + +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/PrepareUploadFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/PrepareUploadFragment.kt new file mode 100644 index 000000000..0b9319b9b --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/PrepareUploadFragment.kt @@ -0,0 +1,347 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.activity.addCallback +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.setFragmentResultListener +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.GridLayoutManager +import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.hzontal.tella_locking_ui.common.extensions.onChange +import com.hzontal.tella_vault.VaultFile +import com.hzontal.tella_vault.filter.FilterType +import dagger.hilt.android.AndroidEntryPoint +import org.horizontal.tella.mobile.MyApplication +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.bus.EventObserver +import org.horizontal.tella.mobile.bus.event.AudioRecordEvent +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerServerStarterManager +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.ProgressFile +import org.horizontal.tella.mobile.databinding.FragmentPrepareUploadBinding +import org.horizontal.tella.mobile.domain.peertopeer.P2PFile +import org.horizontal.tella.mobile.media.MediaFileHandler +import org.horizontal.tella.mobile.util.C +import org.horizontal.tella.mobile.views.activity.camera.CameraActivity +import org.horizontal.tella.mobile.views.activity.camera.CameraActivity.Companion.CAPTURE_WITH_AUTO_UPLOAD +import org.horizontal.tella.mobile.views.adapters.reports.ReportsFilesRecyclerViewAdapter +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.main_connexions.base.BUNDLE_REPORT_AUDIO +import org.horizontal.tella.mobile.views.fragment.main_connexions.base.BUNDLE_REPORT_VAULT_FILE +import org.horizontal.tella.mobile.views.fragment.main_connexions.base.OnNavBckListener +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.FileTransferViewModel +import org.horizontal.tella.mobile.views.fragment.recorder.MicActivity +import org.horizontal.tella.mobile.views.fragment.recorder.REPORT_ENTRY +import org.horizontal.tella.mobile.views.fragment.uwazi.attachments.AttachmentsActivitySelector +import org.horizontal.tella.mobile.views.fragment.uwazi.attachments.VAULT_FILES_FILTER +import org.horizontal.tella.mobile.views.fragment.uwazi.attachments.VAULT_FILE_KEY +import org.horizontal.tella.mobile.views.fragment.uwazi.attachments.VAULT_PICKER_SINGLE +import org.horizontal.tella.mobile.views.interfaces.IReportAttachmentsHandler +import org.hzontal.shared_ui.bottomsheet.BottomSheetUtils +import org.hzontal.shared_ui.bottomsheet.VaultSheetUtils.IVaultFilesSelector +import org.hzontal.shared_ui.bottomsheet.VaultSheetUtils.showVaultSelectFilesSheet +import org.hzontal.shared_ui.utils.DialogUtils +import javax.inject.Inject + +var PREPARE_UPLOAD_ENTRY = "PREPARE_UPLOAD_ENTRY" + +@AndroidEntryPoint +class PrepareUploadFragment : + BaseBindingFragment(FragmentPrepareUploadBinding::inflate), + IReportAttachmentsHandler, OnNavBckListener { + private lateinit var gridLayoutManager: GridLayoutManager + private var isTitleEnabled = false + private val viewModel: FileTransferViewModel by activityViewModels() + private var disposables = + MyApplication.bus().createCompositeDisposable() + + private val filesRecyclerViewAdapter: ReportsFilesRecyclerViewAdapter by lazy { + ReportsFilesRecyclerViewAdapter(this) + } + + @Inject + lateinit var peerServerStarterManager: PeerServerStarterManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setFragmentResultListener(BUNDLE_REPORT_AUDIO) { _, bundle -> + val file = bundle.get(BUNDLE_REPORT_VAULT_FILE) as VaultFile + bundle.remove(BUNDLE_REPORT_VAULT_FILE) + putFiles(listOf(file)) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + initView() + onAudioRecordingListener() + } + + private fun initView() { + gridLayoutManager = GridLayoutManager(context, 3) + binding.filesRecyclerView.apply { + layoutManager = gridLayoutManager + adapter = filesRecyclerViewAdapter + } + binding.toolbar.backClickListener = { + exitOrSave() + } + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { exitOrSave()} + + //TODO HANDLE THIS IN THE NAVMANAGER + val navBackStackEntry = findNavController().currentBackStackEntry + navBackStackEntry?.savedStateHandle + ?.getLiveData("transferRejected") + ?.observe(viewLifecycleOwner) { wasRejected -> + if (wasRejected) { + DialogUtils.showBottomMessage( + baseActivity, getString(R.string.recipient_rejected_the_files), + true + ) + } + } + findNavController().currentBackStackEntry + ?.savedStateHandle + ?.getLiveData("registrationSuccess") + ?.observe(viewLifecycleOwner) { success -> + if (success == true) { + DialogUtils.showBottomMessage( + baseActivity, + "Success! Connected to device", + false + ) + + // Remove it so it's not shown again on back navigation + findNavController().currentBackStackEntry?.savedStateHandle + ?.remove("registrationSuccess") + } + } + binding.toolbar.backClickListener = { + BottomSheetUtils.showConfirmSheet( + baseActivity.supportFragmentManager, + getString((R.string.exit_nearby_sharing)), + getString(R.string.your_progress_will_be_lost), + getString(R.string.action_exit), + getString(R.string.action_cancel), + object : BottomSheetUtils.ActionConfirmed { + override fun accept(isConfirmed: Boolean) { + if (isConfirmed) { + peerServerStarterManager.stopServer() + navManager().navigateBackToStartNearBySharingFragmentAndClearBackStack() + } + } + } + ) + } + highLightButtonsInit() + } + + private fun highLightButtonsInit() { + binding.apply { + reportTitleEt.let { title -> + isTitleEnabled = title.length() > 0 + } + + reportTitleEt.onChange { title -> + isTitleEnabled = title.isNotEmpty() + highLightButtons() + } + + } + } + + private fun exitOrSave() { + navManager().navigateBackToStartNearBySharingFragmentAndClearBackStack() + } + + private fun showSelectFilesSheet() { + showVaultSelectFilesSheet(baseActivity.supportFragmentManager, + baseActivity.getString(R.string.Uwazi_WidgetMedia_Take_Photo), + baseActivity.getString(R.string.Vault_RecordAudio_SheetAction), + baseActivity.getString(R.string.Uwazi_WidgetMedia_Select_From_Device), + baseActivity.getString(R.string.Uwazi_WidgetMedia_Select_From_Tella), + null, + baseActivity.getString(R.string.Uwazi_MiltiFileWidget_SelectFiles), + object : IVaultFilesSelector { + override fun importFromVault() { + showAttachmentsActivity() + } + + override fun goToRecorder() { + showAudioRecorderActivity() + } + + override fun goToCamera() { + showCameraActivity() + } + + override fun importFromDevice() { + importMedia() + } + }) + } + + + private fun showAttachmentsActivity() { + try { + baseActivity.startActivityForResult( + Intent(activity, AttachmentsActivitySelector::class.java) + // .putExtra(VAULT_FILE_KEY, Gson().toJson(ids)) + .putExtra( + VAULT_FILES_FILTER, FilterType.ALL_WITHOUT_DIRECTORY + ).putExtra(VAULT_PICKER_SINGLE, false), C.MEDIA_FILE_ID + ) + } catch (e: Exception) { + FirebaseCrashlytics.getInstance().recordException(e) + } + } + + private fun showCameraActivity() { + try { + val intent = Intent(context, CameraActivity::class.java) + intent.apply { + putExtra(CameraActivity.INTENT_MODE, CameraActivity.IntentMode.COLLECT.name) + putExtra(CAPTURE_WITH_AUTO_UPLOAD, false) + } + + baseActivity.startActivityForResult(intent, C.MEDIA_FILE_ID) + } catch (e: java.lang.Exception) { + FirebaseCrashlytics.getInstance().recordException(e) + } + } + + private fun importMedia() { + baseActivity.maybeChangeTemporaryTimeout { + MediaFileHandler.startSelectMediaActivity( + activity, "image/* video/* audio/*", + arrayOf("image/*", "video/*", "audio/*"), C.IMPORT_FILE + ) + } + } + + private fun showAudioRecorderActivity() { + try { + bundle.putBoolean(REPORT_ENTRY, true) + this.navManager().navigateToMicro() + } catch (e: java.lang.Exception) { + FirebaseCrashlytics.getInstance().recordException(e) + } + val intent = Intent(activity, MicActivity::class.java) + intent.putExtra(PREPARE_UPLOAD_ENTRY, true) + baseActivity.startActivity(intent) + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == C.MEDIA_FILE_ID && resultCode == Activity.RESULT_OK) { + val vaultFile = data?.getStringExtra(VAULT_FILE_KEY) ?: "" + putFiles(viewModel.putVaultFilesInForm(vaultFile).blockingGet()) + } + } + + private fun putFiles(vaultFileList: List) { + vaultFileList.forEach { file -> + filesRecyclerViewAdapter.insertAttachment(file) + } + + // Ensure visibility and highlight buttons + binding.filesRecyclerView.visibility = View.VISIBLE + highLightButtons() + } + + override fun removeFiles() { + highLightButtons() + } + + private fun highLightButtons() { + val isSubmitEnabled = + isTitleEnabled && filesRecyclerViewAdapter.getFiles() + .isNotEmpty() + + val disabled: Float = context?.getString(R.string.alpha_disabled)?.toFloat() ?: 1.0f + val enabled: Float = context?.getString(R.string.alpha_enabled)?.toFloat() ?: 1.0f + + binding.sendReportBtn.setBackgroundResource(if (isSubmitEnabled) R.drawable.bg_round_orange_btn else R.drawable.bg_round_orange16_btn) + binding.sendReportBtn.alpha = (if (isSubmitEnabled) enabled else disabled) + + initClickListeners(isSubmitEnabled) + } + + private fun initClickListeners(isSubmitEnabled: Boolean) { + binding.sendReportBtn.setOnClickListener { + if (isSubmitEnabled) { + val selectedFiles = filesRecyclerViewAdapter.getFiles() + viewModel.p2PSharedState.session?.title = binding.reportTitleEt.text.toString() + + if (selectedFiles.isNotEmpty()) { + val session = viewModel.p2PSharedState.session ?: P2PSession().also { + viewModel.p2PSharedState.session = it + } + + selectedFiles.forEach { vaultFile -> + val p2pFile = P2PFile( + id = vaultFile.id, + fileName = vaultFile.name, + size = vaultFile.size, + fileType = vaultFile.mimeType ?: "application/octet-stream", + //sha256 = vaultFile.hash, + thumbnail = vaultFile.thumb ?: ByteArray(0) + ) + + val progressFile = ProgressFile( + file = p2pFile, + vaultFile = vaultFile, + status = P2PFileStatus.QUEUE + ) + + session.files[vaultFile.id] = progressFile + } + + // Navigate + navManager().navigateFromPrepareUploadFragmentToWaitingSenderFragment() + } else { + baseActivity.showToast("No file selected") + } + } else { + showSubmitReportErrorSnackBar() + } + } + } + + + private fun showSubmitReportErrorSnackBar() { + val errorRes = R.string.Snackbar_Submit_Files_Error + + DialogUtils.showBottomMessage( + baseActivity, + getString(errorRes), + false + ) + } + + override fun playMedia(mediaFile: VaultFile?) { + } + + override fun addFiles() { + showSelectFilesSheet() + } + + override fun onBackPressed(): Boolean { + exitOrSave() + return true + } + + private fun onAudioRecordingListener() { + disposables.wire( + AudioRecordEvent::class.java, + object : EventObserver() { + override fun onNext(event: AudioRecordEvent) { + putFiles(listOf(event.vaultFile)) + } + }) + } + +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/ScanQrCodeFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/ScanQrCodeFragment.kt new file mode 100644 index 000000000..4c2c82264 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/ScanQrCodeFragment.kt @@ -0,0 +1,164 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.view.View +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import com.google.gson.Gson +import com.journeyapps.barcodescanner.BarcodeCallback +import com.journeyapps.barcodescanner.BarcodeResult +import com.journeyapps.barcodescanner.CompoundBarcodeView +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.domain.peertopeer.PeerConnectionPayload +import org.horizontal.tella.mobile.databinding.ScanQrcodeFragmentBinding +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel +import org.hzontal.shared_ui.bottomsheet.BottomSheetUtils.showStandardSheet +import org.hzontal.shared_ui.utils.DialogUtils + +class ScanQrCodeFragment : + BaseBindingFragment(ScanQrcodeFragmentBinding::inflate) { + + private val viewModel: PeerToPeerViewModel by activityViewModels() + private lateinit var barcodeView: CompoundBarcodeView + + companion object { + private const val CAMERA_REQUEST_CODE = 1001 + } + + @RequiresApi(Build.VERSION_CODES.M) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + barcodeView = CompoundBarcodeView(requireContext()) + barcodeView = binding.qrCodeScanView + barcodeView.statusView.visibility = View.GONE + barcodeView.viewFinder.visibility = View.GONE + + if (ContextCompat.checkSelfPermission( + requireContext(), + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + ) { + startScanning() + } else { + baseActivity.maybeChangeTemporaryTimeout { + requestPermissions(arrayOf(Manifest.permission.CAMERA), CAMERA_REQUEST_CODE) + } + } + handleBack() + initListeners() + initObservers() + } + + private fun startScanning() { + barcodeView.decodeContinuous(object : BarcodeCallback { + + override fun barcodeResult(result: BarcodeResult?) { + result?.text?.let { qrContent -> + barcodeView.pause() + + try { + val payload = Gson().fromJson(qrContent, PeerConnectionPayload::class.java) + + viewModel.p2PState.pin = payload.pin + viewModel.p2PState.port = payload.port.toString() + viewModel.p2PState.hash = payload.certificateHash + viewModel.p2PState.ip = payload.ipAddress + + viewModel.startRegistration( + ip = payload.ipAddress, + port = payload.port.toString(), + hash = payload.certificateHash, + pin = payload.pin + ) + + } catch (e: Exception) { + e.printStackTrace() + DialogUtils.showBottomMessage(baseActivity, "Invalid QR Code", true) + // Show a message: Invalid QR Code + } + } + } + + override fun possibleResultPoints(resultPoints: MutableList?) { + } + }) + + barcodeView.resume() + } + + override fun onPause() { + super.onPause() + barcodeView.pause() + } + + override fun onResume() { + super.onResume() + barcodeView.resume() + } + + override fun onDestroyView() { + barcodeView.pauseAndWait() + super.onDestroyView() + } + + override fun onRequestPermissionsResult( + requestCode: Int, permissions: Array, grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == CAMERA_REQUEST_CODE && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { + startScanning() + } + } + + private fun handleBack() { + binding.toolbar.backClickListener = { nav().popBackStack() } + binding.backBtn.setOnClickListener { nav().popBackStack() } + } + + private fun initListeners() { + binding.connectManuallyButton.setOnClickListener { + navManager().navigateFromScanQrCodeToSenderManualConnectionScreen() + } + } + + private fun initObservers() { + viewModel.registrationSuccess.observe(viewLifecycleOwner) { success -> + if (success) { + findNavController().currentBackStackEntry?.savedStateHandle + ?.set("registrationSuccess", true) + navManager().navigateFromScanQrCodeToPrepareUploadFragment() + } else { + // handle error UI + } + + viewModel.bottomMessageError.observe(viewLifecycleOwner) { message -> + DialogUtils.showBottomMessage(baseActivity, message, true) + } + + viewModel.bottomSheetError.observe(viewLifecycleOwner) { (title, description) -> + showStandardSheet( + baseActivity.supportFragmentManager, + title, + description, + null, + getString(R.string.try_again), + null + ) { + viewModel.startRegistration( + ip = viewModel.p2PState.ip, + port = viewModel.p2PState.port, + hash = viewModel.p2PState.hash, + pin = viewModel.p2PState.pin.toString() + ) + } + } + } + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderManualConnectionFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderManualConnectionFragment.kt new file mode 100644 index 000000000..d54cad1d1 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderManualConnectionFragment.kt @@ -0,0 +1,113 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow + +import android.os.Bundle +import android.view.View +import androidx.core.content.ContextCompat +import androidx.fragment.app.activityViewModels +import com.hzontal.tella_locking_ui.common.extensions.onChange +import dagger.hilt.android.AndroidEntryPoint +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.data.peertopeer.P2PSecurity +import org.horizontal.tella.mobile.data.peertopeer.PeerKeyProvider +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerServerStarterManager +import org.horizontal.tella.mobile.databinding.SenderManualConnectionBinding +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel +import org.hzontal.shared_ui.bottomsheet.BottomSheetUtils.showStandardSheet +import org.hzontal.shared_ui.bottomsheet.KeyboardUtil +import org.hzontal.shared_ui.utils.DialogUtils +import javax.inject.Inject + +@AndroidEntryPoint +class SenderManualConnectionFragment : + BaseBindingFragment(SenderManualConnectionBinding::inflate) { + + private val viewModel: PeerToPeerViewModel by activityViewModels() + @Inject + lateinit var peerServerStarterManager: PeerServerStarterManager + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + peerServerStarterManager.stopServer() + PeerKeyProvider.reset() + viewModel.p2PState.clear() + P2PSecurity.allowInsecureManualHandshake = true + initView() + initListeners() + initObservers() + } + + private fun initView() = with(binding) { + ipAddress.onChange { updateNextButtonState() } + pin.onChange { updateNextButtonState() } + port.onChange { updateNextButtonState() } + + updateNextButtonState() + KeyboardUtil(root) + } + + private fun initListeners() = with(binding) { + backBtn.setOnClickListener { nav().popBackStack() } + toolbar.backClickListener = { nav().popBackStack() } + + nextBtn.setOnClickListener { + val ip = ipAddress.text.toString() + val port = port.text.toString() + val pin = this.pin.text.toString() + + viewModel.p2PState.apply { + this.ip = ip + this.port = port + this.pin = pin + } + + viewModel.handleCertificate(ip, port, pin) + } + } + + private fun initObservers() { + viewModel.getHashSuccess.observe(viewLifecycleOwner) { hash -> + P2PSecurity.allowInsecureManualHandshake = false + + bundle.putString("payload", hash) + navManager().navigateFromSenderManualConnectionToConnectManuallyVerification() + } + viewModel.bottomMessageError.observe(viewLifecycleOwner) { message -> + DialogUtils.showBottomMessage(baseActivity, message, true) + } + + viewModel.bottomSheetError.observe(viewLifecycleOwner) { (title, description) -> + showStandardSheet( + baseActivity.supportFragmentManager, + title, + description, + null, + getString(R.string.try_again), + null + ) { + viewModel.handleCertificate( + viewModel.p2PState.ip, + viewModel.p2PState.port, + viewModel.p2PState.pin.toString() + ) + } + } + } + + private fun isInputValid(): Boolean = with(binding) { + ipAddress.text?.isNotBlank() == true && + pin.text?.isNotBlank() == true + } + + private fun updateNextButtonState() = with(binding) { + val enabled = isInputValid() + nextBtn.isEnabled = enabled + nextBtn.setTextColor( + ContextCompat.getColor( + baseActivity, + if (enabled) android.R.color.white else android.R.color.darker_gray + ) + ) + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderUploadFilesFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderUploadFilesFragment.kt new file mode 100644 index 000000000..21d55b483 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderUploadFilesFragment.kt @@ -0,0 +1,76 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow + +import android.os.Bundle +import android.view.View +import androidx.activity.addCallback +import androidx.fragment.app.activityViewModels +import org.horizontal.tella.mobile.MyApplication +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.data.peertopeer.model.P2PFileStatus +import org.horizontal.tella.mobile.data.peertopeer.model.SessionStatus +import org.horizontal.tella.mobile.databinding.FragmentUploadFilesBinding +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow.PeerToPeerFlags +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.FileTransferViewModel +import org.horizontal.tella.mobile.views.fragment.uwazi.widgets.PeerToPeerEndView +import org.hzontal.shared_ui.bottomsheet.BottomSheetUtils.showStandardSheet + +class SenderUploadFilesFragment : + BaseBindingFragment(FragmentUploadFilesBinding::inflate) { + + private val viewModel: FileTransferViewModel by activityViewModels() + private lateinit var endView: PeerToPeerEndView + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.peerToPeerParticipant = PeerToPeerParticipant.SENDER + showFormEndView() + observeUploadProgress() + viewModel.uploadAllFiles() + binding.cancel.setOnClickListener { + showStandardSheet( + baseActivity.supportFragmentManager, + getString(R.string.stop_sharing_files), + getString(R.string.nearby_sharing_will_be_stopped_the_recipient_will_not_have_access_to_files_that_were_not_fully_transferred), + getString(R.string.action_continue).uppercase(), + getString(R.string.stop).uppercase(), + {}, + { + viewModel.closePeerConnection() + PeerToPeerFlags.cancelled = true + baseActivity.finish() + }) + } + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { } + } + + private fun showFormEndView() { + val session = viewModel.p2PSharedState.session ?: return + val files = session.files.values.toList() + + endView = PeerToPeerEndView( + baseActivity, session.title ?: "Transfer" + ) + + endView.setFiles(files, MyApplication.isConnectedToInternet(baseActivity), false) + binding.endViewContainer.removeAllViews() + binding.endViewContainer.addView(endView) + endView.clearPartsProgress(files, session.status) + } + + private fun observeUploadProgress() { + viewModel.uploadProgress.observe(viewLifecycleOwner) { state -> + val files = state.files + val percentFloat = state.percent / 100f + endView.setUploadProgress(files, percentFloat) + + + val allFinished = state.files.all { it.status == P2PFileStatus.FINISHED } + + if (state.sessionStatus == SessionStatus.FINISHED && allFinished) { + viewModel.peerToPeerParticipant = PeerToPeerParticipant.SENDER + navManager().navigateFromUploadSenderFragmentToPeerToPeerResultFragment() + } + } + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderVerificationFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderVerificationFragment.kt new file mode 100644 index 000000000..94d502b17 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderVerificationFragment.kt @@ -0,0 +1,102 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerServerStarterManager +import org.horizontal.tella.mobile.databinding.ConnectManuallyVerificationBinding +import org.horizontal.tella.mobile.util.formatHash +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel +import org.hzontal.shared_ui.bottomsheet.BottomSheetUtils.showStandardSheet +import org.hzontal.shared_ui.utils.DialogUtils +import javax.inject.Inject + +@AndroidEntryPoint +class SenderVerificationFragment : + BaseBindingFragment(ConnectManuallyVerificationBinding::inflate) { + + private val viewModel: PeerToPeerViewModel by activityViewModels() + + @Inject + lateinit var peerServerStarterManager: PeerServerStarterManager + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initListeners() + initView() + initObservers() + } + + private fun initView() { + binding.sequenceDescTextView.text = getString(R.string.nearbySharing_verifyConnection_sender) + binding.confirmAndConnectBtn.setText(getString(R.string.confirm_and_connect)) + binding.hashContentTextView.text = viewModel.p2PState.hash.formatHash() + } + + private fun initListeners() { + binding.confirmAndConnectBtn.setOnClickListener { + // Disable & show waiting immediately + binding.confirmAndConnectBtn.isEnabled = false + binding.confirmAndConnectBtn.setText(getString(R.string.waiting_for_the_recipient)) + viewModel.onUserTappedConfirmAndConnect() + } + + binding.discardBtn.setOnClickListener { + navManager().navigateBackToStartNearBySharingFragmentAndClearBackStack() + } + } + + private fun initObservers() { + // Manual mode + viewModel.isManualConnection = true + + // Button enable/disable from VM + viewModel.canTapConfirm.observe(viewLifecycleOwner) { canTap -> + binding.confirmAndConnectBtn.isEnabled = canTap + if (canTap) { + binding.confirmAndConnectBtn.setText(getString(R.string.confirm_and_connect)) + } + } + viewModel.waitingForOtherSide.observe(viewLifecycleOwner) { waiting -> + if (waiting) { + binding.confirmAndConnectBtn.isEnabled = false + binding.confirmAndConnectBtn.setText(getString(R.string.waiting_for_the_recipient)) + } + } + + viewModel.registrationSuccess.observe(viewLifecycleOwner) { success -> + if (success) { + findNavController().currentBackStackEntry?.savedStateHandle?.set("registrationSuccess", true) + navManager().navigateConnectManuallyVerificationFragmentToprepareUploadFragment() + } + } + + viewModel.bottomMessageError.observe(viewLifecycleOwner) { message -> + DialogUtils.showBottomMessage(baseActivity, message, true) + } + + viewModel.bottomSheetError.observe(viewLifecycleOwner) { (title, description) -> + showStandardSheet( + baseActivity.supportFragmentManager, + title, + description, + null, + getString(R.string.try_again), + null + ) { + // Allow retry: re-enable confirm + binding.confirmAndConnectBtn.isEnabled = true + binding.confirmAndConnectBtn.setText(getString(R.string.confirm_and_connect)) + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + peerServerStarterManager.stopServer() + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/WaitingSenderFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/WaitingSenderFragment.kt new file mode 100644 index 000000000..6c1d87d5b --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/WaitingSenderFragment.kt @@ -0,0 +1,50 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.databinding.FragmentWaitingBinding +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.FileTransferViewModel + +/** + * Created by wafa on 3/6/2025. + */ +class WaitingSenderFragment : + BaseBindingFragment(FragmentWaitingBinding::inflate) { + private val viewModel: FileTransferViewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.toolbar.setStartTextTitle(getString(R.string.send_files)) + binding.waitingText.text = getString(R.string.waiting_for_the_recipient_to_accept_files) + + viewModel.prepareUploadsFromVaultFiles() + binding.toolbar.backClickListener = { + navManager().navigateBackToStartNearBySharingFragmentAndClearBackStack() + } + + viewModel.prepareRejected.observe(viewLifecycleOwner) { event -> + event.getContentIfNotHandled()?.let { wasRejected -> + if (wasRejected) { + findNavController().previousBackStackEntry + ?.savedStateHandle + ?.set("transferRejected", true) + findNavController().popBackStack() + } + } + } + + viewModel.prepareResults.observe(viewLifecycleOwner) { response -> + val fileInfos = response.files + fileInfos.forEach { fileInfo -> + viewModel.p2PSharedState.session?.files?.let { filesMap -> + filesMap[fileInfo.id]?.transmissionId = fileInfo.transmissionId + } + } + navManager().navigateFromWaitingSenderFragmentToUploadFilesFragment() + } + + } +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/FileTransferViewModel.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/FileTransferViewModel.kt new file mode 100644 index 000000000..41b2df3e1 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/FileTransferViewModel.kt @@ -0,0 +1,215 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.hzontal.tella_vault.VaultFile +import dagger.hilt.android.lifecycle.HiltViewModel +import io.reactivex.Observable +import io.reactivex.Single +import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.horizontal.tella.mobile.MyApplication +import org.horizontal.tella.mobile.bus.SingleLiveEvent +import org.horizontal.tella.mobile.data.peertopeer.TellaPeerToPeerClient +import org.horizontal.tella.mobile.data.peertopeer.model.P2PFileStatus +import org.horizontal.tella.mobile.data.peertopeer.model.P2PSharedState +import org.horizontal.tella.mobile.data.peertopeer.model.SessionStatus +import org.horizontal.tella.mobile.data.peertopeer.remote.PrepareUploadResult +import org.horizontal.tella.mobile.domain.peertopeer.PeerPrepareUploadResponse +import org.horizontal.tella.mobile.media.MediaFileHandler +import org.horizontal.tella.mobile.util.Event +import org.horizontal.tella.mobile.util.fromJsonToObjectList +import org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow.PeerToPeerParticipant +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.state.UploadProgressState +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class FileTransferViewModel @Inject constructor( + private val peerClient: TellaPeerToPeerClient, + var p2PSharedState: P2PSharedState +) : ViewModel() { + private val _prepareResults = SingleLiveEvent() + val prepareResults: SingleLiveEvent = _prepareResults + private val _prepareRejected = SingleLiveEvent>() + val prepareRejected: SingleLiveEvent> = _prepareRejected + private val _uploadProgress = SingleLiveEvent() + val uploadProgress: SingleLiveEvent get() = _uploadProgress + var peerToPeerParticipant: PeerToPeerParticipant = PeerToPeerParticipant.SENDER + + fun putVaultFilesInForm(vaultFileList: String): Single> { + return Single.fromCallable { + vaultFileList.fromJsonToObjectList(String::class.java) ?: emptyList() + } + .flatMap { fileIds -> + MyApplication.keyRxVault.rxVault + .firstOrError() + .flatMap { rxVault -> + Observable.fromIterable(fileIds) + .flatMapSingle { fileId -> + rxVault[fileId] + .subscribeOn(Schedulers.io()) + .onErrorReturn { null } // safe, allows null + } + .filter { true } // filter out nulls + .map { it } // safe to force unwrap if you're sure it's not null now + .toList() + } + } + .subscribeOn(Schedulers.io()) + } + + fun prepareUploadsFromVaultFiles() { + Timber.d("session id ***prepareUploadsFromVaultFiles ${p2PSharedState.session?.sessionId}") + + viewModelScope.launch { + when (val result = peerClient.prepareUpload( + ip = p2PSharedState.ip, + port = p2PSharedState.port, + expectedFingerprint = p2PSharedState.hash, + title = getTitleFromState(), + files = getVaultFilesFromState(), + sessionId = getSessionId() + )) { + is PrepareUploadResult.Success -> { + _prepareResults.postValue(PeerPrepareUploadResponse(result.transmissions)) + } + + is PrepareUploadResult.Forbidden -> { + withContext(Dispatchers.Main) { + Timber.w("Rejected") + _prepareRejected.value = Event(true) + } + } + + is PrepareUploadResult.BadRequest -> { + Timber.e("Bad request – possibly invalid data") + } + + is PrepareUploadResult.Conflict -> { + Timber.e("Upload conflict – another session may be active") + } + + is PrepareUploadResult.ServerError -> { + Timber.e("Internal server error – try again later") + } + + is PrepareUploadResult.Failure -> { + Timber.e(result.exception, "Unhandled error during upload") + } + + } + } + } + + fun uploadAllFiles() { + viewModelScope.launch { + val session = p2PSharedState.session ?: return@launch + val ip = p2PSharedState.ip + val port = p2PSharedState.port + val fingerprint = p2PSharedState.hash + + val totalSize = session.files.values.sumOf { it.vaultFile?.size ?: 0L } + + fun postProgress() { + val uploaded = session.files.values.sumOf { it.bytesTransferred } + val percent = if (totalSize > 0) ((uploaded * 100) / totalSize).toInt() else 0 + _uploadProgress.postValue( + UploadProgressState( + title = session.title.orEmpty(), + percent = percent, + sessionStatus = session.status, + files = session.files.values.toList() + ) + ) + } + + // upload sequentially; each file reflects its true result + for (pf in session.files.values) { + val vf = pf.vaultFile ?: continue + val input = MediaFileHandler.getStream(vf) + pf.status = P2PFileStatus.SENDING + postProgress() + + try { + if (input != null) { + val ok = peerClient.uploadFileWithProgress( + ip = ip, + port = port, + expectedFingerprint = fingerprint, + sessionId = session.sessionId.orEmpty(), + fileId = pf.file.id, + transmissionId = pf.transmissionId.orEmpty(), + inputStream = input, + fileSize = vf.size, + fileName = vf.name, + ) { written, _ -> + pf.bytesTransferred = written.toInt() + postProgress() + } + pf.status = if (ok) P2PFileStatus.FINISHED else P2PFileStatus.FAILED + } else { + pf.status = P2PFileStatus.FAILED + } + } catch (e: Exception) { + pf.status = P2PFileStatus.FAILED + Timber.e(e, "Upload failed for ${pf.file.fileName}") + } finally { + input?.close() + } + postProgress() + } + + // session is done (partial or full) + session.status = SessionStatus.FINISHED + _uploadProgress.postValue( + UploadProgressState( + title = session.title.orEmpty(), + percent = 100, + sessionStatus = session.status, + files = session.files.values.toList() + ) + ) + } + } + + private fun getVaultFilesFromState(): List { + return p2PSharedState.session?.files + ?.values + ?.mapNotNull { it.vaultFile } + .orEmpty() + } + + private fun getTitleFromState(): String { + return p2PSharedState.session?.title ?: "" + } + + private fun getSessionId(): String { + return p2PSharedState.session?.sessionId ?: "" + } + + override fun onCleared() { + super.onCleared() + p2PSharedState.clear() + } + + fun closePeerConnection() { + viewModelScope.launch { + val ip = p2PSharedState.ip + val port = p2PSharedState.port + val fingerprint = p2PSharedState.hash + val success = peerClient.closeConnection( + ip = ip, + port = port, + expectedFingerprint = fingerprint, + sessionId = p2PSharedState.session?.sessionId ?: "" + ) + if (!success) Timber.e("Failed to close peer connection.") + } + } + +} + + diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/PeerToPeerViewModel.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/PeerToPeerViewModel.kt new file mode 100644 index 000000000..c4da1bfb4 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/PeerToPeerViewModel.kt @@ -0,0 +1,553 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel + +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.hzontal.tella_vault.VaultFile +import com.hzontal.utils.MediaFile.isImageFileType +import com.hzontal.utils.MediaFile.isVideoFileType +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import org.horizontal.tella.mobile.MyApplication +import org.horizontal.tella.mobile.bus.SingleLiveEvent +import org.horizontal.tella.mobile.data.peertopeer.FingerprintFetcher +import org.horizontal.tella.mobile.data.peertopeer.FingerprintResult +import org.horizontal.tella.mobile.data.peertopeer.ServerPinger +import org.horizontal.tella.mobile.data.peertopeer.TellaPeerToPeerClient +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.P2PSharedState +import org.horizontal.tella.mobile.data.peertopeer.model.P2PSharedState.Companion.createNewSession +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.PrepareUploadRequest +import org.horizontal.tella.mobile.data.peertopeer.remote.RegisterPeerResult +import org.horizontal.tella.mobile.domain.peertopeer.IncomingRegistration +import org.horizontal.tella.mobile.domain.peertopeer.PeerEventManager +import org.horizontal.tella.mobile.media.MediaFileHandler +import org.horizontal.tella.mobile.util.NetworkInfo +import org.horizontal.tella.mobile.util.NetworkInfoManager +import org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow.PeerToPeerParticipant +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.state.BottomSheetProgressState +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.state.UploadProgressState +import timber.log.Timber +import java.io.File +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +@HiltViewModel +class PeerToPeerViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val peerClient: TellaPeerToPeerClient, + peerToPeerManager: PeerToPeerManager, + val p2PState: P2PSharedState +) : ViewModel() { + + // ------------------- Public state / deps ------------------- + var peerToPeerParticipant: PeerToPeerParticipant = PeerToPeerParticipant.SENDER + var isManualConnection: Boolean = true + var hasNavigatedToSuccessFragment = false + var currentNetworkInfo: NetworkInfo? = null + + val clientHash = peerToPeerManager.clientConnected + private val networkInfoManager = NetworkInfoManager(context) + val networkInfo: LiveData get() = networkInfoManager.networkInfo + + // ------------------- Events to the UI ------------------- + private val _registrationSuccess = SingleLiveEvent() + val registrationSuccess: SingleLiveEvent get() = _registrationSuccess + + private val _registrationServerSuccess = SingleLiveEvent() + val registrationServerSuccess: SingleLiveEvent get() = _registrationServerSuccess + + private val _getHashSuccess = SingleLiveEvent() + val getHashSuccess: SingleLiveEvent get() = _getHashSuccess + + val bottomMessageError = SingleLiveEvent() + val bottomSheetError = SingleLiveEvent>() + + private val _incomingPrepareRequest = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + val incomingPrepareRequest: SharedFlow = _incomingPrepareRequest.asSharedFlow() + + private val _incomingRequest = MutableStateFlow(null) + val incomingRequest: StateFlow get() = _incomingRequest + + private val _uploadProgress = SingleLiveEvent() + val uploadProgress: SingleLiveEvent get() = _uploadProgress + + private val _bottomSheetProgress = MutableLiveData() + val bottomSheetProgress: LiveData get() = _bottomSheetProgress + + private val _closeConnection = SingleLiveEvent() + val closeConnection: SingleLiveEvent get() = _closeConnection + + // ------------------- Manual verify UI flags ------------------- + private val _canTapConfirm = MutableLiveData(false) + val canTapConfirm: LiveData get() = _canTapConfirm + + private val _waitingForOtherSide = MutableLiveData(false) + val waitingForOtherSide: LiveData get() = _waitingForOtherSide + + // Cache for "pre-accept" when recipient taps before the request arrives + private var preConfirmRegistration: Boolean = false + + // Keep connection params until user taps Confirm (sender path) + private data class PendingConnectParams( + val ip: String, + val port: String, + val hash: String, + val pin: String + ) + private var pendingParams: PendingConnectParams? = null + + // ------------------- Save counters ------------------- + private val savingOrDone: MutableSet = + Collections.newSetFromMap(ConcurrentHashMap()) + private var totalFilesExpected = 0 + private var savedCount = 0 + private var targetFolderId: String? = null + + // ------------------- Init: subscribe to streams ------------------- + init { + observePrepareUploadEvents() + observeRegistrationEvents() + observeRegistrationRequests() + observeUploadProgress() + observeCloseConnectionEvents() + } + + // ------------------- Observers ------------------- + private fun observePrepareUploadEvents() { + viewModelScope.launch { + PeerEventManager.prepareUploadRequests.collect { request -> + _incomingPrepareRequest.tryEmit(request) // your _incomingPrepareRequest already has replay = 1 + } + } + } + + private fun observeRegistrationEvents() { + viewModelScope.launch { + PeerEventManager.registrationEvents.collect { success -> + _registrationServerSuccess.postValue(success) + if (success) { + _waitingForOtherSide.postValue(false) + _canTapConfirm.postValue(false) // will navigate away + } + } + } + } + + private fun observeCloseConnectionEvents() { + viewModelScope.launch { + PeerEventManager.closeConnectionEvent.collect { success -> + if (success) { + p2PState.session?.status = SessionStatus.CLOSED + emitFinalIfReady(SessionStatus.CLOSED) + } + } + } + } + + private fun observeRegistrationRequests() { + viewModelScope.launch { + PeerEventManager.registrationRequests.collect { (registrationId, payload) -> + if (registrationId.isEmpty()) return@collect + + _incomingRequest.value = IncomingRegistration(registrationId, payload) + + if (!p2PState.isUsingManualConnection) { + // Auto mode: accept immediately + PeerEventManager.confirmRegistration(registrationId, true) + _registrationSuccess.postValue(true) + PeerEventManager.clearRegistrationRequest() + return@collect + } + + // Manual mode: if the recipient tapped confirm earlier, accept now. + if (preConfirmRegistration) { + PeerEventManager.confirmRegistration(registrationId, true) + PeerEventManager.clearRegistrationRequest() + preConfirmRegistration = false + } else { + // Otherwise, allow tapping now (if UI wants to reflect it) + _canTapConfirm.postValue(true) + } + } + } + } + + private fun observeUploadProgress() { + viewModelScope.launch { + PeerEventManager.uploadProgressStateFlow.collect { state -> + initCountersIfNeeded() + _uploadProgress.postValue(state) + + // Save each FINISHED file exactly once (by transmissionId) + state.files.forEach { pf -> + val txId = pf.transmissionId + if (txId != null && + pf.status == P2PFileStatus.FINISHED && + !savingOrDone.contains(txId) + ) { + viewModelScope.launch(Dispatchers.IO) { saveOneFile(pf) } + } + } + + emitFinalIfReady(state.sessionStatus) + } + } + } + + // ------------------- Manual verification entry points ------------------- + + /** + * Called after IP/port/PIN are entered and TLS cert is fetched. + * In manual mode we DO NOT auto-register. We enable the "Confirm & connect" button instead. + */ + fun handleCertificate(ip: String, port: String, pin: String) { + viewModelScope.launch { + val reachable = runCatching { peerClient.pingBeforeRegister(ip, port) }.getOrDefault(false) + if (!reachable) { + bottomSheetError.postValue( + "Connection failed" to "Host not reachable on this Wi-Fi. Check IP/Port and that both devices are on the same network." + ) + return@launch + } + + val fpRes: Result = FingerprintFetcher.fetch(context, ip, port.toInt()) + if (fpRes.isFailure) { + bottomSheetError.postValue( + "Connection failed" to ("Couldn’t read peer certificate. " + (fpRes.exceptionOrNull()?.message ?: "")) + ) + return@launch + } + + val fp = fpRes.getOrNull()!! + p2PState.hash = fp.certHex + _getHashSuccess.postValue(fp.certHex) + + val pinnedPingOk = runCatching { + ServerPinger.notifyServerPinnedByCert( + context = context, + ip = ip, + port = port.toInt(), + expectedCertSha256Hex = fp.certHex + ) + }.isSuccess + + // Manual verification path: wait for user tap + p2PState.isUsingManualConnection = true + pendingParams = PendingConnectParams(ip, port, fp.certHex, pin) + _canTapConfirm.postValue(true) + _waitingForOtherSide.postValue(false) + } + } + + /** Sender tapped confirm: actually initiate /register on peer (using cached params). */ + // In PeerToPeerViewModel + fun onUserTappedConfirmAndConnect() { + _canTapConfirm.postValue(false) + _waitingForOtherSide.postValue(true) + + val params = pendingParams + if (params != null) { + startRegistration(params.ip, params.port, params.hash, params.pin) + return + } + + // Fallback: try using current state or re-run handshake + val ip = p2PState.ip + val port = p2PState.port + val pin = p2PState.pin.orEmpty() + val hash = p2PState.hash + + if (hash.isNotBlank()) { + startRegistration(ip, port, hash, pin) + } else { + viewModelScope.launch { + handleCertificate(ip, port, pin) + pendingParams?.let { startRegistration(it.ip, it.port, it.hash, it.pin) } + ?: run { + _waitingForOtherSide.postValue(false) + _canTapConfirm.postValue(true) + } + } + } + } + + + + /** Recipient tapped confirm: allow pre-accept before request arrives. */ + fun onRecipientConfirmTapped() { + _canTapConfirm.postValue(false) + _waitingForOtherSide.postValue(true) // "Waiting for the sender…" + + val current = _incomingRequest.value + if (current != null) { + onUserConfirmedRegistration(current.registrationId) + } else { + preConfirmRegistration = true + } + } + + /** Recipient send acceptance to server. Do NOT post local registrationSuccess here. */ + fun onUserConfirmedRegistration(registrationId: String) { + viewModelScope.launch { + PeerEventManager.confirmRegistration(registrationId, true) + PeerEventManager.clearRegistrationRequest() + } + } + + fun onUserRejectedRegistration(registrationId: String) { + viewModelScope.launch { + PeerEventManager.confirmRegistration(registrationId, false) + PeerEventManager.clearRegistrationRequest() + } + } + + /** Sender/initiator path: call server /register */ + fun startRegistration(ip: String, port: String, hash: String, pin: String) { + viewModelScope.launch { + when (val result = peerClient.registerPeerDevice(ip, port, hash, pin)) { + is RegisterPeerResult.Success -> { + if (p2PState.session == null) p2PState.session = P2PSharedState.createNewSession() + p2PState.session?.sessionId = result.sessionId + _registrationSuccess.postValue(true) // Used by sender UI + } + RegisterPeerResult.InvalidPin -> bottomMessageError.postValue("Invalid PIN") + RegisterPeerResult.InvalidFormat -> bottomMessageError.postValue("Invalid request format") + RegisterPeerResult.Conflict -> bottomMessageError.postValue("Active session already exists") + RegisterPeerResult.TooManyRequests -> bottomMessageError.postValue("Too many requests, try again later") + RegisterPeerResult.ServerError -> bottomMessageError.postValue("Server error, try again later") + RegisterPeerResult.RejectedByReceiver -> bottomMessageError.postValue("Receiver rejected the registration") + is RegisterPeerResult.Failure -> { + Timber.e(result.exception, "Connection failure") + bottomSheetError.postValue( + "Connection failed" to "Please make sure your connection details are correct and that you are on the same Wi-Fi network." + ) + } + } + } + } + + // ------------------- Prepare/Upload/Save logic (unchanged from your version) ------------------- + + private fun initCountersIfNeeded() { + val session = p2PState.session ?: return + if (totalFilesExpected == 0) { + totalFilesExpected = session.files.size + savedCount = session.files.values.count { it.status == P2PFileStatus.SAVED } + session.files.values.forEach { pf -> + val tx = pf.transmissionId + if (!tx.isNullOrBlank() && (pf.status == P2PFileStatus.SAVED || pf.status == P2PFileStatus.FAILED)) { + savingOrDone.add(tx) + } + } + postBottomSheetProgress() + } + } + + private fun maybeFinalizeAfterSave() { + val session = p2PState.session ?: return + if (!allFilesSavedOrFailed()) return + + val final = when (session.status) { + SessionStatus.CLOSED -> SessionStatus.CLOSED + SessionStatus.SENDING, SessionStatus.SAVING -> computeFinalStatus() + SessionStatus.FINISHED, SessionStatus.FINISHED_WITH_ERRORS -> session.status + else -> computeFinalStatus() + } + + session.status = final + + _uploadProgress.postValue( + UploadProgressState( + title = session.title.orEmpty(), + sessionStatus = final, + files = session.files.values.toList(), + percent = 100 + ) + ) + } + + private fun obtainTargetFolderId(): String { + targetFolderId?.let { return it } + val title = (p2PState.session?.title ?: "").trim() + val finalTitle = if (title.isEmpty()) "Transfer" else title + + val vault = MyApplication.keyRxVault.rxVault.blockingFirst() + val root = vault.root.blockingGet() + val folder = vault.builder() + .setName(finalTitle) + .setType(VaultFile.Type.DIRECTORY) + .build(root.id) + .blockingGet() + targetFolderId = folder.id + return folder.id + } + + private fun saveOneFile(pf: ProgressFile) { + val txId = pf.transmissionId ?: return + if (!savingOrDone.add(txId)) return + + if (p2PState.session?.status == SessionStatus.SENDING) { + p2PState.session?.status = SessionStatus.SAVING + _uploadProgress.postValue( + UploadProgressState( + title = p2PState.session?.title.orEmpty(), + sessionStatus = SessionStatus.SAVING, + files = p2PState.session?.files?.values?.toList().orEmpty(), + percent = 100 + ) + ) + } + + try { + val path = pf.path ?: return + val f = File(path) + if (!f.exists()) return + + val folderId = obtainTargetFolderId() + val vault = MyApplication.keyRxVault.rxVault.blockingFirst() + + val vaultFile = try { + when { + isImageFileType(pf.file.fileType) -> { + val bytes = f.readBytes() + if (pf.file.fileType.contains("png", true)) { + MediaFileHandler.savePngImage(bytes) + } else { + MediaFileHandler.saveJpegPhoto(bytes, folderId).blockingGet() + } + } + isVideoFileType(pf.file.fileType) -> { + MediaFileHandler.saveMp4Video(f, folderId) + } + else -> { + vault.builder(f.inputStream()) + .setName(f.name) + .setMimeType(pf.file.fileType) + .setType(VaultFile.Type.FILE) + .build(folderId) + .blockingGet() + } + } + } catch (e: Exception) { + Timber.e(e, "Failed to save file ${pf.file.fileName}") + null + } + + val txKey = pf.transmissionId + if (txKey != null) { + p2PState.session?.files?.get(txKey)?.status = P2PFileStatus.SAVED + } + pf.status = P2PFileStatus.SAVED + pf.vaultFile = vaultFile + savedCount++ + f.delete() + } catch (e: Exception) { + Timber.e(e, "Saving to vault failed for file ${pf.file.fileName}") + pf.status = P2PFileStatus.FAILED + } finally { + postBottomSheetProgress() + maybeFinalizeAfterSave() + } + } + + private fun postBottomSheetProgress() { + _bottomSheetProgress.postValue( + BottomSheetProgressState( + current = savedCount, + total = totalFilesExpected, + percent = if (totalFilesExpected > 0) (savedCount * 100 / totalFilesExpected) else 0 + ) + ) + } + + private fun sessionIsTerminal(sessionStatus: SessionStatus): Boolean = + sessionStatus == SessionStatus.FINISHED || + sessionStatus == SessionStatus.FINISHED_WITH_ERRORS || + sessionStatus == SessionStatus.CLOSED + + private fun allFilesSavedOrFailed(): Boolean = + p2PState.session?.files?.values?.all { + it.status == P2PFileStatus.SAVED || it.status == P2PFileStatus.FAILED + } == true + + private fun computeFinalStatus(): SessionStatus { + val files = p2PState.session?.files?.values.orEmpty() + val anyFailed = files.any { it.status == P2PFileStatus.FAILED } + return if (anyFailed) SessionStatus.FINISHED_WITH_ERRORS else SessionStatus.FINISHED + } + + private fun emitFinalIfReady(triggerStatus: SessionStatus) { + val session = p2PState.session ?: return + if (!sessionIsTerminal(triggerStatus)) return + if (!allFilesSavedOrFailed()) return + + val final = when (session.status) { + SessionStatus.CLOSED -> SessionStatus.CLOSED + else -> computeFinalStatus() + } + session.status = final + + _uploadProgress.postValue( + UploadProgressState( + title = session.title.orEmpty(), + sessionStatus = final, + files = session.files.values.toList(), + percent = 100 + ) + ) + } + + // ------------------- Misc ------------------- + fun confirmPrepareUpload(sessionId: String, accepted: Boolean) { + PeerEventManager.resolveUserDecision(sessionId, accepted) + } + + fun clearPrepareRequest() { + hasNavigatedToSuccessFragment = false + } + + fun resetRegistrationState() { + _registrationServerSuccess.postValue(false) + } + + @RequiresApi(Build.VERSION_CODES.M) + fun updateNetworkInfo() { + networkInfoManager.fetchCurrentNetworkInfo() + } + + fun closePeerConnection() { + viewModelScope.launch { + val ip = p2PState.ip + val port = p2PState.port + val fingerprint = p2PState.hash + val success = peerClient.closeConnection( + ip = ip, + port = port, + expectedFingerprint = fingerprint, + sessionId = p2PState.session?.sessionId ?: "" + ) + if (!success) Timber.e("Failed to close peer connection.") + } + } + + override fun onCleared() { + super.onCleared() + p2PState.clear() + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/state/BottomSheetProgressState.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/state/BottomSheetProgressState.kt new file mode 100644 index 000000000..e251600c5 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/state/BottomSheetProgressState.kt @@ -0,0 +1,7 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.state + +data class BottomSheetProgressState( + val current: Int, + val total: Int, + val percent: Int +) \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/state/UploadProgressState.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/state/UploadProgressState.kt new file mode 100644 index 000000000..b41d36381 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/state/UploadProgressState.kt @@ -0,0 +1,11 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.state + +import org.horizontal.tella.mobile.data.peertopeer.model.ProgressFile +import org.horizontal.tella.mobile.data.peertopeer.model.SessionStatus + +data class UploadProgressState( + val title: String, + val percent: Int, + val sessionStatus: SessionStatus, + val files: List +) \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/recorder/MicActivity.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/recorder/MicActivity.kt index d0ae39280..c3c0ab21a 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/recorder/MicActivity.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/recorder/MicActivity.kt @@ -33,6 +33,7 @@ import org.horizontal.tella.mobile.views.activity.MetadataActivity import org.horizontal.tella.mobile.views.activity.camera.CameraActivity.Companion.VAULT_CURRENT_ROOT_PARENT import org.horizontal.tella.mobile.views.activity.viewer.toolBar import org.horizontal.tella.mobile.views.fragment.main_connexions.base.BUNDLE_REPORT_VAULT_FILE +import org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow.PREPARE_UPLOAD_ENTRY import org.horizontal.tella.mobile.views.interfaces.VerificationWorkStatusCallback import java.util.Locale import java.util.UUID @@ -44,6 +45,7 @@ class MicActivity : MetadataActivity(), private var animator: ObjectAnimator? = null private var isCollect: Boolean = false private var isReport: Boolean = false + private var isPrepareUpload = false private var notRecording = false private var lastUpdateTime: Long = 0 private var isAddingInProgress = false @@ -75,6 +77,7 @@ class MicActivity : MetadataActivity(), if (intent != null) { isCollect = intent.getBooleanExtra(COLLECT_ENTRY, false) isReport = intent.getBooleanExtra(REPORT_ENTRY, false) + isPrepareUpload = intent.getBooleanExtra(PREPARE_UPLOAD_ENTRY, false) currentRootParent = intent.getStringExtra(VAULT_CURRENT_ROOT_PARENT) } @@ -93,11 +96,11 @@ class MicActivity : MetadataActivity(), recordingName = findViewById(R.id.rec_name) toolBar = findViewById(R.id.toolbar) - if (isCollect || isReport || currentRootParent?.isNotEmpty() == true) { + if (isCollect || isReport || isPrepareUpload || currentRootParent?.isNotEmpty() == true) { mPlay.visibility = View.GONE } - if (isCollect || currentRootParent?.isNotEmpty() == true) { + if (isCollect || isPrepareUpload || currentRootParent?.isNotEmpty() == true) { toolBar.navigationIcon = ContextCompat.getDrawable(this, R.drawable.ic_close_white) @@ -306,7 +309,7 @@ class MicActivity : MetadataActivity(), } private fun maybeReturnCollectRecording(vaultFile: VaultFile?) { - if (isCollect) { + if (isCollect || isPrepareUpload) { MyApplication.bus().post(AudioRecordEvent(vaultFile)) } if (isReport) { diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/uwazi/widgets/PeerToPeerEndView.java b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/uwazi/widgets/PeerToPeerEndView.java new file mode 100644 index 000000000..3a80788d4 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/uwazi/widgets/PeerToPeerEndView.java @@ -0,0 +1,193 @@ +package org.horizontal.tella.mobile.views.fragment.uwazi.widgets; + +import android.content.Context; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.google.android.material.progressindicator.LinearProgressIndicator; + +import org.horizontal.tella.mobile.R; +import org.horizontal.tella.mobile.data.peertopeer.model.P2PFileStatus; +import org.horizontal.tella.mobile.data.peertopeer.model.ProgressFile; +import org.horizontal.tella.mobile.data.peertopeer.model.SessionStatus; +import org.horizontal.tella.mobile.util.FileUtil; +import org.hzontal.shared_ui.submission.SubmittingItem; + +import java.util.List; + +public class PeerToPeerEndView extends FrameLayout { + private final LinearProgressIndicator totalProgress; + private final TextView titleView; + private final TextView formSizeView; + private LinearLayout partsListView; + + private final String title; + private boolean previewUploaded; + + public PeerToPeerEndView(Context context, String title) { + super(context); + inflate(context, R.layout.reports_form_end_view, this); + + this.title = title; + + titleView = findViewById(R.id.title); + titleView.setText(title); + + totalProgress = findViewById(R.id.totalProgress); + formSizeView = findViewById(R.id.formSize); + } + + public void setFiles(List progressFiles, boolean offline, boolean previewUploaded) { + this.previewUploaded = previewUploaded; + + titleView.setText(title); + + partsListView = findViewById(R.id.formPartsList); + partsListView.removeAllViews(); + + for (ProgressFile file : progressFiles) { + partsListView.addView(createProgressFileItemView(file, offline)); + } + + uploadProgressVisibility(progressFiles, true); + setUploadProgress(progressFiles, 0f); + } + + public void setUploadProgress(List progressFiles, float pct) { + if (pct < 0 || pct > 1) return; + + int percentComplete = getTotalUploadedSizePercent(progressFiles); + + for (ProgressFile file : progressFiles) { + String tagId = file.getVaultFile() != null && file.getVaultFile().id != null + ? file.getVaultFile().id + : file.getFile().getId(); + + if (isUploadedStatus(file.getStatus())) { + SubmittingItem item = partsListView.findViewWithTag(tagId); + if (item != null) item.setPartUploaded(); + } + } + + totalProgress.setProgressCompat(percentComplete, true); + setFormSizeLabel(progressFiles, percentComplete); + } + + public void clearPartsProgress(List progressFiles, SessionStatus sessionStatus) { + for (ProgressFile file : progressFiles) { + String tagId = file.getVaultFile() != null && file.getVaultFile().id != null + ? file.getVaultFile().id + : file.getFile().getId(); + + SubmittingItem item = partsListView.findViewWithTag(tagId); + if (item != null) { + if (sessionStatus == SessionStatus.FINISHED || isUploadedStatus(file.getStatus())) { + item.setPartUploaded(); + } else { + item.setPartCleared(); + } + } + } + } + + private int getTotalUploadedSizePercent(List progressFiles) { + long totalUploadedSize = 0; + long totalSize = 0; + + for (ProgressFile file : progressFiles) { + totalUploadedSize += file.getBytesTransferred(); + + long size = file.getVaultFile() != null && file.getVaultFile().size > 0 + ? file.getVaultFile().size + : file.getFile().getSize(); + + totalSize += size; + } + + return totalSize > 0 ? Math.round((totalUploadedSize * 1f / totalSize) * 100) : 0; + } + + private void setFormSizeLabel(List files, int percent) { + int count = files.size(); + long totalSize = 0; + long totalUploaded = 0; + + for (ProgressFile file : files) { + long size = file.getVaultFile() != null && file.getVaultFile().size > 0 + ? file.getVaultFile().size + : file.getFile().getSize(); + + totalSize += size; + totalUploaded += file.getBytesTransferred(); + } + + String label = percent + "% " + getContext().getString(R.string.File_Uploaded) + "\n" + + getResources().getQuantityString(R.plurals.upload_main_meta_number_of_files, count, count) + ", " + + FileUtil.getFileSize(totalUploaded) + "/" + FileUtil.getFileSize(totalSize); + + formSizeView.setText(label); + } + + private void uploadProgressVisibility(List files, boolean isOnline) { + if (!isOnline || files.isEmpty()) { + totalProgress.setVisibility(GONE); + } else { + totalProgress.setVisibility(VISIBLE); + } + } + + private View createProgressFileItemView(@NonNull ProgressFile file, boolean offline) { + SubmittingItem item = new SubmittingItem(getContext(), null, 0); + ImageView thumbView = item.findViewById(R.id.fileThumb); + + String tagId = file.getVaultFile() != null && file.getVaultFile().id != null + ? file.getVaultFile().id + : file.getFile().getId(); + item.setTag(tagId); + + String name = file.getVaultFile() != null && file.getVaultFile().name != null + ? file.getVaultFile().name + : file.getFile().getFileName(); + item.setPartName(name); + + long size = file.getVaultFile() != null && file.getVaultFile().size > 0 + ? file.getVaultFile().size + : file.getFile().getSize(); + item.setPartSize(size); + + byte[] thumb = file.getVaultFile() != null && file.getVaultFile().thumb != null + ? file.getVaultFile().thumb + : file.getFile().getThumbnail(); + + if (thumb != null) { + Glide.with(getContext()) + .load(thumb) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .into(thumbView); + } + + if (isUploadedStatus(file.getStatus())) { + item.setPartUploaded(); + } else { + if (previewUploaded) { + item.setPartUploaded(); + } else { + item.setPartPrepared(offline); + } + } + + return item; + } + + private boolean isUploadedStatus(P2PFileStatus status) { + return status == P2PFileStatus.FINISHED; + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/VaultAdapter.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/VaultAdapter.kt index 4d2ebd342..bb41e2263 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/VaultAdapter.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/VaultAdapter.kt @@ -11,6 +11,7 @@ import org.horizontal.tella.mobile.data.sharedpref.Preferences import org.horizontal.tella.mobile.data.sharedpref.Preferences.hasAcceptedAnalytics import org.horizontal.tella.mobile.data.sharedpref.Preferences.isShowVaultAnalyticsSection import org.horizontal.tella.mobile.data.sharedpref.Preferences.isTimeToShowReminderAnalytics +import org.horizontal.tella.mobile.domain.entity.ServerType import org.horizontal.tella.mobile.domain.entity.collect.CollectForm import org.horizontal.tella.mobile.domain.entity.uwazi.UwaziTemplate import org.horizontal.tella.mobile.views.fragment.vault.adapters.connections.ServerDataItem @@ -57,16 +58,20 @@ class VaultAdapter(private val onClick: VaultClickListener) : return oldList.zip(newList).all { (oldItem, newItem) -> oldItem == newItem } } - fun addConnectionServers(connectionsList: List) { - val sortedConnectionsList = connectionsList.sortedBy { server -> server.type } - val newConnectionsItem = DataItem.ConnectionsItem(sortedConnectionsList) + private fun ServerType.rank(): Int = when (this) { + ServerType.ADD_BUTTON -> Int.MAX_VALUE // always last + else -> ordinal // or a custom map if you want a specific order + } - // Check if the current connections are the same as the new ones - if (favoriteForms.isEmpty() || !areListsEqual( - connectionsList.first().servers, newConnectionsItem.item - ) - ) { - connections = mutableListOf(newConnectionsItem) + fun addConnectionServers(connectionsList: List) { + val ordered = connectionsList.sortedWith( + compareBy { it.type.rank() } + .thenBy { it.type.name } // secondary, harmless + ) + val newItem = DataItem.ConnectionsItem(ordered) + val same = connections.firstOrNull()?.item == newItem.item + if (!same) { + connections = mutableListOf(newItem) updateItems() } } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/connections/ServerAdapter.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/connections/ServerAdapter.kt index 8088081dc..9de87ab5b 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/connections/ServerAdapter.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/connections/ServerAdapter.kt @@ -1,21 +1,46 @@ package org.horizontal.tella.mobile.views.fragment.vault.adapters.connections +import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.domain.entity.ServerType import org.horizontal.tella.mobile.views.fragment.vault.adapters.VaultClickListener class ServerAdapter( - val list: List, + private val list: List, private val vaultClickListener: VaultClickListener -) : RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ServerViewHolder { - return ServerViewHolder.from(parent) +) : RecyclerView.Adapter() { + + private companion object { + const val TYPE_SERVER = 0 + const val TYPE_ADD = 1 + } + + override fun getItemViewType(position: Int): Int = + if (list[position].type == ServerType.ADD_BUTTON) TYPE_ADD else TYPE_SERVER + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return if (viewType == TYPE_ADD) { + val v = inflater.inflate(R.layout.item_add_connection_card, parent, false) + AddButtonViewHolder(v) + } else { + ServerViewHolder.from(parent) // your existing server card holder + } } - override fun onBindViewHolder(holder: ServerViewHolder, position: Int) { - holder.bind(list[position], vaultClickListener) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = list[position] + if (holder is AddButtonViewHolder) { + holder.itemView.setOnClickListener { vaultClickListener.onServerItemClickListener(item) } + } else if (holder is ServerViewHolder) { + holder.bind(item, vaultClickListener) + } } override fun getItemCount() = list.size -} \ No newline at end of file + private class AddButtonViewHolder(view: View) : RecyclerView.ViewHolder(view) +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/connections/ServerViewHolder.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/connections/ServerViewHolder.kt index 7dcb67d74..390877fc8 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/connections/ServerViewHolder.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/connections/ServerViewHolder.kt @@ -14,15 +14,14 @@ import org.horizontal.tella.mobile.views.fragment.vault.adapters.viewholders.bas class ServerViewHolder(val view: View) : BaseViewHolder(view) { private lateinit var reportTypeTextView: TextView private lateinit var reportTypeImg: ImageView + private lateinit var twoLinesReportTypeTextView: TextView + override fun bind(item: ServerDataItem, vaultClickListener: VaultClickListener) { reportTypeTextView = view.findViewById(R.id.server_name_textView) + twoLinesReportTypeTextView = view.findViewById(R.id.two_line_server_name_textView) reportTypeImg = view.findViewById(R.id.server_img) - // Set the default padding - // val defaultPadding = view.context.resources.getDimensionPixelSize(R.dimen.hide_tella_small_margin) - // view.setPadding(view.paddingLeft, defaultPadding, view.paddingRight, view.paddingBottom) - when (item.type) { ServerType.UWAZI -> { reportTypeTextView.text = view.context.getString(R.string.Home_BottomNav_Uwazi) @@ -34,6 +33,7 @@ class ServerViewHolder(val view: View) : BaseViewHolder(view) { ) ) } + ServerType.TELLA_UPLOAD -> { reportTypeTextView.text = view.context.getText(R.string.Home_BottomNav_Reports) reportTypeImg.setImageDrawable( @@ -44,6 +44,7 @@ class ServerViewHolder(val view: View) : BaseViewHolder(view) { ) ) } + ServerType.TELLA_RESORCES -> { reportTypeTextView.text = view.context.getText(R.string.Home_BottomNav_Resources) reportTypeImg.setImageDrawable( @@ -54,6 +55,7 @@ class ServerViewHolder(val view: View) : BaseViewHolder(view) { ) ) } + ServerType.ODK_COLLECT -> { reportTypeTextView.text = view.context.getText(R.string.Home_BottomNav_Forms) reportTypeImg.setImageDrawable( @@ -64,6 +66,7 @@ class ServerViewHolder(val view: View) : BaseViewHolder(view) { ) ) } + ServerType.GOOGLE_DRIVE -> { reportTypeTextView.text = view.context.getText(R.string.google_drive) reportTypeImg.setImageDrawable( @@ -74,6 +77,7 @@ class ServerViewHolder(val view: View) : BaseViewHolder(view) { ) ) } + ServerType.DROP_BOX -> { reportTypeTextView.text = view.context.getString(R.string.dropbox) reportTypeImg.setImageDrawable( @@ -84,6 +88,7 @@ class ServerViewHolder(val view: View) : BaseViewHolder(view) { ) ) } + ServerType.NEXTCLOUD -> { reportTypeTextView.text = view.context.getString(R.string.NextCloud) reportTypeImg.setImageDrawable( @@ -94,7 +99,19 @@ class ServerViewHolder(val view: View) : BaseViewHolder(view) { ) ) } - else -> { // todo create default server type + + ServerType.PEERTOPEER -> { + twoLinesReportTypeTextView.text = view.context.getText(R.string.NearBySharing) + reportTypeImg.setImageDrawable( + ResourcesCompat.getDrawable( + view.resources, + R.drawable.ic_p2p, + null + ) + ) + } + + else -> { } } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/attachements/AttachmentsFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/attachements/AttachmentsFragment.kt index af90a8a86..3bf15929f 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/attachements/AttachmentsFragment.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/attachements/AttachmentsFragment.kt @@ -324,7 +324,6 @@ class AttachmentsFragment : private fun createVaultManageFilesAction(): VaultSheetUtils.IVaultManageFiles { return object : VaultSheetUtils.IVaultManageFiles { override fun goToCamera() { - val intent = Intent(activity, CameraActivity::class.java) intent.putExtra(VAULT_CURRENT_ROOT_PARENT, currentRootID) baseActivity.startActivity(intent) diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/attachements/AttachmentsViewModel.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/attachements/AttachmentsViewModel.kt index cf5a46298..1abf9a7a7 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/attachements/AttachmentsViewModel.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/attachements/AttachmentsViewModel.kt @@ -61,7 +61,6 @@ class AttachmentsViewModel @Inject constructor( val duplicateNameError: LiveData = _duplicateNameError val counterData = MutableLiveData() - //TODO AHLEM FIX THIS val counterData: LiveData = _counterData private val _progressPercent = MutableLiveData>() val progressPercent: LiveData> = _progressPercent private val _mediaImportedWithDelete = MutableLiveData() @@ -110,7 +109,6 @@ class AttachmentsViewModel @Inject constructor( ) } - fun moveFiles(parentId: String?, vaultFiles: List?) { if (vaultFiles == null || parentId == null) return @@ -174,7 +172,6 @@ class AttachmentsViewModel @Inject constructor( ) } - private fun deleteFile(vaultFile: VaultFile): Single { return MyApplication.keyRxVault.rxVault .firstOrError() @@ -183,7 +180,6 @@ class AttachmentsViewModel @Inject constructor( .observeOn(AndroidSchedulers.mainThread()) } - fun createFolder(folderName: String, parent: String) { disposables.add( MyApplication.keyRxVault.rxVault @@ -226,8 +222,7 @@ class AttachmentsViewModel @Inject constructor( fun importVaultFiles(uris: List, parentId: String?, deleteOriginal: Boolean) { if (uris.isEmpty()) return - // counterData.value = 0 - // var counter = 1 + var currentUri: Uri? = null disposables.add(Flowable.fromIterable(uris).flatMap { uri -> MediaFileHandler.importVaultFileUri(getApplication(), uri, parentId).toFlowable() diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/home/HomeVaultFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/home/HomeVaultFragment.kt index f51400974..670637248 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/home/HomeVaultFragment.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/home/HomeVaultFragment.kt @@ -56,6 +56,7 @@ import org.horizontal.tella.mobile.util.TopSheetTestUtils.showBackgroundActiviti import org.horizontal.tella.mobile.util.setMargins import org.horizontal.tella.mobile.views.activity.CollectFormEntryActivity import org.horizontal.tella.mobile.views.activity.MainActivity +import org.horizontal.tella.mobile.views.activity.ServersSettingsActivity import org.horizontal.tella.mobile.views.activity.analytics.AnalyticsActions import org.horizontal.tella.mobile.views.activity.analytics.AnalyticsIntroActivity import org.horizontal.tella.mobile.views.activity.viewer.AudioPlayActivity @@ -98,7 +99,6 @@ class HomeVaultFragment : BaseFragment(), VaultClickListener { private var googleDriveServers: ArrayList? = null private var dropBoxServers: ArrayList? = null private var nextCloudServers: ArrayList? = null - private var favoriteForms: ArrayList? = null private lateinit var disposables: EventCompositeDisposable private var reportServersCounted = false private var collectServersCounted = false @@ -459,6 +459,19 @@ class HomeVaultFragment : BaseFragment(), VaultClickListener { nav().navigate(R.id.action_homeScreen_to_next_cloud_screen) } + ServerType.PEERTOPEER -> { + nav().navigate(R.id.action_homeScreen_to_peerToPeer_screen) + } + + ServerType.ADD_BUTTON -> { + baseActivity.startActivity( + Intent( + baseActivity, + ServersSettingsActivity::class.java + ) + ) + } + else -> {} } } @@ -739,14 +752,14 @@ class HomeVaultFragment : BaseFragment(), VaultClickListener { if (serversList?.isEmpty() == false) { // Use the vaultAdapter to check existing connections vaultAdapter.addConnectionServers(serversList!!) - } else { vaultAdapter.removeConnectionServers() } } private fun handleServerCountsSuccess(serverCounts: ServerCounts) { - // Handle each server type + serversList?.clear() + handleGoogleDriveServers(serverCounts.googleDriveServers) handleDropBoxServers(serverCounts.dropBoxServers) handleNextCloudServers(serverCounts.nextCloudServers) @@ -754,10 +767,16 @@ class HomeVaultFragment : BaseFragment(), VaultClickListener { handleCollectServers(serverCounts.collectServers) handleUwaziServers(serverCounts.uwaziServers) - // Check if we need to show connections + if (Preferences.isEnableHomeNearby()) { + serversList?.add(ServerDataItem(emptyList(), ServerType.PEERTOPEER)) + } + + serversList?.add(ServerDataItem(emptyList(), ServerType.ADD_BUTTON)) + maybeShowConnections() } + private fun handleServerCountsError(error: Throwable?) { error?.let { Timber.d("***onServerCountFailed**$it") diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/settings/LanguageSettings.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/settings/LanguageSettings.kt index 015e0d738..e166126d6 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/settings/LanguageSettings.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/settings/LanguageSettings.kt @@ -49,7 +49,7 @@ class LanguageSettings : BaseFragment(), View.OnClickListener { private fun createLangViews() { if (languages.isEmpty()) { languages = - ArrayList(Arrays.asList(*resources.getStringArray(R.array.ra_lang_codes))) + ArrayList(listOf(*resources.getStringArray(R.array.ra_lang_codes))) languages.add(0, null) val prefferedLang = LocaleManager.getInstance().languageSetting diff --git a/mobile/src/main/res/drawable-xhdpi/ic_add_connection.png b/mobile/src/main/res/drawable-xhdpi/ic_add_connection.png new file mode 100644 index 000000000..6c0482011 Binary files /dev/null and b/mobile/src/main/res/drawable-xhdpi/ic_add_connection.png differ diff --git a/mobile/src/main/res/drawable-xxhdpi/ic_add_connection.png b/mobile/src/main/res/drawable-xxhdpi/ic_add_connection.png new file mode 100644 index 000000000..9df731cfb Binary files /dev/null and b/mobile/src/main/res/drawable-xxhdpi/ic_add_connection.png differ diff --git a/mobile/src/main/res/drawable/bg_add_connection_dashed.xml b/mobile/src/main/res/drawable/bg_add_connection_dashed.xml new file mode 100644 index 000000000..8c359f35f --- /dev/null +++ b/mobile/src/main/res/drawable/bg_add_connection_dashed.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/mobile/src/main/res/drawable/ic_add_connection.png b/mobile/src/main/res/drawable/ic_add_connection.png new file mode 100644 index 000000000..c7506181e Binary files /dev/null and b/mobile/src/main/res/drawable/ic_add_connection.png differ diff --git a/mobile/src/main/res/drawable/ic_add_home.xml b/mobile/src/main/res/drawable/ic_add_home.xml new file mode 100644 index 000000000..270493f5c --- /dev/null +++ b/mobile/src/main/res/drawable/ic_add_home.xml @@ -0,0 +1,11 @@ + + + diff --git a/mobile/src/main/res/drawable/ic_files.xml b/mobile/src/main/res/drawable/ic_files.xml new file mode 100644 index 000000000..684c3c57b --- /dev/null +++ b/mobile/src/main/res/drawable/ic_files.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + diff --git a/mobile/src/main/res/drawable/ic_p2p.xml b/mobile/src/main/res/drawable/ic_p2p.xml new file mode 100644 index 000000000..69ed0f6f2 --- /dev/null +++ b/mobile/src/main/res/drawable/ic_p2p.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/mobile/src/main/res/drawable/ic_p2p_sharing.png b/mobile/src/main/res/drawable/ic_p2p_sharing.png new file mode 100644 index 000000000..d827a7ec5 Binary files /dev/null and b/mobile/src/main/res/drawable/ic_p2p_sharing.png differ diff --git a/mobile/src/main/res/drawable/ic_p2p_small.xml b/mobile/src/main/res/drawable/ic_p2p_small.xml new file mode 100644 index 000000000..689212efc --- /dev/null +++ b/mobile/src/main/res/drawable/ic_p2p_small.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/mobile/src/main/res/drawable/ic_share.xml b/mobile/src/main/res/drawable/ic_share.xml new file mode 100644 index 000000000..1938144ea --- /dev/null +++ b/mobile/src/main/res/drawable/ic_share.xml @@ -0,0 +1,10 @@ + + + diff --git a/mobile/src/main/res/drawable/ic_share_big.xml b/mobile/src/main/res/drawable/ic_share_big.xml new file mode 100644 index 000000000..fd3222f58 --- /dev/null +++ b/mobile/src/main/res/drawable/ic_share_big.xml @@ -0,0 +1,10 @@ + + + diff --git a/mobile/src/main/res/drawable/icon_wifi.xml b/mobile/src/main/res/drawable/icon_wifi.xml new file mode 100644 index 000000000..79f46f896 --- /dev/null +++ b/mobile/src/main/res/drawable/icon_wifi.xml @@ -0,0 +1,13 @@ + + + diff --git a/mobile/src/main/res/drawable/qr_scan_border.xml b/mobile/src/main/res/drawable/qr_scan_border.xml new file mode 100644 index 000000000..2ef2efe84 --- /dev/null +++ b/mobile/src/main/res/drawable/qr_scan_border.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/mobile/src/main/res/drawable/qr_scan_border_camera.xml b/mobile/src/main/res/drawable/qr_scan_border_camera.xml new file mode 100644 index 000000000..fa868009e --- /dev/null +++ b/mobile/src/main/res/drawable/qr_scan_border_camera.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/src/main/res/layout/activity_documentation_settings.xml b/mobile/src/main/res/layout/activity_documentation_settings.xml index 1d10e0668..b50ae37e5 100644 --- a/mobile/src/main/res/layout/activity_documentation_settings.xml +++ b/mobile/src/main/res/layout/activity_documentation_settings.xml @@ -247,6 +247,19 @@ + + + diff --git a/mobile/src/main/res/layout/activity_peer_to_peer.xml b/mobile/src/main/res/layout/activity_peer_to_peer.xml new file mode 100644 index 000000000..06985463c --- /dev/null +++ b/mobile/src/main/res/layout/activity_peer_to_peer.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/mobile/src/main/res/layout/activity_settings.xml b/mobile/src/main/res/layout/activity_settings.xml index 1f42657af..ecaed4dda 100644 --- a/mobile/src/main/res/layout/activity_settings.xml +++ b/mobile/src/main/res/layout/activity_settings.xml @@ -4,27 +4,27 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:background="@color/dark_purple" android:fitsSystemWindows="true" android:textDirection="locale" - android:background="@color/dark_purple" tools:context="org.horizontal.tella.mobile.views.activity.SettingsActivity"> + app:layout_constraintTop_toTopOf="parent"> + app:arrowBackIcon="@drawable/ic_arrow_back_white_24dp" + app:arrowBackIconContentDescription="@string/action_go_back" /> @@ -39,5 +39,5 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/appbar" - app:navGraph="@navigation/settings_navigation"/> + app:navGraph="@navigation/settings_navigation" /> diff --git a/mobile/src/main/res/layout/connect_hotspot_layout.xml b/mobile/src/main/res/layout/connect_hotspot_layout.xml new file mode 100644 index 000000000..221094c99 --- /dev/null +++ b/mobile/src/main/res/layout/connect_hotspot_layout.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mobile/src/main/res/layout/connect_manually_verification.xml b/mobile/src/main/res/layout/connect_manually_verification.xml new file mode 100644 index 000000000..98011d513 --- /dev/null +++ b/mobile/src/main/res/layout/connect_manually_verification.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/layout/fragment_peer_to_peer_result.xml b/mobile/src/main/res/layout/fragment_peer_to_peer_result.xml new file mode 100644 index 000000000..68e5072a2 --- /dev/null +++ b/mobile/src/main/res/layout/fragment_peer_to_peer_result.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/mobile/src/main/res/layout/fragment_prepare_upload.xml b/mobile/src/main/res/layout/fragment_prepare_upload.xml new file mode 100644 index 000000000..9a97778df --- /dev/null +++ b/mobile/src/main/res/layout/fragment_prepare_upload.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +