Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@

import radio.ks3ckc.ft8af.UsbPermissionIntentsKt;
import radio.ks3ckc.ft8af.pskreporter.PskReporterSender;
import radio.ks3ckc.ft8af.sync.CloudlogFailureHint;
import radio.ks3ckc.ft8af.wsjtx.WsjtxCodec;
import radio.ks3ckc.ft8af.wsjtx.WsjtxUdpService;

Expand Down Expand Up @@ -1127,7 +1128,16 @@ public void run() {
boolean qrzOk = false;
boolean wrlOk = false;
if (GeneralVariables.enableCloudlog){
cloudlogOk = ThirdPartyService.UploadToCloudLog(qslRecord);
StringBuilder why = new StringBuilder();
cloudlogOk = ThirdPartyService.UploadToCloudLog(qslRecord, why);
if (!cloudlogOk && why.length() > 0) {
// Say why on screen, not just in debug.log: a Station ID
// from the wrong Wavelog user passes Test Connection and
// then rejects every QSO with no visible symptom.
ToastMessage.show(String.format(
getStringFromResource(R.string.cloudlog_upload_rejected),
CloudlogFailureHint.cloudlogFailureMessage(why.toString())));
Comment on lines +1137 to +1139
}
}
if (GeneralVariables.enableQRZ){
qrzOk = ThirdPartyService.UploadToQRZ(qslRecord);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,9 +312,18 @@ static String QSLRecordToADIF(QSLRecord qslRecord, ServiceType serv){
return logStr.toString();
}
public static boolean UploadToCloudLog(QSLRecord qslRecord){
return UploadToCloudLog(qslRecord, null);
}

/**
* As {@link #UploadToCloudLog(QSLRecord)}, but records why the upload failed into
* {@code failureOut} so the post-QSO path can toast the server's reason instead of
* failing silently.
*/
public static boolean UploadToCloudLog(QSLRecord qslRecord, StringBuilder failureOut){
// Convert to ADIF format
String logStr = QSLRecordToADIF(qslRecord,ServiceType.Cloudlog);
return uploadAdifToCloudlog(logStr);
return uploadAdifToCloudlog(logStr, failureOut);
}

/**
Expand Down Expand Up @@ -345,6 +354,13 @@ public static boolean uploadAdifToCloudlog(String adif, StringBuilder failureOut
// HttpURLConnection won't follow on a POST.
String clRes = cloudlogPost(base, "api/qso", result, failureOut);
Log.d(TAG, "Cloudlog upload " + (clRes != null ? "succeeded" : "failed"));
if (clRes == null) {
// The station id is the one request field the server's reason can point
// at ("station id does not belong to the API key owner") and the one thing
// the POST log line above doesn't show. Not a secret, so log it plainly.
GeneralVariables.fileLog("Cloudlog: upload rejected with station_profile_id="
+ describeStationId(GeneralVariables.getCloudlogStationID()));
}
return clRes != null;
}catch (Exception k){
Log.d(TAG, "Cloudlog upload error: " + k.getClass().getSimpleName());
Expand Down Expand Up @@ -949,6 +965,12 @@ static String sendPostRequest(String url, String json, StringBuilder failureOut,
return null;
}

/** The configured station id for a log line: "(blank)" when unset, since "" vanishes. */
static String describeStationId(String stationId) {
if (stationId == null || stationId.trim().isEmpty()) return "(blank)";
return stationId.trim();
}

private static void appendFailure(StringBuilder failureOut, String reason) {
if (failureOut == null || reason == null || reason.isEmpty()) return;
if (failureOut.length() > 0) failureOut.append("; ");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
@file:JvmName("CloudlogFailureHint")

package radio.ks3ckc.ft8af.sync

import com.k1af.ft8af.GeneralVariables
import com.k1af.ft8af.R

/**
* Turns a rejected Cloudlog / Wavelog / Nextlog upload into something the operator can act on.
*
* The failure line that reaches us (see `ThirdPartyService.describeHttpFailure`) is the raw
* HTTP status plus the server's JSON body, e.g.
*
* HTTP 401: {"status":"failed","reason":"station id does not belong to the API key owner."}
*
* That line is exactly right for `debug.log`, but on screen the JSON wrapper is noise and the
* reason alone still leaves the operator guessing which setting to fix. The two Wavelog
* rejections that account for nearly every "the test passes but nothing uploads" support
* thread are a Station ID that isn't the key owner's and a key without write rights, so those
* get a one-line hint pointing at the setting. Pure string logic, unit-tested; the only
* Android touch-point is [cloudlogFailureMessage] resolving string resources.
*/
enum class CloudlogFailureKind {
/** The `station_profile_id` is blank or belongs to another Wavelog user. */
STATION_ID,

/** The API key is missing, unknown, disabled, or read-only. */
API_KEY,

/** Anything else: transport errors, ADIF import errors, proxies returning HTML… */
OTHER,
}

private val REASON = Regex("\"reason\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"")
private val MESSAGES = Regex("\"messages\"\\s*:\\s*\\[(.*?)]")
private val QUOTED = Regex("\"((?:[^\"\\\\]|\\\\.)*)\"")

private fun unescapeJson(s: String): String {
return s.replace("\\\"", "\"").replace("\\/", "/").replace("\\\\", "\\")
Comment on lines +35 to +39
}

/**
* The server's own explanation, stripped of the HTTP/JSON wrapper: the `reason` field when
* there is one, else the non-empty `messages` entries joined with "; ", else the line as-is
* (a bare "HTTP 502" or "Unknown host: …" is already readable). Blank in, blank out.
*/
fun cloudlogServerReason(failure: String?): String {
val raw = failure?.trim().orEmpty()
if (raw.isEmpty()) return ""
REASON.find(raw)?.let { return unescapeJson(it.groupValues[1]).trim() }
MESSAGES.find(raw)?.let { m ->
val parts =
QUOTED.findAll(m.groupValues[1])
.map { unescapeJson(it.groupValues[1]).trim() }
.filter { it.isNotEmpty() }
.toList()
if (parts.isNotEmpty()) return parts.joinToString("; ")
}
return raw
}

/** Which setting the failure points at. Station check runs first: its text mentions the key too. */
fun classifyCloudlogFailure(failure: String?): CloudlogFailureKind {
val reason = cloudlogServerReason(failure).lowercase()
if (reason.isEmpty()) return CloudlogFailureKind.OTHER
if (reason.contains("station")) return CloudlogFailureKind.STATION_ID
if (reason.contains("api key") || reason.contains("apikey") || reason.contains("key invalid")) {
return CloudlogFailureKind.API_KEY
}
return CloudlogFailureKind.OTHER
}

/** String resource for the fix-it hint, or null when we have nothing better than the reason. */
fun cloudlogHintResId(failure: String?): Int? =
when (classifyCloudlogFailure(failure)) {
CloudlogFailureKind.STATION_ID -> R.string.cloudlog_hint_station_id
CloudlogFailureKind.API_KEY -> R.string.cloudlog_hint_api_key
CloudlogFailureKind.OTHER -> null
}

/**
* One sentence for a toast: the server's reason followed by the hint when there is one.
* Resolves resources through [GeneralVariables.getStringFromResource] so it is callable from
* the Java upload thread in MainViewModel as well as from Kotlin.
*/
fun cloudlogFailureMessage(failure: String?): String {
val reason = cloudlogServerReason(failure)
val hint = cloudlogHintResId(failure)?.let { GeneralVariables.getStringFromResource(it) }.orEmpty()
return when {
hint.isEmpty() -> reason
reason.isEmpty() -> hint
else -> "$reason $hint"
}
}
11 changes: 11 additions & 0 deletions ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/sync/QsoAutoSync.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.SystemClock
import com.k1af.ft8af.GeneralVariables
import com.k1af.ft8af.R
import com.k1af.ft8af.database.DatabaseOpr
import com.k1af.ft8af.log.ThirdPartyService
import com.k1af.ft8af.ui.ToastMessage
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
Expand Down Expand Up @@ -170,6 +173,14 @@ class QsoAutoSync(private val appContext: Context) {
log("start ($reason): $pending pending")
val result = ThirdPartyService.syncAllQSOs(db, null)
log("done ($reason): " + summarize(result))
// The operator never pressed anything for this run, so the only way they
// learn their logbook server is rejecting QSOs is if we say so here.
result.cloudlogError?.takeIf { it.isNotBlank() }?.let { err ->
ToastMessage.show(
GeneralVariables.getStringFromResource(R.string.cloudlog_upload_rejected)
.format(cloudlogFailureMessage(err)),
)
}
} catch (e: Exception) {
log("error ($reason): ${e.javaClass.simpleName} ${e.message}")
} finally {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import radio.ks3ckc.ft8af.sync.cloudlogHintResId
import radio.ks3ckc.ft8af.sync.cloudlogServerReason
import radio.ks3ckc.ft8af.theme.*
import radio.ks3ckc.ft8af.ui.components.AnimatedCounter
import radio.ks3ckc.ft8af.ui.components.EmptyStateWaves
Expand Down Expand Up @@ -428,6 +430,9 @@ fun LogbookScreen(mainViewModel: MainViewModel) {
cloudlogOk = result?.cloudlogOk ?: 0,
qrzOk = result?.qrzOk ?: 0,
wrlOk = result?.wrlOk ?: 0,
cloudlogError = result?.cloudlogError,
qrzError = result?.qrzError,
wrlError = result?.wrlError,
)
// Re-query QSLTable so the row chips pick up the
// newly-set synced_cloudlog / synced_qrz / synced_wrl flags.
Expand Down Expand Up @@ -577,6 +582,10 @@ private data class SyncDialogState(
val wrlAttempted: Boolean,
val finished: Boolean,
val noServicesEnabled: Boolean,
/** Why the first failed upload failed, per service (null = none failed). Shown when finished. */
val cloudlogError: String? = null,
val qrzError: String? = null,
val wrlError: String? = null,
)

@Composable
Expand Down Expand Up @@ -2463,6 +2472,13 @@ private fun CatchUpSyncDialog(
fontSize = 12.sp,
)
}
// "0 accepted" with no reason is what sends people to Discord. Show the
// server's own explanation, plus which setting to look at when we know.
if (state.finished) {
SyncRejection(R.string.log_sync_cloudlog_rejected, state.cloudlogError, hint = true)
SyncRejection(R.string.log_sync_qrz_rejected, state.qrzError, hint = false)
SyncRejection(R.string.log_sync_wrl_rejected, state.wrlError, hint = false)
}
}

Spacer(modifier = Modifier.height(20.dp))
Expand All @@ -2489,6 +2505,37 @@ private fun CatchUpSyncDialog(
}
}

/**
* One rejected-upload line in the catch-up dialog: the service's reason in red, and for
* Cloudlog-family servers the fix-it hint (Station ID / API key) underneath. Renders
* nothing when the service had no failure.
*/
@Composable
private fun SyncRejection(
@StringRes labelRes: Int,
failure: String?,
hint: Boolean,
) {
if (failure.isNullOrBlank()) return
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(labelRes, cloudlogServerReason(failure)),
color = StatusBad,
fontSize = 12.sp,
lineHeight = 16.sp,
)
val hintRes = if (hint) cloudlogHintResId(failure) else null
if (hintRes != null) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(hintRes),
color = TextMuted,
fontSize = 12.sp,
lineHeight = 16.sp,
)
}
}

@Composable
private fun AdifImportDialog(
state: AdifImportUiState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,25 @@ private fun CloudlogSettingsDialog(
modifier = Modifier.fillMaxWidth(),
)
}
// A passing test says nothing about the Station ID, and Wavelog rejects every
// QSO whose station isn't the key owner's. Once the server has answered, warn
// when the entered ID is blank or not in the list it returned for this key.
val serverAnswered = testResult == true || stationList.isNotEmpty()
val stationWarning =
when (checkStationId(stationIdInput.text, stationList)) {
StationIdCheck.BLANK -> R.string.settings_station_id_missing
StationIdCheck.NOT_ON_SERVER -> R.string.settings_station_id_not_on_server
else -> null
}
if (serverAnswered && stationWarning != null) {
Text(
text = stringResource(stationWarning, stationIdInput.text.trim()),
color = StatusBad,
fontSize = 12.sp,
lineHeight = 16.sp,
modifier = Modifier.fillMaxWidth(),
)
}

Row(
modifier = Modifier.fillMaxWidth(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package radio.ks3ckc.ft8af.ui.settings

import com.k1af.ft8af.log.ThirdPartyService

/**
* Does the Station ID in the Cloudlog / Wavelog dialog stand a chance of being accepted?
*
* Test Connection only proves the API key (`api/auth`), and the station list it fetches
* afterwards is scoped to that key's owner. Wavelog rejects every QSO whose
* `station_profile_id` is blank or belongs to a different user with
* "station id does not belong to the API key owner." — so a Station ID that is blank, or
* that doesn't appear in the fetched list, is going to fail before the operator ever
* leaves Settings. Pure so it is unit-testable; the composable just maps the result to a
* warning string.
*/
enum class StationIdCheck {
/** The ID is one of the key owner's stations. */
OK,

/** No ID entered at all. */
BLANK,

/** The server listed stations for this key and this ID is not among them. */
NOT_ON_SERVER,

/** No station list to judge against (not fetched yet, or the key owner has none). */
UNKNOWN,
}

internal fun checkStationId(
stationId: String,
stations: List<ThirdPartyService.StationProfile>,
): StationIdCheck {
val id = stationId.trim()
if (id.isEmpty()) return StationIdCheck.BLANK
if (stations.isEmpty()) return StationIdCheck.UNKNOWN
return if (stations.any { it.stationId == id }) StationIdCheck.OK else StationIdCheck.NOT_ON_SERVER
}
10 changes: 10 additions & 0 deletions ft8af/app/src/main/res/values/strings_compose.xml
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,14 @@
<string name="log_sync_wrl_accepted">World Radio League: %1$d accepted</string>
<string name="log_sync_nothing_to_upload">No QSOs in the logbook to upload yet.</string>
<string name="log_sync_working">Working…</string>
<!-- Rejected-upload lines in the catch-up sync dialog; %1$s = the server's own reason -->
<string name="log_sync_cloudlog_rejected">Cloudlog / Wavelog / Nextlog rejected uploads: %1$s</string>
<string name="log_sync_qrz_rejected">QRZ rejected uploads: %1$s</string>
<string name="log_sync_wrl_rejected">World Radio League rejected uploads: %1$s</string>
<!-- Toast after a logged QSO (or a background catch-up) is refused by the logbook server -->
<string name="cloudlog_upload_rejected">Cloudlog / Wavelog rejected the QSO: %1$s</string>
<string name="cloudlog_hint_station_id">Check the Station ID in Settings › Logging › Cloudlog / Wavelog: tap Test Connection and pick it from the list. It must belong to the same user that created the API key.</string>
<string name="cloudlog_hint_api_key">Check the API key in Settings › Logging › Cloudlog / Wavelog. It must be enabled and have read/write rights.</string>
<string name="log_cd_import_qsos">Import ADIF log</string>
<string name="log_import_title_importing">IMPORTING ADIF LOG…</string>
<string name="log_import_title_complete">IMPORT COMPLETE</string>
Expand Down Expand Up @@ -996,6 +1004,8 @@
<string name="settings_loading_stations">Loading stations...</string>
<string name="settings_station_id">Station ID</string>
<string name="settings_station_id_hint">e.g. 1</string>
<string name="settings_station_id_missing">No Station ID selected. Pick one from the list or every upload will be rejected.</string>
<string name="settings_station_id_not_on_server">Station ID %1$s is not one of this API key\'s stations. Pick one from the list.</string>
<string name="settings_select_a_station">Select a station</string>
<string name="settings_enter_manually">Enter manually</string>
<string name="settings_choose_from_server">Choose from server</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ public void httpFailure_truncatesAnEnormousBody() {
assertThat(out).endsWith("…");
}

@Test
public void stationId_forLogLine_namesBlankExplicitly() {
assertThat(ThirdPartyService.describeStationId(null)).isEqualTo("(blank)");
assertThat(ThirdPartyService.describeStationId(" ")).isEqualTo("(blank)");
assertThat(ThirdPartyService.describeStationId(" 4 ")).isEqualTo("4");
}

@Test
public void qrzFailure_reportsResultAndReason() {
String out = ThirdPartyService.describeQrzFailure(
Expand Down
Loading
Loading