Skip to content
Merged
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
61 changes: 61 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: Android internal builds
on:
push:
branches: [android/playstore-prep]
pull_request:
paths: ['android/**', '.github/workflows/android.yml']
workflow_dispatch:
permissions:
contents: read
concurrency:
group: android-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3
with:
distribution: temurin
java-version: '17'
- uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a
with:
gradle-version: '8.13'
- name: Check listing and release gates
run: node android/release-check.mjs
- name: Prepare pinned web assets
run: node android/prepare.mjs
- name: Compile and verify Android
run: ./android/gradlew -p android --no-daemon assembleDebug bundleRelease testDebugUnitTest lintRelease
- name: Enable emulator acceleration
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: Android emulator acceptance
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d
with:
api-level: 35
arch: x86_64
target: google_apis
profile: pixel_6
disable-animations: true
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
script: bash android/emulator-check.sh
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
if: always()
with:
name: android-internal-${{ github.sha }}
path: |
android/app/build/outputs/apk/debug/*.apk
android/app/build/outputs/bundle/release/*.aab
android/app/build/reports/
android/app/build/test-results/
android/asset-manifest.json
android/emulator-evidence/
if-no-files-found: error
retention-days: 30
9 changes: 9 additions & 0 deletions android/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.gradle/
**/build/
local.properties
*.jks
*.keystore
*.p12
app/src/main/assets/web/
asset-manifest.json
emulator-evidence/
27 changes: 27 additions & 0 deletions android/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Android internal client

This project is a testable Android release candidate, not a claim of Play approval or physical device acceptance. See play-store/ for product-specific listing, data handling, acceptance and console handoff. Package IDs are provisional until first upload. Native app data is separate from Chrome/browser data; use explicit backup/import to migrate.

## Reproducible build

Install JDK 17 and an already licensed Android SDK (API 36 and build-tools 35.0.0), or use the pinned GitHub Android workflow with its runner-provided SDK. For ScreenNote/ContractCheck run npm ci first. No signing secrets are used by CI.

```
node android/prepare.mjs
./android/gradlew -p android assembleDebug testDebugUnitTest lintRelease bundleRelease
node android/release-check.mjs --submission
```

The final check deliberately fails while required release gates remain incomplete. A debug APK is installable for developer testing. The release AAB is UNSIGNED unless private signing configuration is provided and cannot be uploaded as-is. Pin/checksum data and the local-asset manifest tie builds to source. Do not ship a generic wrapper without completing the acceptance flows.

For a controlled signed release provide ANDROID_UPLOAD_KEYSTORE_PATH, ANDROID_UPLOAD_STORE_PASSWORD, ANDROID_UPLOAD_KEY_ALIAS and ANDROID_UPLOAD_KEY_PASSWORD privately. Never commit/upload a key or password, print signing variables, put passwords in command-line arguments or publish signing material in CI artifacts. The developer must retain a private backup. Production signing and Play App Signing enrollment are owner-controlled release steps.

## Native boundary

Only the exact configured HTTPS origin and main frame can send native WebMessages. No wildcard origins or addJavascriptInterface bridge. External websites open in the system browser. Mixed content, cleartext and filesystem URL access are disabled. Native file exports are bounded and use the Android save/share chooser, with temporary content-URI permissions. No all-files-storage permission. Android backup is disabled; explicit product backups remain available.

InvoiceFlow and GreenLight bundle first-launch offline content. ScreenNote bundles its editor and calls the existing online sharing API. ContractCheck bundles its frontend; analysis needs its existing server. Its Android test client gates new Paystack checkout pending a regional billing review. The website checkout is unaffected.

ScreenNote alone includes the native recorder: fresh Android capture consent, foreground notification/stop control, screen-only or microphone audio, private MP4s, a 5-minute/25-MiB cap, recording list and explicit native-to-editor import. It never restarts capture without new consent after service/task termination. System audio and camera overlays are not implemented in this first native recorder and must not be advertised. Physical recording, interruption and media tests remain mandatory.

No Play Console setup, invitations, payments, legal acceptance, upload signing, physical-device tests or publication are implied by this source build.
37 changes: 37 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
plugins { id 'com.android.application' }
android {
namespace 'com.buttercode.mobile'
compileSdk 36
defaultConfig { applicationId 'za.co.buttercode.invoiceflow'; minSdk 26; targetSdk 36; versionCode 1; versionName '0.1.0-internal'; testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
buildConfigField 'String','APP_ORIGIN','"https://invoiceflow-za.vercel.app"'
buildConfigField 'boolean','SCREEN_CAPTURE','false'
buildConfigField 'boolean','BUNDLED_OFFLINE','true'
}
buildFeatures { buildConfig true }
compileOptions { sourceCompatibility JavaVersion.VERSION_17; targetCompatibility JavaVersion.VERSION_17; coreLibraryDesugaringEnabled true }
signingConfigs {
upload {
if (System.getenv('ANDROID_UPLOAD_KEYSTORE_PATH')) {
storeFile file(System.getenv('ANDROID_UPLOAD_KEYSTORE_PATH'))
storePassword System.getenv('ANDROID_UPLOAD_STORE_PASSWORD')
keyAlias System.getenv('ANDROID_UPLOAD_KEY_ALIAS')
keyPassword System.getenv('ANDROID_UPLOAD_KEY_PASSWORD')
}
}
}
buildTypes { release { minifyEnabled false; if(System.getenv('ANDROID_UPLOAD_KEYSTORE_PATH')) signingConfig signingConfigs.upload } }
lint { abortOnError true }
testOptions { unitTests.returnDefaultValues = true }
}
dependencies {
implementation 'androidx.webkit:webkit:1.14.0'
implementation 'androidx.core:core:1.16.0'
implementation 'androidx.activity:activity:1.10.1'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test:runner:1.6.2'
androidTestImplementation 'androidx.test:core:1.6.1'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
}

tasks.configureEach { if(name=='connectedDebugAndroidTest') outputs.upToDateWhen {false} }
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.buttercode.mobile;
import androidx.test.ext.junit.runners.AndroidJUnit4;import androidx.test.platform.app.InstrumentationRegistry;import androidx.test.core.app.ActivityScenario;import android.content.*;import android.graphics.Bitmap;import java.io.*;import java.util.concurrent.*;import java.util.concurrent.atomic.*;import org.junit.Test;import org.junit.runner.RunWith;import static org.junit.Assert.*;
@RunWith(AndroidJUnit4.class) public class LaunchTest {
private ActivityScenario<MainActivity> launch(){Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();Intent intent=context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());assertNotNull(intent);return ActivityScenario.launch(intent);}
private String js(ActivityScenario<MainActivity> scenario,String expression)throws Exception{CountDownLatch ready=new CountDownLatch(1);AtomicReference<String> value=new AtomicReference<>();scenario.onActivity(a->a.web.evaluateJavascript(expression,v->{value.set(v);ready.countDown();}));assertTrue("WebView evaluation timed out",ready.await(10,TimeUnit.SECONDS));return value.get();}
private void ready(ActivityScenario<MainActivity> scenario)throws Exception{for(int n=0;n<80;n++){String v=js(scenario,"document.readyState==='complete'&&!!document.querySelector('#app h1,#root h1')&&document.body.innerText.length>120&&!document.body.innerText.includes('Opening your workspace')");if("true".equals(v))return;Thread.sleep(250);}fail("The packaged app did not render usable content.");}
@androidx.test.filters.SdkSuppress(minSdkVersion=29) @Test public void assetsAndNativeBridgeLoadOffline()throws Exception{
Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();try(InputStream in=context.getAssets().open("web/index.html")){assertTrue(in.read()!=-1);}
try(ActivityScenario<MainActivity> scenario=launch()){
ready(scenario);assertEquals("true",js(scenario,"typeof window.AppHost!=='undefined'&&typeof window.AndroidFiles?.exportUrl==='function'"));
scenario.onActivity(a->{a.web.getSettings().setBlockNetworkLoads(true);a.web.loadUrl(BuildConfig.APP_ORIGIN+"/?offlineSmoke=1");});for(int n=0;n<80;n++){if("true".equals(js(scenario,"location.search.includes('offlineSmoke=1')&&document.readyState==='complete'&&!!document.querySelector('#app h1,#root h1')")))break;Thread.sleep(250);}ready(scenario);
saveScreenshot(context);
assertEquals("false",js(scenario,"document.body.innerText.includes('We couldn’t open your workspace')||document.body.innerText.includes('We couldn’t open your local library')"));
assertEquals("false",js(scenario,"document.documentElement.scrollWidth>innerWidth+2"));

}
}
private void saveScreenshot(Context context)throws Exception{
Bitmap image=InstrumentationRegistry.getInstrumentation().getUiAutomation().takeScreenshot();assertNotNull("No emulator screenshot",image);
android.content.ContentValues values=new android.content.ContentValues();values.put(android.provider.MediaStore.Images.Media.DISPLAY_NAME,BuildConfig.APPLICATION_ID+"-offline.png");values.put(android.provider.MediaStore.Images.Media.MIME_TYPE,"image/png");String mode=context.getResources().getConfiguration().smallestScreenWidthDp>=600?"tablet":"phone";values.put(android.provider.MediaStore.Images.Media.RELATIVE_PATH,"Pictures/AndroidAcceptance/"+mode);android.net.Uri uri=context.getContentResolver().insert(android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI,values);assertNotNull(uri);try(OutputStream out=context.getContentResolver().openOutputStream(uri)){assertNotNull(out);assertTrue(image.compress(Bitmap.CompressFormat.PNG,100,out));}image.recycle();
}
@Test public void nativeExportRequiresExactSequentialBytes()throws Exception{Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();ExportStore store=new ExportStore(context.getCacheDir());String token=store.begin("invoice.pdf","application/pdf",4);try{store.chunk(token,1,"JVBERg==");fail("Out-of-order export accepted");}catch(IOException expected){}store.chunk(token,0,"JVBERg==");File file=store.finish(token);assertEquals(4,file.length());try(InputStream in=new FileInputStream(file)){assertEquals('%',in.read());}assertTrue(file.delete());store.abort();}
}
5 changes: 5 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"><uses-permission android:name="android.permission.INTERNET"/>
<application android:label="InvoiceFlow ZA" android:theme="@style/AppTheme" android:icon="@drawable/app_icon" android:enableOnBackInvokedCallback="true" android:allowBackup="false" android:usesCleartextTraffic="false" android:supportsRtl="true">
<activity android:name=".MainActivity" android:exported="true" android:windowSoftInputMode="adjustResize"><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}.files" android:exported="false" android:grantUriPermissions="true"><meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths"/></provider>
</application></manifest>
16 changes: 16 additions & 0 deletions android/app/src/main/java/com/buttercode/mobile/ExportStore.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.buttercode.mobile;
import android.util.Base64;
import java.io.*;import java.util.UUID;
final class ExportStore {
private final File directory;private File active;private OutputStream output;private String token;private long expected,written;private int sequence;
String name,mime;
ExportStore(File root){directory=new File(root,"exports");directory.mkdirs();}
synchronized String begin(String fileName,String contentType,long size)throws IOException{
if(output!=null)throw new IOException("Finish the current export first.");if(size<1||size>OriginPolicy.MAX_EXPORT_BYTES||!OriginPolicy.exportMime(contentType)||!contentType.matches("[a-zA-Z0-9!#$&^_.+/-]+(?:;[a-zA-Z0-9=_.+ -]+)?"))throw new IOException("Unsupported export type or size.");
long total=0;for(File f:directory.listFiles()==null?new File[0]:directory.listFiles()){if(System.currentTimeMillis()-f.lastModified()>7L*86400000)f.delete();else total+=f.length();}if(total+size>100L*1024*1024)throw new IOException("Export cache is full. Save your files and clear the export cache from Android settings.");
token=UUID.randomUUID().toString();name=OriginPolicy.safeName(fileName);mime=contentType;active=new File(directory,token+"-"+name);expected=size;written=0;sequence=0;output=new FileOutputStream(active);return token;
}
synchronized void chunk(String handle,int index,String encoded)throws IOException{if(output==null||!token.equals(handle)||sequence!=index||encoded.length()>90000)throw new IOException("Invalid export transfer.");byte[] bytes;try{bytes=Base64.decode(encoded,Base64.NO_WRAP);}catch(IllegalArgumentException e){throw new IOException("Invalid export data.");}if(bytes.length>65536||written+bytes.length>expected)throw new IOException("Export exceeds its declared size.");output.write(bytes);written+=bytes.length;sequence++;}
synchronized File finish(String handle)throws IOException{if(output==null||!token.equals(handle)||written!=expected)throw new IOException("Export is incomplete.");output.close();output=null;File completed=active;active=null;return completed;}
synchronized void abort(){try{if(output!=null)output.close();}catch(IOException ignored){}output=null;if(active!=null)active.delete();active=null;token=null;}
}
Loading
Loading