Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_FOLLOWS_HA
import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_FORCE_IN
import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_FORCE_OUT
import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_LINK
import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_SCOPE_LINE
import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_REFRESH
import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_LINK_MANUAL
import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_ENTER_MANUALLY
import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_CHOOSE_ENTITY
import com.loosecannon.servicetag.ui.asset.CANCEL_BUTTON
import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_LINK_CALENDAR
import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_LINK_YEAR_ROUND
import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_NOT_CHECKED_IN_TIME
Expand Down Expand Up @@ -356,8 +362,9 @@ class SeasonSyncScreensTest {
// --- The setup sheet ------------------------------------------------------------------------------------------

/**
* Pins: the sheet opened by Link draws the mode's sentence (P16-44) above an enabled Save before anything is
* written, and a strands refusal draws S55 inside the sheet, which stays open. Targets: P16-44, Save, S55.
* Pins: the sheet opened by Link draws the mode's sentence (P16-44) above Save before anything is written — Save
* held until an entity is given (#105 row 16), then enabled — and a strands refusal draws S55 inside the sheet,
* which stays open. Targets: P16-44, Save, S55.
*/
@Test fun theSetupSheetShowsItsSentenceBeforeSaveAndKeepsS55Open() {
connect()
Expand All @@ -369,12 +376,12 @@ class SeasonSyncScreensTest {

rule.awaitText(SEASON_SYNC_LINK_CALENDAR)
rule.onNodeWithText(SEASON_SYNC_LINK_CALENDAR).performScrollTo().assertIsDisplayed()
rule.onNodeWithText(SAVE_LABEL).performScrollTo().assertIsEnabled()
rule.onNodeWithText(SAVE_LABEL).performScrollTo().assertIsNotEnabled()
assertTrue("the sentence comes before Save", top(SEASON_SYNC_LINK_CALENDAR) < top(SAVE_LABEL))
assertNull("nothing is written before Save", binding(heater))

typeEntityId()
rule.onNodeWithText(SAVE_LABEL).performScrollTo().performClick()
rule.onNodeWithText(SAVE_LABEL).performScrollTo().assertIsEnabled().performClick()

val strands = seasonStrands(listOf(PRE_SEASON_TITLE))
rule.awaitText(strands)
Expand All @@ -385,6 +392,58 @@ class SeasonSyncScreensTest {
assertNull("a refusal writes nothing", binding(heater))
}

/**
* #105 row 18 (B3): the setup sheet opens on the Choose entity row — no typed field — and Enter entity ID manually
* brings the field back, after which typing and Save link exactly as before.
*/
@Test fun theSetupSheetOffersChooseEntityAndManualEntryBringsTheFieldBack() {
connect()
val heater = heater(MANUAL_OUT)
detail(heater, sheet = SeasonSyncSheetPurpose.LINK)
rule.awaitText(SEASON_SYNC_LINK)
rule.onNodeWithText(SEASON_SYNC_LINK).performScrollTo().performClick()

rule.awaitText(SEASON_SYNC_CHOOSE_ENTITY)
rule.onAllNodes(hasSetTextAction() and hasText(SEASON_SYNC_ENTITY_ID)).assertCountEquals(0)
rule.onNodeWithText(SEASON_SYNC_ENTER_MANUALLY).performScrollTo().performClick()
rule.onNode(hasSetTextAction() and hasText(SEASON_SYNC_ENTITY_ID)).assertExists()

typeEntityId()
rule.onNodeWithText(SAVE_LABEL).performScrollTo().performClick()

rule.waitUntil(TIMEOUT_MS) { binding(heater) != null }
assertEquals(ENTITY_ID, binding(heater)!!.entityId)
}

/**
* #105 row 19 (B3): Choose entity opens the browser in the sheet's place — its title, its scope line, Refresh and
* the manual path — and Cancel returns to the form with nothing chosen and nothing written. The rows, the
* sentences and the pick need a Home Assistant to answer, which the emulator has none of: those are the JVM's
* (`LinkSeasonSyncViewModelTest`); the read this tap starts is cancelled when the browser closes.
*/
@Test fun chooseEntityOpensTheBrowserAndCancelReturnsToTheForm() {
connect()
val heater = heater(MANUAL_OUT)
detail(heater, sheet = SeasonSyncSheetPurpose.LINK)
rule.awaitText(SEASON_SYNC_LINK)
rule.onNodeWithText(SEASON_SYNC_LINK).performScrollTo().performClick()
rule.awaitText(SEASON_SYNC_CHOOSE_ENTITY)
rule.awaitText(SEASON_SYNC_LINK_MANUAL)

rule.onNodeWithText(SEASON_SYNC_CHOOSE_ENTITY).performScrollTo().performClick()

rule.awaitText(SEASON_SYNC_SCOPE_LINE)
rule.onNodeWithText(SEASON_SYNC_REFRESH).assertExists()
rule.onNodeWithText(SEASON_SYNC_ENTER_MANUALLY).assertExists()
rule.onAllNodesWithText(SEASON_SYNC_LINK_MANUAL).assertCountEquals(0)

rule.onNodeWithText(CANCEL_BUTTON).performScrollTo().performClick()

rule.awaitText(SEASON_SYNC_LINK_MANUAL)
rule.onNodeWithText(SEASON_SYNC_CHOOSE_ENTITY).assertExists()
assertNull("nothing chosen, nothing written", binding(heater))
}

/**
* Pins: after a YEAR_ROUND link #78's dialog takes the sheet's place, and its "Review maintenance schedules"
* scrolls this page's own scroll to the maintenance sections. Targets: P78-1b, P78-2, "Weekly check".
Expand Down Expand Up @@ -601,9 +660,17 @@ class SeasonSyncScreensTest {
assertTrue("the fixture's binding was not rewritten", written)
}

/** Types the fictional entity id into the sheet's field and waits for the field to hold it. */
/**
* Types the fictional entity id into the sheet's field and waits for the field to hold it, first opening the field
* with Enter entity ID manually (P105-3) when the sheet shows the Choose entity row instead (#105).
*/
private fun typeEntityId() {
val field = rule.onNode(hasSetTextAction() and hasText(SEASON_SYNC_ENTITY_ID))
val shown = hasSetTextAction() and hasText(SEASON_SYNC_ENTITY_ID)
if (rule.onAllNodes(shown).fetchSemanticsNodes().isEmpty()) {
rule.onNodeWithText(SEASON_SYNC_ENTER_MANUALLY).performScrollTo().performClick()
rule.waitUntil(TIMEOUT_MS) { rule.onAllNodes(shown).fetchSemanticsNodes().isNotEmpty() }
}
val field = rule.onNode(shown)
field.performScrollTo().performClick()
field.performTextInput(ENTITY_ID)
rule.waitUntil(TIMEOUT_MS) { inputOf(field) == ENTITY_ID }
Expand Down
20 changes: 20 additions & 0 deletions app/src/main/kotlin/com/loosecannon/servicetag/di/AppGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ import com.loosecannon.servicetag.core.reminders.BuildReminderSubjects
import com.loosecannon.servicetag.core.seasonsync.EditSeasonSyncEntity
import com.loosecannon.servicetag.core.seasonsync.ForgetHaConnection
import com.loosecannon.servicetag.core.seasonsync.HaConnectionRepository
import com.loosecannon.servicetag.core.seasonsync.SyncErrorKind
import com.loosecannon.servicetag.core.seasonsync.HaListOutcome
import com.loosecannon.servicetag.core.seasonsync.LinkSeasonSync
import com.loosecannon.servicetag.core.seasonsync.RecordSeasonSyncResult
import com.loosecannon.servicetag.core.seasonsync.ResumeSeasonSync
Expand Down Expand Up @@ -699,6 +701,24 @@ class AppGraph(private val context: Context) {
resolver = InetHostResolver(networkPermissionGranted),
)

/**
* #105: the setup sheet's foreground entity list — the stored connection and its token, then one `GET /api/states`
* through [haStateClient] under every #16 rule. Null when no connection is stored (the sheet draws P16-10);
* `NEEDS_TOKEN` when the token is gone (P16-11); otherwise the client's own outcome. Nothing reads this in the
* background: only Choose entity and Refresh do.
*/
val listHaEntities: suspend () -> HaListOutcome? = {
val connection = haConnections.get()
if (connection == null) {
null
} else {
when (val token = secretStore.get(connection.id)) {
null -> HaListOutcome.Failed(SyncErrorKind.NEEDS_TOKEN, null)
else -> haStateClient.listStates(connection, token)
}
}
}

/** A cache file the camera can write into through the FileProvider (spec §9.3). */
fun cameraCaptureUri(): Uri {
val file = File(File(context.applicationContext.cacheDir, "camera"), "${ids.newId()}.jpg")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.loosecannon.servicetag.core.seasonsync.HaEndpoint
import com.loosecannon.servicetag.core.seasonsync.HaEndpointPolicy
import com.loosecannon.servicetag.core.seasonsync.HaHostKind
import com.loosecannon.servicetag.core.seasonsync.HaHttpAnswer
import com.loosecannon.servicetag.core.seasonsync.HaListOutcome
import com.loosecannon.servicetag.core.seasonsync.HaReadOutcome
import com.loosecannon.servicetag.core.seasonsync.HaScheme
import com.loosecannon.servicetag.core.seasonsync.HaStateReader
Expand All @@ -19,6 +20,7 @@ import com.loosecannon.servicetag.core.seasonsync.eligibleNow
import com.loosecannon.servicetag.core.seasonsync.isJsonMediaType
import com.loosecannon.servicetag.core.seasonsync.isPrivateLanAddress
import com.loosecannon.servicetag.core.seasonsync.mapHaAnswer
import com.loosecannon.servicetag.core.seasonsync.mapHaStatesAnswer
import com.loosecannon.servicetag.core.seasonsync.parseObject
import com.loosecannon.servicetag.fetch.FailurePhase
import com.loosecannon.servicetag.fetch.transportFailureOf
Expand Down Expand Up @@ -106,19 +108,37 @@ class HomeAssistantStateClient(
is Exchange.Failed -> ConnectionTestOutcome.Failed(exchange.outcome.kind, exchange.outcome.detail)
}

/**
* #105 (B2; owner ruling Q2): the setup sheet's one foreground `GET <base>/api/states`, under exactly the rules
* above — the same gate, headers, timeouts and failure map — with its own body cap, [LIST_MAX_BODY_BYTES], because
* a whole installation's states run to megabytes where one entity's run to bytes. The poll's cap is untouched.
* Called only when the owner taps Choose entity or Refresh; never by the runner or the worker. The answer is mapped
* on [io] too: up to 8 MiB of JSON is not parsed on the caller's thread, which is the sheet's, the main one.
*/
suspend fun listStates(connection: HaConnection, token: Secret): HaListOutcome =
when (val exchange = exchange(connection, STATES_LIST_PATH, token, LIST_MAX_BODY_BYTES)) {
is Exchange.Answered -> withContext(io) { mapHaStatesAnswer(exchange.answer) }
is Exchange.Failed -> HaListOutcome.Failed(exchange.outcome.kind, exchange.outcome.detail)
}

private sealed interface Exchange {
class Answered(val answer: HaHttpAnswer) : Exchange

class Failed(val outcome: HaReadOutcome.NoDecision) : Exchange
}

private suspend fun exchange(connection: HaConnection, path: String, token: Secret): Exchange {
private suspend fun exchange(
connection: HaConnection,
path: String,
token: Secret,
maxBodyBytes: Int = MAX_BODY_BYTES,
): Exchange {
val endpoint = when (val check = HaEndpointPolicy.classify(connection.baseUrl)) {
is EndpointCheck.Allowed -> check.endpoint
is EndpointCheck.Refused -> return failed(SyncErrorKind.ENDPOINT_REFUSED)
}
if (!networkPermissionGranted()) return failed(SyncErrorKind.DENIED)
return withTimeoutOrNull(CALL_MILLIS) { checkedExchange(connection, endpoint, path, token) }
return withTimeoutOrNull(CALL_MILLIS) { checkedExchange(connection, endpoint, path, token, maxBodyBytes) }
?: failed(SyncErrorKind.TIMED_OUT)
}

Expand All @@ -127,6 +147,7 @@ class HomeAssistantStateClient(
endpoint: HaEndpoint,
path: String,
token: Secret,
maxBodyBytes: Int,
): Exchange {
val homeOnly = when (endpoint.scheme) {
HaScheme.HTTP -> true
Expand All @@ -152,7 +173,7 @@ class HomeAssistantStateClient(
return failed(SyncErrorKind.ENDPOINT_REFUSED)
}
if (!sameAsciiHost(url.host.orEmpty(), endpoint.host)) return failed(SyncErrorKind.ENDPOINT_REFUSED)
return withContext(io) { get(url, token) }
return withContext(io) { get(url, token, maxBodyBytes) }
}

/** C19 step 1b: null when every answer is private (C8 rule 5); else the outcome, nothing opened. */
Expand All @@ -166,7 +187,7 @@ class HomeAssistantStateClient(
return if (local) null else failed(SyncErrorKind.NAME_NOT_LOCAL)
}

private suspend fun get(url: URL, token: Secret): Exchange {
private suspend fun get(url: URL, token: Secret, maxBodyBytes: Int): Exchange {
val connection = try {
connectionFor(url, token)
} catch (e: Throwable) {
Expand All @@ -176,7 +197,7 @@ class HomeAssistantStateClient(
cont.invokeOnCancellation { disconnectQuietly(connection) }
if (!cont.isActive) return@suspendCancellableCoroutine
val exchange = try {
answerOf(connection)
answerOf(connection, maxBodyBytes)
} catch (e: Throwable) {
failedBy(e, FailurePhase.CONNECT)
} finally {
Expand All @@ -200,31 +221,31 @@ class HomeAssistantStateClient(
return connection
}

/** Blocks for the head, then reads a 200's body under the cap; any other status answers no body. */
private fun answerOf(connection: HttpURLConnection): Exchange {
/** Blocks for the head, then reads a 200's body under [maxBodyBytes]; any other status answers no body. */
private fun answerOf(connection: HttpURLConnection, maxBodyBytes: Int): Exchange {
val status = connection.responseCode
if (status < 0) return failed(SyncErrorKind.UNREACHABLE)
if (status != OK) return Exchange.Answered(HaHttpAnswer(status, connection.contentType, ByteArray(0), false))
val contentType = connection.contentType
return try {
val (body, truncated) = capped(connection.inputStream)
val (body, truncated) = capped(connection.inputStream, maxBodyBytes)
Exchange.Answered(HaHttpAnswer(status, contentType, body, truncated))
} catch (e: Throwable) {
failedBy(e, FailurePhase.BODY)
}
}

/** At most [MAX_BODY_BYTES], and whether more followed. */
private fun capped(input: InputStream): Pair<ByteArray, Boolean> = input.use {
/** At most [maxBodyBytes], and whether more followed; never a byte past the cap is read (review NOTE-5). */
private fun capped(input: InputStream, maxBodyBytes: Int): Pair<ByteArray, Boolean> = input.use {
val kept = ByteArrayOutputStream()
val buffer = ByteArray(BUFFER_BYTES)
while (kept.size() <= MAX_BODY_BYTES) {
val read = it.read(buffer, 0, minOf(buffer.size, MAX_BODY_BYTES + 1 - kept.size()))
while (kept.size() <= maxBodyBytes) {
val read = it.read(buffer, 0, minOf(buffer.size, maxBodyBytes + 1 - kept.size()))
if (read < 0) break
kept.write(buffer, 0, read)
}
val bytes = kept.toByteArray()
if (bytes.size > MAX_BODY_BYTES) bytes.copyOf(MAX_BODY_BYTES) to true else bytes to false
if (bytes.size > maxBodyBytes) bytes.copyOf(maxBodyBytes) to true else bytes to false
}

/** N-4: TLS first, since #85's rule would fold it into `UNREACHABLE`; a cancellation or an Error propagates. */
Expand All @@ -241,11 +262,17 @@ class HomeAssistantStateClient(
private companion object {
const val STATES_PATH = "/api/states/"
const val API_ROOT_PATH = "/api/"

/** #105: the whole states list, read only on Choose entity and Refresh. */
const val STATES_LIST_PATH = "/api/states"
const val OK = 200
const val CONNECT_MILLIS = 10_000
const val IDLE_MILLIS = 15_000
const val CALL_MILLIS = 30_000L
const val MAX_BODY_BYTES = 64 * 1024

/** #105 (owner ruling Q2): the list call's own cap; the poll keeps [MAX_BODY_BYTES]. */
const val LIST_MAX_BODY_BYTES = 8 * 1024 * 1024
const val BUFFER_BYTES = 8 * 1024
const val MAX_CAUSE_DEPTH = 16

Expand Down
Loading