diff --git a/mobile/build.gradle b/mobile/build.gradle index 1aab63e52..0e1148472 100644 --- a/mobile/build.gradle +++ b/mobile/build.gradle @@ -69,8 +69,8 @@ android { minSdk versions.minSdk targetSdk versions.targetSdk // F-Droid fastlane changelog: fastlane/metadata/android/en-US/changelogs/.txt - versionCode 242 - versionName "3.1.0" + versionCode 244 + versionName "3.2.0" multiDexEnabled true ndk { abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64" } @@ -171,7 +171,9 @@ android { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } - kotlinOptions { jvmTarget = '17' } + kotlinOptions { jvmTarget = '17' + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } buildFeatures { viewBinding = true } 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 index cc07a7c92..2e7fe626c 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/certificate/CertificateUtils.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/certificate/CertificateUtils.kt @@ -2,9 +2,11 @@ package org.horizontal.tella.mobile.certificate import android.util.Base64 import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x509.ExtendedKeyUsage import org.bouncycastle.asn1.x509.Extension import org.bouncycastle.asn1.x509.GeneralName import org.bouncycastle.asn1.x509.GeneralNames +import org.bouncycastle.asn1.x509.KeyPurposeId import org.bouncycastle.asn1.x509.KeyUsage import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder @@ -31,6 +33,31 @@ object CertificateUtils { } + /** Session client certificate for the sender role (no IP SAN required). */ + fun generateSelfSignedClientCertificate(keyPair: KeyPair): X509Certificate { + val now = Date() + val until = Date(now.time + 365L * 24 * 60 * 60 * 1000) + val serial = BigInteger(128, SecureRandom()) + val dn = X500Name("CN=Tella P2P Client, O=Tella, C=US") + val certBuilder = JcaX509v3CertificateBuilder( + dn, serial, now, until, dn, keyPair.public + ) + certBuilder.addExtension( + Extension.basicConstraints, true, org.bouncycastle.asn1.x509.BasicConstraints(false) + ) + certBuilder.addExtension( + Extension.keyUsage, true, KeyUsage(KeyUsage.digitalSignature or KeyUsage.keyEncipherment) + ) + // Some TLS stacks refuse client certs that carry KeyUsage but no clientAuth EKU. + certBuilder.addExtension( + Extension.extendedKeyUsage, false, ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth) + ) + val signer = JcaContentSignerBuilder("SHA256WithRSAEncryption").build(keyPair.private) + val certHolder = certBuilder.build(signer) + return JcaX509CertificateConverter().setProvider(org.bouncycastle.jce.provider.BouncyCastleProvider()) + .getCertificate(certHolder) + } + fun generateSelfSignedCertificate(keyPair: KeyPair, ipAddresses: List): X509Certificate { val uniqueIps = ipAddresses.map { it.trim() }.filter { it.isNotEmpty() }.distinct() require(uniqueIps.isNotEmpty()) { "At least one IP is required for the P2P certificate SAN." } @@ -106,6 +133,41 @@ object CertificateUtils { } } + /** + * Server-side trust manager that accepts only TLS client certificates whose leaf DER hash + * matches [expectedLeafCertSha256Hex]. Used after the receiver pins the sender hash. + */ + fun getLeafCertPinnedClientTrustManager(expectedLeafCertSha256Hex: String): X509TrustManager { + val expected = normalizeHex(expectedLeafCertSha256Hex) + return object : X509TrustManager { + override fun getAcceptedIssuers(): Array = emptyArray() + override fun checkServerTrusted(chain: Array?, authType: String?) = Unit + override fun checkClientTrusted(chain: Array?, authType: String?) { + val leaf = chain?.firstOrNull() + ?: throw CertificateException("Empty client certificate chain") + leaf.checkValidity() + val actual = normalizeHex(getLeafCertificateDerSha256Hex(leaf)) + if (actual != expected) { + throw CertificateException("Client certificate hash mismatch") + } + } + } + } + + /** + * Accepts any valid client certificate (for extracting sender hash before pinning). + */ + fun getClientFingerprintCollectionTrustManager(): X509TrustManager = + object : X509TrustManager { + override fun getAcceptedIssuers(): Array = emptyArray() + override fun checkServerTrusted(chain: Array?, authType: String?) = Unit + override fun checkClientTrusted(chain: Array?, authType: String?) { + val leaf = chain?.firstOrNull() + ?: throw CertificateException("Empty client certificate chain") + leaf.checkValidity() + } + } + /** * Bootstrap trust manager for first-contact fingerprint collection: * it requires a present, currently-valid X.509 leaf cert, but does not require CA-chain trust. 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 index 4e53eb365..44fe15654 100644 --- 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 @@ -19,6 +19,7 @@ import java.io.IOException import java.net.InetSocketAddress import java.net.Socket import java.net.SocketTimeoutException +import java.security.KeyPair import java.security.MessageDigest import java.security.SecureRandom import java.security.cert.X509Certificate @@ -28,6 +29,7 @@ import javax.net.ssl.SSLHandshakeException import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager +import org.horizontal.tella.mobile.data.peertopeer.PeerMtlsSsl import kotlin.coroutines.resume /** @@ -48,15 +50,34 @@ object FingerprintFetcher { // Public: PRE-PIN handshake to read cert (no HTTP), then compute hashes // --------------------------------------------------------------------- suspend fun fetch(context: Context, ip: String, port: Int): Result = - withContext(Dispatchers.IO) { + fetchWithClientIdentity(context, ip, port, clientKeyPair = null, clientCertificate = null) + + /** + * TLS handshake that optionally presents a sender client certificate (mTLS). + */ + suspend fun fetchWithClientIdentity( + context: Context, + ip: String, + port: Int, + clientKeyPair: KeyPair?, + clientCertificate: X509Certificate?, + pinnedReceiverHash: String? = null, + ): 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 (system CA validation) to read leaf cert - createBoundTlsSocket(ip, port, wifi).use { tls -> + // 2) TLS handshake to read peer leaf cert + createBoundTlsSocket( + ip = ip, + port = port, + wifi = wifi, + clientKeyPair = clientKeyPair, + clientCertificate = clientCertificate, + pinnedReceiverHash = pinnedReceiverHash, + ).use { tls -> val cert = tls.session.peerCertificates.first() as X509Certificate return@withContext Result.success(fingerprintFromCert(cert)) } @@ -92,11 +113,21 @@ object FingerprintFetcher { fun buildClientPinnedByCertHash( expectedCertSha256Hex: String, hostForRequests: String, - network: Network? = null + network: Network? = null, + clientKeyPair: KeyPair? = null, + clientCertificate: X509Certificate? = null, ): OkHttpClient { - val trustManager = CertificateUtils.getLeafCertPinnedTrustManager(expectedCertSha256Hex) - val sslContext = SSLContext.getInstance("TLS").apply { - init(null, arrayOf(trustManager), SecureRandom()) + val trustManager: X509TrustManager = if (expectedCertSha256Hex.isBlank()) { + CertificateUtils.getFingerprintCollectionTrustManager() + } else { + CertificateUtils.getLeafCertPinnedTrustManager(expectedCertSha256Hex) + } + val sslContext = if (clientKeyPair != null && clientCertificate != null) { + PeerMtlsSsl.createSenderSslContext(clientKeyPair, clientCertificate, expectedCertSha256Hex.takeIf { it.isNotBlank() }) + } else { + SSLContext.getInstance("TLS").apply { + init(null, arrayOf(trustManager), SecureRandom()) + } } val builder = OkHttpClient.Builder() @@ -180,8 +211,19 @@ object FingerprintFetcher { // Internals used by fetch() // --------------------------------------------------------------------- - private fun createBoundTlsSocket(ip: String, port: Int, wifi: Network?): SSLSocket { - val sslContext = CertificateUtils.getFingerprintCollectionSSLContext() + private fun createBoundTlsSocket( + ip: String, + port: Int, + wifi: Network?, + clientKeyPair: KeyPair? = null, + clientCertificate: X509Certificate? = null, + pinnedReceiverHash: String? = null, + ): SSLSocket { + val sslContext = if (clientKeyPair != null && clientCertificate != null) { + PeerMtlsSsl.createSenderSslContext(clientKeyPair, clientCertificate, pinnedReceiverHash) + } else { + CertificateUtils.getFingerprintCollectionSSLContext() + } val factory = sslContext.socketFactory as SSLSocketFactory val s = factory.createSocket() as SSLSocket diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerClientCertCapturingTrustManager.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerClientCertCapturingTrustManager.kt new file mode 100644 index 000000000..ee0400345 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerClientCertCapturingTrustManager.kt @@ -0,0 +1,96 @@ +package org.horizontal.tella.mobile.data.peertopeer + +import timber.log.Timber +import java.net.Socket +import java.security.cert.X509Certificate +import java.util.concurrent.ConcurrentHashMap +import javax.net.ssl.SSLEngine +import javax.net.ssl.X509ExtendedTrustManager + +/** + * Trust manager used only to let self-signed peer certificates complete the TLS handshake + * so we can capture the client certificate. + * + * This does NOT authorize the peer. + * Authorization happens after the handshake by comparing the captured client certificate + * hash against the QR-scanned pinned sender hash. + * + * Any request whose certificate is missing or does not match the pinned hash must be rejected + * by the route handler. + */ +internal class PeerClientCertCapturingTrustManager : X509ExtendedTrustManager() { + + private val clientLeafByEngine = ConcurrentHashMap() + @Volatile + private var pendingClientLeaf: X509Certificate? = null + + fun peekClientLeafForEngine(engine: SSLEngine): X509Certificate? = + clientLeafByEngine[engine] ?: pendingClientLeaf + + fun peekPendingClientLeaf(): X509Certificate? = pendingClientLeaf + + fun consumePendingClientLeaf(): X509Certificate? = + pendingClientLeaf.also { pendingClientLeaf = null } + + private fun rememberClient(engine: SSLEngine?, chain: Array) { + val leaf = chain.firstOrNull() ?: return + pendingClientLeaf = leaf + if (engine != null) { + clientLeafByEngine[engine] = leaf + Timber.d( + "P2P mTLS trust manager captured client leaf (engine=%d)", + System.identityHashCode(engine), + ) + } else { + Timber.d("P2P mTLS trust manager captured client leaf (no engine)") + } + } + + override fun checkClientTrusted( + chain: Array, + authType: String, + engine: SSLEngine, + ) { + Timber.d( + "P2P mTLS checkClientTrusted(engine) authType=%s chain=%d subject=%s", + authType, chain.size, chain.firstOrNull()?.subjectX500Principal, + ) + rememberClient(engine, chain) + } + + override fun checkClientTrusted( + chain: Array, + authType: String, + socket: Socket, + ) { + Timber.d( + "P2P mTLS checkClientTrusted(socket) authType=%s chain=%d subject=%s", + authType, chain.size, chain.firstOrNull()?.subjectX500Principal, + ) + rememberClient(null, chain) + } + + override fun checkClientTrusted(chain: Array, authType: String) { + Timber.d( + "P2P mTLS checkClientTrusted(plain) authType=%s chain=%d subject=%s", + authType, chain.size, chain.firstOrNull()?.subjectX500Principal, + ) + rememberClient(null, chain) + } + + override fun checkServerTrusted( + chain: Array, + authType: String, + engine: SSLEngine, + ) = Unit + + override fun checkServerTrusted( + chain: Array, + authType: String, + socket: Socket, + ) = Unit + + override fun checkServerTrusted(chain: Array, authType: String) = Unit + + override fun getAcceptedIssuers(): Array = emptyArray() +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerClientCertChannel.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerClientCertChannel.kt new file mode 100644 index 000000000..fa5ba9a78 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerClientCertChannel.kt @@ -0,0 +1,59 @@ +package org.horizontal.tella.mobile.data.peertopeer + +import io.netty.channel.ChannelHandlerContext +import io.netty.channel.ChannelInboundHandlerAdapter +import io.netty.handler.ssl.SslHandler +import io.netty.handler.ssl.SslHandshakeCompletionEvent +import io.netty.util.AttributeKey +import io.netty.util.concurrent.GenericFutureListener +import timber.log.Timber +import java.security.cert.X509Certificate + +internal val PEER_CLIENT_LEAF_CERT_KEY: AttributeKey = + AttributeKey.valueOf("peerClientLeafCert") + +/** Stores the sender leaf certificate on the channel after mTLS handshake completes. */ +internal class PeerClientCertCaptureHandler : ChannelInboundHandlerAdapter() { + + override fun handlerAdded(ctx: ChannelHandlerContext) { + val sslHandler = ctx.pipeline().get(SslHandler::class.java) ?: return + sslHandler.handshakeFuture().addListener(handshakeListener(ctx)) + } + + override fun userEventTriggered(ctx: ChannelHandlerContext, evt: Any) { + if (evt is SslHandshakeCompletionEvent && evt.isSuccess) { + storePeerLeaf(ctx, "handshake-event") + } + super.userEventTriggered(ctx, evt) + } + + private fun handshakeListener(ctx: ChannelHandlerContext): GenericFutureListener> = + GenericFutureListener { future -> + if (future.isSuccess) { + storePeerLeaf(ctx, "handshake-future") + } else { + Timber.w(future.cause(), "P2P server mTLS handshake failed") + } + } + + private fun storePeerLeaf(ctx: ChannelHandlerContext, source: String) { + if (ctx.channel().attr(PEER_CLIENT_LEAF_CERT_KEY).get() != null) return + capturePeerLeaf(ctx)?.let { cert -> + ctx.channel().attr(PEER_CLIENT_LEAF_CERT_KEY).set(cert) + Timber.d("P2P server captured client leaf cert via %s", source) + } ?: Timber.w("P2P server: mTLS handshake ok but no client leaf cert captured") + } + + private fun capturePeerLeaf(ctx: ChannelHandlerContext): X509Certificate? { + val sslHandler = ctx.pipeline().get(SslHandler::class.java) ?: return null + val engine = sslHandler.engine() + PeerMtlsSsl.peekCapturedServerClientCert(engine)?.let { return it } + PeerMtlsSsl.peekPendingServerClientCert()?.let { return it } + return runCatching { + sslHandler.engine().session.peerCertificates?.firstOrNull() as? X509Certificate + }.getOrElse { error -> + Timber.w(error, "Failed to read peer client certificate from SSLSession") + null + } + } +} 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 index 450fd883c..b66b8188a 100644 --- 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 @@ -5,15 +5,18 @@ import java.security.KeyPair import java.security.cert.X509Certificate object PeerKeyProvider { - private var keyPair: KeyPair? = null - private var certificate: X509Certificate? = null - private var certificateKey: String? = null + private var receiverKeyPair: KeyPair? = null + private var receiverCertificate: X509Certificate? = null + private var receiverCertificateKey: String? = null + + private var senderKeyPair: KeyPair? = null + private var senderCertificate: X509Certificate? = null fun getKeyPair(): KeyPair { - if (keyPair == null) { - keyPair = CertificateUtils.generateKeyPair() + if (receiverKeyPair == null) { + receiverKeyPair = CertificateUtils.generateKeyPair() } - return keyPair!! + return receiverKeyPair!! } fun getCertificate(ipAddress: String): X509Certificate = @@ -22,17 +25,31 @@ object PeerKeyProvider { fun getCertificate(ipAddresses: List): X509Certificate { val key = ipAddresses.map { it.trim() }.filter { it.isNotEmpty() }.distinct().sorted().joinToString(",") require(key.isNotEmpty()) - if (certificate == null || certificateKey != key) { - certificate = CertificateUtils.generateSelfSignedCertificate(getKeyPair(), ipAddresses) - certificateKey = key + if (receiverCertificate == null || receiverCertificateKey != key) { + receiverCertificate = CertificateUtils.generateSelfSignedCertificate(getKeyPair(), ipAddresses) + receiverCertificateKey = key + } + return receiverCertificate!! + } + + fun ensureSenderIdentity(): Pair { + if (senderKeyPair == null) { + senderKeyPair = CertificateUtils.generateKeyPair() + senderCertificate = CertificateUtils.generateSelfSignedClientCertificate(senderKeyPair!!) } - return certificate!! + return senderKeyPair!! to senderCertificate!! } + fun getSenderCertificate(): X509Certificate = ensureSenderIdentity().second + + fun getSenderCertificateHash(): String = + CertificateUtils.getLeafCertificateDerSha256Hex(getSenderCertificate()) + fun reset() { - keyPair = null - certificate = null - certificateKey = null + receiverKeyPair = null + receiverCertificate = null + receiverCertificateKey = null + senderKeyPair = null + senderCertificate = null } } - diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerMtlsSsl.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerMtlsSsl.kt new file mode 100644 index 000000000..665907f7f --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerMtlsSsl.kt @@ -0,0 +1,88 @@ +package org.horizontal.tella.mobile.data.peertopeer + +import io.netty.handler.ssl.ApplicationProtocolConfig +import io.netty.handler.ssl.ApplicationProtocolNames +import io.netty.handler.ssl.ClientAuth +import io.netty.handler.ssl.SslContext +import io.netty.handler.ssl.SslContextBuilder +import io.netty.handler.ssl.SslProvider +import org.horizontal.tella.mobile.certificate.CertificateUtils +import java.security.KeyPair +import java.security.KeyStore +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLEngine +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +object PeerMtlsSsl { + + private val serverClientCertCaptor = PeerClientCertCapturingTrustManager() + + fun peekCapturedServerClientCert(engine: SSLEngine): X509Certificate? = + serverClientCertCaptor.peekClientLeafForEngine(engine) + + fun peekPendingServerClientCert(): X509Certificate? = + serverClientCertCaptor.peekPendingClientLeaf() + + fun createSenderKeyManagers(keyPair: KeyPair, certificate: X509Certificate) = + createKeyManagers(keyPair, certificate) + + fun createSenderSslContext( + senderKeyPair: KeyPair, + senderCertificate: X509Certificate, + pinnedReceiverHash: String?, + trustManagerOverride: X509TrustManager? = null, + ): SSLContext { + val trustManager: X509TrustManager = trustManagerOverride + ?: if (pinnedReceiverHash.isNullOrBlank()) { + CertificateUtils.getFingerprintCollectionTrustManager() + } else { + CertificateUtils.getLeafCertPinnedTrustManager(pinnedReceiverHash) + } + return SSLContext.getInstance("TLS").apply { + init( + createKeyManagers(senderKeyPair, senderCertificate), + arrayOf(trustManager), + SecureRandom(), + ) + } + } + + fun buildNettyServerSslContext( + keyPair: KeyPair, + certificate: X509Certificate, + ): SslContext { + return SslContextBuilder.forServer(keyPair.private, certificate) + .sslProvider(SslProvider.JDK) + .trustManager(serverClientCertCaptor) + .clientAuth(ClientAuth.REQUIRE) + .protocols("TLSv1.3", "TLSv1.2") + .applicationProtocolConfig( + ApplicationProtocolConfig( + ApplicationProtocolConfig.Protocol.ALPN, + ApplicationProtocolConfig.SelectorFailureBehavior.NO_ADVERTISE, + ApplicationProtocolConfig.SelectedListenerFailureBehavior.ACCEPT, + ApplicationProtocolNames.HTTP_1_1, + ), + ) + .build() + } + + private fun createKeyManagers(keyPair: KeyPair, certificate: X509Certificate) = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply { + val password = "peer".toCharArray() + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(null, null) + setKeyEntry( + "peer", + keyPair.private, + password, + arrayOf(certificate), + ) + } + init(keyStore, password) + }.keyManagers +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerProtocolConstants.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerProtocolConstants.kt new file mode 100644 index 000000000..0ea1e600c --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerProtocolConstants.kt @@ -0,0 +1,5 @@ +package org.horizontal.tella.mobile.data.peertopeer + +object PeerProtocolConstants { + const val PROTOCOL_VERSION = 2 +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerServerCertCapturingTrustManager.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerServerCertCapturingTrustManager.kt new file mode 100644 index 000000000..1f655d6d3 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerServerCertCapturingTrustManager.kt @@ -0,0 +1,37 @@ +package org.horizontal.tella.mobile.data.peertopeer + +import java.security.cert.X509Certificate +import javax.net.ssl.X509TrustManager + +/** + * Client-side wrapper that records the receiver (server) leaf certificate seen during the TLS + * handshake. Needed because OkHttp's [okhttp3.Handshake.peerCertificates] can be empty on some + * Android builds even after a successful handshake (SSLSession does not always expose peer certs + * with custom trust managers), which would break the manual flow's receiver-hash bootstrap. + */ +internal class PeerServerCertCapturingTrustManager( + private val delegate: X509TrustManager, + /** + * Fired the moment the server leaf is seen during the TLS handshake — before the (possibly + * server-held) HTTP response is read. + * so the manual sender can show the receiver-hash verification screen immediately. + */ + private val onServerLeafCaptured: ((X509Certificate) -> Unit)? = null, +) : X509TrustManager { + + @Volatile + var lastServerLeaf: X509Certificate? = null + private set + + override fun checkClientTrusted(chain: Array?, authType: String?) = + delegate.checkClientTrusted(chain, authType) + + override fun checkServerTrusted(chain: Array?, authType: String?) { + delegate.checkServerTrusted(chain, authType) + val leaf = chain?.firstOrNull() + lastServerLeaf = leaf + if (leaf != null) onServerLeafCaptured?.invoke(leaf) + } + + override fun getAcceptedIssuers(): Array = delegate.acceptedIssuers +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerTlsCallExtensions.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerTlsCallExtensions.kt new file mode 100644 index 000000000..bb7acd8b8 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerTlsCallExtensions.kt @@ -0,0 +1,51 @@ +package org.horizontal.tella.mobile.data.peertopeer + +import io.ktor.server.application.ApplicationCall +import io.ktor.server.netty.NettyApplicationCall +import io.ktor.server.routing.RoutingCall +import io.ktor.server.routing.RoutingPipelineCall +import io.netty.handler.ssl.SslHandler +import org.horizontal.tella.mobile.certificate.CertificateUtils +import timber.log.Timber +import java.security.cert.X509Certificate + +/** Inside route handlers Ktor 3 wraps the engine call in [RoutingCall]; unwrap to reach Netty. */ +private fun ApplicationCall.nettyCall(): NettyApplicationCall? { + val unwrapped = when (this) { + is RoutingCall -> pipelineCall.engineCall + is RoutingPipelineCall -> engineCall + else -> this + } + return unwrapped as? NettyApplicationCall +} + +fun ApplicationCall.peerClientLeafCertificate(): X509Certificate? { + val nettyCall = nettyCall() + if (nettyCall == null) { + Timber.w("P2P server: cannot unwrap %s to NettyApplicationCall", this::class.simpleName) + return PeerMtlsSsl.peekPendingServerClientCert() + } + val channel = nettyCall.context.channel() + channel.attr(PEER_CLIENT_LEAF_CERT_KEY).get()?.let { return it } + + val sslHandler = channel.pipeline().get(SslHandler::class.java) + if (sslHandler != null) { + val engine = sslHandler.engine() + PeerMtlsSsl.peekCapturedServerClientCert(engine)?.let { return it } + runCatching { + engine.session.peerCertificates + ?.firstOrNull { it is X509Certificate } as? X509Certificate + }.getOrNull()?.let { return it } + } + + PeerMtlsSsl.peekPendingServerClientCert()?.let { + channel.attr(PEER_CLIENT_LEAF_CERT_KEY).compareAndSet(null, it) + return it + } + + Timber.w("P2P server: no client leaf cert on channel (sslHandler=%s)", sslHandler != null) + return null +} + +fun ApplicationCall.peerClientCertificateHashHex(): String? = + peerClientLeafCertificate()?.let { CertificateUtils.getLeafCertificateDerSha256Hex(it) } 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 index 27294737b..2344dd298 100644 --- 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 @@ -52,7 +52,7 @@ object ServerPinger { val client = builder.build() val req = Request.Builder() - .url("https://$ip:$port/api/v1/ping") + .url("https://$ip:$port/api/v2/ping") .post(RequestBody.create(null, ByteArray(0))) .build() @@ -86,7 +86,7 @@ object ServerPinger { ) val req = Request.Builder() - .url("https://$ip:$port/api/v1/ping") + .url("https://$ip:$port/api/v2/ping") .post(RequestBody.create(null, ByteArray(0))) .build() @@ -111,7 +111,7 @@ object ServerPinger { ) val req = Request.Builder() - .url("https://$ip:$port/api/v1/ping") + .url("https://$ip:$port/api/v2/ping") .post(RequestBody.create(null, ByteArray(0))) .build() 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 index cf464a138..79e63399f 100644 --- 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 @@ -6,40 +6,59 @@ import android.net.Network import android.net.NetworkCapabilities import android.os.Build import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.ConnectionSpec +import okhttp3.MediaType.Companion.toMediaType 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.PeerMtlsSsl +import org.horizontal.tella.mobile.data.peertopeer.PeerKeyProvider 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.PeerManualPingSession import org.horizontal.tella.mobile.data.peertopeer.remote.PeerUploadOutcome import org.horizontal.tella.mobile.data.peertopeer.remote.PrepareUploadRequest +import org.horizontal.tella.mobile.data.peertopeer.remote.PeerPingResult 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.PeerEventManager 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.IOException import java.io.InputStream import java.security.SecureRandom import java.util.UUID import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.net.ssl.SSLContext +import javax.net.ssl.X509TrustManager class TellaPeerToPeerClient @Inject constructor( @ApplicationContext private val appContext: Context ) { + companion object { + private const val REGISTER_READ_TIMEOUT_SEC = 120L + } + + /** Background scope that keeps a manual ping open while its HTTP response is held server-side. */ + private val manualPingScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + suspend fun registerPeerDevice( ip: String, port: String, @@ -56,8 +75,8 @@ class TellaPeerToPeerClient @Inject constructor( ) val jsonPayload = Json.encodeToString(payload) - val requestBody = jsonPayload.toRequestBody() - val client = getClientWithFingerprintValidation(ip, expectedFingerprint) + val requestBody = jsonPayload.toRequestBody(CONTENT_TYPE_JSON.toMediaType()) + val client = getMtlsClient(ip, expectedFingerprint, forRegistration = true) val request = Request.Builder() .url(url) @@ -67,9 +86,19 @@ class TellaPeerToPeerClient @Inject constructor( .addHeader("Connection", "close") .build() + Timber.d("registerPeerDevice payload=%s", jsonPayload) + return@withContext try { client.newCall(request).execute().use { response -> + response.handshake?.let { hs -> + Timber.d( + "registerPeerDevice TLS localCerts=%d peerCerts=%d", + hs.localCertificates.size, + hs.peerCertificates.size, + ) + } val body = response.body.string() + Timber.d("registerPeerDevice code=%d body=%s", response.code, body.take(300)) if (response.isSuccessful) { when (val parsed = parseSessionIdFromResponse(body)) { @@ -79,9 +108,12 @@ class TellaPeerToPeerClient @Inject constructor( } } else { when (response.code) { - 400 -> RegisterPeerResult.InvalidFormat + 400 -> parseRegisterBadRequest(body) 401 -> RegisterPeerResult.InvalidPin 403 -> RegisterPeerResult.RejectedByReceiver + // 406 Unsupported version / 404 unknown (e.g. older v1-only) route — the + // peer runs an incompatible protocol (protocol §6). + 404, 406 -> RegisterPeerResult.IncompatibleProtocol 409 -> RegisterPeerResult.Conflict 429 -> RegisterPeerResult.TooManyRequests 500 -> RegisterPeerResult.ServerError @@ -112,8 +144,8 @@ class TellaPeerToPeerClient @Inject constructor( files = files, ) val jsonPayload = Json.encodeToString(requestPayload) - val requestBody = jsonPayload.toRequestBody() - val client = getClientWithFingerprintValidation(ip, expectedFingerprint) + val requestBody = jsonPayload.toRequestBody(CONTENT_TYPE_JSON.toMediaType()) + val client = getMtlsClient(ip, expectedFingerprint) try { val request = Request.Builder() @@ -157,7 +189,7 @@ class TellaPeerToPeerClient @Inject constructor( ip, port, sessionId, fileId, transmissionId, uploadNonce ) - val client = getClientWithFingerprintValidation(ip, expectedFingerprint) + val client = getMtlsClient(ip, expectedFingerprint) val requestBody = ProgressRequestBody(inputStream, fileSize, onProgress) val request = Request.Builder() @@ -210,8 +242,8 @@ class TellaPeerToPeerClient @Inject constructor( 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 requestBody = payload.toRequestBody(CONTENT_TYPE_JSON.toMediaType()) + val client = getMtlsClient(ip, expectedFingerprint) val request = Request.Builder() .url(url) @@ -238,6 +270,13 @@ class TellaPeerToPeerClient @Inject constructor( // ---------------- Internals ---------------- + private fun parseRegisterBadRequest(body: String): RegisterPeerResult = + when { + body.contains("Client certificate required", ignoreCase = true) -> + RegisterPeerResult.ClientCertificateRequired + else -> RegisterPeerResult.InvalidFormat + } + private fun parseSessionIdFromResponse(body: String): RegisterPeerResult { return try { val json = JSONObject(body) @@ -297,80 +336,63 @@ class TellaPeerToPeerClient @Inject constructor( * considered for parity with iOS defaults, but would block Nearby Sharing on minSdk 21 devices where 1.3 is * unavailable—so we keep 1.2+1.3, matching the product call on the cross-platform thread (Feb 18 discussion). */ - private fun getClientWithFingerprintValidation( + private fun senderIdentity() = PeerKeyProvider.ensureSenderIdentity() + + private fun getMtlsClient( ip: String, - expectedFingerprintHex: String + expectedFingerprintHex: String, + requirePinnedReceiver: Boolean = true, + serverCertCaptor: PeerServerCertCapturingTrustManager? = null, + forRegistration: Boolean = false, ): OkHttpClient { - val expected = normalizeHex(expectedFingerprintHex) - - val trustManager = CertificateUtils.getLeafCertPinnedTrustManager(expected) - - val sslContext = SSLContext.getInstance("TLS").apply { - init(null, arrayOf(trustManager), SecureRandom()) + val (senderKeyPair, senderCert) = senderIdentity() + val pinned = if (requirePinnedReceiver) normalizeHex(expectedFingerprintHex) else null + val baseTrustManager: X509TrustManager = if (pinned.isNullOrEmpty()) { + CertificateUtils.getFingerprintCollectionTrustManager() + } else { + CertificateUtils.getLeafCertPinnedTrustManager(pinned) } + val trustManager: X509TrustManager = serverCertCaptor ?: baseTrustManager + val sslContext = PeerMtlsSsl.createSenderSslContext( + senderKeyPair = senderKeyPair, + senderCertificate = senderCert, + pinnedReceiverHash = pinned, + trustManagerOverride = trustManager, + ) val tlsSpec = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) .tlsVersions(TlsVersion.TLS_1_3, TlsVersion.TLS_1_2) .allEnabledCipherSuites() .build() + val readTimeoutSec = if (forRegistration) REGISTER_READ_TIMEOUT_SEC else 20L val builder = OkHttpClient.Builder() .sslSocketFactory(sslContext.socketFactory, trustManager) .connectionSpecs(listOf(tlsSpec)) .protocols(listOf(Protocol.HTTP_1_1)) .connectTimeout(5, TimeUnit.SECONDS) - .readTimeout(20, TimeUnit.SECONDS) - .writeTimeout(20, TimeUnit.SECONDS) - - // For LAN / hotspot peers, binding to an arbitrary "Wi‑Fi" Network can pick the wrong interface - // (e.g. secondary saved Wi‑Fi) and yield EHOSTUNREACH while the default network routes correctly. - if (!isPrivateOrLinkLocalIpv4(ip)) { - pickWifiNetwork(appContext)?.let { network -> - builder.socketFactory(network.socketFactory) - } + .readTimeout(readTimeoutSec, TimeUnit.SECONDS) + .writeTimeout(readTimeoutSec, TimeUnit.SECONDS) + + pickWifiNetworkForP2P(appContext)?.let { network -> + builder.socketFactory(network.socketFactory) } return builder.build() } - /** True for RFC1918 / link-local so we let the OS choose the socket's outgoing interface. */ - private fun isPrivateOrLinkLocalIpv4(ip: String): Boolean { - val parts = ip.trim().split('.').mapNotNull { it.toIntOrNull() } - if (parts.size != 4) return false - val a = parts[0] - val b = parts[1] - return when { - a == 10 -> true - a == 172 && b in 16..31 -> true - a == 192 && b == 168 -> true - a == 169 && b == 254 -> true - a == 127 -> true - else -> false - } - } - + /** Any Wi-Fi network — local-only / hotspot links often lack VALIDATED or INTERNET. */ @Suppress("DEPRECATION") - private fun pickWifiNetwork(context: Context): Network? { + private fun pickWifiNetworkForP2P(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 - } - } + cm.allNetworks.firstOrNull { n -> + cm.getNetworkCapabilities(n)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true + }?.let { return it } } - - // 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 + val info = cm.getNetworkInfo(n) + info?.isConnected == true && info.type == ConnectivityManager.TYPE_WIFI } } @@ -378,55 +400,165 @@ class TellaPeerToPeerClient @Inject constructor( hexLike.trim().replace(":", "").replace("\\s".toRegex(), "").lowercase() - /** Discovery client for /ping before we have a pin; uses system CA validation (no trust-all). */ - private fun newDiscoveryClient(network: Network?): OkHttpClient { - val trustManager = CertificateUtils.getFingerprintCollectionTrustManager() - val ssl = 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() - - return OkHttpClient.Builder() - .sslSocketFactory(ssl.socketFactory, trustManager) - .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 = + /** + * Protocol v2 initial ping with sender client certificate attached. + * Returns the receiver leaf cert hash (from the TLS handshake) plus the `senderShowHash` flag + * parsed from the response body (protocol §3.1), or null on failure. + * + * Per the protocol security note, callers must only act on [PeerPingResult.senderShowHash] AFTER the + * receiver hash has been verified — the ping channel isn't authenticated until then. + */ + suspend fun pingAndFetchReceiverHash(ip: String, port: String): PeerPingResult? = withContext(Dispatchers.IO) { - val network = pickWifiNetwork(appContext) - val client = newDiscoveryClient(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 url = PeerApiRoutes.buildUrl(ip, port, PeerApiRoutes.PING) + val serverCertCaptor = PeerServerCertCapturingTrustManager( + CertificateUtils.getFingerprintCollectionTrustManager() + ) + val client = getMtlsClient( + ip, + expectedFingerprintHex = "", + requirePinnedReceiver = false, + serverCertCaptor = serverCertCaptor, + // The receiver now HOLDS the ping until the recipient confirms the receiver hash + // (iOS parity), so use the long human-gated read timeout like register. + forRegistration = true, + ) val req = Request.Builder() - .url("https://$ip:$port/api/v1/ping") - .post(okhttp3.RequestBody.create(null, ByteArray(0))) // or "".toRequestBody(null) + .url(url) + .post(ByteArray(0).toRequestBody()) + .addHeader("Connection", "close") .build() runCatching { client.newCall(req).execute().use { resp -> if (resp.code == 429) { - Timber.w("pingBeforeRegister: rate limited (429)") + Timber.w("pingAndFetchReceiverHash: rate limited (429)") + } + // 406 Unsupported version / 404 unknown route — peer runs an incompatible + // protocol (protocol §6). Signal it so the UI can surface the version mismatch. + if (resp.code == 406 || resp.code == 404) { + Timber.w("pingAndFetchReceiverHash $url -> incompatible protocol (HTTP %d)", resp.code) + PeerEventManager.emitIncompatibleProtocol() + return@withContext null + } + if (!resp.isSuccessful) { + Timber.w("pingAndFetchReceiverHash $url -> HTTP %d", resp.code) + return@withContext null } - // consider any HTTP code as “host reachable” - Timber.d("pingBeforeRegister $url -> HTTP %d", resp.code) - resp.code in 100..599 + val body = resp.body.string() + // Defaults to false when the field/body is absent (older or non-conforming peer), + val senderShowHash = runCatching { + JSONObject(body).optBoolean("senderShowHash", false) + }.getOrDefault(false) + // SSLSession does not always expose peer certs (see sender log peerCerts=0), + // so fall back to the cert recorded by our trust manager during the handshake. + val handshakeCert = (resp.handshake?.peerCertificates?.firstOrNull() + as? java.security.cert.X509Certificate) + ?: serverCertCaptor.lastServerLeaf + if (handshakeCert == null) { + Timber.w("pingAndFetchReceiverHash: no server cert from handshake or captor") + return@withContext null + } + val receiverHash = CertificateUtils.getLeafCertificateDerSha256Hex(handshakeCert) + PeerPingResult(receiverHash = receiverHash, senderShowHash = senderShowHash) } }.getOrElse { Timber.w(it, "Ping failed for $url") - false + null } } + suspend fun pingBeforeRegister(ip: String, port: String): Boolean = + pingAndFetchReceiverHash(ip, port) != null + + /** + * Starts a manual `/api/v2/ping` and returns immediately (iOS parity: `startManualPing`). + * + * The returned [PeerManualPingSession] exposes the receiver hash from the TLS handshake right + * away (so the sender can show the receiver-hash verification screen), while the HTTP request + * stays open in the background until the recipient confirms — at which point the held body yields + * `senderShowHash`. + */ + fun startManualPing(ip: String, port: String): PeerManualPingSession { + val receiverHashDeferred = CompletableDeferred() + val senderShowHashDeferred = CompletableDeferred() + + val job = manualPingScope.launch { + val url = PeerApiRoutes.buildUrl(ip, port, PeerApiRoutes.PING) + val captor = PeerServerCertCapturingTrustManager( + CertificateUtils.getFingerprintCollectionTrustManager(), + onServerLeafCaptured = { leaf -> + if (!receiverHashDeferred.isCompleted) { + receiverHashDeferred.complete( + CertificateUtils.getLeafCertificateDerSha256Hex(leaf) + ) + } + }, + ) + val client = getMtlsClient( + ip, + expectedFingerprintHex = "", + requirePinnedReceiver = false, + serverCertCaptor = captor, + forRegistration = true, + ) + val req = Request.Builder() + .url(url) + .post(ByteArray(0).toRequestBody()) + .addHeader("Connection", "close") + .build() + + try { + client.newCall(req).execute().use { resp -> + if (resp.code == 406 || resp.code == 404) { + Timber.w("startManualPing %s -> incompatible protocol (HTTP %d)", url, resp.code) + PeerEventManager.emitIncompatibleProtocol() + val ex = IOException("incompatible protocol (HTTP ${resp.code})") + receiverHashDeferred.completeExceptionallyIfActive(ex) + senderShowHashDeferred.completeExceptionallyIfActive(ex) + return@launch + } + if (!resp.isSuccessful) { + Timber.w("startManualPing %s -> HTTP %d", url, resp.code) + val ex = IOException("ping HTTP ${resp.code}") + receiverHashDeferred.completeExceptionallyIfActive(ex) + senderShowHashDeferred.completeExceptionallyIfActive(ex) + return@launch + } + // Fallback in case the trust manager callback didn't fire (older builds). + if (!receiverHashDeferred.isCompleted) { + val cert = (resp.handshake?.peerCertificates?.firstOrNull() + as? java.security.cert.X509Certificate) + ?: captor.lastServerLeaf + if (cert != null) { + receiverHashDeferred.complete( + CertificateUtils.getLeafCertificateDerSha256Hex(cert) + ) + } else { + receiverHashDeferred.completeExceptionallyIfActive( + IOException("no server cert from handshake or captor") + ) + } + } + val body = resp.body.string() + val senderShowHash = runCatching { + JSONObject(body).optBoolean("senderShowHash", false) + }.getOrDefault(false) + senderShowHashDeferred.complete(senderShowHash) + } + } catch (e: Exception) { + Timber.w(e, "startManualPing failed for %s", url) + receiverHashDeferred.completeExceptionallyIfActive(e) + senderShowHashDeferred.completeExceptionallyIfActive(e) + } + } + + return PeerManualPingSession(receiverHashDeferred, senderShowHashDeferred, job) + } + + private fun CompletableDeferred.completeExceptionallyIfActive(ex: Throwable) { + if (!isCompleted) completeExceptionally(ex) + } + } 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 index ab314b6e4..2ffcc01bd 100644 --- 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 @@ -4,15 +4,21 @@ import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.ApplicationCall import io.ktor.server.application.ApplicationCallPipeline import io.ktor.server.application.call import io.ktor.server.application.install import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.applicationEnvironment +import io.ktor.server.engine.connector import io.ktor.server.engine.embeddedServer -import io.ktor.server.engine.sslConnector import io.ktor.server.netty.Netty +import io.netty.handler.ssl.SslHandler +import org.horizontal.tella.mobile.data.peertopeer.model.P2PConnectionPhase +import org.horizontal.tella.mobile.data.peertopeer.model.P2PVerificationStep +import org.horizontal.tella.mobile.domain.peertopeer.PeerEventManager import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.request.path import io.ktor.server.request.receive import io.ktor.server.request.receiveStream import io.ktor.server.response.respond @@ -20,6 +26,7 @@ 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.route import io.ktor.server.routing.routing import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -36,11 +43,10 @@ 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.NearbySharingTransferConfig -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.PeerPingResponse 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 @@ -49,7 +55,6 @@ import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.File import java.security.KeyPair -import java.security.KeyStore import java.security.cert.X509Certificate import java.util.UUID import java.util.concurrent.ConcurrentHashMap @@ -67,7 +72,6 @@ class TellaPeerToPeerServer( private val pin: String, private val keyPair: KeyPair, private val certificate: X509Certificate, - private val keyStoreConfig: KeyStoreConfig, private val peerToPeerManager: PeerToPeerManager, private val p2PSharedState: P2PSharedState, private val receiveDir: File, @@ -79,6 +83,7 @@ class TellaPeerToPeerServer( private val transferNonceManager = TransferNonceManager() private val registerPinFailuresByNonce = ConcurrentHashMap() private val rateLimiter = PeerTimedRateLimiter(rateLimitConfig) + private val nettyServerSslContext = PeerMtlsSsl.buildNettyServerSslContext(keyPair, certificate) override val certificatePem: String get() = CertificateUtils.certificateToPem(certificate) @@ -100,33 +105,28 @@ class TellaPeerToPeerServer( override fun start() { Timber.e( - "SERVER STARTED bind=%s port=%d advertisedToPeer=%s", + "SERVER STARTED bind=%s port=%d advertisedToPeer=%s mTLS=REQUIRE", P2PNetworkAddressPolicy.INADDR_ANY_IPV4, serverPort, advertisedHost, ) - val keyStore = KeyStore.getInstance("PKCS12").apply { - load(null, null) - setKeyEntry( - keyStoreConfig.alias, keyPair.private, keyStoreConfig.password, arrayOf(certificate) - ) - } - embedded = embeddedServer( Netty, environment = applicationEnvironment { }, configure = { enableHttp2 = false enableH2c = false - sslConnector( - keyStore = keyStore, - keyAlias = keyStoreConfig.alias, - keyStorePassword = { keyStoreConfig.password }, - privateKeyPassword = { keyStoreConfig.password }, - ) { + connector { host = P2PNetworkAddressPolicy.INADDR_ANY_IPV4 port = serverPort - enabledProtocols = listOf("TLSv1.3", "TLSv1.2") + } + channelPipelineConfig = { + if (get(SslHandler::class.java) == null) { + addFirst("ssl", nettyServerSslContext.newHandler(channel().alloc())) + } + if (get("peer-cert-capture") == null) { + addAfter("ssl", "peer-cert-capture", PeerClientCertCaptureHandler()) + } } }, ) { @@ -159,14 +159,62 @@ class TellaPeerToPeerServer( get("/") { call.respondText("The server is running securely over HTTPS.") } + + post(PeerApiRoutes.V1_REGISTER) { + CoroutineScope(Dispatchers.IO).launch { + PeerEventManager.emitIncompatibleProtocol() + } + call.respond(HttpStatusCode.Forbidden, "Unsupported version") + } + + post(PeerApiRoutes.V1_PING) { + // Guard the emit so a stray v1 ping can't disrupt an already-established session. + if (p2PSharedState.connectionPhase != P2PConnectionPhase.MTLS_ESTABLISHED && + p2PSharedState.connectionPhase != P2PConnectionPhase.REGISTERED + ) { + CoroutineScope(Dispatchers.IO).launch { + PeerEventManager.emitIncompatibleProtocol() + } + } + call.respond(HttpStatusCode.Forbidden, "Unsupported version") + } - // Client presence hint (optional) post(PeerApiRoutes.PING) { call.peerClientIpForRateLimit() + val clientHash = call.peerClientCertificateHashHex() + p2PSharedState.isUsingManualConnection = true + if (p2PSharedState.pinnedSenderHash.isBlank()) { + p2PSharedState.senderShowHash = true + } + val senderShowHash = p2PSharedState.senderShowHash + Timber.d( + "P2P ping: clientCertHash=%s senderShowHash=%b (holding for receiver hash confirmation)", + clientHash ?: "null", senderShowHash, + ) CoroutineScope(Dispatchers.IO).launch { - peerToPeerManager.notifyClientConnected(p2PSharedState.hash) + peerToPeerManager.notifyClientConnected(p2PSharedState.localReceiverHash.ifBlank { p2PSharedState.hash }) + peerToPeerManager.notifyRecipientHashVerification() } - call.respondText("ping", status = HttpStatusCode.OK) + val confirmed = if (p2PSharedState.receiverHashConfirmed) { + true + } else { + try { + PeerEventManager.holdPingForReceiverHash() + } catch (e: Exception) { + Timber.e(e, "P2P ping: holdPingForReceiverHash failed") + try { + call.respond(HttpStatusCode.InternalServerError, "Internal error") + } catch (_: Exception) { + } + return@post + } + } + Timber.d("P2P ping: receiver hash confirmed=%b -> responding", confirmed) + if (!confirmed) { + call.respond(HttpStatusCode.Forbidden, "Rejected") + return@post + } + call.respond(HttpStatusCode.OK, PeerPingResponse(senderShowHash)) } // 1) Register a session @@ -198,7 +246,8 @@ class TellaPeerToPeerServer( return@post } - if (!isValidPin(request.pin) || pin != request.pin) { + val requestPin = request.pin.orEmpty() + if (!isValidPin(requestPin) || pin != requestPin) { val count = (registerPinFailuresByNonce[regNonce] ?: 0) + 1 registerPinFailuresByNonce[regNonce] = count if (count >= MAX_REGISTER_PIN_ATTEMPTS) { @@ -211,6 +260,30 @@ class TellaPeerToPeerServer( registerPinFailuresByNonce.remove(regNonce) + val clientCertHash = call.peerClientCertificateHashHex() + val pinnedSender = p2PSharedState.pinnedSenderHash + val manual = p2PSharedState.isUsingManualConnection + Timber.d( + "P2P register: manual=%b receiverCanScanQr=%b senderShowHash=%b pinnedSenderHash=%s phase=%s clientCertHash=%s", + manual, + p2PSharedState.receiverCanScanQr, + p2PSharedState.senderShowHash, + pinnedSender.ifBlank { "" }, + p2PSharedState.connectionPhase, + clientCertHash ?: "null", + ) + + if (pinnedSender.isNotBlank() && clientCertHash != null && + !pinnedSender.equals(clientCertHash, ignoreCase = true) + ) { + Timber.w( + "P2P register: 403 sender cert mismatch (stale pin?) pinned=%s actual=%s", + pinnedSender, clientCertHash, + ) + call.respond(HttpStatusCode.Forbidden, "Sender certificate mismatch") + return@post + } + val sessionId = UUID.randomUUID().toString() val session = PeerResponse(sessionId) if (p2PSharedState.session == null) { @@ -219,12 +292,90 @@ class TellaPeerToPeerServer( p2PSharedState.session?.sessionId = sessionId serverSession = session + // manual (ping-based) connections. QR flows accept immediately. Sender-hash + // verification (flow D) happens only when the sender cert was not pinned via QR. + val needsSenderHashVerification = manual && pinnedSender.isBlank() + Timber.d( + "P2P register: needsSenderHashVerification=%b", + needsSenderHashVerification + ) + + if (needsSenderHashVerification) { + // Flow D needs the client cert to pin the sender after verification. + if (clientCertHash == null) { + call.respond( + HttpStatusCode.BadRequest, + "Client certificate required" + ) + return@post + } + if (!p2PSharedState.receiverHashConfirmed) { + Timber.d("P2P register: holding for receiver hash confirmation (flow D)") + val receiverHashOk = try { + PeerEventManager.awaitReceiverHashConfirmation( + alreadyConfirmed = p2PSharedState.receiverHashConfirmed, + ) + } catch (e: Exception) { + Timber.e( + e, + "P2P register: awaitReceiverHashConfirmation failed" + ) + call.respond( + HttpStatusCode.InternalServerError, + "Internal error" + ) + return@post + } + if (!receiverHashOk) { + call.respond( + HttpStatusCode.Forbidden, + "Receiver rejected the registration", + ) + return@post + } + p2PSharedState.receiverHashConfirmed = true + } + p2PSharedState.activeVerificationStep = P2PVerificationStep.SENDER_HASH + Timber.d( + "P2P register: awaiting receiver confirmation of sender hash %s", + clientCertHash + ) + val senderVerified = try { + PeerEventManager.emitSenderHashVerification( + clientCertHash, + alreadyConfirmed = p2PSharedState.senderHashConfirmed, + ) + } catch (e: Exception) { + Timber.e(e, "P2P register: emitSenderHashVerification failed") + call.respond(HttpStatusCode.InternalServerError, "Internal error") + return@post + } + Timber.d( + "P2P register: emitSenderHashVerification result=%b", + senderVerified + ) + if (!senderVerified) { + call.respond( + HttpStatusCode.Forbidden, + "Receiver rejected the registration" + ) + return@post + } + p2PSharedState.senderHashConfirmed = true + p2PSharedState.pinSenderHash(clientCertHash) + } else if (pinnedSender.isNotBlank()) { + p2PSharedState.pinSenderHash(pinnedSender) + } + + Timber.d("P2P register: awaiting registration acceptance") val accepted = try { PeerEventManager.emitIncomingRegistrationRequest(sessionId, request) } catch (e: Exception) { + Timber.e(e, "P2P register: emitIncomingRegistrationRequest failed") call.respond(HttpStatusCode.InternalServerError, "Internal error") return@post } + Timber.d("P2P register: registration accepted=%b", accepted) if (!accepted) { call.respond( @@ -233,6 +384,7 @@ class TellaPeerToPeerServer( return@post } + p2PSharedState.markRegistered() launch { PeerEventManager.emitRegistrationSuccess() } call.respond(HttpStatusCode.OK, session) } catch (e: Exception) { @@ -250,6 +402,8 @@ class TellaPeerToPeerServer( // 2) Prepare upload → create receiving session, STATUS = SENDING post(PeerApiRoutes.PREPARE_UPLOAD) { try { + if (!validateSenderClientCertificate(call)) return@post + val request = try { call.receive() } catch (e: Exception) { @@ -328,6 +482,8 @@ class TellaPeerToPeerServer( // 3) Upload each file (transport-only; recipient will SAVE later) put(PeerApiRoutes.UPLOAD) { try { + if (!validateSenderClientCertificate(call)) return@put + val sessionId = call.parameters["sessionId"] val fileId = call.parameters["fileId"] val transmissionId = call.parameters["transmissionId"] @@ -490,6 +646,8 @@ class TellaPeerToPeerServer( // 4) Close session (transport finished/cancelled by sender) post(PeerApiRoutes.CLOSE) { + if (!validateSenderClientCertificate(call)) return@post + val payload = try { call.receive>() } catch (_: Exception) { @@ -528,6 +686,24 @@ class TellaPeerToPeerServer( } } + // Fallback for any unmatched route. + // a non-current ping/register (e.g. /api/v100/ping) -> 406 "Unsupported version"; + // everything else -> 404 "Not found". + route("{...}") { + handle { + val path = call.request.path() + val version = PeerApiRoutes.apiVersion(path) + if (version != null && + version != PeerProtocolConstants.PROTOCOL_VERSION && + PeerApiRoutes.isPingOrRegister(path) + ) { + call.respond(HttpStatusCode.NotAcceptable, "Unsupported version") + } else { + call.respond(HttpStatusCode.NotFound, "Not found") + } + } + } + } }.start(wait = false) @@ -554,6 +730,26 @@ class TellaPeerToPeerServer( private fun isValidPin(pin: String) = pin.length == 6 + private suspend fun ApplicationCall.respondClientCertRejected() { + respond(HttpStatusCode.Forbidden, "Client certificate mismatch") + } + + private suspend fun validateSenderClientCertificate(call: ApplicationCall): Boolean { + if (p2PSharedState.connectionPhase != P2PConnectionPhase.MTLS_ESTABLISHED && + p2PSharedState.connectionPhase != P2PConnectionPhase.REGISTERED + ) { + return true + } + val expected = p2PSharedState.pinnedSenderHash + if (expected.isBlank()) return true + val actual = call.peerClientCertificateHashHex() + if (actual == null || !actual.equals(expected, ignoreCase = true)) { + call.respondClientCertRejected() + return false + } + return true + } + /** * [File.createTempFile] requires prefix length ≥ 3; strip path-like characters for safety. */ 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 index 78789c11f..b0a1aee98 100644 --- 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 @@ -25,6 +25,7 @@ class PeerServerStarterManager @Inject constructor( /** Last credentials the running server was started with (avoids redundant stop/start). */ private var listeningAdvertisedHost: String? = null + /** SHA-256 of UTF-8 PIN; raw PIN is not retained after a successful start. */ private var listeningPinSha256: ByteArray? = null @@ -52,6 +53,9 @@ class PeerServerStarterManager @Inject constructor( ) { return true } + + peerToPeerManager.clearClientConnected() + peerToPeerManager.clearRecipientHashVerification() val hadServer = server != null server?.let { try { @@ -81,7 +85,6 @@ class PeerServerStarterManager @Inject constructor( keyPair = keyPair, pin = pin, certificate = cert, - keyStoreConfig = config, peerToPeerManager = peerToPeerManager, p2PSharedState = p2PSharedState, receiveDir = receiveDir, @@ -114,6 +117,8 @@ class PeerServerStarterManager @Inject constructor( server = null listeningAdvertisedHost = null listeningPinSha256 = null + peerToPeerManager.clearClientConnected() + peerToPeerManager.clearRecipientHashVerification() Timber.d("P2P embedded server: stopped (holder cleared)") } 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 index bc353ea1f..34d27400a 100644 --- 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 @@ -4,15 +4,27 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow class PeerToPeerManager { - // Keep the latest ping/cert-hash event for late collectors (manual recipient flow can subscribe after iOS ping). + // Keep the latest ping/cert-hash event for late collectors (manual recipient flow can subscribe after ping). private val _clientConnected = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) val clientConnected = _clientConnected.asSharedFlow() - suspend fun notifyClientConnected(hash : String) { + /** Receiver should show recipient-hash verification after manual ping (protocol step 1). */ + private val _recipientHashVerification = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + val recipientHashVerification = _recipientHashVerification.asSharedFlow() + + suspend fun notifyClientConnected(hash: String) { _clientConnected.emit(hash) } + suspend fun notifyRecipientHashVerification() { + _recipientHashVerification.emit(Unit) + } + fun clearClientConnected() { _clientConnected.resetReplayCache() } + + fun clearRecipientHashVerification() { + _recipientHashVerification.resetReplayCache() + } } \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/managers/ReceiverSessionSetup.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/managers/ReceiverSessionSetup.kt new file mode 100644 index 000000000..f93f95c3c --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/managers/ReceiverSessionSetup.kt @@ -0,0 +1,87 @@ +package org.horizontal.tella.mobile.data.peertopeer.managers + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.horizontal.tella.mobile.certificate.CertificateUtils +import org.horizontal.tella.mobile.data.peertopeer.P2PNetworkAddressPolicy +import org.horizontal.tella.mobile.data.peertopeer.PeerKeyProvider +import org.horizontal.tella.mobile.data.peertopeer.model.P2PSharedState +import org.horizontal.tella.mobile.data.peertopeer.port +import org.horizontal.tella.mobile.domain.peertopeer.KeyStoreConfig +import org.horizontal.tella.mobile.domain.peertopeer.PeerConnectionQrCodec +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Starts the receiver mTLS server and populates [P2PSharedState] with the freshly generated + * connection credentials (IP / port / PIN / certificate hash). Shared by the QR screen and the + * manual-connection screen so neither has to own the other's setup logic. + */ +@Singleton +class ReceiverSessionSetup @Inject constructor( + private val peerServerStarterManager: PeerServerStarterManager, + private val p2PSharedState: P2PSharedState, +) { + + suspend fun start(primaryIpHint: String, discoveredIps: List): String? { + val hint = primaryIpHint.trim() + val mergedForSelection = buildList { + if (hint.isNotEmpty()) add(hint) + for (address in discoveredIps) { + if (address.isNotBlank() && address !in this) add(address) + } + } + + val allIps = P2PNetworkAddressPolicy.filterAndOrderForAdvertise(mergedForSelection) + if (allIps.isEmpty()) { + Timber.e( + "P2P receiver: no site-local (RFC1918) IPv4 after policy filter; raw merged=%s", + mergedForSelection.joinToString(), + ) + return null + } + + val advertiseToPeerPrimary = allIps.first() + val keyPair = PeerKeyProvider.getKeyPair() + val certificate = PeerKeyProvider.getCertificate(allIps) + val certHash = CertificateUtils.getLeafCertificateDerSha256Hex(certificate) + val pinString = (100000..999999).random().toString() + + val started = withContext(Dispatchers.IO) { + peerServerStarterManager.startServer( + advertiseToPeerPrimary, + keyPair, + pinString, + certificate, + KeyStoreConfig(), + p2PSharedState, + ) + } + if (!started) { + Timber.e("P2P receiver: server failed to start") + return null + } + + p2PSharedState.pin = pinString + p2PSharedState.port = port.toString() + p2PSharedState.hash = certHash + p2PSharedState.ip = advertiseToPeerPrimary + p2PSharedState.advertisedIpAddresses = allIps + p2PSharedState.localReceiverHash = certHash + + return PeerConnectionQrCodec.toReceiverJson( + ipAddresses = allIps, + port = port, + certificateHash = certHash, + pin = pinString, + senderShowHash = false, + ) + } + + /** True when a receiver server is already running with credentials available for reuse. */ + fun hasRunningSession(): Boolean = + peerServerStarterManager.isRunning() && + p2PSharedState.pin?.isNotBlank() == true && + p2PSharedState.port.isNotBlank() +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PConnectionPhase.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PConnectionPhase.kt new file mode 100644 index 000000000..d53d4ba10 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PConnectionPhase.kt @@ -0,0 +1,17 @@ +package org.horizontal.tella.mobile.data.peertopeer.model + +enum class P2PConnectionPhase { + IDLE, + /** Step 1 complete — sender pinned receiver certificate hash. */ + RECEIVER_PINNED, + /** Step 2 complete — both certificate hashes pinned; mTLS established. */ + MTLS_ESTABLISHED, + REGISTERED, +} + +enum class P2PVerificationStep { + /** Confirm recipient / receiver certificate hash (protocol step 1). */ + RECIPIENT_HASH, + /** Confirm sender certificate hash (protocol step 2). */ + SENDER_HASH, +} 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 index f3b0a621a..080c459ef 100644 --- 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 @@ -2,12 +2,33 @@ package org.horizontal.tella.mobile.data.peertopeer.model class P2PSharedState( var ip: String = "", + /** All site-local IPv4s advertised to the peer (QR + manual info). Primary is [ip]. */ + var advertisedIpAddresses: List = emptyList(), var port: String = "", + /** Pinned receiver (server) certificate hash — used by sender for TLS pinning. */ var hash: String = "", var pin: String? = null, var session: P2PSession? = null, private var failedAttempts: Int = 0, var isUsingManualConnection: Boolean = false, + /** SHA-256 hex of this device's sender (client) certificate for the session. */ + var localSenderHash: String = "", + /** SHA-256 hex of this device's receiver (server) certificate for the session. */ + var localReceiverHash: String = "", + /** Sender-side pin of receiver certificate (same as [hash] once step 1 completes). */ + var pinnedReceiverHash: String = "", + /** Receiver-side pin of sender certificate after step 2. */ + var pinnedSenderHash: String = "", + var connectionPhase: P2PConnectionPhase = P2PConnectionPhase.IDLE, + var receiverCanScanQr: Boolean = true, + var senderCanScanQr: Boolean = true, + /** From receiver QR — desktop receivers skip sender QR in step 2. */ + var senderShowHash: Boolean = false, + /** Recipient confirmed their own server cert hash (flow D step 1). */ + var receiverHashConfirmed: Boolean = false, + /** Recipient confirmed sender cert hash (flow D step 2). */ + var senderHashConfirmed: Boolean = false, + var activeVerificationStep: P2PVerificationStep? = null, ) { companion object { @@ -21,14 +42,44 @@ class P2PSharedState( } } + fun pinReceiverHash(receiverHash: String) { + pinnedReceiverHash = receiverHash + hash = receiverHash + connectionPhase = P2PConnectionPhase.RECEIVER_PINNED + } + + fun pinSenderHash(senderHash: String) { + pinnedSenderHash = senderHash + if (connectionPhase == P2PConnectionPhase.RECEIVER_PINNED || + connectionPhase == P2PConnectionPhase.IDLE + ) { + connectionPhase = P2PConnectionPhase.MTLS_ESTABLISHED + } + } + + fun markRegistered() { + connectionPhase = P2PConnectionPhase.REGISTERED + } fun clear() { ip = "" + advertisedIpAddresses = emptyList() port = "" hash = "" pin = null session = null failedAttempts = 0 isUsingManualConnection = false + localSenderHash = "" + localReceiverHash = "" + pinnedReceiverHash = "" + pinnedSenderHash = "" + connectionPhase = P2PConnectionPhase.IDLE + receiverCanScanQr = true + senderCanScanQr = true + senderShowHash = false + receiverHashConfirmed = false + senderHashConfirmed = false + activeVerificationStep = null } } 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 index 0b8b98424..457589253 100644 --- 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 @@ -3,11 +3,29 @@ package org.horizontal.tella.mobile.data.peertopeer.remote import okhttp3.HttpUrl.Companion.toHttpUrlOrNull 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" + const val REGISTER = "/api/v2/register" + const val PREPARE_UPLOAD = "/api/v2/prepare-upload" + const val UPLOAD = "/api/v2/upload" + const val PING = "/api/v2/ping" + const val CLOSE = "/api/v2/close-connection" + + // Legacy v1 routes — kept for incompatibility detection + const val V1_REGISTER = "/api/v1/register" + const val V1_PING = "/api/v1/ping" + + fun apiVersion(path: String): Int? { + val components = path.split("/").filter { it.isNotEmpty() } + if (components.size < 2) return null + if (components[0] != "api") return null + val versionSegment = components[1] + if (!versionSegment.startsWith("v")) return null + return versionSegment.drop(1).toIntOrNull() + } + + fun isPingOrRegister(path: String): Boolean { + val last = path.split("/").lastOrNull { it.isNotEmpty() } ?: return false + return last == "ping" || last == "register" + } fun buildUrl(ip: String, port: String, endpoint: String, secure: Boolean = true): String { @@ -27,7 +45,13 @@ object PeerApiRoutes { nonce: String, ): String { val base = buildUrl(ip, port, UPLOAD).toHttpUrlOrNull() - ?: return "${buildUrl(ip, port, UPLOAD)}?sessionId=$sessionId&fileId=$fileId&transmissionId=$transmissionId&nonce=$nonce" + ?: return "${ + buildUrl( + ip, + port, + UPLOAD + ) + }?sessionId=$sessionId&fileId=$fileId&transmissionId=$transmissionId&nonce=$nonce" return base.newBuilder() .addQueryParameter("sessionId", sessionId) .addQueryParameter("fileId", fileId) diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PeerManualPingSession.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PeerManualPingSession.kt new file mode 100644 index 000000000..32e9f208e --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PeerManualPingSession.kt @@ -0,0 +1,28 @@ +package org.horizontal.tella.mobile.data.peertopeer.remote + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Job + +/** + * An in-flight manual `/api/v2/ping` + * + * The receiver (server) certificate hash resolves from the TLS handshake as soon as the connection + * is established — before the server-held HTTP response — so the sender can navigate to the + * receiver-hash verification screen immediately. [awaitSenderShowHash] resolves later, from the held + * HTTP body, once the recipient confirms the receiver hash on their device. + */ +class PeerManualPingSession( + private val receiverHash: CompletableDeferred, + private val senderShowHash: CompletableDeferred, + private val job: Job, +) { + /** Receiver leaf cert hash from the TLS handshake (resolves quickly). */ + suspend fun awaitReceiverHash(): String = receiverHash.await() + + /** `senderShowHash` from the held HTTP body (resolves after the recipient confirms). */ + suspend fun awaitSenderShowHash(): Boolean = senderShowHash.await() + + fun cancel() { + job.cancel() + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PeerPingResult.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PeerPingResult.kt new file mode 100644 index 000000000..f3c7b44a0 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PeerPingResult.kt @@ -0,0 +1,13 @@ +package org.horizontal.tella.mobile.data.peertopeer.remote + +/** + * Result of the manual-flow ping: the receiver leaf cert hash extracted from the TLS handshake, plus + * the `senderShowHash` flag parsed from the ping response body (protocol §3.1). + * + * Per the protocol security note, [senderShowHash] must only be acted on AFTER the receiver hash has + * been verified (the channel isn't authenticated until then). + */ +data class PeerPingResult( + val receiverHash: String, + val senderShowHash: Boolean, +) 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 index 204056f2f..546b4de70 100644 --- 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 @@ -3,10 +3,13 @@ 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 ClientCertificateRequired : RegisterPeerResult() // 400 mTLS 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 + /** Peer responded on v1 or QR lacked protocol_version (protocol §6). */ + data object IncompatibleProtocol : RegisterPeerResult() data class Failure(val exception: Throwable) : RegisterPeerResult() } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerConnectionQrCodec.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerConnectionQrCodec.kt index 4e44d1d44..c5690ae2e 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerConnectionQrCodec.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerConnectionQrCodec.kt @@ -3,60 +3,108 @@ package org.horizontal.tella.mobile.domain.peertopeer import com.google.gson.JsonArray import com.google.gson.JsonObject import com.google.gson.JsonParser +import org.horizontal.tella.mobile.data.peertopeer.PeerProtocolConstants /** - * QR payload aligned with iOS [ConnectionInfo] JSON ( Swift `JSONEncoder` + [CodingKeys] ): - * `ip_address` (JSON array of IPv4 strings), `port` (number), `certificate_hash` (hex), `pin` (string). - * The default `port` must match [org.horizontal.tella.mobile.data.peertopeer.PeerToPeerConstants.NEARBY_SHARING_TLS_PORT] - * and the bound P2P HTTPS listener. + * QR payloads for Nearby Sharing protocol v2. + * + * Receiver QR: `ip_address`, `port`, `certificate_hash`, `pin`, `protocol_version`, `sender_show_hash` + * Sender QR: `certificate_hash` only */ -data class ParsedPeerQr( +data class ParsedReceiverQr( val ipAddresses: List, val port: Int, - val certificateHash: String?, + val certificateHash: String, val pin: String, + val protocolVersion: Int, + val senderShowHash: Boolean, ) +data class ParsedSenderQr( + val certificateHash: String, +) + +/** @deprecated Use [ParsedReceiverQr] */ +typealias ParsedPeerQr = ParsedReceiverQr + +sealed class PeerQrParseResult { + data class Receiver(val qr: ParsedReceiverQr) : PeerQrParseResult() + data class Sender(val qr: ParsedSenderQr) : PeerQrParseResult() + data object IncompatibleVersion : PeerQrParseResult() + data object Invalid : PeerQrParseResult() +} + object PeerConnectionQrCodec { - fun parse(qrContent: String): ParsedPeerQr? { + fun parseAny(qrContent: String): PeerQrParseResult { return try { - val obj = JsonParser.parseString(qrContent).asJsonObject - - val ipEl = obj.get("ip_address") ?: return null - val ips = when { - ipEl.isJsonArray -> ipEl.asJsonArray.mapNotNull { - it.takeIf { it.isJsonPrimitive }?.asString?.trim() + val trimmed = qrContent.trim().trimStart('\uFEFF') + val obj = JsonParser.parseString(trimmed).asJsonObject + val hasIp = obj.has("ip_address") + val hasPort = obj.has("port") + val hasPin = obj.has("pin") + when { + hasIp && hasPort && hasPin -> parseReceiverObject(obj)?.let { PeerQrParseResult.Receiver(it) } + ?: receiverParseFailure(obj) + obj.has("certificate_hash") && !hasIp -> { + val hash = obj.stringField("certificate_hash").orEmpty() + if (hash.isEmpty()) PeerQrParseResult.Invalid + else PeerQrParseResult.Sender(ParsedSenderQr(hash)) } - ipEl.isJsonPrimitive -> listOf(ipEl.asString.trim()) - else -> emptyList() - }.filter { it.isNotEmpty() } - - if (ips.isEmpty()) return null + else -> PeerQrParseResult.Invalid + } + } catch (_: Exception) { + PeerQrParseResult.Invalid + } + } - val port = obj.get("port") - ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber } - ?.asInt ?: return null + private fun receiverParseFailure(obj: JsonObject): PeerQrParseResult { + val versionEl = obj.get("protocol_version") + val protocolVersion = when { + versionEl == null || versionEl.isJsonNull -> 1 + else -> obj.intField("protocol_version") + } + return if (protocolVersion == null || protocolVersion != PeerProtocolConstants.PROTOCOL_VERSION) { + PeerQrParseResult.IncompatibleVersion + } else { + PeerQrParseResult.Invalid + } + } - val cert = obj.get("certificate_hash") - ?.takeUnless { it.isJsonNull } - ?.asString ?: return null + /** Legacy entry point; returns null for sender-only QRs or incompatible versions. */ + fun parse(qrContent: String): ParsedReceiverQr? = + when (val result = parseAny(qrContent)) { + is PeerQrParseResult.Receiver -> result.qr + else -> null + } - val pin = obj.get("pin") - ?.takeUnless { it.isJsonNull } - ?.asString ?: return null + fun parseSender(qrContent: String): ParsedSenderQr? = + when (val result = parseAny(qrContent)) { + is PeerQrParseResult.Sender -> result.qr + else -> null + } - ParsedPeerQr(ips, port, cert, pin) - } catch (e: Exception) { - null + /** + * True for pre-v2 receiver QRs (no [protocol_version] and no [certificate_hash]). + * Tella Desktop and other legacy v2 peers may omit [protocol_version] but still include [certificate_hash]. + */ + fun isV1ReceiverQr(qrContent: String): Boolean { + return try { + val obj = JsonParser.parseString(qrContent.trim().trimStart('\uFEFF')).asJsonObject + obj.has("ip_address") && obj.has("port") && obj.has("pin") && + !obj.has("protocol_version") && !obj.has("certificate_hash") + } catch (_: Exception) { + false } } - fun toJson( + fun toReceiverJson( ipAddresses: List, port: Int, certificateHash: String, - pin: String + pin: String, + senderShowHash: Boolean = false, + protocolVersion: Int = PeerProtocolConstants.PROTOCOL_VERSION, ): String { require(ipAddresses.isNotEmpty()) { "ipAddresses cannot be empty" } require(port in 1..65535) { "Invalid port" } @@ -64,20 +112,106 @@ object PeerConnectionQrCodec { require(pin.isNotBlank()) { "pin cannot be blank" } val obj = JsonObject() - val ipArray = JsonArray() ipAddresses .map { it.trim() } .filter { it.isNotEmpty() } .forEach { ipArray.add(it) } - require(ipArray.size() > 0) { "All IPs are empty" } obj.add("ip_address", ipArray) obj.addProperty("port", port) obj.addProperty("certificate_hash", certificateHash) obj.addProperty("pin", pin) + obj.addProperty("protocol_version", protocolVersion) + obj.addProperty("sender_show_hash", senderShowHash) + return obj.toString() + } + fun toSenderJson(certificateHash: String): String { + require(certificateHash.isNotBlank()) { "certificateHash cannot be blank" } + val obj = JsonObject() + obj.addProperty("certificate_hash", certificateHash) return obj.toString() } + + /** @deprecated Use [toReceiverJson] */ + fun toJson( + ipAddresses: List, + port: Int, + certificateHash: String, + pin: String, + ): String = toReceiverJson(ipAddresses, port, certificateHash, pin) + + private fun parseReceiverObject(obj: JsonObject): ParsedReceiverQr? { + val ipEl = obj.get("ip_address") ?: return null + val ips = (when { + ipEl.isJsonArray -> ipEl.asJsonArray.mapNotNull { element -> + element.stringValue()?.trim() + } + else -> ipEl.stringValue()?.trim()?.let { listOf(it) } + } ?: emptyList()).filter { it.isNotEmpty() } + if (ips.isEmpty()) return null + + val port = obj.intField("port") ?: return null + + val cert = obj.stringField("certificate_hash").orEmpty() + if (cert.isEmpty()) return null + + val pin = obj.stringField("pin").orEmpty() + if (pin.isEmpty()) return null + + val versionEl = obj.get("protocol_version") + val desktopLegacy = versionEl == null || versionEl.isJsonNull + val protocolVersion = when { + desktopLegacy -> { + // Tella Desktop QR: same fields as v2 but omits protocol_version / sender_show_hash + if (cert.isNotEmpty()) PeerProtocolConstants.PROTOCOL_VERSION else 1 + } + else -> obj.intField("protocol_version") ?: return null + } + + if (protocolVersion != PeerProtocolConstants.PROTOCOL_VERSION) return null + + val senderShowHash = obj.get("sender_show_hash")?.takeIf { !it.isJsonNull }?.asBoolean + ?: desktopLegacy + + return ParsedReceiverQr( + ipAddresses = ips, + port = port, + certificateHash = cert, + pin = pin, + protocolVersion = protocolVersion, + senderShowHash = senderShowHash, + ) + } + + private fun JsonObject.stringField(key: String): String? = get(key).stringValue() + + private fun JsonObject.intField(key: String): Int? = get(key).intValue() + + /** Accept string or numeric JSON primitives (iOS always uses strings for pin; some encoders use numbers). */ + private fun com.google.gson.JsonElement?.stringValue(): String? = when { + this == null || isJsonNull -> null + isJsonPrimitive -> asJsonPrimitive.let { primitive -> + when { + primitive.isString -> primitive.asString + primitive.isNumber -> primitive.asNumber.toString() + else -> null + } + } + else -> null + } + + private fun com.google.gson.JsonElement?.intValue(): Int? = when { + this == null || isJsonNull -> null + isJsonPrimitive -> asJsonPrimitive.let { primitive -> + when { + primitive.isNumber -> primitive.asInt + primitive.isString -> primitive.asString.trim().toIntOrNull() + else -> null + } + } + else -> null + } } 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 index a93549c11..cf9826d1c 100644 --- 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 @@ -39,6 +39,21 @@ object PeerEventManager { private val decisionMap = mutableMapOf>() private val registrationDecisionMap = mutableMapOf>() + private var senderHashVerificationDeferred: CompletableDeferred? = null + private var receiverHashConfirmationDeferred: CompletableDeferred? = null + private var pingReceiverHashDeferred: CompletableDeferred? = null + + private val _senderHashVerificationRequests = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + ) + val senderHashVerificationRequests = _senderHashVerificationRequests.asSharedFlow() + + private val _incompatibleProtocol = MutableSharedFlow( + replay = 1, + extraBufferCapacity = 1, + ) + val incompatibleProtocol = _incompatibleProtocol.asSharedFlow() private val _uploadProgressStateFlow = MutableSharedFlow(replay = 0) val uploadProgressStateFlow = _uploadProgressStateFlow.asSharedFlow() @@ -88,4 +103,61 @@ object PeerEventManager { fun confirmRegistration(registrationId: String, accepted: Boolean) { registrationDecisionMap.remove(registrationId)?.complete(accepted) } + + suspend fun emitSenderHashVerification(senderCertHash: String, alreadyConfirmed: Boolean = false): Boolean { + if (alreadyConfirmed) return true + val deferred = CompletableDeferred() + senderHashVerificationDeferred = deferred + _senderHashVerificationRequests.emit(senderCertHash) + return deferred.await() + } + + fun confirmSenderHashVerification(accepted: Boolean) { + senderHashVerificationDeferred?.complete(accepted) + senderHashVerificationDeferred = null + } + + /** + * Flow D: `/register` awaits this when sender hash verification is required but the + * recipient has not yet confirmed the receiver hash. + */ + suspend fun awaitReceiverHashConfirmation(alreadyConfirmed: Boolean): Boolean { + if (alreadyConfirmed) return true + val deferred = CompletableDeferred() + receiverHashConfirmationDeferred = deferred + return try { + deferred.await() + } catch (_: Exception) { + false + } + } + + suspend fun holdPingForReceiverHash(): Boolean { + val deferred = CompletableDeferred() + pingReceiverHashDeferred = deferred + return try { + deferred.await() + } catch (_: Exception) { + false + } + } + + fun confirmReceiverHashVerification(accepted: Boolean) { + // Release both the held ping (receiver-hash handshake) and any register held on the same gate. + pingReceiverHashDeferred?.complete(accepted) + pingReceiverHashDeferred = null + receiverHashConfirmationDeferred?.complete(accepted) + receiverHashConfirmationDeferred = null + } + + fun resetReceiverHashConfirmation() { + pingReceiverHashDeferred?.cancel() + pingReceiverHashDeferred = null + receiverHashConfirmationDeferred?.cancel() + receiverHashConfirmationDeferred = null + } + + suspend fun emitIncompatibleProtocol() { + _incompatibleProtocol.emit(Unit) + } } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerPingResponse.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerPingResponse.kt new file mode 100644 index 000000000..1feb172b5 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerPingResponse.kt @@ -0,0 +1,15 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +import kotlinx.serialization.Serializable + +/** + * Body of `POST /api/v2/ping` (protocol §3.1). `senderShowHash` tells the manual sender whether it + * must run sender-hash verification after register: true when the receiver has not already pinned the + * sender certificate (e.g. it could not scan the sender QR — flow D), false when it has (flow C). + * + * Field name is camelCase to match the protocol doc and the iOS `PingResponse`. + */ +@Serializable +data class PeerPingResponse( + val senderShowHash: Boolean, +) 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 index 1faf3bdd8..da2ac0b6b 100644 --- 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 @@ -4,11 +4,10 @@ import kotlinx.serialization.Serializable @Serializable data class PeerRegisterPayload( - val pin: String, - /** Optional on the wire like iOS `RegisterRequest`; sender always supplies a value. */ + val pin: String? = null, val nonce: String? = null, ) { companion object { - val EMPTY = PeerRegisterPayload(pin = "", nonce = null) + val EMPTY = PeerRegisterPayload(pin = null, nonce = null) } } \ No newline at end of file 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 376828d64..c9602dfd6 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 @@ -170,6 +170,34 @@ class NavigationManager( navigateToWithBundle(R.id.action_startNearBySharingFragment_to_scanQrCodeScreen) } + fun navigateFromStartNearBySharingFragmentToSenderShowQrScreen() { + navigateToWithBundle(R.id.action_startNearBySharingFragment_to_senderShowQrScreen) + } + + fun navigateFromStartNearBySharingFragmentToScanSenderQrScreen() { + navigateToWithBundle(R.id.action_startNearBySharingFragment_to_scanSenderQrScreen) + } + + fun navigateFromSenderShowQrToScanReceiverQrScreen() { + navigateToWithBundle(R.id.action_senderShowQrScreen_to_scanQrCodeScreen) + } + + fun navigateFromSenderShowQrToSenderManualConnectionScreen() { + navigateToWithBundle(R.id.action_senderShowQrScreen_to_senderManualConnectionScreen) + } + + fun navigateFromScanSenderQrToQrCodeScreen() { + navigateToWithBundle(R.id.action_scanSenderQrScreen_to_qrCodeScreen) + } + + fun navigateFromScanSenderQrToDeviceInfoScreen() { + navigateToWithBundle(R.id.action_scanSenderQrScreen_to_deviceInfoScreen) + } + + fun navigateFromScanQrCodeToSenderVerification() { + navigateToWithBundle(R.id.action_scanQrCodeScreen_to_connectManuallyVerificationFragment) + } + fun navigateFromStartNearBySharingFragmentToTipsToConnectFragment() { navigateToWithBundle(R.id.action_startNearBySharingFragment_to_tipsToConnectFragment) } @@ -182,6 +210,14 @@ class NavigationManager( navigateToWithBundle(R.id.action_connectHotspotScreen_to_scanQrCodeScreen) } + fun navigateFromActionConnectHotspotScreenToSenderShowQrScreen() { + navigateToWithBundle(R.id.action_connectHotspotScreen_to_senderShowQrScreen) + } + + fun navigateFromActionConnectHotspotScreenToScanSenderQrScreen() { + navigateToWithBundle(R.id.action_connectHotspotScreen_to_scanSenderQrScreen) + } + fun navigateFromScanQrCodeToDeviceInfo() { navigateToWithBundle(R.id.action_qrCodeScreen_to_deviceInfoScreen) } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/feedback/SendFeedbackFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/feedback/SendFeedbackFragment.kt index fa1634051..3ea24f291 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/feedback/SendFeedbackFragment.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/feedback/SendFeedbackFragment.kt @@ -162,7 +162,7 @@ class SendFeedbackFragment : val enabled: Float = context?.getString(R.string.alpha_enabled)?.toFloat() ?: 1.0f // Determine the background resource based on the value of isSubmitEnabled - binding.sendFeedbackBtn.setBackgroundResource(if (isSubmitEnabled) R.drawable.bg_round_orange_btn else R.drawable.bg_round_orange16_btn) + binding.sendFeedbackBtn.setBackgroundResource(if (isSubmitEnabled) R.drawable.bg_round_orange_btn else R.drawable.bg_round_orange_disabled) binding.sendFeedbackBtn.alpha = (if (isSubmitEnabled) enabled else disabled) binding.sendFeedbackBtn.isEnabled = isSubmitEnabled } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/main_connexions/base/BaseReportsEntryFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/main_connexions/base/BaseReportsEntryFragment.kt index 59c486822..10e7ccf36 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/main_connexions/base/BaseReportsEntryFragment.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/main_connexions/base/BaseReportsEntryFragment.kt @@ -192,7 +192,7 @@ abstract class BaseReportsEntryFragment : 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.setBackgroundResource(if (isSubmitEnabled) R.drawable.bg_round_orange_btn else R.drawable.bg_round_orange_disabled) binding.sendLaterBtn.alpha = (if (isSubmitEnabled) enabled else disabled) binding.sendReportBtn.alpha = (if (isSubmitEnabled) enabled else disabled) diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/PeerQrScanMode.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/PeerQrScanMode.kt new file mode 100644 index 000000000..c79cc5178 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/PeerQrScanMode.kt @@ -0,0 +1,7 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer + +object PeerQrScanMode { + const val ARG_SCAN_MODE = "scan_mode" + const val SCAN_RECEIVER_QR = "scan_receiver_qr" + const val SCAN_SENDER_QR = "scan_sender_qr" +} 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 index 93761d4e0..9b1ed3944 100644 --- 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 @@ -105,10 +105,11 @@ class StartNearBySharingFragment : BaseBindingFragment(FragmentQrCode lateinit var peerServerStarterManager: PeerServerStarterManager @Inject - lateinit var peerToPeerManager: PeerToPeerManager - - @Inject - lateinit var p2PSharedState: P2PSharedState + lateinit var receiverSessionSetup: ReceiverSessionSetup /** Ensures only one setup runs at a time so server PIN and QR payload cannot diverge. */ private val qrSetupMutex = Mutex() @@ -102,6 +92,8 @@ class QRCodeFragment : BaseBindingFragment(FragmentQrCode handleConnectManually() viewModel.isManualConnection = false + viewModel.p2PState.isUsingManualConnection = false + viewModel.p2PState.receiverCanScanQr = true viewModel.registrationServerSuccess.observe(viewLifecycleOwner) { success -> if (success) { @@ -123,57 +115,11 @@ class QRCodeFragment : BaseBindingFragment(FragmentQrCode } private suspend fun setupServerAndQr(primaryIpHint: String) { - // New receiver session: drop any stale ping event from a previous attempt. - peerToPeerManager.clearClientConnected() - Timber.d("P2P receiver session started: cleared stale ping replay cache") - - val discovered = viewModel.collectLocalIpv4AddressesForNearbySharing() - val hint = primaryIpHint.trim() - val mergedForSelection = buildList { - if (hint.isNotEmpty()) add(hint) - for (a in discovered) { - if (a.isNotBlank() && a !in this) add(a) - } - } - val allIps = P2PNetworkAddressPolicy.filterAndOrderForAdvertise(mergedForSelection) - if (allIps.isEmpty()) { - Timber.e( - "P2P QR: no site-local (RFC1918) IPv4 for certificate/QR after policy filter; raw merged=%s", - mergedForSelection.joinToString(), - ) - return - } - val advertiseToPeerPrimary = allIps.first() - val keyPair = PeerKeyProvider.getKeyPair() - val certificate = PeerKeyProvider.getCertificate(allIps) - val config = KeyStoreConfig() - - val certHash = CertificateUtils.getLeafCertificateDerSha256Hex(certificate) - val pin = (100000..999999).random() - val port = port - val pinString = pin.toString() - - val started = withContext(Dispatchers.IO) { - peerServerStarterManager.startServer( - advertiseToPeerPrimary, - keyPair, - pinString, - certificate, - config, - p2PSharedState - ) - } - if (!started) { - Timber.e("P2P QR: server failed to start; not updating PIN/QR") - return - } - - p2PSharedState.pin = pinString - p2PSharedState.port = port.toString() - p2PSharedState.hash = certHash - p2PSharedState.ip = advertiseToPeerPrimary - - val json = PeerConnectionQrCodec.toJson(allIps, port, certHash, pinString) + val json = receiverSessionSetup.start( + primaryIpHint = primaryIpHint, + discoveredIps = viewModel.collectLocalIpv4AddressesForNearbySharing(), + ) ?: return + Timber.d("P2P receiver QR payload=%s", json) qrPayload = json generateQrCode(json) } @@ -230,8 +176,8 @@ class QRCodeFragment : BaseBindingFragment(FragmentQrCode } private fun connectManually() { - val json = qrPayload ?: return - bundle.putString("payload", json) + viewModel.p2PState.receiverCanScanQr = false + viewModel.p2PState.isUsingManualConnection = true navManager().navigateFromScanQrCodeToDeviceInfo() } 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 index 3584401b0..dc2bfe752 100644 --- 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 @@ -10,6 +10,7 @@ import kotlinx.coroutines.withContext 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.data.peertopeer.model.P2PVerificationStep import org.horizontal.tella.mobile.databinding.ConnectManuallyVerificationBinding import org.horizontal.tella.mobile.util.formatHash import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment @@ -21,6 +22,7 @@ class RecipientVerificationFragment : BaseBindingFragment(ConnectManuallyVerificationBinding::inflate) { private val viewModel: PeerToPeerViewModel by activityViewModels() + @Inject lateinit var peerServerStarterManager: PeerServerStarterManager @@ -33,23 +35,60 @@ class RecipientVerificationFragment : } private fun initUI() = with(binding) { - // Simpler instruction text (update your string as needed) + refreshVerificationUi() + } + + private fun senderHash(): String = + viewModel.p2PState.pinnedSenderHash.ifBlank { viewModel.p2PState.hash } + + private fun refreshVerificationUi() = with(binding) { + val step = viewModel.p2PState.activeVerificationStep sequenceDescTextView.text = getString(R.string.nearbySharing_verifyConnection_recipient) - hashContentTextView.text = viewModel.p2PState.hash.formatHash() + if (step == P2PVerificationStep.SENDER_HASH) { + sequenceTitleTextView.text = getString(R.string.verification_step2_sender_hash) + hashContentTextView.text = senderHash().formatHash() + } else { + sequenceTitleTextView.text = getString(R.string.verification_step1_recipient_hash) + hashContentTextView.text = viewModel.p2PState.localReceiverHash + .ifBlank { viewModel.p2PState.hash } + .formatHash() + } + setConfirmButtonForStep(step) + } + + private fun setConfirmButtonForStep(step: P2PVerificationStep?) { - // IMPORTANT: button is enabled immediately - confirmAndConnectBtn.isEnabled = true - confirmAndConnectBtn.setText(getString(R.string.confirm_and_connect)) + applyConfirmButtonState( + enabled = true, + title = if (step == P2PVerificationStep.SENDER_HASH) { + getString(R.string.confirm_and_connect) + } else { + getString(R.string.confirm_and_continue) + }, + ) } + private fun applyConfirmButtonState(enabled: Boolean, title: String) = with(binding) { + confirmAndConnectBtn.isEnabled = enabled + confirmAndConnectBtn.setBackgroundResource( + if (enabled) R.drawable.bg_round_orange_btn else R.drawable.bg_round_orange_disabled + ) + confirmAndConnectBtn.setText(title) + } + + private fun waitingTextForStep(step: P2PVerificationStep?): String = + getString(R.string.waiting_for_the_sender) + 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)) + applyConfirmButtonState( + enabled = false, + title = waitingTextForStep(viewModel.p2PState.activeVerificationStep), + ) viewModel.onRecipientConfirmTapped() } } @@ -66,21 +105,29 @@ class RecipientVerificationFragment : } } + viewModel.getHashSuccess.observe(viewLifecycleOwner) { + refreshVerificationUi() + } + + viewModel.incompatibleProtocolError.observe(viewLifecycleOwner) { + navigateBackAndStopServer() + } + 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)) + applyConfirmButtonState( + enabled = false, + title = waitingTextForStep(viewModel.p2PState.activeVerificationStep), + ) } } viewModel.canTapConfirm.observe(viewLifecycleOwner) { canTap -> if (canTap) { - confirmAndConnectBtn.isEnabled = true - confirmAndConnectBtn.setText(getString(R.string.confirm_and_connect)) + setConfirmButtonForStep(viewModel.p2PState.activeVerificationStep) } } } 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 index 7ccf0374d..d94a1bee3 100644 --- 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 @@ -1,69 +1,140 @@ package org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow +import android.os.Build import android.os.Bundle import android.view.View import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerServerStarterManager +import org.horizontal.tella.mobile.data.peertopeer.managers.ReceiverSessionSetup +import org.horizontal.tella.mobile.data.peertopeer.model.P2PVerificationStep import org.horizontal.tella.mobile.databinding.ShowDeviceInfoLayoutBinding -import org.horizontal.tella.mobile.domain.peertopeer.ParsedPeerQr -import org.horizontal.tella.mobile.domain.peertopeer.PeerConnectionQrCodec import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel -import timber.log.Timber +import javax.inject.Inject +/** + * Recipient "Connect manually" screen. Standalone counterpart to [QRCodeFragment]: it brings up its + * own receiver server (or reuses the one already started by the QR screen) and shows the IP / PIN / + * port the sender must type in. When the sender pings, it moves on to recipient-hash verification. + */ +@AndroidEntryPoint class ShowDeviceInfoFragment : BaseBindingFragment(ShowDeviceInfoLayoutBinding::inflate) { + private val viewModel: PeerToPeerViewModel by activityViewModels() - private var parsedQr: ParsedPeerQr? = null + @Inject + lateinit var receiverSessionSetup: ReceiverSessionSetup + + @Inject + lateinit var peerServerStarterManager: PeerServerStarterManager + private var movedToVerification = false + private var startedOwnServer = false + private var serverStartRequested = false + + private val serverSetupMutex = Mutex() + private var serverSetupDone = false override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - arguments?.getString("payload")?.let { payloadJson -> - parsedQr = PeerConnectionQrCodec.parse(payloadJson) - } + viewModel.p2PState.isUsingManualConnection = true + viewModel.p2PState.receiverCanScanQr = false initListeners() - initView() initObservers() + ensureServer() } - private fun initView() { - val parsed = parsedQr - if (parsed != null) { - binding.connectCode.setRightText(parsed.ipAddresses.joinToString(", ")) - binding.pin.setRightText(parsed.pin) - binding.port.setRightText(parsed.port.toString()) + private fun initListeners() { + binding.toolbar.backClickListener = { navigateBack() } + } + + private fun ensureServer() { + if (receiverSessionSetup.hasRunningSession()) { + // Reuse the server already started by the QR screen. + serverSetupDone = true + showCredentials() + return + } + if (serverStartRequested) return + serverStartRequested = true + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + viewModel.networkInfo.observe(viewLifecycleOwner) { info -> + startServer(info.ipAddress.orEmpty()) + } + viewModel.updateNetworkInfo() } else { - binding.connectCode.setRightText(viewModel.p2PState.ip) - binding.pin.setRightText(viewModel.p2PState.pin) - binding.port.setRightText(viewModel.p2PState.port) + startServer(viewModel.currentNetworkInfo?.ipAddress.orEmpty()) } } - private fun initListeners() { - binding.toolbar.backClickListener = { nav().popBackStack() } + private fun startServer(primaryIpHint: String) { + viewLifecycleOwner.lifecycleScope.launch { + + serverSetupMutex.withLock { + if (serverSetupDone || receiverSessionSetup.hasRunningSession()) { + serverSetupDone = true + showCredentials() + return@withLock + } + val json = receiverSessionSetup.start( + primaryIpHint = primaryIpHint, + discoveredIps = viewModel.collectLocalIpv4AddressesForNearbySharing(), + ) ?: return@withLock + startedOwnServer = true + serverSetupDone = true + showCredentials() + } + } + } + + private fun showCredentials() = with(binding) { + val ips = viewModel.p2PState.advertisedIpAddresses + .takeIf { it.isNotEmpty() } + ?.joinToString(", ") + ?: viewModel.p2PState.ip + connectCode.setRightText(ips) + pin.setRightText(viewModel.p2PState.pin) + port.setRightText(viewModel.p2PState.port) } private fun initObservers() { lifecycleScope.launch { - viewModel.clientHash.collect { clientHash -> - with(viewModel.p2PState) { - val p = parsedQr - ip = p?.ipAddresses?.firstOrNull().orEmpty() - port = p?.port?.toString().orEmpty() - pin = p?.pin - hash = clientHash - } - moveToVerificationIfNeeded() + viewModel.clientHash.collect { + moveToRecipientHashVerificationIfNeeded() + } + } + lifecycleScope.launch { + viewModel.recipientHashVerification.collect { + moveToRecipientHashVerificationIfNeeded() } } } - private fun moveToVerificationIfNeeded() { + private fun moveToRecipientHashVerificationIfNeeded() { if (movedToVerification) return movedToVerification = true + viewModel.p2PState.activeVerificationStep = P2PVerificationStep.RECIPIENT_HASH navManager().navigateFromDeviceInfoScreenTRecipientVerificationScreen() } -} \ No newline at end of file + + private fun navigateBack() { + // Only tear down the server if this screen started it; if it was reused from the QR screen, + // leave that screen's server running. + if (startedOwnServer) { + viewLifecycleOwner.lifecycleScope.launch { + withContext(Dispatchers.IO) { peerServerStarterManager.stopServer() } + nav().popBackStack() + } + } else { + nav().popBackStack() + } + } +} 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 index 20da9457a..24650db59 100644 --- 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 @@ -285,7 +285,7 @@ class PrepareUploadFragment : 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.setBackgroundResource(if (isSubmitEnabled) R.drawable.bg_round_orange_btn else R.drawable.bg_round_orange_disabled) binding.sendReportBtn.alpha = (if (isSubmitEnabled) enabled else disabled) initClickListeners(isSubmitEnabled) 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 index ac3a7e419..b9700425e 100644 --- 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 @@ -7,18 +7,24 @@ import android.os.Bundle import android.view.View import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat +import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController +import com.google.zxing.BarcodeFormat import com.journeyapps.barcodescanner.BarcodeCallback import com.journeyapps.barcodescanner.BarcodeResult import com.journeyapps.barcodescanner.CompoundBarcodeView +import com.journeyapps.barcodescanner.DefaultDecoderFactory import org.horizontal.tella.mobile.R -import org.horizontal.tella.mobile.domain.peertopeer.PeerConnectionQrCodec import org.horizontal.tella.mobile.databinding.ScanQrcodeFragmentBinding +import org.horizontal.tella.mobile.domain.peertopeer.PeerConnectionQrCodec +import org.horizontal.tella.mobile.domain.peertopeer.PeerQrParseResult import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.PeerQrScanMode 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 timber.log.Timber class ScanQrCodeFragment : BaseBindingFragment(ScanQrcodeFragmentBinding::inflate) { @@ -26,6 +32,10 @@ class ScanQrCodeFragment : private val viewModel: PeerToPeerViewModel by activityViewModels() private lateinit var barcodeView: CompoundBarcodeView + private val scanMode: String + get() = arguments?.getString(PeerQrScanMode.ARG_SCAN_MODE) + ?: PeerQrScanMode.SCAN_RECEIVER_QR + companion object { private const val CAMERA_REQUEST_CODE = 1001 } @@ -34,10 +44,16 @@ class ScanQrCodeFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - barcodeView = CompoundBarcodeView(requireContext()) + binding.titleTextView.text = when (scanMode) { + PeerQrScanMode.SCAN_SENDER_QR -> getString(R.string.qr_code_scan_sender_instruction) + else -> getString(R.string.qr_code_scan_instruction) + } + barcodeView = binding.qrCodeScanView barcodeView.statusView.visibility = View.GONE barcodeView.viewFinder.visibility = View.GONE + // Avoid false reads from 1D barcodes / screen glare; connection QRs are always QR_CODE. + barcodeView.barcodeView.decoderFactory = DefaultDecoderFactory(listOf(BarcodeFormat.QR_CODE)) if (ContextCompat.checkSelfPermission( requireContext(), @@ -57,36 +73,90 @@ class ScanQrCodeFragment : private fun startScanning() { barcodeView.decodeContinuous(object : BarcodeCallback { - override fun barcodeResult(result: BarcodeResult?) { result?.text?.let { raw -> - val trimmed = raw.trim() - val parsed = PeerConnectionQrCodec.parse(trimmed) ?: return@let - val cert = parsed.certificateHash?.takeIf { it.isNotBlank() } ?: return@let - - barcodeView.pause() - - viewModel.p2PState.pin = parsed.pin - viewModel.p2PState.port = parsed.port.toString() - viewModel.p2PState.hash = cert - viewModel.p2PState.ip = parsed.ipAddresses.firstOrNull().orEmpty() - - viewModel.startRegistrationWithIpCandidates( - rawCandidates = parsed.ipAddresses, - port = parsed.port.toString(), - hash = cert, - pin = parsed.pin, - ) + handleScannedPayload(raw.trim()) } } override fun possibleResultPoints(resultPoints: MutableList?) { } }) - barcodeView.resume() } + private fun handleScannedPayload(trimmed: String) { + Timber.d("P2P QR scan mode=%s payload=%s", scanMode, trimmed.take(200)) + when (scanMode) { + PeerQrScanMode.SCAN_SENDER_QR -> handleSenderQr(trimmed) + else -> handleReceiverQr(trimmed) + } + } + + private fun handleSenderQr(trimmed: String) { + if (!trimmed.startsWith("{")) { + Timber.w("P2P QR ignored non-JSON sender payload: %s", trimmed.take(80)) + return + } + when (val parsed = PeerConnectionQrCodec.parseAny(trimmed)) { + is PeerQrParseResult.Sender -> { + barcodeView.pause() + viewModel.onSenderQrScanned(parsed.qr.certificateHash) + navManager().navigateFromScanSenderQrToQrCodeScreen() + } + is PeerQrParseResult.Receiver -> showWrongQrTypeError( + getString(R.string.peer_to_peer_scan_sender_qr_expected), + ) + PeerQrParseResult.IncompatibleVersion -> { + barcodeView.pause() + viewModel.showIncompatibleProtocolError() + } + PeerQrParseResult.Invalid -> showInvalidQrError() + } + } + + private fun handleReceiverQr(trimmed: String) { + if (!trimmed.startsWith("{")) { + Timber.w("P2P QR ignored non-JSON payload: %s", trimmed.take(80)) + if (trimmed.all { it.isDigit() }) { + showWrongQrTypeError(getString(R.string.peer_to_peer_qr_scanned_number_not_json)) + } + return + } + if (PeerConnectionQrCodec.isV1ReceiverQr(trimmed)) { + barcodeView.pause() + viewModel.showIncompatibleProtocolError() + return + } + when (val parsed = PeerConnectionQrCodec.parseAny(trimmed)) { + is PeerQrParseResult.Receiver -> { + barcodeView.pause() + viewModel.onReceiverQrScanned(parsed.qr) + } + is PeerQrParseResult.Sender -> showWrongQrTypeError( + getString(R.string.peer_to_peer_scan_recipient_qr_expected), + ) + PeerQrParseResult.IncompatibleVersion -> { + barcodeView.pause() + viewModel.showIncompatibleProtocolError() + } + PeerQrParseResult.Invalid -> showInvalidQrError() + } + } + + private fun showWrongQrTypeError(message: String) { + barcodeView.pause() + bottomSheetError(getString(R.string.connection_failed), message) + } + + private fun showInvalidQrError() { + barcodeView.pause() + bottomSheetError( + getString(R.string.connection_failed), + getString(R.string.peer_to_peer_invalid_qr_code), + ) + } + override fun onPause() { super.onPause() barcodeView.pause() @@ -94,11 +164,15 @@ class ScanQrCodeFragment : override fun onResume() { super.onResume() - barcodeView.resume() + if (::barcodeView.isInitialized) { + barcodeView.resume() + } } override fun onDestroyView() { - barcodeView.pauseAndWait() + if (::barcodeView.isInitialized) { + barcodeView.pauseAndWait() + } super.onDestroyView() } @@ -116,39 +190,76 @@ class ScanQrCodeFragment : } private fun initListeners() { - binding.connectManuallyButton.setOnClickListener { - navManager().navigateFromScanQrCodeToSenderManualConnectionScreen() + if (scanMode == PeerQrScanMode.SCAN_RECEIVER_QR) { + binding.connectManuallyButton.setOnClickListener { + viewModel.p2PState.senderCanScanQr = false + navManager().navigateFromScanQrCodeToSenderManualConnectionScreen() + } + } else { + binding.connectManuallyButton.setOnClickListener { + viewModel.p2PState.receiverCanScanQr = false + viewModel.p2PState.isUsingManualConnection = true + navManager().navigateFromScanSenderQrToDeviceInfoScreen() + } } } private fun initObservers() { viewModel.registrationSuccess.observe(viewLifecycleOwner) { success -> - if (success) { + if (success && scanMode == PeerQrScanMode.SCAN_RECEIVER_QR) { findNavController().currentBackStackEntry?.savedStateHandle ?.set("registrationSuccess", true) navManager().navigateFromScanQrCodeToPrepareUploadFragment() } } + viewModel.navigateToSenderVerification.observe(viewLifecycleOwner) { go -> + if (go && scanMode == PeerQrScanMode.SCAN_RECEIVER_QR) { + viewModel.navigateToSenderVerification.postValue(false) + navManager().navigateFromScanQrCodeToSenderVerification() + } + } + viewModel.bottomMessageError.observe(viewLifecycleOwner) { message -> DialogUtils.showBottomMessage(baseActivity, message, false) } viewModel.bottomSheetError.observe(viewLifecycleOwner) { (title, description) -> - showStandardSheet( - baseActivity.supportFragmentManager, - title, - description, - getString(R.string.try_again), - null, - onConfirmClick = { - if (isAdded) { - viewModel.resetRegistrationState() - barcodeView.resume() - } - }, - onCancelClick = null, + bottomSheetError(title, description) + } + + viewModel.incompatibleProtocolError.observe(viewLifecycleOwner) { + bottomSheetError( + getString(R.string.peer_to_peer_incompatible_versions_title), + getString(R.string.peer_to_peer_incompatible_versions_message), ) } + + if (scanMode == PeerQrScanMode.SCAN_RECEIVER_QR) { + viewModel.isRegistering.observe(viewLifecycleOwner) { registering -> + binding.progressCircular.isVisible = registering + binding.qrCodeScanViewFrame.isVisible = !registering + binding.connectManuallyButton.isEnabled = !registering + } + } + } + + private fun bottomSheetError(title: String, description: String) { + showStandardSheet( + baseActivity.supportFragmentManager, + title, + description, + getString(R.string.try_again), + null, + onConfirmClick = { + if (isAdded) { + viewModel.resetRegistrationState() + if (::barcodeView.isInitialized) { + barcodeView.resume() + } + } + }, + onCancelClick = null, + ) } } 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 index 825498534..647980429 100644 --- 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 @@ -7,31 +7,27 @@ 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.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 + private var waitingForOtherSide = false override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - peerServerStarterManager.stopServer() - PeerKeyProvider.reset() - viewModel.p2PState.clear() + // Do NOT reset the sender identity here: in flow C the receiver has already scanned the + // sender QR and pinned this session's certificate hash. Regenerating it would make the + // cert presented at /register differ from the pinned hash → 403 "Sender certificate + // mismatch". The session is already reset at entry (StartNearBySharing.resetConnectionState). initView() initListeners() initObservers() @@ -65,6 +61,11 @@ class SenderManualConnectionFragment : } private fun initObservers() { + viewModel.waitingForOtherSide.observe(viewLifecycleOwner) { waiting -> + waitingForOtherSide = waiting + updateNextButtonState() + } + viewModel.getHashSuccess.observe(viewLifecycleOwner) { hash -> bundle.putString("payload", hash) navManager().navigateFromSenderManualConnectionToConnectManuallyVerification() @@ -95,7 +96,7 @@ class SenderManualConnectionFragment : } private fun updateNextButtonState() = with(binding) { - val enabled = isInputValid() + val enabled = isInputValid() && !waitingForOtherSide nextBtn.isEnabled = enabled nextBtn.setTextColor( ContextCompat.getColor( diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderShowQrFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderShowQrFragment.kt new file mode 100644 index 000000000..2575c04ca --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderShowQrFragment.kt @@ -0,0 +1,77 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow + +import android.graphics.Color +import android.os.Bundle +import android.view.View +import androidx.core.graphics.createBitmap +import androidx.fragment.app.activityViewModels +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import org.horizontal.tella.mobile.databinding.FragmentSenderShowQrBinding +import org.horizontal.tella.mobile.domain.peertopeer.PeerConnectionQrCodec +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel +import timber.log.Timber +import kotlin.math.roundToInt + +/** + * Sender flow step 1 — show sender certificate QR for the recipient to scan (iOS-aligned). + */ +class SenderShowQrFragment : + BaseBindingFragment(FragmentSenderShowQrBinding::inflate) { + + private val viewModel: PeerToPeerViewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.prepareSenderSession() + val payload = PeerConnectionQrCodec.toSenderJson(viewModel.p2PState.localSenderHash) + generateQrCode(payload) + handleBack() + binding.scanRecipientQrButton.setOnClickListener { + navManager().navigateFromSenderShowQrToScanReceiverQrScreen() + } + binding.connectManuallyButton.setOnClickListener { + viewModel.p2PState.senderCanScanQr = false + navManager().navigateFromSenderShowQrToSenderManualConnectionScreen() + } + } + + private fun generateQrCode(content: String) { + try { + val sizePx = (215f * resources.displayMetrics.density).roundToInt().coerceAtLeast(215) + val hints = mapOf( + EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.H, + EncodeHintType.MARGIN to 0, + ) + val bitMatrix = QRCodeWriter().encode( + content, + BarcodeFormat.QR_CODE, + sizePx, + sizePx, + hints, + ) + val w = bitMatrix.width + val h = bitMatrix.height + val pixels = IntArray(w * h) + for (y in 0 until h) { + val offset = y * w + for (x in 0 until w) { + pixels[offset + x] = + if (bitMatrix.get(x, y)) Color.BLACK else Color.WHITE + } + } + val bitmap = createBitmap(w, h) + bitmap.setPixels(pixels, 0, w, 0, 0, w, h) + binding.qrCodeImageView.setImageBitmap(bitmap) + } catch (e: Exception) { + Timber.e(e, "P2P sender QR: encode failed") + } + } + + private fun handleBack() { + binding.toolbar.backClickListener = { nav().popBackStack() } + } +} 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 index 07cfd3eda..20cc84e1f 100644 --- 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 @@ -7,6 +7,7 @@ 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.data.peertopeer.model.P2PVerificationStep import org.horizontal.tella.mobile.databinding.ConnectManuallyVerificationBinding import org.horizontal.tella.mobile.util.formatHash import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment @@ -32,16 +33,50 @@ class SenderVerificationFragment : } 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() + refreshVerificationUi() + } + + private fun refreshVerificationUi() { + val step = viewModel.p2PState.activeVerificationStep + binding.sequenceDescTextView.text = + getString(R.string.nearbySharing_verifyConnection_sender) + if (step == P2PVerificationStep.SENDER_HASH) { + binding.sequenceTitleTextView.text = getString(R.string.verification_step2_sender_hash) + binding.hashContentTextView.text = viewModel.p2PState.localSenderHash.formatHash() + binding.hashContentTextView.setBackgroundResource(R.drawable.bg_verification_hash_step2) + } else { + binding.sequenceTitleTextView.text = + getString(R.string.verification_step1_recipient_hash) + binding.hashContentTextView.text = viewModel.p2PState.hash.formatHash() + binding.hashContentTextView.setBackgroundResource(org.hzontal.shared_ui.R.drawable.bg_dual_text_check) + } + updateConfirmButton() + } + + private fun updateConfirmButton() { + val step = viewModel.p2PState.activeVerificationStep + val waiting = viewModel.waitingForOtherSide.value == true + val canTap = viewModel.canTapConfirm.value == true + + if (step == P2PVerificationStep.SENDER_HASH || waiting) { + applyConfirmButtonState(false, getString(R.string.waiting_for_the_recipient)) + } else { + applyConfirmButtonState(canTap, getString(R.string.confirm_and_continue)) + } + } + + private fun applyConfirmButtonState(enabled: Boolean, title: String) { + binding.confirmAndConnectBtn.isEnabled = enabled + binding.confirmAndConnectBtn.setBackgroundResource( + if (enabled) R.drawable.bg_round_orange_btn else R.drawable.bg_round_orange_disabled + ) + binding.confirmAndConnectBtn.setText(title) } private fun initListeners() { binding.confirmAndConnectBtn.setOnClickListener { - // Disable & show waiting immediately - binding.confirmAndConnectBtn.isEnabled = false - binding.confirmAndConnectBtn.setText(getString(R.string.waiting_for_the_recipient)) + if (viewModel.p2PState.activeVerificationStep == P2PVerificationStep.SENDER_HASH) return@setOnClickListener + applyConfirmButtonState(false, getString(R.string.waiting_for_the_recipient)) viewModel.onUserTappedConfirmAndConnect() } @@ -54,18 +89,16 @@ class SenderVerificationFragment : // 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)) - } + // Button enable/disable + label from VM + viewModel.canTapConfirm.observe(viewLifecycleOwner) { + updateConfirmButton() } - viewModel.waitingForOtherSide.observe(viewLifecycleOwner) { waiting -> - if (waiting) { - binding.confirmAndConnectBtn.isEnabled = false - binding.confirmAndConnectBtn.setText(getString(R.string.waiting_for_the_recipient)) - } + viewModel.waitingForOtherSide.observe(viewLifecycleOwner) { + updateConfirmButton() + } + + viewModel.getHashSuccess.observe(viewLifecycleOwner) { + refreshVerificationUi() } viewModel.registrationSuccess.observe(viewLifecycleOwner) { success -> 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 index af01e912a..f07a8e067 100644 --- 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 @@ -23,10 +23,12 @@ import kotlinx.coroutines.launch import org.horizontal.tella.mobile.R 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.certificate.CertificateUtils +import org.horizontal.tella.mobile.data.peertopeer.PeerKeyProvider import org.horizontal.tella.mobile.data.peertopeer.TellaPeerToPeerClient +import org.horizontal.tella.mobile.data.peertopeer.model.P2PVerificationStep +import org.horizontal.tella.mobile.domain.peertopeer.ParsedReceiverQr +import org.horizontal.tella.mobile.domain.peertopeer.PeerEventManager 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 @@ -34,10 +36,10 @@ import org.horizontal.tella.mobile.data.peertopeer.model.P2PSharedState.Companio 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.PeerManualPingSession import org.horizontal.tella.mobile.data.peertopeer.remote.RegisterPeerResult import org.horizontal.tella.mobile.domain.peertopeer.IncomingRegistration import org.horizontal.tella.mobile.domain.peertopeer.NearbySharingIpPreference -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 @@ -85,6 +87,7 @@ class PeerToPeerViewModel @Inject constructor( hasNavigatedFromWaitingToPrepareSuccess val clientHash = peerToPeerManager.clientConnected + val recipientHashVerification = peerToPeerManager.recipientHashVerification private val networkInfoManager = NetworkInfoManager(context) val networkInfo: LiveData get() = networkInfoManager.networkInfo @@ -101,8 +104,10 @@ class PeerToPeerViewModel @Inject constructor( val bottomMessageError = SingleLiveEvent() val bottomSheetError = SingleLiveEvent>() - private val _incomingPrepareRequest = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) - val incomingPrepareRequest: SharedFlow = _incomingPrepareRequest.asSharedFlow() + private val _incomingPrepareRequest = + MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + val incomingPrepareRequest: SharedFlow = + _incomingPrepareRequest.asSharedFlow() private val _incomingRequest = MutableStateFlow(null) val incomingRequest: StateFlow get() = _incomingRequest @@ -123,6 +128,14 @@ class PeerToPeerViewModel @Inject constructor( private val _waitingForOtherSide = MutableLiveData(false) val waitingForOtherSide: LiveData get() = _waitingForOtherSide + private val _navigateToSenderVerification = SingleLiveEvent() + val navigateToSenderVerification: SingleLiveEvent get() = _navigateToSenderVerification + + val incompatibleProtocolError = SingleLiveEvent() + + private val _isRegistering = MutableLiveData(false) + val isRegistering: LiveData get() = _isRegistering + // Cache for "pre-accept" when recipient taps before the request arrives private var preConfirmRegistration: Boolean = false @@ -133,9 +146,13 @@ class PeerToPeerViewModel @Inject constructor( val hash: String, val pin: String ) + private var pendingParams: PendingConnectParams? = null - /** Reuse registration nonce for the same target until registration succeeds (iOS RegistrationNonceContext). */ + /** Live manual ping : receiver hash from TLS now, senderShowHash on confirm. */ + private var manualPingSession: PeerManualPingSession? = null + + /** Reuse registration nonce for the same target until registration succeeds */ private data class RegistrationNonceContext( val ip: String, val port: String, @@ -177,6 +194,75 @@ class PeerToPeerViewModel @Inject constructor( observeRegistrationRequests() observeUploadProgress() observeCloseConnectionEvents() + observeSenderHashVerification() + observeIncompatibleProtocol() + } + + fun resetConnectionState() { + PeerKeyProvider.reset() + p2PState.clear() + isManualConnection = true + registrationNonceContext = null + pendingParams = null + manualPingSession?.cancel() + manualPingSession = null + preConfirmRegistration = false + PeerEventManager.resetReceiverHashConfirmation() + } + + fun prepareSenderSession() { + val (_, cert) = PeerKeyProvider.ensureSenderIdentity() + p2PState.localSenderHash = CertificateUtils.getLeafCertificateDerSha256Hex(cert) + } + + fun onSenderQrScanned(senderHash: String) { + p2PState.pinSenderHash(senderHash) + } + + fun onReceiverQrScanned(parsed: ParsedReceiverQr) { + p2PState.pin = parsed.pin + p2PState.port = parsed.port.toString() + p2PState.ip = parsed.ipAddresses.firstOrNull().orEmpty() + p2PState.advertisedIpAddresses = parsed.ipAddresses + p2PState.senderShowHash = parsed.senderShowHash + p2PState.pinReceiverHash(parsed.certificateHash) + + if (parsed.senderShowHash) { + _navigateToSenderVerification.postValue(true) + return + } + + startRegistrationWithIpCandidates( + rawCandidates = parsed.ipAddresses, + port = parsed.port.toString(), + hash = parsed.certificateHash, + pin = parsed.pin, + ) + } + + fun showIncompatibleProtocolError() { + incompatibleProtocolError.call() + } + + private fun observeSenderHashVerification() { + viewModelScope.launch { + PeerEventManager.senderHashVerificationRequests.collect { senderHash -> + p2PState.activeVerificationStep = P2PVerificationStep.SENDER_HASH + // Pending display until recipient confirms (server pins after confirm). + p2PState.hash = senderHash + _getHashSuccess.postValue(senderHash) + _canTapConfirm.postValue(true) + _waitingForOtherSide.postValue(false) + } + } + } + + private fun observeIncompatibleProtocol() { + viewModelScope.launch { + PeerEventManager.incompatibleProtocol.collect { + showIncompatibleProtocolError() + } + } } // ------------------- Observers ------------------- @@ -219,8 +305,11 @@ class PeerToPeerViewModel @Inject constructor( _incomingRequest.value = IncomingRegistration(registrationId, payload) - if (!p2PState.isUsingManualConnection) { - // Auto mode: accept immediately + if (p2PState.pinnedSenderHash.isNotBlank()) { + Timber.d( + "P2P registration auto-accepted (pinnedSenderHash set, manual=%b)", + p2PState.isUsingManualConnection, + ) PeerEventManager.confirmRegistration(registrationId, true) _registrationSuccess.postValue(true) PeerEventManager.clearRegistrationRequest() @@ -265,86 +354,144 @@ class PeerToPeerViewModel @Inject constructor( // ------------------- 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. + * Called after IP/port/PIN are entered. → + * startManualPing): the receiver hash is read from the TLS handshake, so we navigate to the + * receiver-hash verification screen immediately — before the recipient confirms. The held HTTP + * body (senderShowHash) is awaited later, when the sender taps "Confirm and continue". */ 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) { + prepareSenderSession() + p2PState.isUsingManualConnection = true + p2PState.senderCanScanQr = false + p2PState.activeVerificationStep = P2PVerificationStep.RECIPIENT_HASH + // Brief wait while the TLS handshake completes (receiver hash comes from the handshake). + _waitingForOtherSide.postValue(true) + + manualPingSession?.cancel() + val session = peerClient.startManualPing(ip, port) + manualPingSession = session + + val receiverHash = try { + session.awaitReceiverHash() + } catch (e: Exception) { + Timber.w(e, "P2P manual ping: receiver hash failed") + manualPingSession = null + _waitingForOtherSide.postValue(false) bottomSheetError.postValue( - "Connection failed" to ("Couldn’t read peer certificate. " + (fpRes.exceptionOrNull()?.message ?: "")) + context.getString(R.string.connection_failed) to + context.getString(R.string.peer_to_peer_manual_ping_failed) ) 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) + Timber.d("P2P manual ping: receiverHash=%s (from handshake)", receiverHash) + p2PState.hash = receiverHash + pendingParams = PendingConnectParams(ip, port, receiverHash, pin) + _getHashSuccess.postValue(receiverHash) // navigate to Step 1 (recipient hash) + _canTapConfirm.postValue(true) // enable "Confirm and continue" _waitingForOtherSide.postValue(false) } } - /** Sender tapped confirm: actually initiate /register on peer (using cached params). */ - // In PeerToPeerViewModel + /** + * Sender tapped confirm. + * - Step 2 (sender hash) is passive — just wait for the recipient. + * - Step 1 (recipient hash): pin the receiver hash, then await the held ping body for + * `senderShowHash` (released once the recipient confirms), then /register. + */ fun onUserTappedConfirmAndConnect() { _canTapConfirm.postValue(false) _waitingForOtherSide.postValue(true) + if (p2PState.activeVerificationStep == P2PVerificationStep.SENDER_HASH) { + return + } + val params = pendingParams + val session = manualPingSession + if (params != null && session != null) { + viewModelScope.launch { + p2PState.pinReceiverHash(params.hash) + val senderShowHash = try { + session.awaitSenderShowHash() + } catch (e: Exception) { + Timber.w(e, "P2P manual ping: senderShowHash failed") + manualPingSession = null + _waitingForOtherSide.postValue(false) + bottomSheetError.postValue( + context.getString(R.string.connection_failed) to + context.getString(R.string.peer_to_peer_manual_ping_failed) + ) + return@launch + } + manualPingSession = null + p2PState.senderShowHash = senderShowHash + Timber.d( + "P2P manual confirm: receiverHash=%s senderShowHash=%b", + params.hash, senderShowHash, + ) + startRegistration(params.ip, params.port, params.hash, params.pin) + if (senderShowHash) showSenderHashAfterRegister() + } + return + } + + // Fallback: params cached but no live ping session — register directly. if (params != null) { + p2PState.pinReceiverHash(params.hash) startRegistration(params.ip, params.port, params.hash, params.pin) + if (p2PState.senderShowHash) showSenderHashAfterRegister() 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) + if (p2PState.senderShowHash) showSenderHashAfterRegister() } 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) - } - } + handleCertificate(ip, port, pin) } } + /** + * After the manual sender sends /register, show this device's own hash on step 2 so the recipient + * can cross-check it. The sender takes no action on this step — they wait for the recipient to + * confirm. Only invoked when the ping reported `senderShowHash == true` (flow D); in flow C the + * receiver already pinned the sender via QR, so this screen is skipped entirely. + */ + private fun showSenderHashAfterRegister() { + p2PState.activeVerificationStep = P2PVerificationStep.SENDER_HASH + _getHashSuccess.postValue(p2PState.localSenderHash) + _waitingForOtherSide.postValue(true) + _canTapConfirm.postValue(false) + } /** Recipient tapped confirm: allow pre-accept before request arrives. */ fun onRecipientConfirmTapped() { _canTapConfirm.postValue(false) - _waitingForOtherSide.postValue(true) // "Waiting for the sender…" + _waitingForOtherSide.postValue(true) + + if (p2PState.activeVerificationStep == P2PVerificationStep.SENDER_HASH) { + p2PState.senderHashConfirmed = true + preConfirmRegistration = true + PeerEventManager.confirmSenderHashVerification(true) + return + } + + if (p2PState.activeVerificationStep == P2PVerificationStep.RECIPIENT_HASH || + p2PState.isUsingManualConnection + ) { + // Flow D step 1 — unblocks /register held on the server. + p2PState.receiverHashConfirmed = true + PeerEventManager.confirmReceiverHashVerification(true) + preConfirmRegistration = true + return + } val current = _incomingRequest.value if (current != null) { @@ -381,7 +528,12 @@ class PeerToPeerViewModel @Inject constructor( * Tries `/register` for each candidate IP in order. Candidates are reordered so addresses on the same * IPv4 /24-style subnet as this device are tried first, then the rest. */ - fun startRegistrationWithIpCandidates(rawCandidates: List, port: String, hash: String, pin: String) { + fun startRegistrationWithIpCandidates( + rawCandidates: List, + port: String, + hash: String, + pin: String + ) { val distinct = rawCandidates.map { it.trim() }.filter { it.isNotEmpty() }.distinct() if (distinct.isEmpty()) return val candidates = NearbySharingIpPreference.preferredNearbySharingIPOrder( @@ -390,55 +542,76 @@ class PeerToPeerViewModel @Inject constructor( ) val pinTrimmed = pin.trim() viewModelScope.launch { - candidates.forEachIndexed { index, ip -> - val nonce = registrationNonceFor(ip, port, pinTrimmed) - when (val result = peerClient.registerPeerDevice(ip, port, hash, pinTrimmed, nonce)) { - is RegisterPeerResult.Success -> { - registrationNonceContext = null - if (p2PState.session == null) p2PState.session = P2PSharedState.createNewSession() - p2PState.session?.sessionId = result.sessionId - p2PState.ip = ip - _registrationSuccess.postValue(true) - return@launch - } - RegisterPeerResult.InvalidPin -> { - bottomMessageError.postValue(context.getString(R.string.peer_to_peer_invalid_pin)) - return@launch - } - RegisterPeerResult.InvalidFormat -> { - bottomMessageError.postValue(context.getString(R.string.peer_to_peer_invalid_request_format)) - return@launch - } - RegisterPeerResult.RejectedByReceiver -> { - bottomMessageError.postValue(context.getString(R.string.peer_to_peer_receiver_rejected_registration)) - return@launch - } - RegisterPeerResult.Conflict -> { - if (index < candidates.lastIndex && shouldRetryRegisterWithNextIp(result)) return@forEachIndexed - bottomMessageError.postValue(context.getString(R.string.peer_to_peer_active_session_exists)) - return@launch - } - RegisterPeerResult.TooManyRequests -> { - if (index < candidates.lastIndex && shouldRetryRegisterWithNextIp(result)) return@forEachIndexed - bottomSheetError.postValue( - "Connection failed" to "Please make sure your connection details are correct and that you are on the same Wi-Fi network." - ) - return@launch - } - RegisterPeerResult.ServerError -> { - if (index < candidates.lastIndex && shouldRetryRegisterWithNextIp(result)) return@forEachIndexed - bottomMessageError.postValue(context.getString(R.string.peer_to_peer_server_error_try_later)) - return@launch - } - is RegisterPeerResult.Failure -> { - if (index < candidates.lastIndex && shouldRetryRegisterWithNextIp(result)) return@forEachIndexed - 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." - ) - return@launch + _isRegistering.postValue(true) + try { + candidates.forEachIndexed { index, ip -> + val nonce = registrationNonceFor(ip, port, pinTrimmed) + when (val result = + peerClient.registerPeerDevice(ip, port, hash, pinTrimmed, nonce)) { + is RegisterPeerResult.Success -> { + registrationNonceContext = null + if (p2PState.session == null) p2PState.session = + P2PSharedState.createNewSession() + p2PState.session?.sessionId = result.sessionId + p2PState.ip = ip + p2PState.markRegistered() + _registrationSuccess.postValue(true) + return@launch + } + + RegisterPeerResult.IncompatibleProtocol -> { + showIncompatibleProtocolError() + return@launch + } + + RegisterPeerResult.InvalidPin -> { + bottomMessageError.postValue(context.getString(R.string.peer_to_peer_invalid_pin)) + return@launch + } + + RegisterPeerResult.InvalidFormat, + RegisterPeerResult.ClientCertificateRequired -> { + bottomMessageError.postValue(context.getString(R.string.peer_to_peer_invalid_request_format)) + return@launch + } + + RegisterPeerResult.RejectedByReceiver -> { + bottomMessageError.postValue(context.getString(R.string.peer_to_peer_receiver_rejected_registration)) + return@launch + } + + RegisterPeerResult.Conflict -> { + if (index < candidates.lastIndex && shouldRetryRegisterWithNextIp(result)) return@forEachIndexed + bottomMessageError.postValue(context.getString(R.string.peer_to_peer_active_session_exists)) + return@launch + } + + RegisterPeerResult.TooManyRequests -> { + if (index < candidates.lastIndex && shouldRetryRegisterWithNextIp(result)) return@forEachIndexed + bottomSheetError.postValue( + "Connection failed" to "Please make sure your connection details are correct and that you are on the same Wi-Fi network." + ) + return@launch + } + + RegisterPeerResult.ServerError -> { + if (index < candidates.lastIndex && shouldRetryRegisterWithNextIp(result)) return@forEachIndexed + bottomMessageError.postValue(context.getString(R.string.peer_to_peer_server_error_try_later)) + return@launch + } + + is RegisterPeerResult.Failure -> { + if (index < candidates.lastIndex && shouldRetryRegisterWithNextIp(result)) return@forEachIndexed + 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." + ) + return@launch + } } } + } finally { + _isRegistering.postValue(false) } } } @@ -448,7 +621,8 @@ class PeerToPeerViewModel @Inject constructor( RegisterPeerResult.TooManyRequests, RegisterPeerResult.ServerError, is RegisterPeerResult.Failure, - -> true + -> true + else -> false } @@ -545,7 +719,10 @@ class PeerToPeerViewModel @Inject constructor( * QuickTime / `.mov` is not handled by [MediaFileHandler.saveMp4Video] (MP4 pipeline). Store the file as-is * so it lands in the transfer folder; in-app playback depends on codecs, same as other opaque imports. */ - private fun shouldStoreP2pVideoAsOpaqueContainer(pf: ProgressFile, receivedFile: File): Boolean { + private fun shouldStoreP2pVideoAsOpaqueContainer( + pf: ProgressFile, + receivedFile: File + ): Boolean { val mime = pf.file.fileType.lowercase(Locale.US) if (mime.contains("quicktime")) return true return vaultDisplayNameForP2pReceive(pf, receivedFile).lowercase(Locale.US).endsWith(".mov") @@ -743,8 +920,8 @@ class PeerToPeerViewModel @Inject constructor( // Server progress still says SENDING after a failed hash check; we must still close the UI. val canFinalize = sessionIsTerminal(triggerStatus) || - triggerStatus == SessionStatus.SENDING || - triggerStatus == SessionStatus.SAVING + triggerStatus == SessionStatus.SENDING || + triggerStatus == SessionStatus.SAVING if (!canFinalize) return if (session.status == SessionStatus.FINISHED || diff --git a/mobile/src/main/res/drawable/bg_verification_hash_step2.xml b/mobile/src/main/res/drawable/bg_verification_hash_step2.xml new file mode 100644 index 000000000..e90bfbc04 --- /dev/null +++ b/mobile/src/main/res/drawable/bg_verification_hash_step2.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/mobile/src/main/res/layout/connect_manually_verification.xml b/mobile/src/main/res/layout/connect_manually_verification.xml index ee0bee35a..33efdbe16 100644 --- a/mobile/src/main/res/layout/connect_manually_verification.xml +++ b/mobile/src/main/res/layout/connect_manually_verification.xml @@ -34,13 +34,16 @@ android:layout_height="wrap_content" android:orientation="vertical"> - + android:layout_marginHorizontal="@dimen/activity_horizontal_margin" + android:gravity="center" + android:textStyle="bold" + tools:text="@string/verification_step1_recipient_hash" /> @@ -103,6 +107,7 @@ android:layout_height="54dp" android:layout_marginTop="@dimen/activity_horizontal_margin" app:text_all_caps="true" + app:text_round_bold="true" app:text="@string/discard_and_start_over" /> diff --git a/mobile/src/main/res/layout/fragment_prepare_upload.xml b/mobile/src/main/res/layout/fragment_prepare_upload.xml index 9a97778df..0739d7b50 100644 --- a/mobile/src/main/res/layout/fragment_prepare_upload.xml +++ b/mobile/src/main/res/layout/fragment_prepare_upload.xml @@ -105,7 +105,7 @@ android:layout_gravity="center" android:layout_marginHorizontal="@dimen/tella_main_vertical" android:layout_marginVertical="@dimen/activity_vertical_large_margin" - android:background="@drawable/bg_round_orange16_btn" + android:background="@drawable/bg_round_orange_disabled" android:fontFamily="@font/open_sans" android:gravity="center" android:text="@string/send_files_title" diff --git a/mobile/src/main/res/layout/fragment_qr_code.xml b/mobile/src/main/res/layout/fragment_qr_code.xml index 10cdcdd5d..ba9c4d65f 100644 --- a/mobile/src/main/res/layout/fragment_qr_code.xml +++ b/mobile/src/main/res/layout/fragment_qr_code.xml @@ -24,7 +24,7 @@ android:layout_marginHorizontal="@dimen/activity_horizontal_margin" android:layout_marginTop="50dp" android:gravity="center" - android:text="@string/qr_code_sender_instruction" + android:text="@string/qr_code_recipient_show_instruction" android:textColor="@android:color/white" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" diff --git a/mobile/src/main/res/layout/fragment_send_feedback.xml b/mobile/src/main/res/layout/fragment_send_feedback.xml index b34263bf5..30658987c 100644 --- a/mobile/src/main/res/layout/fragment_send_feedback.xml +++ b/mobile/src/main/res/layout/fragment_send_feedback.xml @@ -173,7 +173,7 @@ android:layout_marginStart="@dimen/activity_horizontal_large_margin" android:layout_marginEnd="@dimen/activity_horizontal_large_margin" android:layout_marginBottom="18dp" - android:background="@drawable/bg_round_orange16_btn" + android:background="@drawable/bg_round_orange_disabled" android:text="@string/collect.end_action_submit" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" diff --git a/mobile/src/main/res/layout/fragment_sender_show_qr.xml b/mobile/src/main/res/layout/fragment_sender_show_qr.xml new file mode 100644 index 000000000..616b650f8 --- /dev/null +++ b/mobile/src/main/res/layout/fragment_sender_show_qr.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/navigation/peer_to_peer_graph.xml b/mobile/src/main/res/navigation/peer_to_peer_graph.xml index 8b0778f43..253e038aa 100644 --- a/mobile/src/main/res/navigation/peer_to_peer_graph.xml +++ b/mobile/src/main/res/navigation/peer_to_peer_graph.xml @@ -19,6 +19,12 @@ + + @@ -37,6 +43,12 @@ + + @@ -53,17 +65,54 @@ app:destination="@id/prepareUploadFragment" /> + + + + + + + + + + + + + #DCDCDC #D6933B #FF071013 + #66071013 @color/space_cadet @color/space_cadet diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index e94bdf040..95aac3960 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -1030,7 +1030,23 @@ If you forget your lock, there is currently no way to recover it. Your data insi To submit a report, please type in a title and attach a file. Connect to device Show this QR code for the sender to scan - Scan the recipient’s QR code + Step 1: Show this QR code for the recipient to scan + Step 2: Show this QR code for the sender to scan + Step 2: Scan the recipient’s QR code + Scan recipient QR code + Continue to step 2 + Step 1: Scan the sender’s QR code + Connection failed + Could not reach the other device. Check the connection details and that both devices are on the same Wi-Fi network. + Incompatible versions + The other device is running an older, incompatible version of Tella. Both devices need to be updated to use Nearby Sharing. + This QR code is not valid for Nearby Sharing. + Scan the recipient\'s QR code (the one that shows their IP address and PIN), not the sender QR from step 1. + Scan the sender\'s QR code (certificate only), not the recipient\'s connection QR. + The scanner read a number, not a connection QR. Center the square QR on the recipient screen (not the PIN text), avoid scanning a laptop/emulator display if possible, and wait until the QR fully loads. + Step 1: Confirm recipient hash + Step 2: Confirm sender hash + Confirm and continue Having trouble with the QR code? Connect manually Show your device information @@ -1042,7 +1058,7 @@ If you forget your lock, there is currently no way to recover it. Your data insi Verification Make sure that this sequence matches what is shown on the sender’s device. To ensure that the connection is safe, make sure that the sequence of numbers above matches what is shown on the recipient’s device. - To ensure that the connection is safe, make sure that the sequence of numbers above matches what is shown on the other device. + To ensure the connection is safe, make sure the sequence of characters above matches the sequence on the sender’s device. If the sequences do not match, the connection may not be secure and should be discarded. Confirm and connect Wi-Fi diff --git a/mobile/src/test/java/org/horizontal/tella/mobile/domain/peertopeer/PeerConnectionQrCodecTest.kt b/mobile/src/test/java/org/horizontal/tella/mobile/domain/peertopeer/PeerConnectionQrCodecTest.kt new file mode 100644 index 000000000..015a45db8 --- /dev/null +++ b/mobile/src/test/java/org/horizontal/tella/mobile/domain/peertopeer/PeerConnectionQrCodecTest.kt @@ -0,0 +1,79 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PeerConnectionQrCodecTest { + + private val certHash = "a".repeat(64) + + @Test + fun parseReceiverQr_iosStyleJson() { + val json = + """{"ip_address":["192.168.1.1"],"port":53320,"certificate_hash":"$certHash","pin":"123456","protocol_version":2}""" + val result = PeerConnectionQrCodec.parseAny(json) + assertTrue(result is PeerQrParseResult.Receiver) + val qr = (result as PeerQrParseResult.Receiver).qr + assertEquals(listOf("192.168.1.1"), qr.ipAddresses) + assertEquals(53320, qr.port) + assertEquals(certHash, qr.certificateHash) + assertEquals("123456", qr.pin) + assertEquals(2, qr.protocolVersion) + } + + @Test + fun parseReceiverQr_numericPin() { + val json = + """{"ip_address":["10.0.0.2"],"port":53320,"certificate_hash":"$certHash","pin":123456,"protocol_version":2}""" + val result = PeerConnectionQrCodec.parseAny(json) + assertTrue(result is PeerQrParseResult.Receiver) + assertEquals("123456", (result as PeerQrParseResult.Receiver).qr.pin) + } + + @Test + fun parseSenderQr_iosStyleJson() { + val json = """{"certificate_hash":"$certHash"}""" + val result = PeerConnectionQrCodec.parseAny(json) + assertTrue(result is PeerQrParseResult.Sender) + assertEquals(certHash, (result as PeerQrParseResult.Sender).qr.certificateHash) + } + + @Test + fun senderQrRejectedWhenScanningReceiver() { + val json = """{"certificate_hash":"$certHash"}""" + val result = PeerConnectionQrCodec.parseAny(json) + assertTrue(result is PeerQrParseResult.Sender) + } + + @Test + fun roundTripReceiverQr() { + val json = PeerConnectionQrCodec.toReceiverJson( + ipAddresses = listOf("192.168.0.5"), + port = 53320, + certificateHash = certHash, + pin = "482910", + ) + val result = PeerConnectionQrCodec.parseAny(json) + assertTrue(result is PeerQrParseResult.Receiver) + } + + @Test + fun roundTripSenderQr() { + val json = PeerConnectionQrCodec.toSenderJson(certHash) + val result = PeerConnectionQrCodec.parseAny(json) + assertTrue(result is PeerQrParseResult.Sender) + } + + @Test + fun parseReceiverQr_tellaDesktopLegacyJson() { + val json = + """{"ip_address":["192.168.1.5"],"port":53320,"certificate_hash":"$certHash","pin":"482910"}""" + assertEquals(false, PeerConnectionQrCodec.isV1ReceiverQr(json)) + val result = PeerConnectionQrCodec.parseAny(json) + assertTrue(result is PeerQrParseResult.Receiver) + val qr = (result as PeerQrParseResult.Receiver).qr + assertEquals(2, qr.protocolVersion) + assertTrue(qr.senderShowHash) + } +} diff --git a/shared-ui/src/main/res/drawable/bg_round_orange16_btn.xml b/shared-ui/src/main/res/drawable/bg_round_orange_disabled.xml similarity index 100% rename from shared-ui/src/main/res/drawable/bg_round_orange16_btn.xml rename to shared-ui/src/main/res/drawable/bg_round_orange_disabled.xml diff --git a/shared-ui/src/main/res/layout/layout_round_button.xml b/shared-ui/src/main/res/layout/layout_round_button.xml index 1964bc85d..e0ff46c2e 100644 --- a/shared-ui/src/main/res/layout/layout_round_button.xml +++ b/shared-ui/src/main/res/layout/layout_round_button.xml @@ -20,7 +20,7 @@ android:textStyle="bold" android:textColor="@color/wa_white" android:fontFamily="@font/open_sans" - android:textSize="18sp" + android:textSize="16sp" android:textAllCaps="true" />