Skip to content
Draft
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
3 changes: 2 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ jobs:
arch: x86_64
target: google_apis
disable-animations: true
script: ./gradlew connectedDebugAndroidTest
# Store screenshots are generated by screenshots.yml, not run as tests here.
script: ./gradlew connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.notPackage=com.circuitqueest.app.screenshots

- name: Upload instrumented test report
if: always()
Expand Down
58 changes: 58 additions & 0 deletions .github/workflows/screenshots.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: Store Screenshots

# Regenerates Google Play screenshots on a 1080x1920 (16:9) emulator.
# Runs on demand, or on PRs that touch the screenshot test or store assets.
on:
workflow_dispatch:
pull_request:
branches: [ master ]
paths:
- 'app/src/androidTest/java/com/circuitqueest/app/screenshots/**'
- 'app/src/main/java/com/circuitqueest/app/ui/**'
- 'play-store/**'
- '.github/workflows/screenshots.yml'

jobs:
screenshots:
runs-on: ubuntu-latest
timeout-minutes: 45

steps:
- uses: actions/checkout@v4

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: gradle

- name: Grant execute permission for gradlew
run: chmod +x gradlew

- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

- name: Capture screenshots on emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
arch: x86_64
target: google_apis
profile: Nexus 5X
disable-animations: true
script: |
./gradlew installDebug installDebugAndroidTest
adb shell am instrument -w -e package com.circuitqueest.app.screenshots com.circuitqueest.app.test/androidx.test.runner.AndroidJUnitRunner
mkdir -p screenshots
adb pull /sdcard/Android/data/com.circuitqueest.app/files/screenshots/. screenshots/

- name: Upload screenshots
uses: actions/upload-artifact@v4
with:
name: store-screenshots
path: screenshots/
retention-days: 14
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ CircuitQueest is an offline-first Android app that teaches electrical engineerin
- **Quest progression** — pass a topic's quiz (60%+) to unlock the next, in the order shown on the quest map; no internet required
- **XP system** — +50 XP per lesson, 10 XP per point above your best quiz score, +100 bonus on your first pass
- **Dual question types** — multiple choice and numeric input with tolerance-aware grading
- **Review mode** — every missed question goes into a review queue (⟲ chip on the quest map, "Review mistakes" after a quiz); replay until you get them right
- **Blueprint mode** — toggleable alternate palette (deep navy + white-28% borders)
- **Shared-element transitions** — topic cards morph into the lesson hero
- **Offline-first** — all content and progress in a local Room database; no account needed
Expand Down Expand Up @@ -100,12 +101,12 @@ CircuitQueest is an offline-first Android app that teaches electrical engineerin
| Navigation | Navigation Compose with shared-element transitions |
| Storage | Room 2.6.1 (offline-first, no backend) |
| Fonts | Space Grotesk + JetBrains Mono (bundled, fully offline) |
| Min SDK | 26 (Android 8.0) · Target SDK 35 |
| Build | Gradle 8.11.1 · AGP 8.7.3 · KSP |
| Min SDK | 26 (Android 8.0) · Target SDK 36 (Android 16) |
| Build | Gradle 8.13 · AGP 8.13.2 · KSP |

## Quick Start

**Prerequisites:** JDK 17, Android SDK (platform 35)
**Prerequisites:** JDK 17, Android SDK (platform 36, build-tools 36)

```bash
git clone https://github.com/HighviewOne/CircuitQueest.git
Expand Down
8 changes: 8 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
# Unreleased

### New
- **Review mode.** Every question you get wrong goes into a review queue. Replay them from the ⟲ chip on the quest map or "Review N mistakes" after a quiz; a correct answer clears the question. Reviews award no XP, so they can't be farmed.

### Platform
- **Targets Android 16 (API 36)**, as Google Play requires since August 31, 2026. AGP 8.13.2, Gradle 8.13.
- Database version 2 (adds the review queue) via a tested Room auto-migration — existing progress is kept.

### Engineering
- **Google Play launch kit** in `play-store/`: bundle signing script, listing text, icon, feature graphic, emulator-generated screenshots, and a step-by-step console guide.
- **Release signing key.** Releases are now signed with a dedicated release key instead of the Android debug key, using APK Signature Scheme v3.1 key rotation so the next version still installs as an update over v2.4 and earlier (no uninstall, progress kept). See `docs/DEPLOYMENT.md` → Code Signing.

---
Expand Down
9 changes: 7 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ plugins {

android {
namespace = "com.circuitqueest.app"
compileSdk = 35
compileSdk = 36

defaultConfig {
applicationId = "com.circuitqueest.app"
minSdk = 26
targetSdk = 35
targetSdk = 36
versionCode = 24
versionName = "2.4"

Expand Down Expand Up @@ -53,6 +53,11 @@ android {
buildFeatures {
compose = true
}

// MigrationTestHelper reads the exported Room schemas as test assets.
sourceSets {
getByName("androidTest").assets.srcDir("$projectDir/schemas")
}
}

dependencies {
Expand Down
147 changes: 147 additions & 0 deletions app/schemas/com.circuitqueest.app.data.db.AppDatabase/2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "8b22e44063a5d7932a919876b8ebf0b3",
"entities": [
{
"tableName": "topic_progress",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`topicId` TEXT NOT NULL, `lessonCompleted` INTEGER NOT NULL, `quizCompleted` INTEGER NOT NULL, `bestScore` INTEGER NOT NULL, `totalQuestions` INTEGER NOT NULL, `xpEarned` INTEGER NOT NULL, `lastAccessedTimestamp` INTEGER NOT NULL, PRIMARY KEY(`topicId`))",
"fields": [
{
"fieldPath": "topicId",
"columnName": "topicId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lessonCompleted",
"columnName": "lessonCompleted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "quizCompleted",
"columnName": "quizCompleted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bestScore",
"columnName": "bestScore",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "totalQuestions",
"columnName": "totalQuestions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "xpEarned",
"columnName": "xpEarned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastAccessedTimestamp",
"columnName": "lastAccessedTimestamp",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"topicId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "quiz_results",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `topicId` TEXT NOT NULL, `score` INTEGER NOT NULL, `totalQuestions` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "topicId",
"columnName": "topicId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "score",
"columnName": "score",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "totalQuestions",
"columnName": "totalQuestions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "missed_questions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`topicId` TEXT NOT NULL, `questionId` TEXT NOT NULL, `missedAt` INTEGER NOT NULL, PRIMARY KEY(`topicId`, `questionId`))",
"fields": [
{
"fieldPath": "topicId",
"columnName": "topicId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "questionId",
"columnName": "questionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "missedAt",
"columnName": "missedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"topicId",
"questionId"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8b22e44063a5d7932a919876b8ebf0b3')"
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.circuitqueest.app.data.db

import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.circuitqueest.app.data.db.entity.MissedQuestion
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue

/**
* Every installed copy of v2.4 has a version-1 database. If a migration is wrong,
* Room throws on launch for all of them, so each schema step is tested here.
*/
@RunWith(AndroidJUnit4::class)
class MigrationTest {

private val dbName = "migration-test"

@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java,
emptyList(),
FrameworkSQLiteOpenHelperFactory()
)

@Test
fun migrate1To2_keepsProgress_andAddsReviewQueue(): Unit = runBlocking {
helper.createDatabase(dbName, 1).apply {
execSQL(
"INSERT INTO topic_progress (topicId, lessonCompleted, quizCompleted, bestScore, " +
"totalQuestions, xpEarned, lastAccessedTimestamp) " +
"VALUES ('ohms_law', 1, 1, 7, 7, 220, 123)"
)
execSQL(
"INSERT INTO quiz_results (topicId, score, totalQuestions, timestamp) " +
"VALUES ('ohms_law', 7, 7, 456)"
)
close()
}

// Validates the migrated schema against app/schemas/.../2.json.
helper.runMigrationsAndValidate(dbName, 2, true).close()

val context = InstrumentationRegistry.getInstrumentation().targetContext
val db = Room.databaseBuilder(context, AppDatabase::class.java, dbName).build()
try {
val progress = db.progressDao().getProgressOnce("ohms_law")
assertNotNull(progress)
assertEquals(220, progress.xpEarned)
assertTrue(progress.quizCompleted)
assertEquals(1, db.quizResultDao().getResultsForTopic("ohms_law").first().size)

db.missedQuestionDao().upsert(MissedQuestion("ohms_law", "ohm_q2"))
assertEquals(1, db.missedQuestionDao().count().first())
} finally {
db.close()
context.deleteDatabase(dbName)
}
}
}
Loading
Loading