Implement Phase 1: project skeleton and data layer

Android project with Kotlin, Jetpack Compose, and Room. Includes:
- Gradle build system with version catalog, foojay JDK resolver, lint config
- Room entities (Notebook, Page, Stroke) with packed float BLOB encoding
- DAOs and repositories for all entities
- Unit tests for blob roundtrip and PageSize enum (10 tests, all passing)
- Minimal Application class and stub MainActivity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 14:03:57 -07:00
parent 47778222b7
commit 0b53023a25
35 changed files with 1090 additions and 14 deletions

78
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,78 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
}
android {
namespace = "net.metacircular.engpad"
compileSdk = 36
defaultConfig {
applicationId = "net.metacircular.engpad"
minSdk = 30
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
lint {
warningsAsErrors = true
abortOnError = true
checkDependencies = true
// AGP 9.x requires Gradle 9.x; suppress until we're ready to migrate
disable += "AndroidGradlePluginVersion"
}
}
kotlin {
jvmToolchain(17)
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.navigation.compose)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
implementation(libs.coroutines.android)
testImplementation(libs.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.room.testing)
}

3
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,3 @@
# eng-pad ProGuard rules
# Room
-keep class net.metacircular.engpad.data.model.** { *; }

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".EngPadApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.EngPad">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.EngPad">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,8 @@
package net.metacircular.engpad
import android.app.Application
import net.metacircular.engpad.data.db.EngPadDatabase
class EngPadApp : Application() {
val database: EngPadDatabase by lazy { EngPadDatabase.getInstance(this) }
}

View File

@@ -0,0 +1,15 @@
package net.metacircular.engpad
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.Text
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Text("eng-pad")
}
}
}

View File

@@ -0,0 +1,22 @@
package net.metacircular.engpad.data.db
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Encode a FloatArray as a packed little-endian byte array.
* Points are stored as [x0, y0, x1, y1, ...].
*/
fun FloatArray.toBlob(): ByteArray {
val buffer = ByteBuffer.allocate(size * 4).order(ByteOrder.LITTLE_ENDIAN)
for (f in this) buffer.putFloat(f)
return buffer.array()
}
/**
* Decode a packed little-endian byte array back to a FloatArray.
*/
fun ByteArray.toFloatArray(): FloatArray {
val buffer = ByteBuffer.wrap(this).order(ByteOrder.LITTLE_ENDIAN)
return FloatArray(size / 4) { buffer.getFloat() }
}

View File

@@ -0,0 +1,34 @@
package net.metacircular.engpad.data.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import net.metacircular.engpad.data.model.Notebook
import net.metacircular.engpad.data.model.Page
import net.metacircular.engpad.data.model.Stroke
@Database(
entities = [Notebook::class, Page::class, Stroke::class],
version = 1,
exportSchema = false,
)
abstract class EngPadDatabase : RoomDatabase() {
abstract fun notebookDao(): NotebookDao
abstract fun pageDao(): PageDao
abstract fun strokeDao(): StrokeDao
companion object {
@Volatile
private var instance: EngPadDatabase? = null
fun getInstance(context: Context): EngPadDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
EngPadDatabase::class.java,
"engpad.db",
).build().also { instance = it }
}
}
}

View File

@@ -0,0 +1,26 @@
package net.metacircular.engpad.data.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
import net.metacircular.engpad.data.model.Notebook
@Dao
interface NotebookDao {
@Insert
suspend fun insert(notebook: Notebook): Long
@Update
suspend fun update(notebook: Notebook)
@Query("SELECT * FROM notebooks ORDER BY updated_at DESC")
fun getAll(): Flow<List<Notebook>>
@Query("SELECT * FROM notebooks WHERE id = :id")
suspend fun getById(id: Long): Notebook?
@Query("DELETE FROM notebooks WHERE id = :id")
suspend fun deleteById(id: Long)
}

View File

@@ -0,0 +1,25 @@
package net.metacircular.engpad.data.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import net.metacircular.engpad.data.model.Page
@Dao
interface PageDao {
@Insert
suspend fun insert(page: Page): Long
@Query("SELECT * FROM pages WHERE notebook_id = :notebookId ORDER BY page_number ASC")
fun getByNotebookId(notebookId: Long): Flow<List<Page>>
@Query("SELECT * FROM pages WHERE id = :id")
suspend fun getById(id: Long): Page?
@Query("SELECT MAX(page_number) FROM pages WHERE notebook_id = :notebookId")
suspend fun getMaxPageNumber(notebookId: Long): Int?
@Query("DELETE FROM pages WHERE id = :id")
suspend fun deleteById(id: Long)
}

View File

@@ -0,0 +1,30 @@
package net.metacircular.engpad.data.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import net.metacircular.engpad.data.model.Stroke
@Dao
interface StrokeDao {
@Insert
suspend fun insert(stroke: Stroke): Long
@Insert
suspend fun insertAll(strokes: List<Stroke>)
@Query("SELECT * FROM strokes WHERE page_id = :pageId ORDER BY stroke_order ASC")
suspend fun getByPageId(pageId: Long): List<Stroke>
@Query("DELETE FROM strokes WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("DELETE FROM strokes WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<Long>)
@Query("SELECT MAX(stroke_order) FROM strokes WHERE page_id = :pageId")
suspend fun getMaxStrokeOrder(pageId: Long): Int?
@Query("UPDATE strokes SET point_data = :pointData WHERE id = :id")
suspend fun updatePointData(id: Long, pointData: ByteArray)
}

View File

@@ -0,0 +1,14 @@
package net.metacircular.engpad.data.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "notebooks")
data class Notebook(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
@ColumnInfo(name = "page_size") val pageSize: String,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "updated_at") val updatedAt: Long,
)

View File

@@ -0,0 +1,29 @@
package net.metacircular.engpad.data.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "pages",
foreignKeys = [
ForeignKey(
entity = Notebook::class,
parentColumns = ["id"],
childColumns = ["notebook_id"],
onDelete = ForeignKey.CASCADE,
)
],
indices = [
Index(value = ["notebook_id"]),
Index(value = ["notebook_id", "page_number"], unique = true),
],
)
data class Page(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "notebook_id") val notebookId: Long,
@ColumnInfo(name = "page_number") val pageNumber: Int,
@ColumnInfo(name = "created_at") val createdAt: Long,
)

View File

@@ -0,0 +1,17 @@
package net.metacircular.engpad.data.model
/**
* Page sizes in canonical coordinates (300 DPI).
*/
enum class PageSize(val widthPt: Int, val heightPt: Int) {
/** 8.5 × 11 inches */
REGULAR(2550, 3300),
/** 11 × 17 inches */
LARGE(3300, 5100);
companion object {
fun fromString(value: String): PageSize =
entries.first { it.name.equals(value, ignoreCase = true) }
}
}

View File

@@ -0,0 +1,52 @@
package net.metacircular.engpad.data.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "strokes",
foreignKeys = [
ForeignKey(
entity = Page::class,
parentColumns = ["id"],
childColumns = ["page_id"],
onDelete = ForeignKey.CASCADE,
)
],
indices = [Index(value = ["page_id"])],
)
data class Stroke(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "page_id") val pageId: Long,
@ColumnInfo(name = "pen_size") val penSize: Float,
val color: Int,
@ColumnInfo(name = "point_data") val pointData: ByteArray,
@ColumnInfo(name = "stroke_order") val strokeOrder: Int,
@ColumnInfo(name = "created_at") val createdAt: Long,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Stroke) return false
return id == other.id &&
pageId == other.pageId &&
penSize == other.penSize &&
color == other.color &&
pointData.contentEquals(other.pointData) &&
strokeOrder == other.strokeOrder &&
createdAt == other.createdAt
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + pageId.hashCode()
result = 31 * result + penSize.hashCode()
result = 31 * result + color
result = 31 * result + pointData.contentHashCode()
result = 31 * result + strokeOrder
result = 31 * result + createdAt.hashCode()
return result
}
}

View File

@@ -0,0 +1,48 @@
package net.metacircular.engpad.data.repository
import kotlinx.coroutines.flow.Flow
import net.metacircular.engpad.data.db.NotebookDao
import net.metacircular.engpad.data.db.PageDao
import net.metacircular.engpad.data.model.Notebook
import net.metacircular.engpad.data.model.Page
class NotebookRepository(
private val notebookDao: NotebookDao,
private val pageDao: PageDao,
) {
fun getAll(): Flow<List<Notebook>> = notebookDao.getAll()
suspend fun getById(id: Long): Notebook? = notebookDao.getById(id)
/**
* Create a notebook and its first page. Returns the notebook ID.
*/
suspend fun create(title: String, pageSize: String): Long {
val now = System.currentTimeMillis()
val notebookId = notebookDao.insert(
Notebook(
title = title,
pageSize = pageSize,
createdAt = now,
updatedAt = now,
)
)
pageDao.insert(
Page(
notebookId = notebookId,
pageNumber = 1,
createdAt = now,
)
)
return notebookId
}
suspend fun delete(id: Long) = notebookDao.deleteById(id)
suspend fun updateTitle(id: Long, title: String) {
val notebook = notebookDao.getById(id) ?: return
notebookDao.update(
notebook.copy(title = title, updatedAt = System.currentTimeMillis())
)
}
}

View File

@@ -0,0 +1,50 @@
package net.metacircular.engpad.data.repository
import kotlinx.coroutines.flow.Flow
import net.metacircular.engpad.data.db.PageDao
import net.metacircular.engpad.data.db.StrokeDao
import net.metacircular.engpad.data.model.Page
import net.metacircular.engpad.data.model.Stroke
class PageRepository(
private val pageDao: PageDao,
private val strokeDao: StrokeDao,
) {
fun getPages(notebookId: Long): Flow<List<Page>> =
pageDao.getByNotebookId(notebookId)
suspend fun getById(id: Long): Page? = pageDao.getById(id)
/**
* Add a new page to a notebook. Returns the page ID.
*/
suspend fun addPage(notebookId: Long): Long {
val maxPage = pageDao.getMaxPageNumber(notebookId) ?: 0
return pageDao.insert(
Page(
notebookId = notebookId,
pageNumber = maxPage + 1,
createdAt = System.currentTimeMillis(),
)
)
}
suspend fun deletePage(id: Long) = pageDao.deleteById(id)
suspend fun getStrokes(pageId: Long): List<Stroke> =
strokeDao.getByPageId(pageId)
suspend fun addStroke(stroke: Stroke): Long = strokeDao.insert(stroke)
suspend fun deleteStroke(id: Long) = strokeDao.deleteById(id)
suspend fun deleteStrokes(ids: List<Long>) = strokeDao.deleteByIds(ids)
suspend fun insertStrokes(strokes: List<Stroke>) = strokeDao.insertAll(strokes)
suspend fun getNextStrokeOrder(pageId: Long): Int =
(strokeDao.getMaxStrokeOrder(pageId) ?: 0) + 1
suspend fun updateStrokePoints(id: Long, pointData: ByteArray) =
strokeDao.updatePointData(id, pointData)
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M0,0h108v108H0z" />
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Simple pen icon -->
<path
android:fillColor="#000000"
android:pathData="M54,24 L62,72 L54,80 L46,72 Z" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">eng-pad</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.EngPad" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="exports" path="exports/" />
</paths>

View File

@@ -0,0 +1,33 @@
package net.metacircular.engpad.data
import net.metacircular.engpad.data.model.PageSize
import org.junit.Assert.assertEquals
import org.junit.Test
class PageSizeTest {
@Test
fun `regular page dimensions at 300 DPI`() {
assertEquals(2550, PageSize.REGULAR.widthPt)
assertEquals(3300, PageSize.REGULAR.heightPt)
}
@Test
fun `large page dimensions at 300 DPI`() {
assertEquals(3300, PageSize.LARGE.widthPt)
assertEquals(5100, PageSize.LARGE.heightPt)
}
@Test
fun `fromString case insensitive`() {
assertEquals(PageSize.REGULAR, PageSize.fromString("regular"))
assertEquals(PageSize.REGULAR, PageSize.fromString("REGULAR"))
assertEquals(PageSize.LARGE, PageSize.fromString("large"))
assertEquals(PageSize.LARGE, PageSize.fromString("Large"))
}
@Test(expected = NoSuchElementException::class)
fun `fromString invalid value throws`() {
PageSize.fromString("unknown")
}
}

View File

@@ -0,0 +1,63 @@
package net.metacircular.engpad.data
import net.metacircular.engpad.data.db.toBlob
import net.metacircular.engpad.data.db.toFloatArray
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Test
class StrokeBlobTest {
@Test
fun `roundtrip simple points`() {
val points = floatArrayOf(100f, 200f, 300f, 400f, 500f, 600f)
val blob = points.toBlob()
val decoded = blob.toFloatArray()
assertArrayEquals(points, decoded, 0f)
}
@Test
fun `roundtrip single point`() {
val points = floatArrayOf(1.08f, 2550.0f)
val blob = points.toBlob()
val decoded = blob.toFloatArray()
assertArrayEquals(points, decoded, 0f)
}
@Test
fun `roundtrip empty array`() {
val points = floatArrayOf()
val blob = points.toBlob()
val decoded = blob.toFloatArray()
assertEquals(0, decoded.size)
}
@Test
fun `blob size is 4 bytes per float`() {
val points = floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f)
val blob = points.toBlob()
assertEquals(points.size * 4, blob.size)
}
@Test
fun `roundtrip fractional coordinates`() {
val points = floatArrayOf(
14.4f, 28.8f, // grid intersection
4.488f, 5.906f, // pen width values
2550f, 3300f, // regular page corner
3300f, 5100f, // large page corner
)
val blob = points.toBlob()
val decoded = blob.toFloatArray()
assertArrayEquals(points, decoded, 0f)
}
@Test
fun `roundtrip negative coordinates`() {
// Negative values shouldn't appear in practice but encoding must handle them
val points = floatArrayOf(-1f, -2f, 0f, 0f)
val blob = points.toBlob()
val decoded = blob.toFloatArray()
assertArrayEquals(points, decoded, 0f)
}
}