diff --git a/ft8cn/app/build.gradle b/ft8cn/app/build.gradle index 812a70a4..c512e741 100644 --- a/ft8cn/app/build.gradle +++ b/ft8cn/app/build.gradle @@ -176,6 +176,16 @@ dependencies { // Image loading for QRZ profile avatars implementation 'io.coil-kt:coil-compose:2.6.0' + // AWS Cognito user-pool SRP login — used only to mint a pota.app ID token for + // in-app POTA log upload (POST api.pota.app/adif). POTA's user pool/client IDs + // are public (shipped in pota.app's JS). cognitoidentityprovider pulls in + // aws-android-sdk-core transitively; no other AWS modules are needed. + implementation 'com.amazonaws:aws-android-sdk-cognitoidentityprovider:2.76.0' + + // Keystore-backed EncryptedSharedPreferences for the POTA refresh token — + // a long-lived credential that must not sit in plaintext app storage. + implementation 'androidx.security:security-crypto:1.1.0-alpha06' + // --- JVM unit + Robolectric tests --- testImplementation 'junit:junit:4.13.2' // 4.14.1 ships android-all for SDK 35; older versions fail with diff --git a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pota/PotaAuth.kt b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pota/PotaAuth.kt new file mode 100644 index 00000000..61c46948 --- /dev/null +++ b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pota/PotaAuth.kt @@ -0,0 +1,258 @@ +package radio.ks3ckc.ft8us.pota + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import android.util.Log +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.amazonaws.mobileconnectors.cognitoidentityprovider.CognitoUserPool +import com.amazonaws.mobileconnectors.cognitoidentityprovider.CognitoUserSession +import com.amazonaws.mobileconnectors.cognitoidentityprovider.continuations.AuthenticationContinuation +import com.amazonaws.mobileconnectors.cognitoidentityprovider.continuations.AuthenticationDetails +import com.amazonaws.mobileconnectors.cognitoidentityprovider.continuations.ChallengeContinuation +import com.amazonaws.mobileconnectors.cognitoidentityprovider.continuations.MultiFactorAuthenticationContinuation +import com.amazonaws.mobileconnectors.cognitoidentityprovider.handlers.AuthenticationHandler +import com.amazonaws.regions.Regions +import com.bg7yoz.ft8cn.GeneralVariables +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.io.FileWriter +import java.net.HttpURLConnection +import java.net.URL +import java.nio.charset.StandardCharsets +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.coroutines.resume + +/** + * Authenticates against pota.app's AWS Cognito user pool so the app can upload + * activation logs directly (see [PotaClient.uploadAdif]). + * + * POTA's pool/client IDs are public — they ship in pota.app's own JS bundle and + * are reproduced in the open-source `pota-adif-upload` Rust client. The user logs + * in with their normal pota.app account email + password. + * + * Flow: + * - [login] runs Cognito's USER_SRP_AUTH (password never leaves the SRP proof) + * via the AWS SDK and stashes the long-lived **refresh token**. + * - [idToken] returns a short-lived JWT ID token, refreshing it from the stored + * refresh token via REFRESH_TOKEN_AUTH (a plain JSON POST — no SRP, no SDK) + * when the cached one is missing or near expiry. + * + * Storage note: the refresh token is a long-lived credential (effectively + * standing account access), so it's persisted in an Android-Keystore-backed + * [EncryptedSharedPreferences] file rather than plaintext — keeping it out of + * reach of adb backup, filesystem extraction, and other apps. + */ +object PotaAuth { + private const val TAG = "PotaAuth" + + // Public POTA Cognito identifiers (from pota.app's JS / the pota-adif-upload crate). + private const val POOL_ID = "us-east-2_nA5jZ0klh" + private const val CLIENT_ID = "7hluqct0n2nckib7i7sd5753oa" + private val REGION = Regions.US_EAST_2 + private const val COGNITO_IDP = "https://cognito-idp.us-east-2.amazonaws.com/" + + private const val PREFS = "pota_auth" + private const val KEY_REFRESH = "refresh_token" + private const val KEY_EMAIL = "email" + + // Refresh ~5 min before the ID token's nominal 60-min lifetime expires. + private const val ID_TOKEN_TTL_MS = 55 * 60 * 1000L + + @Volatile private var cachedIdToken: String? = null + @Volatile private var cachedIdTokenExpiryMs: Long = 0L + + // Lazily-built, cached encrypted store. EncryptedSharedPreferences.create is + // relatively expensive (Keystore + Tink init), so reuse one instance. + @Volatile private var encPrefs: SharedPreferences? = null + + private fun prefs(ctx: Context): SharedPreferences { + encPrefs?.let { return it } + return synchronized(this) { + encPrefs ?: buildPrefs(ctx.applicationContext).also { encPrefs = it } + } + } + + private fun buildPrefs(ctx: Context): SharedPreferences { + return try { + createEncryptedPrefs(ctx) + } catch (e: Exception) { + // Keystore can fail on a corrupted keyset (e.g. after an app restore to a + // device with no matching key). Drop the unreadable file and rebuild it + // once; the user simply has to sign in again. + log("EncryptedSharedPreferences init failed, recreating: ${e.javaClass.simpleName}") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + ctx.deleteSharedPreferences(PREFS) + } else { + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().commit() + } + createEncryptedPrefs(ctx) + } + } + + private fun createEncryptedPrefs(ctx: Context): SharedPreferences { + val masterKey = MasterKey.Builder(ctx) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + return EncryptedSharedPreferences.create( + ctx, + PREFS, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + /** Whether a refresh token is stored (i.e. the user has logged in at least once). */ + fun isLoggedIn(): Boolean { + val ctx = GeneralVariables.getMainContext() ?: return false + return !prefs(ctx).getString(KEY_REFRESH, null).isNullOrBlank() + } + + /** Email the user last logged in with, for display. */ + fun loggedInEmail(): String? = + GeneralVariables.getMainContext()?.let { prefs(it).getString(KEY_EMAIL, null) } + + fun logout() { + cachedIdToken = null + cachedIdTokenExpiryMs = 0L + GeneralVariables.getMainContext()?.let { prefs(it).edit().clear().apply() } + log("logout — cleared stored token") + } + + /** + * Log in with a pota.app account. On success the refresh token is persisted and + * a usable ID token is cached. Returns the ID token on success. + */ + suspend fun login(email: String, password: String): Result = withContext(Dispatchers.IO) { + val ctx = GeneralVariables.getMainContext() + ?: return@withContext Result.failure(IllegalStateException("no app context")) + try { + val session = srpLogin(ctx, email.trim(), password) + val refresh = session.refreshToken?.token + if (refresh.isNullOrBlank()) { + return@withContext Result.failure(IllegalStateException("no refresh token in session")) + } + prefs(ctx).edit() + .putString(KEY_REFRESH, refresh) + .putString(KEY_EMAIL, email.trim()) + .apply() + val id = session.idToken?.jwtToken + ?: return@withContext Result.failure(IllegalStateException("no id token in session")) + cachedIdToken = id + cachedIdTokenExpiryMs = System.currentTimeMillis() + ID_TOKEN_TTL_MS + log("login ok email=${email.trim()} (refresh stored)") + Result.success(id) + } catch (e: Exception) { + log("login FAILED: ${e.javaClass.simpleName}: ${e.message ?: "?"}") + Result.failure(e) + } + } + + /** + * A valid ID token, refreshed from the stored refresh token when needed. + * Returns null if the user has never logged in or the refresh failed (e.g. + * the refresh token was revoked) — callers should then prompt for login. + */ + suspend fun idToken(): String? = withContext(Dispatchers.IO) { + cachedIdToken?.let { + if (System.currentTimeMillis() < cachedIdTokenExpiryMs) return@withContext it + } + val ctx = GeneralVariables.getMainContext() ?: return@withContext null + val refresh = prefs(ctx).getString(KEY_REFRESH, null) + if (refresh.isNullOrBlank()) return@withContext null + try { + val id = refreshIdToken(refresh) + cachedIdToken = id + cachedIdTokenExpiryMs = System.currentTimeMillis() + ID_TOKEN_TTL_MS + log("idToken refreshed") + id + } catch (e: Exception) { + log("idToken refresh FAILED: ${e.javaClass.simpleName}: ${e.message ?: "?"}") + null + } + } + + // --- Cognito SRP login via the AWS SDK (callback API wrapped as a coroutine) --- + + private suspend fun srpLogin(ctx: Context, email: String, password: String): CognitoUserSession = + suspendCancellableCoroutine { cont -> + val pool = CognitoUserPool(ctx, POOL_ID, CLIENT_ID, null, REGION) + val handler = object : AuthenticationHandler { + override fun onSuccess(userSession: CognitoUserSession, newDevice: com.amazonaws.mobileconnectors.cognitoidentityprovider.CognitoDevice?) { + if (cont.isActive) cont.resume(userSession) + } + + override fun getAuthenticationDetails(continuation: AuthenticationContinuation, userId: String?) { + continuation.setAuthenticationDetails(AuthenticationDetails(email, password, null)) + continuation.continueTask() + } + + override fun getMFACode(continuation: MultiFactorAuthenticationContinuation) { + // POTA accounts don't use MFA; surface it as an error rather than hang. + if (cont.isActive) cont.resumeWith(Result.failure(UnsupportedOperationException("MFA not supported"))) + } + + override fun authenticationChallenge(continuation: ChallengeContinuation) { + if (cont.isActive) cont.resumeWith(Result.failure(UnsupportedOperationException("auth challenge not supported"))) + } + + override fun onFailure(exception: Exception) { + if (cont.isActive) cont.resumeWith(Result.failure(exception)) + } + } + // getSession runs the SRP handshake on the calling thread (already Dispatchers.IO). + pool.getUser(email).getSession(handler) + } + + // --- REFRESH_TOKEN_AUTH: trade the refresh token for a fresh ID token (no SRP) --- + + private fun refreshIdToken(refreshToken: String): String { + val body = JSONObject().apply { + put("AuthFlow", "REFRESH_TOKEN_AUTH") + put("ClientId", CLIENT_ID) + put("AuthParameters", JSONObject().put("REFRESH_TOKEN", refreshToken)) + }.toString() + + var conn: HttpURLConnection? = null + try { + conn = (URL(COGNITO_IDP).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = 10_000 + readTimeout = 10_000 + doOutput = true + setRequestProperty("Content-Type", "application/x-amz-json-1.1") + setRequestProperty("X-Amz-Target", "AWSCognitoIdentityProviderService.InitiateAuth") + } + conn.outputStream.use { it.write(body.toByteArray(StandardCharsets.UTF_8)) } + val code = conn.responseCode + if (code !in 200..299) { + val err = conn.errorStream?.bufferedReader(StandardCharsets.UTF_8)?.use { it.readText() } ?: "" + throw IllegalStateException("InitiateAuth http $code: ${err.take(200)}") + } + val resp = conn.inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() } + return JSONObject(resp) + .getJSONObject("AuthenticationResult") + .getString("IdToken") + } finally { + conn?.disconnect() + } + } + + private fun log(msg: String) { + Log.d(TAG, msg) + try { + val ctx = GeneralVariables.getMainContext() ?: return + val dir = ctx.getExternalFilesDir(null) ?: return + val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.US).format(Date()) + FileWriter(File(dir, "debug.log"), true).use { it.append("$ts PotaAuth: $msg\n") } + } catch (_: Exception) { + } + } +} diff --git a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pota/PotaClient.kt b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pota/PotaClient.kt index 735f4a94..d54e4c43 100644 --- a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pota/PotaClient.kt +++ b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pota/PotaClient.kt @@ -107,6 +107,85 @@ object PotaClient { } } + /** + * Upload one ADIF document to the authenticated endpoint. [idToken] is a + * Cognito ID token from [PotaAuth.idToken]; it goes in the Authorization + * header verbatim (POTA's API Gateway expects the raw JWT, not "Bearer …"). + * The body is multipart/form-data with a single `adif` part, matching the + * pota.app website uploader. Returns the (possibly empty) response body on + * success, or a failure carrying the HTTP status / error text. + */ + suspend fun uploadAdif(idToken: String, filename: String, adif: String): Result = + withContext(Dispatchers.IO) { + val boundary = "----ft8af${System.nanoTime()}" + val preamble = buildString { + append("--").append(boundary).append("\r\n") + append("Content-Disposition: form-data; name=\"adif\"; filename=\"").append(filename).append("\"\r\n") + append("Content-Type: application/octet-stream\r\n\r\n") + }.toByteArray(StandardCharsets.UTF_8) + val epilogue = "\r\n--$boundary--\r\n".toByteArray(StandardCharsets.UTF_8) + val payload = adif.toByteArray(StandardCharsets.UTF_8) + + var conn: HttpURLConnection? = null + try { + conn = (URL("$BASE_URL/adif").openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = IO_TIMEOUT_MS + readTimeout = 30_000 + doOutput = true + setRequestProperty("User-Agent", USER_AGENT) + setRequestProperty("Authorization", idToken) + setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary") + setRequestProperty("Accept", "application/json") + } + conn.outputStream.use { out -> + out.write(preamble) + out.write(payload) + out.write(epilogue) + } + val code = conn.responseCode + if (code !in 200..299) { + val err = conn.errorStream?.bufferedReader(StandardCharsets.UTF_8)?.use { it.readText() } ?: "" + log("uploadAdif $filename -> http $code ${err.take(200)}") + return@withContext Result.failure(IllegalStateException("HTTP $code${if (err.isNotBlank()) ": ${err.take(160)}" else ""}")) + } + val resp = conn.inputStream?.bufferedReader(StandardCharsets.UTF_8)?.use { it.readText() } ?: "" + log("uploadAdif ok $filename (${payload.size}B) -> ${resp.take(120)}") + Result.success(resp) + } catch (e: Exception) { + log("uploadAdif $filename failed: ${e.javaClass.simpleName}: ${e.message ?: "?"}") + Result.failure(e) + } finally { + conn?.disconnect() + } + } + + /** Fetch the user's recent upload/processing jobs (authenticated). Raw JSON. */ + suspend fun getJobs(idToken: String): String? = withContext(Dispatchers.IO) { + var conn: HttpURLConnection? = null + try { + conn = (URL("$BASE_URL/user/jobs").openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = IO_TIMEOUT_MS + readTimeout = IO_TIMEOUT_MS + setRequestProperty("User-Agent", USER_AGENT) + setRequestProperty("Authorization", idToken) + setRequestProperty("Accept", "application/json") + } + val code = conn.responseCode + if (code != HttpURLConnection.HTTP_OK) { + log("getJobs -> http $code") + return@withContext null + } + conn.inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() } + } catch (e: Exception) { + log("getJobs failed: ${e.javaClass.simpleName}: ${e.message ?: "?"}") + null + } finally { + conn?.disconnect() + } + } + private fun httpGet(url: String): String? { var conn: HttpURLConnection? = null return try { diff --git a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/pota/PotaAdifExporter.kt b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/pota/PotaAdifExporter.kt index 1dd475d9..6981a181 100644 --- a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/pota/PotaAdifExporter.kt +++ b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/pota/PotaAdifExporter.kt @@ -2,7 +2,7 @@ package radio.ks3ckc.ft8us.ui.pota import android.content.Context import android.content.Intent -import android.net.Uri +import android.database.sqlite.SQLiteDatabase import androidx.core.content.FileProvider import com.bg7yoz.ft8cn.MainViewModel import kotlinx.coroutines.CoroutineScope @@ -22,12 +22,105 @@ import java.util.Locale * QSO carries MY_SIG=POTA / MY_SIG_INFO=. For multi-park activations (two-fers, * three-fers, etc.) we generate one ADIF file per park — each containing the same QSOs but * with MY_SIG_INFO set to that single park — and share all files in one intent. + * + * [buildActivationAdif] is the shared core: both the share-sheet path here and the + * authenticated in-app upload (PotaClient.uploadAdif) generate identical bytes from it. */ object PotaAdifExporter { private const val AUTHORITY = "radio.ks3ckc.ft8af.fileprovider" private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + /** One park's ADIF document plus the filename it should be uploaded/shared under. */ + data class NamedAdif(val parkRef: String, val filename: String, val content: String) + + /** + * Build one ADIF document per park reference for [activation]. Each document + * contains the same QSO rows with MY_SIG_INFO pinned to that single park, so a + * two-fer produces two uploads. Runs the DB read on the calling thread — call + * from a background dispatcher. + */ + fun buildActivationAdif(db: SQLiteDatabase, activation: PotaActivation): List { + // The DB stores the full comma-separated park string in my_sig_info, so we + // match on that, then split into per-park files below. + val cursor = db.rawQuery( + "SELECT * FROM QSLTable WHERE my_sig = 'POTA' AND my_sig_info = ? " + + "ORDER BY qso_date, time_on", + arrayOf(activation.parkRef), + ) + + data class QsoRow( + val call: String?, val grid: String?, val mode: String?, + val band: String?, val freq: String?, val rstSent: String?, + val rstRcvd: String?, val date: String?, val timeOn: String?, + val timeOff: String?, val station: String?, val myGrid: String?, + val mySig: String?, val sig: String?, val sigInfo: String?, + ) + + val rows = mutableListOf() + cursor.use { c -> + val callIdx = c.getColumnIndex("call") + val gridIdx = c.getColumnIndex("gridsquare") + val modeIdx = c.getColumnIndex("mode") + val bandIdx = c.getColumnIndex("band") + val freqIdx = c.getColumnIndex("freq") + val rstSentIdx = c.getColumnIndex("rst_sent") + val rstRcvdIdx = c.getColumnIndex("rst_rcvd") + val dateIdx = c.getColumnIndex("qso_date") + val timeOnIdx = c.getColumnIndex("time_on") + val timeOffIdx = c.getColumnIndex("time_off") + val stationIdx = c.getColumnIndex("station_callsign") + val myGridIdx = c.getColumnIndex("my_gridsquare") + val mySigIdx = c.getColumnIndex("my_sig") + val sigIdx = c.getColumnIndex("sig") + val sigInfoIdx = c.getColumnIndex("sig_info") + while (c.moveToNext()) { + rows.add( + QsoRow( + call = c.getString(callIdx), grid = c.getString(gridIdx), + mode = c.getString(modeIdx), band = c.getString(bandIdx), + freq = c.getString(freqIdx), rstSent = c.getString(rstSentIdx), + rstRcvd = c.getString(rstRcvdIdx), date = c.getString(dateIdx), + timeOn = c.getString(timeOnIdx), timeOff = c.getString(timeOffIdx), + station = c.getString(stationIdx), myGrid = c.getString(myGridIdx), + mySig = c.getString(mySigIdx), sig = c.getString(sigIdx), + sigInfo = c.getString(sigInfoIdx), + ), + ) + } + } + + val ts = SimpleDateFormat("yyyyMMdd-HHmm", Locale.US).format(Date(activation.startedAtMs)) + return activation.parkRefs.map { parkRef -> + val sb = StringBuilder() + sb.append("FT8AF POTA Activation $parkRef\n") + sb.append("3.1.4 ") + sb.append("FT8AF ") + sb.append("\n") + for (r in rows) { + adifField(sb, "CALL", r.call) + adifField(sb, "GRIDSQUARE", r.grid) + adifField(sb, "MODE", r.mode) + adifField(sb, "BAND", r.band) + adifField(sb, "FREQ", r.freq) + adifField(sb, "RST_SENT", r.rstSent) + adifField(sb, "RST_RCVD", r.rstRcvd) + adifField(sb, "QSO_DATE", r.date) + adifField(sb, "TIME_ON", r.timeOn) + adifField(sb, "TIME_OFF", r.timeOff) + adifField(sb, "STATION_CALLSIGN", r.station) + adifField(sb, "MY_GRIDSQUARE", r.myGrid) + adifField(sb, "MY_SIG", r.mySig) + // Override MY_SIG_INFO to this single park (not the comma-separated value from DB). + adifField(sb, "MY_SIG_INFO", parkRef) + adifField(sb, "SIG", r.sig) + adifField(sb, "SIG_INFO", r.sigInfo) + sb.append("\n") + } + NamedAdif(parkRef = parkRef, filename = "pota-$parkRef-$ts.adi", content = sb.toString()) + } + } + fun shareActivationAdif( context: Context, mainViewModel: MainViewModel, @@ -40,93 +133,15 @@ object PotaAdifExporter { } scope.launch { try { - // Read all QSO rows for this activation. The DB stores the full - // comma-separated park string in my_sig_info, so we match on that. - val cursor = db.rawQuery( - "SELECT * FROM QSLTable WHERE my_sig = 'POTA' AND my_sig_info = ? " + - "ORDER BY qso_date, time_on", - arrayOf(activation.parkRef), - ) - - // Collect QSO field values so we can write them into multiple files. - data class QsoRow( - val call: String?, val grid: String?, val mode: String?, - val band: String?, val freq: String?, val rstSent: String?, - val rstRcvd: String?, val date: String?, val timeOn: String?, - val timeOff: String?, val station: String?, val myGrid: String?, - val mySig: String?, val sig: String?, val sigInfo: String?, - ) - - val rows = mutableListOf() - cursor.use { c -> - val callIdx = c.getColumnIndex("call") - val gridIdx = c.getColumnIndex("gridsquare") - val modeIdx = c.getColumnIndex("mode") - val bandIdx = c.getColumnIndex("band") - val freqIdx = c.getColumnIndex("freq") - val rstSentIdx = c.getColumnIndex("rst_sent") - val rstRcvdIdx = c.getColumnIndex("rst_rcvd") - val dateIdx = c.getColumnIndex("qso_date") - val timeOnIdx = c.getColumnIndex("time_on") - val timeOffIdx = c.getColumnIndex("time_off") - val stationIdx = c.getColumnIndex("station_callsign") - val myGridIdx = c.getColumnIndex("my_gridsquare") - val mySigIdx = c.getColumnIndex("my_sig") - val sigIdx = c.getColumnIndex("sig") - val sigInfoIdx = c.getColumnIndex("sig_info") - while (c.moveToNext()) { - rows.add( - QsoRow( - call = c.getString(callIdx), grid = c.getString(gridIdx), - mode = c.getString(modeIdx), band = c.getString(bandIdx), - freq = c.getString(freqIdx), rstSent = c.getString(rstSentIdx), - rstRcvd = c.getString(rstRcvdIdx), date = c.getString(dateIdx), - timeOn = c.getString(timeOnIdx), timeOff = c.getString(timeOffIdx), - station = c.getString(stationIdx), myGrid = c.getString(myGridIdx), - mySig = c.getString(mySigIdx), sig = c.getString(sigIdx), - sigInfo = c.getString(sigInfoIdx), - ), - ) - } - } - + val docs = buildActivationAdif(db, activation) val dir = context.getExternalFilesDir(null) ?: run { onResult(false) return@launch } - val ts = SimpleDateFormat("yyyyMMdd-HHmm", Locale.US).format(Date(activation.startedAtMs)) val parks = activation.parkRefs - // Write one ADIF file per park reference. - val files = parks.map { parkRef -> - val sb = StringBuilder() - sb.append("FT8AF POTA Activation $parkRef\n") - sb.append("3.1.4 ") - sb.append("FT8AF ") - sb.append("\n") - for (r in rows) { - adifField(sb, "CALL", r.call) - adifField(sb, "GRIDSQUARE", r.grid) - adifField(sb, "MODE", r.mode) - adifField(sb, "BAND", r.band) - adifField(sb, "FREQ", r.freq) - adifField(sb, "RST_SENT", r.rstSent) - adifField(sb, "RST_RCVD", r.rstRcvd) - adifField(sb, "QSO_DATE", r.date) - adifField(sb, "TIME_ON", r.timeOn) - adifField(sb, "TIME_OFF", r.timeOff) - adifField(sb, "STATION_CALLSIGN", r.station) - adifField(sb, "MY_GRIDSQUARE", r.myGrid) - adifField(sb, "MY_SIG", r.mySig) - // Override MY_SIG_INFO to this single park (not the comma-separated value from DB). - adifField(sb, "MY_SIG_INFO", parkRef) - adifField(sb, "SIG", r.sig) - adifField(sb, "SIG_INFO", r.sigInfo) - sb.append("\n") - } - val file = File(dir, "pota-$parkRef-$ts.adi") - file.writeText(sb.toString()) - file + val files = docs.map { doc -> + File(dir, doc.filename).apply { writeText(doc.content) } } val uris = ArrayList( diff --git a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/pota/PotaScreen.kt b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/pota/PotaScreen.kt index f301e908..9588c479 100644 --- a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/pota/PotaScreen.kt +++ b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/pota/PotaScreen.kt @@ -31,11 +31,14 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -56,13 +59,18 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bg7yoz.ft8cn.GeneralVariables import com.bg7yoz.ft8cn.MainViewModel import com.bg7yoz.ft8cn.R +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import radio.ks3ckc.ft8us.pota.PotaAuth import radio.ks3ckc.ft8us.pota.PotaClient import radio.ks3ckc.ft8us.pota.PotaSessionManager import radio.ks3ckc.ft8us.pota.PotaSpotsRepository @@ -555,15 +563,45 @@ private fun SpotRow(spot: PotaSpot, onClick: () -> Unit) { @Composable private fun HistoryTab(mainViewModel: MainViewModel) { val context = LocalContext.current + val scope = rememberCoroutineScope() var history by remember { mutableStateOf>(emptyList()) } var refreshKey by remember { mutableIntStateOf(0) } val activation by PotaSessionManager.currentActivation.collectAsStateWithLifecycle() + // Which row's upload is in flight (shows a spinner on that row), and which + // row is waiting on a sign-in before its upload can start. + var uploadingId by remember { mutableStateOf(null) } + var loginFor by remember { mutableStateOf(null) } + var loginSubmitting by remember { mutableStateOf(false) } + // Reload whenever activation state changes (start/end will appear here). LaunchedEffect(refreshKey, activation?.id, activation?.endedAtMs) { history = PotaSessionManager.history() } + fun startUpload(row: PotaActivation) { + // Only one upload at a time — a second tap (on this or another row) while + // one is in flight would clobber uploadingId and fire a parallel upload. + if (uploadingId != null) return + uploadingId = row.id + scope.launch { + val res = uploadActivation(mainViewModel, row) + uploadingId = null + res.onSuccess { n -> + Toast.makeText(context, context.getString(R.string.pota_upload_success, n), Toast.LENGTH_LONG).show() + }.onFailure { e -> + // A missing/revoked token surfaces as NotSignedInException even when a + // stale refresh token is still on disk — re-prompt for login instead of + // just toasting an error the user can't act on. + if (e is NotSignedInException) { + loginFor = row + } else { + Toast.makeText(context, context.getString(R.string.pota_upload_failed, e.message ?: "?"), Toast.LENGTH_LONG).show() + } + } + } + } + Column(modifier = Modifier.fillMaxSize()) { if (history.isEmpty()) { Text( @@ -579,6 +617,14 @@ private fun HistoryTab(mainViewModel: MainViewModel) { items(history, key = { it.id }) { row -> HistoryRow( row = row, + uploading = uploadingId == row.id, + onUploadToPota = { + // Always attempt the upload; uploadActivation re-prompts for + // sign-in (via NotSignedInException) when there's no usable + // token — whether the user never logged in or the stored + // refresh token has since been revoked/expired. + startUpload(row) + }, onOpenUploadPage = { runCatching { val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://pota.app/#/user/upload")) @@ -596,12 +642,135 @@ private fun HistoryTab(mainViewModel: MainViewModel) { } } } + + loginFor?.let { pending -> + PotaLoginDialog( + submitting = loginSubmitting, + onDismiss = { if (!loginSubmitting) loginFor = null }, + onSubmit = { email, pass -> + loginSubmitting = true + scope.launch { + val r = PotaAuth.login(email, pass) + loginSubmitting = false + r.onSuccess { + loginFor = null + startUpload(pending) + }.onFailure { e -> + Toast.makeText(context, context.getString(R.string.pota_login_failed, e.message ?: "?"), Toast.LENGTH_LONG).show() + } + } + }, + ) + } +} + +/** No usable ID token (never logged in, or the stored refresh token was revoked). */ +private class NotSignedInException : Exception("not signed in") + +/** + * Build the per-park ADIF for [activation] and upload each document to POTA's + * authenticated endpoint. Returns the number of parks uploaded on success, or a + * failure carrying the first error. A [NotSignedInException] specifically means + * the caller should prompt for login (no token, or the refresh token is dead). + */ +private suspend fun uploadActivation( + mainViewModel: MainViewModel, + activation: PotaActivation, +): Result { + val token = PotaAuth.idToken() + ?: return Result.failure(NotSignedInException()) + val db = mainViewModel.databaseOpr?.db + ?: return Result.failure(IllegalStateException("no database")) + val docs = withContext(Dispatchers.IO) { PotaAdifExporter.buildActivationAdif(db, activation) } + if (docs.isEmpty()) return Result.failure(IllegalStateException("no QSOs to upload")) + var ok = 0 + var lastErr: String? = null + for (doc in docs) { + PotaClient.uploadAdif(token, doc.filename, doc.content) + .onSuccess { ok++ } + .onFailure { lastErr = it.message } + } + return if (ok == docs.size) { + Result.success(ok) + } else { + Result.failure(IllegalStateException(lastErr ?: "uploaded $ok of ${docs.size}")) + } +} + +@Composable +private fun PotaLoginDialog( + submitting: Boolean, + onDismiss: () -> Unit, + onSubmit: (email: String, password: String) -> Unit, +) { + var email by rememberSaveable { mutableStateOf(PotaAuth.loggedInEmail().orEmpty()) } + // Plain remember (not rememberSaveable) — the password must never be written to + // SavedInstanceState, which can be persisted to disk on process death. Matches + // the QRZ credentials dialog. + var password by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + containerColor = BgSurface, + title = { Text(stringResource(R.string.pota_login_title), color = TextPrimary) }, + text = { + Column { + Text( + stringResource(R.string.pota_login_desc), + color = TextMuted, + fontSize = 12.sp, + ) + Spacer(Modifier.height(10.dp)) + OutlinedTextField( + value = email, + onValueChange = { email = it.trim() }, + label = { Text(stringResource(R.string.pota_login_email)) }, + singleLine = true, + enabled = !submitting, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + colors = textFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.pota_login_password)) }, + singleLine = true, + enabled = !submitting, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + colors = textFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + Button( + onClick = { onSubmit(email, password) }, + enabled = !submitting && email.isNotBlank() && password.isNotBlank(), + colors = ButtonDefaults.buttonColors(containerColor = Accent, contentColor = BgApp), + ) { + if (submitting) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), color = BgApp, strokeWidth = 2.dp) + } else { + Text(stringResource(R.string.pota_login_button), fontWeight = FontWeight.SemiBold) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !submitting) { + Text(stringResource(R.string.pota_login_cancel), color = TextMuted) + } + }, + ) } @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) @Composable private fun HistoryRow( row: PotaActivation, + uploading: Boolean, + onUploadToPota: () -> Unit, onOpenUploadPage: () -> Unit, onShareAdif: () -> Unit, ) { @@ -662,6 +831,21 @@ private fun HistoryRow( Spacer(Modifier.height(2.dp)) Text(stringResource(R.string.pota_notes_quote, row.notes), color = TextMuted, fontSize = 11.sp) } + if (row.qsoCount > 0) { + Spacer(Modifier.height(8.dp)) + Button( + onClick = onUploadToPota, + enabled = !uploading, + colors = ButtonDefaults.buttonColors(containerColor = Accent, contentColor = BgApp), + modifier = Modifier.fillMaxWidth(), + ) { + if (uploading) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), color = BgApp, strokeWidth = 2.dp) + } else { + Text(stringResource(R.string.pota_upload_to_pota), fontWeight = FontWeight.SemiBold, fontSize = 13.sp) + } + } + } Spacer(Modifier.height(8.dp)) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { OutlinedButton(onClick = onShareAdif, modifier = Modifier.weight(1f)) { diff --git a/ft8cn/app/src/main/res/values/strings_compose.xml b/ft8cn/app/src/main/res/values/strings_compose.xml index a8321523..f9ffcdc6 100644 --- a/ft8cn/app/src/main/res/values/strings_compose.xml +++ b/ft8cn/app/src/main/res/values/strings_compose.xml @@ -256,6 +256,16 @@ %1$d QSO Share ADIF Open pota.app + Upload to POTA + Uploaded %1$d park log(s) to POTA + Upload failed: %1$s + Sign in to POTA + Use your pota.app account email and password. + Email + Password + Sign in + Cancel + Sign-in failed: %1$s Add park Remove Maximum of 10 parks reached diff --git a/ft8cn/app/src/test/kotlin/radio/ks3ckc/ft8us/ui/pota/BuildActivationAdifTest.kt b/ft8cn/app/src/test/kotlin/radio/ks3ckc/ft8us/ui/pota/BuildActivationAdifTest.kt new file mode 100644 index 00000000..fdbf6270 --- /dev/null +++ b/ft8cn/app/src/test/kotlin/radio/ks3ckc/ft8us/ui/pota/BuildActivationAdifTest.kt @@ -0,0 +1,179 @@ +package radio.ks3ckc.ft8us.ui.pota + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import radio.ks3ckc.ft8us.pota.model.PotaActivation +import java.util.TimeZone + +/** + * Coverage for [PotaAdifExporter.buildActivationAdif] — the shared ADIF builder + * behind both the share-sheet export and the in-app POTA upload. It reads QSO + * rows from SQLite and emits one document per park, so the test drives a real + * (Robolectric) in-memory database. + * + * The filename embeds a timestamp formatted in the default timezone, so the + * suite pins the JVM default to UTC to keep the expected name deterministic. + */ +@RunWith(RobolectricTestRunner::class) +class BuildActivationAdifTest { + + private lateinit var db: SQLiteDatabase + private var savedTz: TimeZone? = null + + // 2024-06-01 12:34:00 UTC — drives the "pota--20240601-1234.adi" name. + private val startedAtMs = 1_717_245_240_000L + + @Before + fun setUp() { + savedTz = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + db = SQLiteDatabase.create(null) + // Only the columns buildActivationAdif reads (it does SELECT * then looks + // up each by name); my_sig_info is the WHERE key, the rest are emitted. + db.execSQL( + """ + CREATE TABLE QSLTable ( + call TEXT, gridsquare TEXT, mode TEXT, band TEXT, freq TEXT, + rst_sent TEXT, rst_rcvd TEXT, qso_date TEXT, time_on TEXT, time_off TEXT, + station_callsign TEXT, my_gridsquare TEXT, my_sig TEXT, sig TEXT, + sig_info TEXT, my_sig_info TEXT + ) + """.trimIndent(), + ) + } + + @After + fun tearDown() { + db.close() + savedTz?.let { TimeZone.setDefault(it) } + } + + /** + * Insert one POTA QSO. [mySigInfo] is the value stored in the DB's + * my_sig_info column — the WHERE key, which for a two-fer is the full + * comma-separated park string. + */ + private fun insertQso( + call: String, + qsoDate: String, + timeOn: String, + mySigInfo: String, + mode: String = "FT8", + ) { + db.insert("QSLTable", null, ContentValues().apply { + put("call", call) + put("gridsquare", "FN42") + put("mode", mode) + put("band", "20m") + put("freq", "14.074") + put("rst_sent", "-05") + put("rst_rcvd", "-10") + put("qso_date", qsoDate) + put("time_on", timeOn) + put("time_off", timeOn) + put("station_callsign", "K1ABC") + put("my_gridsquare", "FN31") + put("my_sig", "POTA") + put("sig", "") + put("sig_info", "") + put("my_sig_info", mySigInfo) + }) + } + + private fun activation(parkRef: String) = PotaActivation( + id = 1L, + parkRef = parkRef, + operator = "K1ABC", + startedAtMs = startedAtMs, + endedAtMs = startedAtMs + 60_000L, + qsoCount = 0, + notes = null, + ) + + @Test + fun singlePark_emitsOneDocumentWithHeaderAndDeterministicName() { + insertQso("W1AW", "20240601", "1230", mySigInfo = "K-1234") + insertQso("K9XYZ", "20240601", "1231", mySigInfo = "K-1234") + + val docs = PotaAdifExporter.buildActivationAdif(db, activation("K-1234")) + + assertThat(docs).hasSize(1) + val doc = docs.single() + assertThat(doc.parkRef).isEqualTo("K-1234") + assertThat(doc.filename).isEqualTo("pota-K-1234-20240601-1234.adi") + assertThat(doc.content).startsWith("FT8AF POTA Activation K-1234\n") + assertThat(doc.content).contains("3.1.4 ") + assertThat(doc.content).contains("FT8AF ") + assertThat(doc.content).contains("\n") + // Two QSOs in, two records out. + assertThat(doc.content.split("").size - 1).isEqualTo(2) + assertThat(doc.content).contains("W1AW ") + assertThat(doc.content).contains("K9XYZ ") + } + + @Test + fun singlePark_overridesMySigInfoToTheParkRef() { + insertQso("W1AW", "20240601", "1230", mySigInfo = "K-1234") + + val doc = PotaAdifExporter.buildActivationAdif(db, activation("K-1234")).single() + + // MY_SIG_INFO is pinned to the park (6 UTF-8 bytes), not copied from DB. + assertThat(doc.content).contains("K-1234 ") + assertThat(doc.content).contains("POTA ") + } + + @Test + fun rowsAreOrderedByDateThenTime() { + // Insert out of order; the query's ORDER BY qso_date, time_on must sort them. + insertQso("LATER", "20240601", "1300", mySigInfo = "K-1234") + insertQso("EARLY", "20240601", "1200", mySigInfo = "K-1234") + + val content = PotaAdifExporter.buildActivationAdif(db, activation("K-1234")).single().content + + assertThat(content.indexOf("EARLY")).isLessThan(content.indexOf("LATER")) + } + + @Test + fun twoFer_emitsOneDocumentPerPark_sameQsosDifferentMySigInfo() { + // A two-fer stores the full comma string in my_sig_info (the WHERE key). + insertQso("W1AW", "20240601", "1230", mySigInfo = "K-1234,K-5678") + insertQso("K9XYZ", "20240601", "1231", mySigInfo = "K-1234,K-5678") + + val docs = PotaAdifExporter.buildActivationAdif(db, activation("K-1234,K-5678")) + + assertThat(docs.map { it.parkRef }).containsExactly("K-1234", "K-5678").inOrder() + + val first = docs[0] + val second = docs[1] + // Both files carry the same QSOs... + assertThat(first.content).contains("W1AW ") + assertThat(second.content).contains("W1AW ") + assertThat(first.content.split("").size).isEqualTo(second.content.split("").size) + // ...but each pins MY_SIG_INFO to its own park. + assertThat(first.content).contains("K-1234 ") + assertThat(first.content).doesNotContain("K-5678 ") + assertThat(second.content).contains("K-5678 ") + assertThat(second.content).doesNotContain("K-1234 ") + // Filenames are per-park. + assertThat(first.filename).isEqualTo("pota-K-1234-20240601-1234.adi") + assertThat(second.filename).isEqualTo("pota-K-5678-20240601-1234.adi") + } + + @Test + fun onlyPotaRowsMatchingTheParkAreIncluded() { + insertQso("INPARK", "20240601", "1230", mySigInfo = "K-1234") + // Different park — must not bleed into K-1234's document. + insertQso("OTHER", "20240601", "1230", mySigInfo = "K-9999") + + val content = PotaAdifExporter.buildActivationAdif(db, activation("K-1234")).single().content + + assertThat(content).contains("INPARK") + assertThat(content).doesNotContain("OTHER") + } +}