Skip to content
Open
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
60 changes: 60 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: CI

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

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

# The repo does not commit the Gradle wrapper JAR, so install a known
# good Gradle version directly (matches gradle-wrapper.properties).
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: '9.3.1'

# The debug signing config expects a debug.keystore at the repo root
# (gitignored). Generate it on CI so the debug build can be signed.
- name: Generate debug keystore if missing
run: |
if [ ! -f debug.keystore ]; then
keytool -genkeypair -v -keystore debug.keystore -storetype PKCS12 \
-alias androiddebugkey -keypass android -storepass android \
-keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Android Debug,O=Android,C=US"
fi

- name: Build debug APK
run: gradle --no-daemon :app:assembleDebug

# Downloadable installable APK on every run (Actions tab → artifacts)
- name: Upload debug APK artifact
uses: actions/upload-artifact@v4
with:
name: CalcQuick-debug-apk
path: app/build/outputs/apk/debug/*.apk
if-no-files-found: error

- name: Run unit tests (Robolectric)
run: gradle --no-daemon :app:testDebugUnitTest

- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-test-results
path: app/build/test-results/**/*.xml
if-no-files-found: ignore
72 changes: 72 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: Release APK

# Builds a SIGNED release APK ready to sideload (or convert to AAB for Play).
#
# Requirements — add these repository secrets (see SECURITY.md for why the
# keystore must never be committed):
# RELEASE_KEYSTORE_BASE64 — the upload keystore, base64-encoded
# RELEASE_STORE_PASSWORD — keystore password
# RELEASE_KEY_PASSWORD — key password
# RELEASE_KEY_ALIAS — normally "upload"
#
# Create the keystore locally once, then base64 it:
# keytool -genkeypair -v -keystore my-upload-key.jks -storetype PKCS12 \
# -alias upload -keyalg RSA -keysize 2048 -validity 10000 \
# -dname "CN=CalcQuick, O=rodri2482, C=US"
# powershell: [Convert]::ToBase64String([IO.File]::ReadAllBytes("my-upload-key.jks")) > base64.txt
# linux/mac: base64 -w0 my-upload-key.jks
# BACK UP this keystore — losing it means losing the ability to update the app.

on:
workflow_dispatch:
push:
tags: [ 'v*' ]

jobs:
signed-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

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

- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: '9.3.1'

- name: Decode release keystore
env:
KEYSTORE_B64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
run: |
if [ -z "$KEYSTORE_B64" ]; then
echo "::error::RELEASE_KEYSTORE_BASE64 secret is not set"; exit 1
fi
echo "$KEYSTORE_B64" | base64 -d > my-upload-key.jks
chmod 600 my-upload-key.jks

- name: Build signed release APK (R8 minified)
env:
STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
run: gradle --no-daemon :app:assembleRelease

# expose KEY_ALIAS as environment var also read by build.gradle.kts
- name: Upload signed APK artifact
uses: actions/upload-artifact@v4
with:
name: CalcQuick-release-apk-${{ github.ref_name }}
path: app/build/outputs/apk/release/*.apk
if-no-files-found: error

- name: Attach APK to tag release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
files: app/build/outputs/apk/release/*.apk
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,10 @@
local.properties
.env
debug.keystore

# Secrets: never commit signing material or env files
my-upload-key.jks
*.jks
keystore.properties
*.keystore
!**/debug.keystore
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 rodri2482

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
76 changes: 76 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# CalcQuick

A fast, multi-mode calculator for Android built with **Jetpack Compose** and **Material 3**.

> Internal project name: *Apex Calc*

## Features

- **Standard & Scientific modes** — live expression preview as you type, DEG/RAD toggle, memory keys, factorial, powers, calculator-convention percent (`100-50%` → `50`), modulo (`10%3`), implicit multiplication (`2(3)`, `2PI`)
- **Unit Converter** — length, weight, temperature, area, volume, speed, time, data storage, energy
- **Loan calculator** — monthly payment, total payment, total interest
- **Tip & Split** — bill, tip %, per-person totals
- **Discount + Tax** — final price and savings
- **BMI** — metric and imperial
- **History** — persisted across app restarts (Room), starred favorites, recall into the display
- **Themes** — Neon Cyber (dark), OLED Black, Clean Light, Midnight Blue
- Haptic feedback, safe-drawing edge-to-edge UI

## Tech stack

| Layer | Choice |
|------------|-----------------------------------------------|
| UI | Jetpack Compose + Material 3 |
| State | MVVM (`StateFlow` + `AndroidViewModel`) |
| Evaluation | Custom zero-dependency recursive-descent parser (no `eval`, no code-execution surface) |
| Package | `com.rodri2482.calcquick` (Play-Store-safe namespace) |
| Storage | Room (calculation history) · SharedPreferences (settings) |
| Build | Kotlin 2.2, AGP 9, Gradle 9, KSP, R8-minified release |

## Building

Requirements: JDK 21, Android SDK 36.

```bash
gradle :app:assembleDebug # debug build
gradle :app:testDebugUnitTest # unit tests (Robolectric)
```

> If you use Android Studio, open the project and build normally. In CI,
> `gradle/actions/setup-gradle` with Gradle 9.3.1 is used because the wrapper
> JAR is not committed.

## Releasing / Deploying

- **Debug APK (fastest, zero setup):** every CI run on GitHub Actions uploads a
signed, installable `CalcQuick-debug-apk` artifact — download it from the run's
Artifacts section, copy it to a phone, and install (allow "Install unknown apps" for
the file manager).
- **Signed release APK (sideload-ready):** add the repository secrets
`RELEASE_KEYSTORE_BASE64`, `RELEASE_STORE_PASSWORD`, `RELEASE_KEY_PASSWORD`
(see `release.yml` header for how to create the keystore), then run the
**Release APK** workflow manually or push a `v*` tag. The signed APK is
uploaded as both an artifact and a GitHub Release asset.
- **Google Play:** click the developer fee ($25 one-time), create the app entry,
and upload an AAB built with `gradle :app:bundleRelease` using the same
environment variables. Bump `versionCode` before every upload; a release AAB
is required at store submission. Default setting: minSdk 24, targetSdk 36.

## Tests

`app/src/test/java/com/example/MathExpressionEvaluatorTest.kt` covers the
expression engine: precedence, division-by-zero, percent/factorial semantics,
degree/radian trigonometry, locale-safe decimal formatting, overflow, and the
paren-nesting crash guard.

## Security notes

- No network permissions; the release minified with R8.
- `.env` and keystores are gitignored; the `.env.example` placeholder key is
intentionally left commented out — do not bake API keys into `BuildConfig`
of a shipping APK (they are trivially extractable).
- See [SECURITY.md](SECURITY.md) for reporting issues.

## License

[MIT](LICENSE)
28 changes: 28 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Security Policy

## Supported versions

The latest commit on `main` is supported. Always update before reporting an
issue that is already fixed.

## Reporting a vulnerability

Please do **not** open a public GitHub issue for security reports.

1. Email the owner via the contact in their GitHub profile, **or**
2. Use GitHub's private vulnerability reporting
(Repository → Security → Report a vulnerability).

Include: affected version/commit, a minimal reproduction, and (if possible) a
suggested fix. You can expect an initial response within 7 days.

## Known design constraints

- The expression parser has a nesting limit (150 parentheses) to prevent
stack-overflow crashes from pathological input.
- No API key is baked into the APK. Never uncomment `GEMINI_API_KEY` in
`.env.example` for release builds — keys shipped in an APK are extractable
by anyone with the binary. Use a backend proxy if server-side AI is needed.
- Release builds are sign-configured via environment variables
(`STORE_PASSWORD`, `KEY_PASSWORD`, `KEYSTORE_PATH`); never commit the
upload keystore (`my-upload-key.jks` is gitignored).
50 changes: 13 additions & 37 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import com.google.gms.googleservices.GoogleServicesPlugin.MissingGoogleServicesStrategy
import com.google.gms.googleservices.GoogleServicesPlugin.MissingGoogleServicesStrategy

plugins {
alias(libs.plugins.android.application)
Expand All @@ -9,11 +9,11 @@ plugins {
}

android {
namespace = "com.example"
namespace = "com.rodri2482.calcquick"
compileSdk { version = release(36) { minorApiLevel = 1 } }

defaultConfig {
applicationId = "com.aistudio.calculator.apexcalc"
applicationId = "com.rodri2482.calcquick"
minSdk = 24
targetSdk = 36
versionCode = 1
Expand All @@ -29,7 +29,7 @@ android {
val keystorePath = System.getenv("KEYSTORE_PATH") ?: "${rootDir}/my-upload-key.jks"
storeFile = file(keystorePath)
storePassword = System.getenv("STORE_PASSWORD")
keyAlias = "upload"
keyAlias = System.getenv("KEY_ALIAS") ?: "upload"
keyPassword = System.getenv("KEY_PASSWORD")
}
create("debugConfig") {
Expand All @@ -43,7 +43,10 @@ android {
buildTypes {
release {
isCrunchPngs = false
isMinifyEnabled = false
// R8 shrinking removes unused library code, shrinks resources, and
// obfuscates the release APK.
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
// Do not remove or modify this signingConfig assignment. It is necessary for Android apps in
// AI Studio.
Expand Down Expand Up @@ -80,54 +83,28 @@ secrets {

googleServices { missingGoogleServicesStrategy = MissingGoogleServicesStrategy.WARN }

// Some unused dependencies are commented out below instead of being removed.
// This makes it easy to add them back in the future if needed.
// Unused dependencies were removed: retrofit, okhttp, logging-interceptor,
// moshi (+ codegen), firebase-bom/ai/appcheck (recaptcha AND debug), because
// the app neither initializes Firebase nor performs any networking.
// Re-add any of them only when the feature that uses them actually ships.
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(platform(libs.firebase.bom))
// implementation(libs.accompanist.permissions)
implementation(libs.androidx.activity.compose)
// implementation(libs.androidx.camera.camera2)
// implementation(libs.androidx.camera.core)
// implementation(libs.androidx.camera.lifecycle)
// implementation(libs.androidx.camera.view)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
// implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// implementation(libs.androidx.navigation.compose)
// Room: persisted calculation history
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.room.runtime)
// implementation(libs.coil.compose)
implementation(libs.converter.moshi)
implementation(libs.firebase.ai)
// Uncomment to use Firestore:
// implementation(libs.firebase.firestore)

// Uncomment ALL FOUR of the following dependencies together to use Firebase Auth and Google
// Sign-In via Credential Manager:
// implementation(libs.firebase.auth)
// implementation(libs.androidx.credentials)
// implementation(libs.androidx.credentials.play.services)
// implementation(libs.googleid)
implementation(libs.firebase.appcheck.recaptcha)
implementation(libs.firebase.appcheck.debug)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.logging.interceptor)
implementation(libs.moshi.kotlin)
implementation(libs.okhttp)
// implementation(libs.play.services.location)
implementation(libs.retrofit)
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.androidx.core)
testImplementation(libs.androidx.junit)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
Expand All @@ -140,5 +117,4 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
"ksp"(libs.androidx.room.compiler)
"ksp"(libs.moshi.kotlin.codegen)
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.example
package com.rodri2482.calcquick

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
Expand Down
Loading
Loading