diff --git a/.github/scripts/publish-reports.sh b/.github/scripts/publish-reports.sh index dafdff74ae9..52b970ac3e3 100644 --- a/.github/scripts/publish-reports.sh +++ b/.github/scripts/publish-reports.sh @@ -8,6 +8,10 @@ # Needs the gh CLI on the runner and GH_TOKEN with contents: write (to create the release/tag and # upload/delete its assets). # +# With REPORTS_TAG set (release-test-report.yml), the bundle goes to that version's pre-release +# instead, under its plain name and without the retention cleanup - that release is created by the +# calling workflow and kept until deleted by hand. +# # Usage: publish-reports.sh set -euo pipefail @@ -17,6 +21,16 @@ retention_days=14 command -v gh >/dev/null || { echo "::error::gh CLI not found on this runner (brew install gh)"; exit 1; } +if [ -n "${REPORTS_TAG:-}" ]; then + name="$(basename "$file")" + gh release upload "$REPORTS_TAG" "$file" --clobber + { + echo "## Reports: $name" + echo "[Download $name]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/download/$REPORTS_TAG/$name) - attached to the [$REPORTS_TAG]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$REPORTS_TAG) pre-release" + } >> "$GITHUB_STEP_SUMMARY" + exit 0 +fi + # unit-test.yml and android-test.yml run at the same time, so both may try to create the release on # the first run - if the create fails, the other job has won the race, as long as it exists now if ! gh release view "$tag" >/dev/null 2>&1; then diff --git a/.github/scripts/release-report-summary.sh b/.github/scripts/release-report-summary.sh new file mode 100644 index 00000000000..4f2344baa9e --- /dev/null +++ b/.github/scripts/release-report-summary.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Downloads the report bundles attached to a version's test-report pre-release, totals the JUnit +# results (and unit test line coverage) per suite, and writes them as the release notes and the +# job summary. Used by release-test-report.yml after the test jobs have uploaded their bundles. +# +# Needs the gh CLI on the runner and GH_TOKEN with contents: write. +# +# Usage: release-report-summary.sh +set -euo pipefail + +tag="$1" +version="$2" +work="$RUNNER_TEMP/release-report" +notes="$work/notes.md" +rm -rf "$work" +mkdir -p "$work/bundles" + +gh release download "$tag" --pattern '*.tar.gz' --dir "$work/bundles" || true + +run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" +{ + echo "# $version release test reports summary" + echo + echo "- Branch: \`$GITHUB_REF_NAME\`" + echo "- Commit: [\`${GITHUB_SHA:0:9}\`]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA)" + echo "- Run: [$GITHUB_RUN_ID]($run_url)" + echo "- Date: $(date -u +%Y-%m-%d) (UTC)" + echo + echo "| Suite | Result | Tests | Passed | Failed | Skipped | Report |" + echo "|---|---|---:|---:|---:|---:|---|" +} > "$notes" + +failures="$work/failures.md" +: > "$failures" +coverage="" + +for suite in "Unit tests:unit-test-reports.tar.gz" \ + "Instrumentation tests (API 30, small screen):android-test-reports-api30.tar.gz" \ + "Instrumentation tests (API 34, Pixel 6):android-test-reports-api34.tar.gz"; do + label="${suite%%:*}" + bundle="${suite#*:}" + if [ ! -f "$work/bundles/$bundle" ]; then + echo "| $label | ⚠️ no report | | | | | |" >> "$notes" + continue + fi + dir="$work/extract/${bundle%.tar.gz}" + mkdir -p "$dir" + tar -xzf "$work/bundles/$bundle" -C "$dir" + + # JUnit XML from both Gradle (test-results) and AGP (androidTest-results): one per + # file with tests/failures/errors/skipped attributes, failing s followed by + # or + xmls=$(find "$dir" -path '*-results/*' -name '*.xml' | sort) + if [ -z "$xmls" ]; then + echo "| $label | ⚠️ no results | | | | | |" >> "$notes" + continue + fi + # shellcheck disable=SC2086 + read -r tests failed skipped < <(awk ' + function attr(name, m) { return match($0, name "=\"[0-9]+\"") ? substr($0, RSTART + length(name) + 2, RLENGTH - length(name) - 3) + 0 : 0 } + /]/ { t += attr("tests"); f += attr("failures") + attr("errors"); s += attr("skipped") } + END { print t + 0, f + 0, s + 0 }' $xmls) + passed=$((tests - failed - skipped)) + result="✅ passed" + [ "$failed" -gt 0 ] && result="❌ failed" + url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/download/$tag/$bundle" + echo "| $label | $result | $tests | $passed | $failed | $skipped | [$bundle]($url) |" >> "$notes" + + if [ "$failed" -gt 0 ]; then + echo "**$label**" >> "$failures" + # shellcheck disable=SC2086 + awk ' + function attr(name, m) { return match($0, name "=\"[^\"]*\"") ? substr($0, RSTART + length(name) + 2, RLENGTH - length(name) - 3) : "" } + /]/ { tc = attr("classname") "." attr("name") } + /]|]/ { if (tc != "") print "- `" tc "`"; tc = "" }' $xmls | sort -u >> "$failures" + echo >> "$failures" + fi + + kover="$dir/app/build/reports/kover/report.xml" + if [ -f "$kover" ]; then + line_counter=$(grep -o '' "$kover" | tail -1) + missed=$(echo "$line_counter" | grep -o 'missed="[0-9]*"' | grep -o '[0-9]*') + covered=$(echo "$line_counter" | grep -o 'covered="[0-9]*"' | grep -o '[0-9]*') + total=$((missed + covered)) + if [ "$total" -gt 0 ]; then + coverage="**$(awk "BEGIN { printf \"%.1f\", ($covered/$total)*100 }")%** ($covered/$total lines)" + fi + fi +done + +{ + if [ -n "$coverage" ]; then + echo + echo "Unit test line coverage: $coverage" + fi + if [ -s "$failures" ]; then + echo + echo "## Failed tests" + echo + cat "$failures" + fi + echo + echo "Each bundle holds the HTML report and JUnit XML; the instrumentation bundles also have the screen recording and per-test screenshots." +} >> "$notes" + +gh release edit "$tag" --notes-file "$notes" +cat "$notes" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/android-test.yml b/.github/workflows/android-test.yml index 9e62e99d7a8..0c2a7826072 100644 --- a/.github/workflows/android-test.yml +++ b/.github/workflows/android-test.yml @@ -5,6 +5,13 @@ on: branches: - "aviv-dev-latest" workflow_dispatch: + # called by release-test-report.yml, which passes the tag of the version's pre-release so the + # bundles are attached there instead of to ci-reports + workflow_call: + inputs: + release-tag: + type: string + default: "" permissions: contents: write # attaching the report bundle to the ci-reports pre-release (publish-reports.sh) @@ -129,4 +136,5 @@ jobs: continue-on-error: true env: GH_TOKEN: ${{ github.token }} + REPORTS_TAG: ${{ inputs.release-tag }} run: bash .github/scripts/publish-reports.sh "artifact-bundle/android-test-reports-api${{ matrix.api-level }}.tar.gz" diff --git a/.github/workflows/build-debug-apk.yml b/.github/workflows/build-debug-apk.yml deleted file mode 100644 index 7e95cd3ac9e..00000000000 --- a/.github/workflows/build-debug-apk.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Build debug apk - -on: - [workflow_dispatch] - -permissions: - contents: read - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: set up JDK 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Set env - run: echo "COMMIT_SHA=$(git log -n 1 --format='%h')" >> $GITHUB_ENV - - - name: Encode git revision - run: sed -i -e "s/versionName = \"\([^ ]*\).*\"/versionName = \"\1 (git $COMMIT_SHA)\"/" app/build.gradle.kts - - - name: Build with Gradle - run: ./gradlew assembleDebug -Papp.streetcomplete.debug=true - - - name: Rename APK - run: mv app/build/outputs/apk/debug/app-debug.apk app/build/outputs/apk/debug/StreetComplete-debug-$COMMIT_SHA.apk - - - name: Archive APK - uses: actions/upload-artifact@v4 - with: - name: StreetComplete-debug-apk-${{ env.COMMIT_SHA }} - path: app/build/outputs/apk/debug/*.apk - retention-days: 30 diff --git a/.github/workflows/generate-quest-list.yml b/.github/workflows/generate-quest-list.yml deleted file mode 100644 index 4e6cc4f41af..00000000000 --- a/.github/workflows/generate-quest-list.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Generate quest list - -on: - push: - branches: - - master - workflow_dispatch: - -jobs: - generate-quest-list: - name: Generate quest list - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: set up JDK 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - - - uses: gradle/actions/setup-gradle@v3 - - - name: Generate quest list - run: ./gradlew generateQuestList - - - uses: actions/upload-artifact@v4 - with: - name: quest-list.csv - path: ./quest-list.csv diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml deleted file mode 100644 index 92a70b23b0f..00000000000 --- a/.github/workflows/gradle-wrapper-validation.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: "Validate Gradle Wrapper" - -on: - push: - paths: - - 'gradle/wrapper/**' - - 'gradlew' - - 'gradlew.bat' - pull_request: - paths: - - 'gradle/wrapper/**' - - 'gradlew' - - 'gradlew.bat' - -jobs: - validation: - name: "Validation" - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: gradle/wrapper-validation-action@v1 diff --git a/.github/workflows/lint.yml.disabled b/.github/workflows/lint.yml.disabled deleted file mode 100644 index 49b8864bc4a..00000000000 --- a/.github/workflows/lint.yml.disabled +++ /dev/null @@ -1,31 +0,0 @@ -name: "Lint" - -on: - push: - branches: - - "master" - workflow_dispatch: - -jobs: - ktlint: - name: "Kotlin" - runs-on: ubuntu-latest - env: - lintResultFilename: ktlint-result.txt - steps: - - name: Checkout the source code - uses: actions/checkout@v4 - - - name: Run Kotlin linter - id: ktlint-check - uses: musichin/ktlint-check@v3.0.0 - with: - ktlint-version: "1.6.0" - reporter: plain?group_by_file,output=${{ env.lintResultFilename }} - relative: true - continue-on-error: true - - - name: Kotlin linter result - run: | - cat ${{ env.lintResultFilename }} - [ "${{ steps.ktlint-check.outcome }}" == "success" ] || exit 1 diff --git a/.github/workflows/release-test-report.yml b/.github/workflows/release-test-report.yml new file mode 100644 index 00000000000..b644382e18e --- /dev/null +++ b/.github/workflows/release-test-report.yml @@ -0,0 +1,79 @@ +name: Release test report + +# Run by hand (Actions > Release test report > Run workflow, picking the branch to release) to get +# the unit + instrumentation test reports for a release build. They're attached to a +# "v-test-reports" pre-release titled " release test reports summary", whose +# notes hold the pass/fail summary. Running again for the same version replaces that pre-release. + +on: + workflow_dispatch: + inputs: + version: + description: "Version name (leave empty to use appVersionName from app/build.gradle.kts)" + type: string + default: "" + +permissions: + contents: write # creating the pre-release and attaching the report bundles + actions: read + checks: write + +concurrency: + group: release-test-report-${{ github.ref }} + +jobs: + prepare: + runs-on: [self-hosted, macOS] + outputs: + version: ${{ steps.release.outputs.version }} + tag: ${{ steps.release.outputs.tag }} + steps: + - uses: actions/checkout@v4 + + - name: Create the pre-release + id: release + env: + GH_TOKEN: ${{ github.token }} + INPUT_VERSION: ${{ inputs.version }} + run: | + version="$INPUT_VERSION" + if [ -z "$version" ]; then + version=$(sed -n 's/^val appVersionName = "\(.*\)"/\1/p' app/build.gradle.kts) + fi + [ -n "$version" ] || { echo "::error::couldn't read appVersionName from app/build.gradle.kts"; exit 1; } + tag="v$version-test-reports" + # a re-run replaces the old reports (and moves the tag to this run's commit) rather than + # mixing bundles from two runs in one release + if gh release view "$tag" >/dev/null 2>&1; then + gh release delete "$tag" --yes --cleanup-tag + fi + gh release create "$tag" --prerelease --target "$GITHUB_SHA" \ + --title "$version release test reports summary" \ + --notes "Tests are running: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + unit-test: + needs: prepare + uses: ./.github/workflows/unit-test.yml + with: + release-tag: ${{ needs.prepare.outputs.tag }} + + android-test: + needs: prepare + uses: ./.github/workflows/android-test.yml + with: + release-tag: ${{ needs.prepare.outputs.tag }} + + summary: + needs: [prepare, unit-test, android-test] + # also (especially) when tests failed - the summary is what shows which ones + if: always() && needs.prepare.result == 'success' + runs-on: [self-hosted, macOS] + steps: + - uses: actions/checkout@v4 + + - name: Write release notes + env: + GH_TOKEN: ${{ github.token }} + run: bash .github/scripts/release-report-summary.sh "${{ needs.prepare.outputs.tag }}" "${{ needs.prepare.outputs.version }}" diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 73b8b07bd0c..e50d3eaea50 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -5,6 +5,13 @@ on: branches: - "aviv-dev-latest" workflow_dispatch: + # called by release-test-report.yml, which passes the tag of the version's pre-release so the + # bundles are attached there instead of to ci-reports + workflow_call: + inputs: + release-tag: + type: string + default: "" permissions: contents: write # attaching the report bundle to the ci-reports pre-release (publish-reports.sh) @@ -82,4 +89,5 @@ jobs: continue-on-error: true env: GH_TOKEN: ${{ github.token }} + REPORTS_TAG: ${{ inputs.release-tag }} run: bash .github/scripts/publish-reports.sh artifact-bundle/unit-test-reports.tar.gz diff --git a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/screens/main/LongFormChangeTest.kt b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/screens/main/LongFormChangeTest.kt new file mode 100644 index 00000000000..bc060bcd593 --- /dev/null +++ b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/screens/main/LongFormChangeTest.kt @@ -0,0 +1,88 @@ +package de.westnordost.streetcomplete.screens.main + +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.quest.Quest +import de.westnordost.streetcomplete.data.quest.VisibleQuestsSource +import de.westnordost.streetcomplete.screens.workspaces.FakeWorkspaceRepository +import de.westnordost.streetcomplete.testutils.MockOsmServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The workspace's quest definition is changed on the server (element type "Sidewalk" becomes + * "Sidewalks") while the user is in the workspace. They switch workspace, re-open the same one and + * tap the sidewalk. This used to crash (NPE in AbstractQuestForm.onCreate): the quests cached under + * the old long form outlived MainActivity, so the pin opened a form whose quest type was no longer + * registered. + */ +@RunWith(AndroidJUnit4::class) +class LongFormChangeTest : MapSyncTestBase() { + + @Test + fun reopeningWorkspaceAfterLongFormChangedOpensQuestOfNewLongForm() { + longForm = OLD_LONG_FORM + val activity = openWorkspace() + waitUntil("map data downloaded") { mapDownloads() > 0 } + val oldQuest = awaitQuestOfType(OLD_ELEMENT_TYPE) + + // an admin changes the quest definition, the user goes back to the list via "Switch workspace?" + longForm = LONG_FORM + val downloadsBefore = mapDownloads() + onView(withId(R.id.workspace_container)).perform(click()) + onView(withText(R.string.confirmation_switch_workspace_confirm)).inRoot(isDialog()).perform(click()) + waitForText(FakeWorkspaceRepository.TEST_WORKSPACE_TITLE) + composeTestRule.onNodeWithText(FakeWorkspaceRepository.TEST_WORKSPACE_TITLE).performClick() + val reopened = awaitActivity(MainActivity::class.java) + + val source: VisibleQuestsSource = koin.get() + assertNull("quest of the old long form is still cached", source.get(oldQuest.key)) + + waitUntil("map data downloaded again") { mapDownloads() > downloadsBefore } + val newQuest = awaitQuestOfType(NEW_ELEMENT_TYPE) + assertEquals(oldQuest.position, newQuest.position) + tapQuestPin(reopened, newQuest) + waitUntil("long form of the new quest definition opened") { isDisplayed(SURFACE_Q) } + } + + private fun mapDownloads() = MockOsmServer.snapshot().requests.count { it == "GET map" } + + private fun awaitQuestOfType(typeName: String): Quest { + val source: VisibleQuestsSource = koin.get() + var quest: Quest? = null + waitUntil("$typeName quest for way ${sidewalk.id} on the map") { + quest = questFor(source, sidewalk.id)?.takeIf { it.type.name == typeName } + quest != null + } + return quest!! + } + + companion object { + const val OLD_ELEMENT_TYPE = "Sidewalk" + const val NEW_ELEMENT_TYPE = "Sidewalks" // the element type of MapSyncTestBase.LONG_FORM + + // the tester's first quest definition + val OLD_LONG_FORM = """{ + "version": "3.2.0", + "recency_period": 90, + "elements": [{ + "element_type": "$OLD_ELEMENT_TYPE", + "element_type_icon": "sidewalk", + "quest_query": "ways with (footway=sidewalk)", + "quests": [ + {"quest_id": 101, "quest_title": "TextEntry Quest", "quest_description": "This is a TextEntry Quest. Enter some text.", + "quest_type": "TextEntry", "quest_tag": "ext:text_entry"} + ] + }] + }""" + } +} diff --git a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/screens/main/MapSyncTestBase.kt b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/screens/main/MapSyncTestBase.kt index 9c71e8834da..f768a86f262 100644 --- a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/screens/main/MapSyncTestBase.kt +++ b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/screens/main/MapSyncTestBase.kt @@ -10,6 +10,7 @@ import android.os.SystemClock import android.provider.MediaStore import android.view.MotionEvent import android.view.ViewConfiguration +import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onNodeWithText @@ -138,6 +139,8 @@ abstract class MapSyncTestBase { protected lateinit var scenario: ActivityScenario @Volatile protected var overrideConflicts: String = "null" + /** The quest definition the workspace API returns - a test can change it, like an admin would. */ + @Volatile protected var longForm: String = LONG_FORM private var previousAnimationScales: List = emptyList() @Before @@ -411,9 +414,16 @@ abstract class MapSyncTestBase { //region waiting protected fun waitForText(text: String) { - composeTestRule.waitUntil(timeoutMillis = TIMEOUT) { - composeTestRule.onAllNodes(hasText(text, substring = true)).fetchSemanticsNodes().isNotEmpty() - } + composeTestRule.waitUntil(timeoutMillis = TIMEOUT) { hasNodes(hasText(text, substring = true)) } + } + + // fetchSemanticsNodes throws while no compose hierarchy exists, e.g. for a moment while one + // activity replaces another (opening a workspace, the forced logout restarting + // WorkSpaceActivity) - that is "not there yet", not a reason to end the wait early + private fun hasNodes(matcher: SemanticsMatcher): Boolean = try { + composeTestRule.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() + } catch (_: IllegalStateException) { + false } /** Polls [condition] while letting both Compose and the main looper run. */ @@ -463,7 +473,7 @@ abstract class MapSyncTestBase { "id": $WORKSPACE_ID, "title": "${FakeWorkspaceRepository.TEST_WORKSPACE_TITLE}", "type": "osw", "description": null, "createdAt": "2025-01-01T00:00:00Z", "createdBy": "user-1", "createdByName": "Jane Doe", "externalAppAccess": 1, "overrideConflicts": $overrideConflicts, - "imageryListDef": null, "kartaViewToken": null, "longFormQuestDef": $LONG_FORM, + "imageryListDef": null, "kartaViewToken": null, "longFormQuestDef": $longForm, "tdeiMetadata": null, "tdeiProjectGroupId": null, "tdeiRecordId": null, "tdeiServiceId": null }""" diff --git a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/screens/main/QuestFormRestoreTest.kt b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/screens/main/QuestFormRestoreTest.kt new file mode 100644 index 00000000000..783fe227eb8 --- /dev/null +++ b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/screens/main/QuestFormRestoreTest.kt @@ -0,0 +1,44 @@ +package de.westnordost.streetcomplete.screens.main + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry +import androidx.test.runner.lifecycle.Stage +import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry +import org.junit.Test +import org.junit.runner.RunWith + +/** + * A quest form is open, the app goes to the background and Android kills the process. When the + * user comes back, MainActivity is recreated from its saved state, restoring the form - which looks + * up its quest type in the registry. This used to crash (NPE in AbstractQuestForm.onCreate): the + * fragments were restored in super.onCreate, before the long form's quest types were registered. + */ +@RunWith(AndroidJUnit4::class) +class QuestFormRestoreTest : MapSyncTestBase() { + + @Test + fun questFormIsRestoredWhenMainActivityIsRecreatedInAFreshProcess() { + val activity = openWorkspace() + openSidewalkQuest(activity) + + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + // the registry as it is in a fresh process: empty until MainActivity fills it + koin.get().addItem(emptyList()) + activity.recreate() + } + + var recreated: MainActivity? = null + waitUntil("MainActivity recreated") { + instrumentation.runOnMainSync { + recreated = ActivityLifecycleMonitorRegistry.getInstance() + .getActivitiesInStage(Stage.RESUMED) + .filterIsInstance() + .firstOrNull { it !== activity } + } + recreated != null + } + waitUntil("restored long form shown") { isDisplayed(SURFACE_Q) } + } +} diff --git a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/screens/workspaces/WorkspaceLoginFlowTest.kt b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/screens/workspaces/WorkspaceLoginFlowTest.kt index 3a366f44c00..e55aae9dc83 100644 --- a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/screens/workspaces/WorkspaceLoginFlowTest.kt +++ b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/screens/workspaces/WorkspaceLoginFlowTest.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.net.Uri import android.util.Base64 import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasText import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry import androidx.test.runner.lifecycle.Stage @@ -327,15 +328,20 @@ class WorkspaceLoginFlowTest { } private fun waitForText(text: String) { - composeTestRule.waitUntil(timeoutMillis = 20_000) { - composeTestRule.onAllNodes(hasText(text, substring = true)).fetchSemanticsNodes().isNotEmpty() - } + composeTestRule.waitUntil(timeoutMillis = 20_000) { hasNodes(hasText(text, substring = true)) } } private fun waitForWorkspaceListToolbar() { - composeTestRule.waitUntil(timeoutMillis = 20_000) { - composeTestRule.onAllNodesWithContentDescription(PROFILE_NAV_DESCRIPTION).fetchSemanticsNodes().isNotEmpty() - } + composeTestRule.waitUntil(timeoutMillis = 20_000) { hasNodes(hasContentDescription(PROFILE_NAV_DESCRIPTION)) } + } + + // fetchSemanticsNodes throws while no compose hierarchy exists, e.g. for a moment while one + // activity replaces another (opening a workspace, the forced logout restarting + // WorkSpaceActivity) - that is "not there yet", not a reason to end the wait early + private fun hasNodes(matcher: SemanticsMatcher): Boolean = try { + composeTestRule.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() + } catch (_: IllegalStateException) { + false } private fun waitUntil(condition: () -> Boolean) { diff --git a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/screens/workspaces/WorkspaceLongFormFlowTest.kt b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/screens/workspaces/WorkspaceLongFormFlowTest.kt index 758f496e68c..1193e07d1bf 100644 --- a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/screens/workspaces/WorkspaceLongFormFlowTest.kt +++ b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/screens/workspaces/WorkspaceLongFormFlowTest.kt @@ -6,6 +6,7 @@ import android.app.Instrumentation import android.content.Intent import android.os.Parcel import android.os.ParcelFileDescriptor +import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onAllNodesWithContentDescription @@ -354,9 +355,16 @@ class WorkspaceLongFormFlowTest { } private fun waitForText(text: String) { - composeTestRule.waitUntil(timeoutMillis = 20_000) { - composeTestRule.onAllNodes(hasText(text, substring = true)).fetchSemanticsNodes().isNotEmpty() - } + composeTestRule.waitUntil(timeoutMillis = 20_000) { hasNodes(hasText(text, substring = true)) } + } + + // fetchSemanticsNodes throws while no compose hierarchy exists, e.g. for a moment while one + // activity replaces another (opening a workspace, the forced logout restarting + // WorkSpaceActivity) - that is "not there yet", not a reason to end the wait early + private fun hasNodes(matcher: SemanticsMatcher): Boolean = try { + composeTestRule.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() + } catch (_: IllegalStateException) { + false } private fun mapLaunches(): List = diff --git a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/testutils/OfflineMapTilesDownloader.kt b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/testutils/OfflineMapTilesDownloader.kt new file mode 100644 index 00000000000..03aa6e6bc8d --- /dev/null +++ b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/testutils/OfflineMapTilesDownloader.kt @@ -0,0 +1,27 @@ +package de.westnordost.streetcomplete.testutils + +import android.util.Log +import de.westnordost.streetcomplete.data.maptiles.MapTilesDownloader +import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox +import java.util.Collections + +/** + * Stands in for the app's map tiles downloader in all instrumented tests (see + * [SandboxedTestRunner]): downloading the map tiles for offline use goes to the real tile servers + * (through MapLibre, so [RealNetworkGuard] doesn't cover it), which made tests slow and flaky - a + * tile download that took long or hung kept the next map data download from starting. The tiles + * don't matter for any test, the map data download is what tests check (see MockOsmServer). + */ +object OfflineMapTilesDownloader : MapTilesDownloader { + const val TAG = "OfflineMapTiles" + + /** The areas the app wanted map tiles for, in order. */ + val downloads: MutableList = Collections.synchronizedList(mutableListOf()) + + override suspend fun download(bbox: BoundingBox) { + downloads.add(bbox) + Log.i(TAG, "Not downloading map tiles for $bbox (offline in tests)") + } + + override suspend fun clear() {} +} diff --git a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/testutils/RealNetworkGuard.kt b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/testutils/RealNetworkGuard.kt index e1c00bef9f1..46937597e90 100644 --- a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/testutils/RealNetworkGuard.kt +++ b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/testutils/RealNetworkGuard.kt @@ -18,8 +18,9 @@ import java.util.Collections * * Why: tests once reached the production OSM proxy (and most likely the production TDEI refresh * endpoint) because a mock was bypassed - see MockOsmServer.attachTo. This makes that impossible - * instead of relying on every test being set up right. (Map tiles are fetched by MapLibre, not - * these clients, and still go out.) + * instead of relying on every test being set up right. (MapLibre fetches map tiles itself, not + * through these clients: tiles for showing the map still go out, downloading them for offline use + * is replaced by [OfflineMapTilesDownloader].) */ object RealNetworkGuard { const val TAG = "RealNetworkGuard" diff --git a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/testutils/SandboxedTestRunner.kt b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/testutils/SandboxedTestRunner.kt index a8c1b3898e7..ab3b23dbbb2 100644 --- a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/testutils/SandboxedTestRunner.kt +++ b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/testutils/SandboxedTestRunner.kt @@ -2,13 +2,22 @@ package de.westnordost.streetcomplete.testutils import android.app.Application import androidx.test.runner.AndroidJUnitRunner +import de.westnordost.streetcomplete.data.maptiles.MapTilesDownloader import org.koin.core.context.GlobalContext +import org.koin.core.context.loadKoinModules +import org.koin.dsl.module /** The instrumentation runner for all instrumented tests: blocks real network requests on the - * app's Ktor clients from the moment the app has started (see [RealNetworkGuard]). */ + * app's Ktor clients from the moment the app has started (see [RealNetworkGuard]) and doesn't + * download map tiles (see [OfflineMapTilesDownloader]). */ class SandboxedTestRunner : AndroidJUnitRunner() { override fun callApplicationOnCreate(app: Application) { super.callApplicationOnCreate(app) RealNetworkGuard.install(GlobalContext.get()) + // before anything got a Downloader (a single, created on first use) - it keeps the + // map tiles downloader it was created with + loadKoinModules(module { + factory { OfflineMapTilesDownloader } + }) } } diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ApplicationModule.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ApplicationModule.kt index 37012923a0d..ac14b4c8e17 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ApplicationModule.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ApplicationModule.kt @@ -22,12 +22,12 @@ import de.westnordost.streetcomplete.util.network.retryOnTransientHttpFailure import de.westnordost.streetcomplete.util.satellite_layers.ImageryRepository import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig +import io.ktor.client.plugins.HttpRequestRetry import io.ktor.client.plugins.auth.Auth import io.ktor.client.plugins.auth.providers.BearerTokens import io.ktor.client.plugins.auth.providers.bearer import io.ktor.client.plugins.compression.ContentEncoding import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.HttpRequestRetry import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.logging.DEFAULT import io.ktor.client.plugins.logging.LogLevel @@ -36,6 +36,7 @@ import io.ktor.client.plugins.logging.Logging import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.userAgent import io.ktor.serialization.kotlinx.json.json @@ -134,14 +135,21 @@ val appModule = module { install(ContentEncoding) { gzip() } - installWorkspaceBearerAuth(context, preferences, environmentManager, userLoginController, get(named("refreshClient"))) + installWorkspaceBearerAuth( + context, + preferences, + environmentManager, + userLoginController, + get(named("refreshClient")) + ) install(Logging) { logger = object : Logger { override fun log(message: String) { Log.d("OsmClient", message) // Avoid System.err } } - level = LogLevel.ALL + level = HTTP_LOG_LEVEL + sanitizeHeader { it == HttpHeaders.Authorization } } } } @@ -160,14 +168,21 @@ val appModule = module { install(ContentEncoding) { gzip() } - installWorkspaceBearerAuth(context, preferences, environmentManager, userLoginController, get(named("refreshClient"))) + installWorkspaceBearerAuth( + context, + preferences, + environmentManager, + userLoginController, + get(named("refreshClient")) + ) install(Logging) { logger = object : Logger { override fun log(message: String) { Log.d("KtorClient", message) // Avoid System.err } } - level = LogLevel.ALL + level = HTTP_LOG_LEVEL + sanitizeHeader { it == HttpHeaders.Authorization } } defaultRequest { userAgent(ApplicationConstants.USER_AGENT) @@ -185,7 +200,8 @@ val appModule = module { } install(Logging) { logger = Logger.DEFAULT - level = LogLevel.ALL + level = HTTP_LOG_LEVEL + sanitizeHeader { it == HttpHeaders.Authorization } } } } @@ -201,7 +217,8 @@ val appModule = module { Log.d("KtorClient", message) // Avoid System.err } } - level = LogLevel.ALL + level = HTTP_LOG_LEVEL + sanitizeHeader { it == HttpHeaders.Authorization } } install(HttpRequestRetry) { retryOnServerErrors(maxRetries = 3) @@ -274,3 +291,7 @@ suspend fun refreshJwtToken( null } } + +// HTTP logs end up in the log database and from there in emailed crash reports - full bodies +// (map data, long forms) make those multi-MB, so only log them in debug builds +private val HTTP_LOG_LEVEL = if (BuildConfig.DEBUG) LogLevel.ALL else LogLevel.INFO diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/maptiles/MapTilesDownloaderAndroid.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/maptiles/MapTilesDownloaderAndroid.kt index 6107f4766ba..9b9fd28d808 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/maptiles/MapTilesDownloaderAndroid.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/maptiles/MapTilesDownloaderAndroid.kt @@ -1,9 +1,12 @@ package de.westnordost.streetcomplete.data.maptiles import android.content.Context +import de.westnordost.streetcomplete.ApplicationConstants import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox import de.westnordost.streetcomplete.screens.main.map.maplibre.awaitCreateOfflineRegion import de.westnordost.streetcomplete.screens.main.map.maplibre.awaitDownload +import de.westnordost.streetcomplete.screens.main.map.maplibre.awaitGetOfflineRegions +import de.westnordost.streetcomplete.screens.main.map.maplibre.awaitGetStatus import de.westnordost.streetcomplete.screens.main.map.maplibre.awaitResetDatabase import de.westnordost.streetcomplete.screens.main.map.maplibre.toLatLng import de.westnordost.streetcomplete.util.ktx.format @@ -44,6 +47,12 @@ class MapTilesDownloaderAndroid(private val context: Context) : MapTilesDownload // (could also be done directly from the long) val metadata = nowAsEpochMilliseconds().toString().toByteArray(Charsets.UTF_8) try { + // the map tiles don't depend on the workspace - when only the map data needs to be + // downloaded again (e.g. workspace switched), don't fetch the same tiles again + if (hasFreshTiles(bounds)) { + Log.i(TAG, "Not downloading tiles, already downloaded recently") + return@withContext + } val offlineRegion = OfflineManager.getInstance(context).awaitCreateOfflineRegion(regionDefinition, metadata) val time = nowAsEpochMilliseconds() val status = offlineRegion.awaitDownload() @@ -59,9 +68,29 @@ class MapTilesDownloaderAndroid(private val context: Context) : MapTilesDownload } } + /** Whether a completed download of the same map style within [REFRESH_TILES_AFTER] covers + * [bounds]. Its timestamp is stored when the region is created, so whether it completed + * (rather than failed or was cancelled) is checked separately. */ + private suspend fun hasFreshTiles(bounds: LatLngBounds): Boolean { + val freshAfter = nowAsEpochMilliseconds() - REFRESH_TILES_AFTER + return OfflineManager.getInstance(context).awaitGetOfflineRegions().any { region -> + val timestamp = region.metadata.toString(Charsets.UTF_8).toLongOrNull() ?: 0 + val definition = region.definition as? OfflineTilePyramidRegionDefinition + timestamp > freshAfter + && definition?.styleURL == styleUrl + && definition.bounds?.contains(bounds) == true + && region.awaitGetStatus().isComplete + } + } + companion object { private const val TAG = "MapTilesDownload" + /** The base map hardly changes - much less often than the map data + * ([ApplicationConstants.REFRESH_DATA_AFTER]), and well before the downloaded regions are + * deleted ([ApplicationConstants.DELETE_OLD_DATA_AFTER]) */ + private const val REFRESH_TILES_AFTER = 7L * 24 * 60 * 60 * 1000 // 7 days in ms + private const val styleUrl = "https://streetcomplete.app/map-jawg/streetcomplete.json" } } diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainActivity.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainActivity.kt index af59650b5aa..1aa152d816f 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainActivity.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainActivity.kt @@ -301,14 +301,16 @@ class MainActivity : override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() + // before super.onCreate: that restores the fragments, and a quest form restored after + // process death looks up its quest type in the registry, which is still empty until then + doLongForm() super.onCreate(savedInstanceState) + editHistoryViewModel.refreshForNewWorkspace() if (savedInstanceState == null) { handleIntent(intent) } - doLongForm() - LocalBroadcastManager.getInstance(this).registerReceiver( requestLocationPermissionResultReceiver, IntentFilter(LocationRequestFragment.REQUEST_LOCATION_PERMISSION_RESULT) @@ -1608,6 +1610,8 @@ class MainActivity : private suspend fun showQuestDetails(quest: Quest) { val mapFragment = mapFragment ?: return if (isQuestDetailsCurrentlyDisplayedFor(quest.key)) return + // AbstractQuestForm resolves its quest type by name from the registry + if (questTypeRegistry.getByName(quest.type.name) == null) return var checkResult: GigQuestCheckResult = GigQuestCheckResult.NotChecked if (quest is OsmQuest) { @@ -1785,6 +1789,9 @@ class MainActivity : questTypes.add(index to AddGenericLong(item, recencyPeriodInDays)) } questTypeRegistry.addItem(questTypes) + // the quest cache outlives this activity - quests cached under the previous long form + // would show stale pins whose quest type is no longer registered (form crashes on open) + visibleQuestsSource.clearAllCaches() allEditTypes.registries.clear() allEditTypes.registries.addAll(listOf(questTypeRegistry)) @@ -1796,8 +1803,6 @@ class MainActivity : allEditTypes.unknownTypeFallback = { name -> AddGenericLong(Elements(elementType = name), recencyPeriodInDays) } - - editHistoryViewModel.refreshForNewWorkspace() } private fun onClickImageryLayerButton() { diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/map/maplibre/OfflineManager.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/map/maplibre/OfflineManager.kt index a6e6333fa2b..318d41fcc1a 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/map/maplibre/OfflineManager.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/map/maplibre/OfflineManager.kt @@ -51,6 +51,19 @@ suspend fun OfflineManager.awaitResetDatabase(): Unit = suspendCoroutine { cont }) } +suspend fun OfflineRegion.awaitGetStatus(): OfflineRegionStatus = suspendCoroutine { cont -> + getStatus(object : OfflineRegion.OfflineRegionStatusCallback { + override fun onStatus(status: OfflineRegionStatus?) { + if (status != null) cont.resume(status) + else cont.resumeWithException(OfflineManagerException("No status")) + } + + override fun onError(error: String?) { + cont.resumeWithException(OfflineManagerException(error.orEmpty())) + } + }) +} + suspend fun OfflineRegion.awaitDelete(): Unit = suspendCoroutine { cont -> delete(object : OfflineRegion.OfflineRegionDeleteCallback { override fun onDelete() { diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/user/UserActivity.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/user/UserActivity.kt index 160b89e022c..c7951b0ae62 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/user/UserActivity.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/user/UserActivity.kt @@ -7,6 +7,7 @@ import androidx.compose.material3.Surface import androidx.core.app.ActivityCompat import com.russhwolf.settings.SettingsListener import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry import de.westnordost.streetcomplete.screens.BaseActivity import de.westnordost.streetcomplete.screens.settings.SettingsActivity import de.westnordost.streetcomplete.screens.settings.SettingsViewModel @@ -17,11 +18,20 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel +/** The registry is only filled once a workspace is loaded (it's emptied again whenever the + * workspace list opens) - before that, the quest forms screen could only ever show "Nothing + * found", so the entry is hidden (null) instead */ +internal fun showQuestFormsActionOrNull( + questTypeRegistry: QuestTypeRegistry, + showQuestForms: () -> Unit, +): (() -> Unit)? = if (questTypeRegistry.isNotEmpty()) showQuestForms else null + class UserActivity : BaseActivity() { private val viewModel by viewModel() private val settingsViewModel by viewModel() private val preferences: Preferences by inject() + private val questTypeRegistry: QuestTypeRegistry by inject() private val listeners = mutableListOf() @@ -35,7 +45,7 @@ class UserActivity : BaseActivity() { settingsViewModel, preferences, onClickBack = { finish() }, - onClickShowQuestForms = { + onClickShowQuestForms = showQuestFormsActionOrNull(questTypeRegistry) { startActivity(SettingsActivity.createLaunchShowQuestFormsIntent(this)) }, onBiometricEnabledChanged = ::onBiometricEnabledChanged diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/user/UserProfileScreen.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/user/UserProfileScreen.kt index 6f422e39b04..205e7c25169 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/user/UserProfileScreen.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/user/UserProfileScreen.kt @@ -95,7 +95,8 @@ fun UserProfileScreen( settingsViewModel: SettingsViewModel, preferences: Preferences, onClickBack: () -> Unit, - onClickShowQuestForms: () -> Unit, + /** null hides the entry - there are no quest forms to show until a workspace is loaded */ + onClickShowQuestForms: (() -> Unit)?, onBiometricEnabledChanged: KSuspendFunction1, ) { var isBiometricEnabled by remember { mutableStateOf(preferences.isBiometricEnabled) } @@ -299,11 +300,13 @@ fun UserProfileScreen( Text(stringResource(theme.title)) } - PreferenceCategory("Debug") { - Preference( - name = "Show Quest Forms", - onClick = onClickShowQuestForms - ) { NextScreenIcon() } + if (onClickShowQuestForms != null) { + PreferenceCategory("Debug") { + Preference( + name = "Show Quest Forms", + onClick = onClickShowQuestForms + ) { NextScreenIcon() } + } } // PreferenceRow( diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/workspaces/WorkSpaceActivity.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/workspaces/WorkSpaceActivity.kt index ebb557f4634..073bbf080f9 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/workspaces/WorkSpaceActivity.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/workspaces/WorkSpaceActivity.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -177,6 +178,7 @@ class WorkSpaceActivity : AppCompatActivity() { "$APP_NAME needs location to be enabled to fetch workspaces nearby", textAlign = TextAlign.Center, modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, style = TextStyle(fontSize = 20.sp) ) if (!isLocationEnabled.value) { diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/ktx/Context.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/ktx/Context.kt index a3acac5e39e..0cc888333d0 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/ktx/Context.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/ktx/Context.kt @@ -63,9 +63,24 @@ fun Context.sendEmail(to: String, subject: String, text: String? = null) { fun Context.sendErrorReportEmail(errorReport: String) = sendEmail( to = ApplicationConstants.ERROR_REPORTS_EMAIL, subject = ApplicationConstants.USER_AGENT + " " + "Error Report", - text = "Describe how to reproduce it here:\n\n\n\n$errorReport" + text = "Describe how to reproduce it here:\n\n\n\n${errorReport.truncatedForIntent()}" ) +// an intent extra must fit in the ~1MB binder transaction buffer (TransactionTooLargeException +// otherwise) - the attached log can be far larger. Keep the head (device info + stack trace) and +// the newest log lines. +internal const val MAX_REPORT_HEAD_LENGTH = 20_000 +internal const val MAX_REPORT_TAIL_LENGTH = 80_000 + +internal fun String.truncatedForIntent(): String = + if (length <= MAX_REPORT_HEAD_LENGTH + MAX_REPORT_TAIL_LENGTH) { + this + } else { + take(MAX_REPORT_HEAD_LENGTH) + + "\n\n[... ${length - MAX_REPORT_HEAD_LENGTH - MAX_REPORT_TAIL_LENGTH} characters omitted ...]\n\n" + + takeLast(MAX_REPORT_TAIL_LENGTH) + } + fun Context.openUri(uri: String): Boolean = try { startActivity(Intent(Intent.ACTION_VIEW, uri.toUri())) diff --git a/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/download/DownloaderTest.kt b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/download/DownloaderTest.kt new file mode 100644 index 00000000000..f318386dea8 --- /dev/null +++ b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/download/DownloaderTest.kt @@ -0,0 +1,126 @@ +package de.westnordost.streetcomplete.data.download + +import de.westnordost.streetcomplete.data.download.tiles.DownloadedTilesController +import de.westnordost.streetcomplete.data.download.tiles.DownloadedTilesDao +import de.westnordost.streetcomplete.data.download.tiles.TilePos +import de.westnordost.streetcomplete.data.maptiles.MapTilesDownloader +import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataDownloader +import de.westnordost.streetcomplete.testutils.any +import de.westnordost.streetcomplete.testutils.mock +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse + +class DownloaderTest { + + private lateinit var dao: DownloadedTilesDao + private lateinit var downloadedTiles: DownloadedTilesController + private lateinit var listener: DownloadProgressSource.Listener + /** runs while the (fake) map tiles download is in progress */ + private var duringDownload: suspend () -> Unit = {} + private lateinit var downloader: Downloader + + private val bbox = BoundingBox(47.6540, -122.3050, 47.6560, -122.3020) + + @BeforeTest fun setUp() { + dao = mock() + downloadedTiles = DownloadedTilesController(dao) + listener = mock() + val mapTilesDownloader = object : MapTilesDownloader { + override suspend fun download(bbox: BoundingBox) { duringDownload() } + override suspend fun clear() {} + } + downloader = Downloader(mock(), mock(), mapTilesDownloader, downloadedTiles, mock(), Mutex()) + downloader.addListener(listener) + } + + @Test fun `marks tiles as downloaded`() { + runBlocking { downloader.download(bbox, false) } + + verify(dao).put(any()) + verify(listener).onSuccess() + } + + @Test fun `does not mark tiles as downloaded when they were invalidated during the download`() { + // e.g. the workspace was switched while an auto-download of the previous one was running + duringDownload = { downloadedTiles.invalidateAll() } + + runBlocking { downloader.download(bbox, false) } + + verify(dao, never()).put(any()) + // so that the auto-syncer checks again - and finds the tiles not downloaded + verify(listener).onSuccess() + } + + @Test fun `does not mark tiles as downloaded when they were cleared during the download`() { + duringDownload = { downloadedTiles.clear() } + + runBlocking { downloader.download(bbox, false) } + + verify(dao, never()).put(any()) + } + + @Test fun `an invalidation before the download does not keep it from marking its tiles`() { + downloadedTiles.invalidateAll() + + runBlocking { downloader.download(bbox, false) } + + verify(dao).put(any()) + } + + @Test fun `a single invalidated tile does not keep a running download from marking its tiles`() { + // e.g. the uploader invalidates the tile of an element it found changed - the rest of the + // downloaded area is still good + duringDownload = { downloadedTiles.invalidate(TilePos(0, 0)) } + + runBlocking { downloader.download(bbox, false) } + + verify(dao).put(any()) + } + + @Test fun `a hanging download is cancelled when all tiles are invalidated, and reports success`() { + // e.g. the tile server is unreachable when the workspace is switched + duringDownload = { awaitCancellation() } + + runBlocking { + val download = launch { downloader.download(bbox, false) } + while (!downloader.isDownloadInProgress) yield() + yield() // let it get to the (hanging) map tiles download + + downloadedTiles.invalidateAll() + + withTimeout(5_000) { download.join() } + } + + assertFalse(downloader.isDownloadInProgress) + verify(dao, never()).put(any()) + // so that the auto-syncer checks again and starts a fresh download + verify(listener).onSuccess() + } + + @Test fun `a download cancelled from outside is not reported as success`() { + // e.g. the user cancelled it from the notification + duringDownload = { awaitCancellation() } + + runBlocking { + val download = launch { downloader.download(bbox, false) } + while (!downloader.isDownloadInProgress) yield() + yield() + + download.cancel() + withTimeout(5_000) { download.join() } + } + + assertFalse(downloader.isDownloadInProgress) + verify(listener, never()).onSuccess() + } +} diff --git a/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/download/tiles/DownloadedTilesControllerTest.kt b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/download/tiles/DownloadedTilesControllerTest.kt index 3d48286bc0a..39b514baa45 100644 --- a/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/download/tiles/DownloadedTilesControllerTest.kt +++ b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/download/tiles/DownloadedTilesControllerTest.kt @@ -55,6 +55,19 @@ class DownloadedTilesControllerTest { verify(listener).onUpdated() } + @Test fun `invalidating all or clearing increases invalidationCount`() { + ctrl.invalidateAll() + ctrl.clear() + assertEquals(2, ctrl.invalidationCount) + } + + @Test fun `put, invalidating a single tile and deleteOlderThan do not change invalidationCount`() { + ctrl.put(TilesRect(0, 0, 1, 1)) + ctrl.invalidate(TilePos(0, 0)) + ctrl.deleteOlderThan(123L) + assertEquals(0, ctrl.invalidationCount) + } + @Test fun getAll() { val r = listOf(TilePos(0, 1)) on(dao.getAll(123)).thenReturn(r) diff --git a/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/quest/VisibleQuestsSourceTest.kt b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/quest/VisibleQuestsSourceTest.kt index 8532c880db0..37fc48a0d01 100644 --- a/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/quest/VisibleQuestsSourceTest.kt +++ b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/quest/VisibleQuestsSourceTest.kt @@ -27,6 +27,7 @@ import org.mockito.Mockito.verifyNoInteractions import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue class VisibleQuestsSourceTest { @@ -155,6 +156,28 @@ class VisibleQuestsSourceTest { assertEquals(1, quests.size) } + // regression: the workspace's long form changed (element type "Sidewalk" -> "Sidewalks") while + // its quests were cached - the stale quest's form crashed looking its type up in the registry + @Test fun `clearAllCaches drops cached quests whose type is no longer registered`() { + val bboxCacheWillRequest = bbox.asBoundingBoxOfEnclosingTiles(16) + val staleQuest = OsmQuest(TestQuestTypeA(), ElementType.NODE, 1L, pGeom()) + on(osmQuestSource.getAllInBBox(bboxCacheWillRequest, questTypeNames)).thenReturn(listOf(staleQuest)) + on(questsHiddenSource.get(any())).thenReturn(null) + assertEquals(listOf(staleQuest), source.getAll(bbox)) + + val newQuestType = TestQuestTypeD() + val newQuest = OsmQuest(newQuestType, ElementType.NODE, 1L, pGeom()) + questTypeRegistry.addItem(listOf(0 to newQuestType)) + on(osmQuestSource.getAllInBBox(bboxCacheWillRequest, listOf(newQuestType.name))).thenReturn(listOf(newQuest)) + // the cache alone doesn't know the registry changed + assertEquals(staleQuest, source.get(staleQuest.key)) + + source.clearAllCaches() + + assertNull(source.get(staleQuest.key)) + assertEquals(listOf(newQuest), source.getAll(bbox)) + } + @Test fun `osm quests added or removed triggers listener`() { val quests = listOf(osmQuest(elementId = 1), osmQuest(elementId = 2)) val deleted = listOf(osmQuestKey(elementId = 3), osmQuestKey(elementId = 4)) diff --git a/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/screens/user/ShowQuestFormsActionTest.kt b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/screens/user/ShowQuestFormsActionTest.kt new file mode 100644 index 00000000000..4342d519564 --- /dev/null +++ b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/screens/user/ShowQuestFormsActionTest.kt @@ -0,0 +1,39 @@ +package de.westnordost.streetcomplete.screens.user + +import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry +import de.westnordost.streetcomplete.data.quest.TestQuestTypeA +import de.westnordost.streetcomplete.data.quest.TestQuestTypeB +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ShowQuestFormsActionTest { + + @Test fun `hidden before any workspace is loaded`() { + val registry = QuestTypeRegistry(emptyList()) + + assertNull(showQuestFormsActionOrNull(registry) {}) + } + + @Test fun `shown once a workspace has loaded its quest types`() { + val registry = QuestTypeRegistry(emptyList()) + // what MainActivity does when a workspace loads + registry.addItem(listOf(0 to TestQuestTypeA(), 1 to TestQuestTypeB())) + + var launched = 0 + val action = showQuestFormsActionOrNull(registry) { launched++ } + + assertNotNull(action) + action() + assertEquals(1, launched) + } + + @Test fun `hidden again after going back to the workspace list`() { + val registry = QuestTypeRegistry(listOf(0 to TestQuestTypeA())) + // what WorkSpaceActivity does when the workspace list opens + registry.addItem(emptyList()) + + assertNull(showQuestFormsActionOrNull(registry) {}) + } +} diff --git a/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/util/ktx/ErrorReportTruncationTest.kt b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/util/ktx/ErrorReportTruncationTest.kt new file mode 100644 index 00000000000..c20b857bdaa --- /dev/null +++ b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/util/ktx/ErrorReportTruncationTest.kt @@ -0,0 +1,35 @@ +package de.westnordost.streetcomplete.util.ktx + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +// regression: "send crash report" crashed with TransactionTooLargeException (3.9MB report, mostly +// the attached log) - the report must be cut down to fit in an intent extra +class ErrorReportTruncationTest { + + @Test fun `short report is sent unchanged`() { + val report = "Stack trace:\nNullPointerException\nLog:\nsomething" + assertEquals(report, report.truncatedForIntent()) + } + + @Test fun `report of exactly the maximum length is sent unchanged`() { + val report = "x".repeat(MAX_REPORT_HEAD_LENGTH + MAX_REPORT_TAIL_LENGTH) + assertEquals(report, report.truncatedForIntent()) + } + + @Test fun `huge report keeps stack trace and newest log lines`() { + val head = "Stack trace:\njava.lang.NullPointerException\n\tat AbstractQuestForm.onCreate\nLog:\n" + val newestLog = "\nnewest log line" + val report = head + "old log line\n".repeat(300_000) + newestLog // ~3.9M chars + + val truncated = report.truncatedForIntent() + + val omitted = report.length - MAX_REPORT_HEAD_LENGTH - MAX_REPORT_TAIL_LENGTH + assertTrue(truncated.startsWith(head)) + assertTrue(truncated.endsWith(newestLog)) + assertTrue(truncated.contains("[... $omitted characters omitted ...]")) + // well within the ~1MB binder buffer (strings are parcelled as UTF-16) + assertTrue(truncated.length * 2 < 500_000) + } +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/download/Downloader.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/download/Downloader.kt index a78cfb2319e..b29bc392d6d 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/download/Downloader.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/download/Downloader.kt @@ -3,6 +3,7 @@ package de.westnordost.streetcomplete.data.download import de.westnordost.streetcomplete.ApplicationConstants import de.westnordost.streetcomplete.data.AuthorizationException import de.westnordost.streetcomplete.data.download.tiles.DownloadedTilesController +import de.westnordost.streetcomplete.data.download.tiles.DownloadedTilesSource import de.westnordost.streetcomplete.data.download.tiles.TilesRect import de.westnordost.streetcomplete.data.download.tiles.enclosingTilesRect import de.westnordost.streetcomplete.data.maptiles.MapTilesDownloader @@ -15,10 +16,14 @@ import de.westnordost.streetcomplete.util.ktx.format import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds import de.westnordost.streetcomplete.util.logs.Log import de.westnordost.streetcomplete.util.math.area +import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlin.concurrent.Volatile import kotlin.coroutines.cancellation.CancellationException import kotlin.math.max @@ -40,6 +45,25 @@ class Downloader( override var isDownloadInProgress: Boolean = false private set + /** The running download and DownloadedTilesController.invalidationCount when it started. */ + @Volatile private var runningDownload: Job? = null + @Volatile private var invalidationCountAtStart = 0 + + // all tiles invalidated while downloading (e.g. workspace switched): the running download is + // outdated - cancel it rather than wait for it, a hanging map tiles download (tile server + // unreachable) would otherwise keep the next workspace from downloading anything + private val downloadedTilesListener = object : DownloadedTilesSource.Listener { + override fun onUpdated() { + if (downloadedTilesController.invalidationCount != invalidationCountAtStart) { + runningDownload?.cancel() + } + } + } + + init { + downloadedTilesController.addListener(downloadedTilesListener) + } + suspend fun download(bbox: BoundingBox, isUserInitiated: Boolean) { var hasError = false try { @@ -64,8 +88,11 @@ class Downloader( Log.i(TAG, "Starting download ($sqkm km², bbox: $bboxString)") val time = nowAsEpochMilliseconds() + val invalidationCount = downloadedTilesController.invalidationCount + invalidationCountAtStart = invalidationCount coroutineScope { + runningDownload = coroutineContext[Job] // all downloaders run concurrently. Only the map data download holds the lock // shared with the uploader (both write OSM data) - map tiles don't touch it, and // MapLibre's tile download waits for connectivity rather than failing, so with the @@ -74,13 +101,25 @@ class Downloader( launch { mutex.withLock { mapDataDownloader.download(tilesBbox) } } launch { mapTilesDownloader.download(tilesBbox) } } - putDownloadedAlready(tiles) + // invalidated while downloading (e.g. workspace switched): the next auto-download + // (triggered by onSuccess below) has to fetch it again instead of skipping it as fresh + if (downloadedTilesController.invalidationCount == invalidationCount) { + putDownloadedAlready(tiles) + } else { + Log.i(TAG, "Downloaded tiles were invalidated during the download, not marking them as downloaded") + } val seconds = (nowAsEpochMilliseconds() - time) / 1000.0 Log.i(TAG, "Finished download ($sqkm km², bbox: $bboxString) in ${seconds.format(1)}s") } catch (e: CancellationException) { - hasError = true - Log.i(TAG, "Download cancelled") + if (currentCoroutineContext().isActive) { + // only the download itself was cancelled, as outdated (see downloadedTilesListener) + // - not an error: onSuccess below lets the auto-download start a fresh one + Log.i(TAG, "Download cancelled, tiles were invalidated during the download") + } else { + hasError = true + Log.i(TAG, "Download cancelled") + } } catch (e: Exception) { hasError = true Log.e(TAG, "Unable to download", e) @@ -90,6 +129,7 @@ class Downloader( listeners.forEach { it.onError(e) } throw e } finally { + runningDownload = null isDownloadInProgress = false isUserInitiatedDownloadInProgress = false listeners.forEach { it.onFinished() } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/download/tiles/DownloadedTilesController.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/download/tiles/DownloadedTilesController.kt index 2ebda607f45..b10cde96a46 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/download/tiles/DownloadedTilesController.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/download/tiles/DownloadedTilesController.kt @@ -3,6 +3,7 @@ package de.westnordost.streetcomplete.data.download.tiles import de.westnordost.streetcomplete.ApplicationConstants import de.westnordost.streetcomplete.util.Listeners import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds +import kotlin.concurrent.Volatile class DownloadedTilesController( private val dao: DownloadedTilesDao @@ -10,6 +11,13 @@ class DownloadedTilesController( private val listeners = Listeners() + /** Goes up whenever all tiles are invalidated or cleared (workspace switched, new app version + * etc.) - a download that was already running when this changed fetches data that is no + * longer wanted, see [Downloader]. Not for a single invalidated tile (after an upload), for + * which the rest of a running download is still good. */ + @Volatile var invalidationCount: Int = 0 + private set + override fun contains(tilesRect: TilesRect, ignoreOlderThan: Long): Boolean = dao.contains(tilesRect, ignoreOlderThan) @@ -22,6 +30,7 @@ class DownloadedTilesController( } fun clear() { + invalidationCount++ dao.deleteAll() onUpdated() } @@ -32,6 +41,7 @@ class DownloadedTilesController( } fun invalidateAll() { + invalidationCount++ dao.updateAllTimesNewerThan(getOldTime()) onUpdated() } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/VisibleQuestsSource.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/VisibleQuestsSource.kt index f67c9b43249..467c7152f09 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/VisibleQuestsSource.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/VisibleQuestsSource.kt @@ -203,6 +203,10 @@ class VisibleQuestsSource( fun clearCache() = cache.clear() + /** Cached quests hold the quest type instances they were created with - drop every + * workspace's cache when the quest type registry is rebuilt (see MainActivity.doLongForm) */ + fun clearAllCaches() = lock.withLock { workSpaceCache.clear() } + fun trimCache() = cache.trim(SPATIAL_CACHE_TILES / 3) private fun updateVisibleQuests(