Inkfold
A pen-first note-taking app for Android phones and tablets. Write with a stylus
or your finger, organise notes into multi-page files, and sync everything to a
folder in your Google Drive or Microsoft OneDrive, with a local cache for
offline use.
This app is built with Google Play distribution in mind; the debug and (when
configured) signed release builds from CI can also be sideloaded directly.
Features
- Pen and finger input — one pointer draws; two fingers scroll and zoom.
Stylus pressure modulates pen stroke width.
- Continuous vertical scrolling through all pages of a notebook, on a
full-screen canvas with a floating overlay toolbar.
- Three tools
- Pen — adjustable colour and size, pressure-sensitive.
- Highlighter — adjustable colour and size, semi-transparent so text
underneath stays readable.
- Eraser — adjustable size, with two modes: delete objects (removes
whole strokes it touches) or rub out to white (paints opaque white).
- Pen mode toggle — when on, only a stylus draws and a single finger
scrolls; when off, touch draws and two fingers scroll/zoom. Pen mode has
palm rejection: finger input is ignored while the stylus is hovering or
was just used, and palm-sized contacts are dropped.
- Documents & pages — create, open, rename and delete documents. Each
document is a folder of pages described by an
index.inkfold manifest;
pages are independently portrait or landscape.
- Mixed page types — graphical (handwriting) pages saved as self-describing
SVG, and Markdown text pages (
.md), rendered inline in the same
continuous scroll. Tap a Markdown page to edit it (# headings, **bold**,
*italic*, - bullets, `code`).
- Undo / redo for strokes and erases.
- Google Drive or Microsoft OneDrive sync — two-way sync to an app-private
folder in your chosen cloud (only one is signed in at a time), backed by a
local cache so the app works fully offline. Syncs automatically every few
minutes while the app is open (and on document close), plus a periodic
background sync via WorkManager while it's closed, plus on demand.
Conflicts resolve last-write-wins by modification time.
- Export — save a notebook PDF to a chosen location via the system file
picker, or share it; export each page as a JPG.
- Responsive — a grid library on the home screen and a canvas that fits any
screen, so it works on both phones and tablets.
- Light/dark theme, with Material You dynamic colour on Android 12+.
Tech overview
app/src/main/java/uk/co/mfrost/inkfold/
├── MainActivity.kt # Compose host + sign-in launcher
├── model/Notebook.kt # Notebook / Page / Stroke + manifest model
├── drawing/
│ ├── DrawingView.kt # custom canvas: input, scroll/zoom, undo/redo
│ ├── StrokeRenderer.kt # shared stroke painting (editor + export)
│ └── SvgPage.kt # page <-> self-describing SVG (round-trip)
├── storage/DocumentRepository.kt # folder + SVG store / offline cache
├── sync/DriveSync.kt # Google Drive folder-per-document sync
├── sync/OneDriveSync.kt # Microsoft OneDrive folder-per-document sync
├── export/Exporter.kt # PDF + JPG export
└── ui/ # Compose screens, view model, theme
Building
You need JDK 17 and the Android SDK (compileSdk 36). The Gradle wrapper is
checked in.
# Debug APK
./gradlew assembleDebug
# Output: app/build/outputs/apk/debug/app-debug.apk
# Install on a connected device
./gradlew installDebug
Open the folder in Android Studio (Koala or newer) for day-to-day development.
See CLAUDE.md for the project's style guide (Kotlin/Android
conventions and commit message format) — it applies to human and AI
contributions alike.
Continuous integration
.github/workflows/build.yml runs on every push and pull request (and via
manual dispatch). It:
- sets up JDK 17 and the Android SDK,
- builds the debug APK (
assembleDebug),
- runs Android Lint (
lintDebug),
- uploads the APK as a build artifact (
inkfold-debug-apk),
- when the release keystore secrets are configured (see below), also builds
a signed release APK (
inkfold-release-apk) and a signed release AAB
(inkfold-release-aab) — the AAB is the format the Play Console requires
for a Play Store upload.
Download the APK from the Artifacts section of a completed run and sideload
it onto your device.
Claude-assisted issue triage
Two workflows let Claude turn a GitHub issue into a pull request, with a
human approval step before any AI call is made, so nothing costs API usage
or gets implemented unasked:
claude-issue-plan.yml — when an issue is opened, posts a static
comment: "This will be addressed by Claude. The repository owner needs to
comment /approve to continue." No AI call here — it's a plain GitHub
Actions script, so this step is free and doesn't need any secret.
claude-issue-implement.yml — only when the repo owner comments
exactly /approve on that issue does Claude get invoked at all: it reads
the issue, implements it, pushes a branch, and opens a pull request
referencing the issue. It never merges; the PR itself is the second
approval gate, reviewed and merged like any other.
claude-issue-implement.yml needs an ANTHROPIC_API_KEY repository secret
(Settings → Secrets and variables → Actions) to call Claude; without it,
approving an issue just fails harmlessly (the build.yml CI workflow is
unaffected, and no AI usage is incurred either way until you comment
/approve).
Enabling Google Drive sync
The app works without sign-in; sync is optional. To enable it you must create
your own OAuth client, because Google ties OAuth credentials to your app's
signing certificate.
- In the Google Cloud Console create a
project and enable the Google Drive API.
- Configure the OAuth consent screen (External is fine for personal use)
and add your Google account as a test user. Add the
.../auth/drive.file scope.
- Create an OAuth client ID → Android:
- Install the app, open the overflow menu on the home screen, and choose
Sign in to Google Drive. You'll be asked to pick a Google account from
the system account picker, then (the first time) to grant Drive access.
Use the cloud icon to sync on demand.
No API keys or secrets are stored in the repo — Android OAuth clients are
identified by package name + signing certificate, not a secret.
If a sync pass seems stuck or fails, open the overflow menu and choose
Sync log to see what the last few sync attempts actually did (each pass
now also times out after 2 minutes instead of hanging indefinitely).
Using Drive sync with the CI-built APK (fixed signing keystore)
The debug APK from CI is signed with the runner's throwaway keystore, whose
SHA-1 you can't register. To make Drive sync work from a CI build, sign a
release APK with your own keystore stored in GitHub Secrets:
- Generate a keystore locally (keep it safe; don't commit it):
keytool -genkeypair -v -keystore inkfold-release.jks \
-alias inkfold -keyalg RSA -keysize 2048 -validity 10000
Choose a store password and key password (they may be the same).
- Read its SHA-1 (register this in the OAuth Android client, step 3 above):
keytool -list -v -keystore inkfold-release.jks -alias inkfold
- Base64-encode the keystore for the secret:
base64 -w0 inkfold-release.jks # Linux
base64 -i inkfold-release.jks # macOS
- In the repo: Settings → Secrets and variables → Actions → New repository
secret, add:
KEYSTORE_BASE64 — the base64 string from step 3
KEYSTORE_PASSWORD — the store password
KEY_ALIAS — inkfold
KEY_PASSWORD — the key password
- Re-run the build. When the secret is present, CI also produces a
inkfold-release-apk artifact, signed with your keystore. Install that
one. (Uninstall any earlier debug build first — different signature.)
The same keystore SHA-1 also works for a local ./gradlew installDebug only if
you point the debug build at it; simplest is to just use the release artifact.
Troubleshooting sign-in
If the Drive consent screen appears, flickers, and closes without granting
access, the app will show "Drive sign-in failed (error 10)". Error 10 is
DEVELOPER_ERROR: the certificate the installed APK is signed with has no
matching Android OAuth client in your Google Cloud project. Register the SHA-1
of the exact keystore you installed from (see step 3 above) and add your
account as a test user on the consent screen. Other codes: 12501 =
cancelled, 7 = network error.
Enabling OneDrive sync
OneDrive sync is a separate, optional backend to Drive (only one can be signed
in at a time). Unlike Drive, enabling it requires a build-time app ID, because
Microsoft identity platform apps are identified by an application ID rather
than by the APK's signing certificate.
- In the Azure Portal go to Microsoft Entra ID
→ App registrations → New registration.
- Name: anything, e.g. "Inkfold".
- Supported account types: "Accounts in any organizational directory
and personal Microsoft accounts" (so both personal OneDrive and
OneDrive for Business work).
- Leave the redirect URI blank here; it's added in the next step.
- Open the new app registration's Authentication page → Add a
platform → Mobile and desktop applications, and add this exact custom
redirect URI:
uk.co.mfrost.inkfold://auth
- Open API permissions → Add a permission → Microsoft Graph → Delegated
permissions, and add
Files.ReadWrite.AppFolder and offline_access
(User.Read is included by default). No admin consent is needed for these
for personal use.
- Copy the Application (client) ID from the registration's Overview
page.
- Supply it to the build as a Gradle property (not committed to the repo —
it isn't secret, but it's tied to your own app registration):
echo "oneDriveClientId=<your-client-id>" >> ~/.gradle/gradle.properties
or pass it directly: ./gradlew assembleDebug -PoneDriveClientId=<your-client-id>.
(Gradle also recognises the ORG_GRADLE_PROJECT_oneDriveClientId environment
variable, which is handy for CI.)
- Rebuild and install the app, open the overflow menu on the home screen, and
choose Sign in to OneDrive. This opens a browser tab to sign in and
consent; it redirects back into the app to finish.
No client secret is stored in the app or the repo: this is a public client
using the PKCE authorization code flow, so the client ID alone (not secret by
itself) is all that's needed.
Notes & limitations
- The object eraser removes whole strokes it intersects (vector erase). The
"rub out to white" eraser paints opaque white strokes on top rather than
splitting the underlying ink, which is simple and looks correct on white
pages.
- Sync is last-write-wins per notebook; it is not designed for simultaneous
editing of the same notebook on two devices.
- Pages use an A4-proportioned coordinate space, so PDF/JPG exports come out at a
consistent aspect ratio regardless of device.