From a53c5afb01a91c2b61500fcf5d7134017203f4fe Mon Sep 17 00:00:00 2001 From: buttercode101 Date: Mon, 26 Oct 2026 11:41:26 +0200 Subject: [PATCH 1/5] Add Android internal client, native file controls and Play release preparation --- .github/workflows/android.yml | 45 ++++++++++ android/.gitignore | 8 ++ android/README.md | 27 ++++++ android/app/build.gradle | 33 ++++++++ .../com/buttercode/mobile/LaunchTest.java | 5 ++ android/app/src/main/AndroidManifest.xml | 5 ++ .../com/buttercode/mobile/ExportStore.java | 16 ++++ .../com/buttercode/mobile/MainActivity.java | 77 ++++++++++++++++++ .../com/buttercode/mobile/OriginPolicy.java | 14 ++++ .../app/src/main/res/drawable/app_icon.xml | 1 + android/app/src/main/res/values/strings.xml | 1 + android/app/src/main/res/values/styles.xml | 1 + android/app/src/main/res/xml/file_paths.xml | 1 + .../buttercode/mobile/OriginPolicyTest.java | 8 ++ android/build.gradle | 1 + android/gradle.properties | 3 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43705 bytes .../gradle/wrapper/gradle-wrapper.properties | 8 ++ android/gradlew | 5 ++ android/host-support.js | 13 +++ android/play-store/CONSOLE-HANDOFF.md | 19 +++++ android/play-store/DEVICE-ACCEPTANCE.md | 16 ++++ .../play-store/data-safety-working-notes.json | 18 ++++ android/play-store/listing-draft.json | 13 +++ android/prepare.mjs | 16 ++++ android/release-check.mjs | 5 ++ android/release-gates.json | 16 ++++ android/settings.gradle | 4 + dist/app.js | 4 +- 29 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/android.yml create mode 100644 android/.gitignore create mode 100644 android/README.md create mode 100644 android/app/build.gradle create mode 100644 android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/com/buttercode/mobile/ExportStore.java create mode 100644 android/app/src/main/java/com/buttercode/mobile/MainActivity.java create mode 100644 android/app/src/main/java/com/buttercode/mobile/OriginPolicy.java create mode 100644 android/app/src/main/res/drawable/app_icon.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/main/res/xml/file_paths.xml create mode 100644 android/app/src/test/java/com/buttercode/mobile/OriginPolicyTest.java create mode 100644 android/build.gradle create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/host-support.js create mode 100644 android/play-store/CONSOLE-HANDOFF.md create mode 100644 android/play-store/DEVICE-ACCEPTANCE.md create mode 100644 android/play-store/data-safety-working-notes.json create mode 100644 android/play-store/listing-draft.json create mode 100644 android/prepare.mjs create mode 100644 android/release-check.mjs create mode 100644 android/release-gates.json create mode 100644 android/settings.gradle diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..a97e532 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,45 @@ +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 + - 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 + if-no-files-found: error + retention-days: 30 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..e346670 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,8 @@ +.gradle/ +**/build/ +local.properties +*.jks +*.keystore +*.p12 +app/src/main/assets/web/ +asset-manifest.json diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..60c5f2d --- /dev/null +++ b/android/README.md @@ -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. diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..a761e27 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,33 @@ +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' + 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.ext:junit:1.2.1' +} diff --git a/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java b/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java new file mode 100644 index 0000000..3d3f2e1 --- /dev/null +++ b/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java @@ -0,0 +1,5 @@ +package com.buttercode.mobile; +import androidx.test.ext.junit.runners.AndroidJUnit4;import androidx.test.platform.app.InstrumentationRegistry;import org.junit.Test;import org.junit.runner.RunWith;import static org.junit.Assert.*; +@RunWith(AndroidJUnit4.class) public class LaunchTest { + @Test public void assetsArePackaged() throws Exception {try(java.io.InputStream in=InstrumentationRegistry.getInstrumentation().getTargetContext().getAssets().open("web/index.html")){assertTrue(in.read()!=-1);}} +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..95ca0b9 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/com/buttercode/mobile/ExportStore.java b/android/app/src/main/java/com/buttercode/mobile/ExportStore.java new file mode 100644 index 0000000..28584e6 --- /dev/null +++ b/android/app/src/main/java/com/buttercode/mobile/ExportStore.java @@ -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;} +} diff --git a/android/app/src/main/java/com/buttercode/mobile/MainActivity.java b/android/app/src/main/java/com/buttercode/mobile/MainActivity.java new file mode 100644 index 0000000..bd3211a --- /dev/null +++ b/android/app/src/main/java/com/buttercode/mobile/MainActivity.java @@ -0,0 +1,77 @@ +package com.buttercode.mobile; + +import android.app.*;import android.os.*;import android.content.*;import android.content.pm.PackageManager;import android.graphics.Color;import android.net.Uri;import android.view.*;import android.webkit.*;import android.widget.*;import android.provider.MediaStore; +import androidx.core.content.FileProvider;import androidx.webkit.*; +import org.json.*;import java.io.*;import java.util.*;import java.util.concurrent.*; + +public class MainActivity extends Activity { + protected WebView web;private LinearLayout root;private ProgressBar progress;private TextView failure; + private ExportStore exports;private final ExecutorService files=Executors.newSingleThreadExecutor(); + private ValueCallback chooser;private Uri cameraUri;private File saveFile;private String saveMime; + private static final int PICK=10,CAMERA=11,SAVE=12,MIC=13,PROJECTION=14; + private boolean microphone;private final String origin=BuildConfig.APP_ORIGIN; + @Override public void onCreate(Bundle state){super.onCreate(state);exports=new ExportStore(getCacheDir()); + root=new LinearLayout(this);root.setOrientation(LinearLayout.VERTICAL);root.setBackgroundColor(Color.rgb(247,246,242)); + root.setOnApplyWindowInsetsListener((v,insets)->{if(Build.VERSION.SDK_INT>=30){android.graphics.Insets bars=insets.getInsets(WindowInsets.Type.systemBars()|WindowInsets.Type.displayCutout());v.setPadding(bars.left,bars.top,bars.right,bars.bottom);}else v.setPadding(insets.getSystemWindowInsetLeft(),insets.getSystemWindowInsetTop(),insets.getSystemWindowInsetRight(),insets.getSystemWindowInsetBottom());return insets;}); + setContentView(root);getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); + progress=new ProgressBar(this,null,android.R.attr.progressBarStyleHorizontal);progress.setIndeterminate(true);root.addView(progress,new LinearLayout.LayoutParams(-1,4)); + failure=new TextView(this);failure.setPadding(20,16,20,16);failure.setTextColor(Color.rgb(140,35,20));failure.setVisibility(View.GONE);root.addView(failure); + web=new WebView(this);root.addView(web,new LinearLayout.LayoutParams(-1,0,1)); + WebSettings settings=web.getSettings();settings.setJavaScriptEnabled(true);settings.setDomStorageEnabled(true);settings.setAllowFileAccess(false);settings.setAllowContentAccess(false);settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);settings.setMediaPlaybackRequiresUserGesture(true);settings.setUserAgentString(settings.getUserAgentString()+" ButtercodeAndroid/1 "+BuildConfig.APPLICATION_ID); + CookieManager.getInstance().setAcceptThirdPartyCookies(web,false);WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG); + if(!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)){failure.setText("Update Android System WebView in Google Play, then reopen this app.");failure.setVisibility(View.VISIBLE);return;} + WebViewCompat.addWebMessageListener(web,"AppHost",Set.of(origin),(view,message,source,isMainFrame,reply)->{ + if(!isMainFrame||!OriginPolicy.trusted(source.toString(),origin)||!OriginPolicy.trusted(web.getUrl(),origin))return; + String raw=message.getData();if(raw==null||raw.length()>100000)return; + files.execute(()->{String id="";try{JSONObject body=new JSONObject(raw);id=body.getString("id");if(id.length()>80)throw new IOException("Invalid request.");String op=body.getString("op");Object data; + switch(op){case "exportBegin":data=new JSONObject().put("token",exports.begin(body.optString("name"),body.optString("mime"),body.optLong("size")));break; + case "exportChunk":exports.chunk(body.getString("token"),body.getInt("sequence"),body.getString("bytes"));data=true;break; + case "exportFinish":File completed=exports.finish(body.getString("token"));String mime=exports.mime,name=exports.name;runOnUiThread(()->offerExport(completed,name,mime));data=true;break; + case "exportAbort":exports.abort();data=true;break; + case "print":runOnUiThread(()->{android.print.PrintManager pm=(android.print.PrintManager)getSystemService(PRINT_SERVICE);pm.print(getString(com.buttercode.mobile.R.string.app_name),web.createPrintDocumentAdapter(getString(com.buttercode.mobile.R.string.app_name)),null);});data=true;break; + default:throw new IOException("Unsupported native operation.");} + post(reply,new JSONObject().put("id",id).put("ok",true).put("data",data)); + }catch(Exception e){try{post(reply,new JSONObject().put("id",id).put("ok",false).put("error",e instanceof IOException?e.getMessage():"The file transfer could not complete."));}catch(JSONException ignored){}}}); + }); + web.setWebViewClient(new WebViewClient(){ + @Override public WebResourceResponse shouldInterceptRequest(WebView view,WebResourceRequest request){return localResponse(request.getUrl());} + @Override public boolean shouldOverrideUrlLoading(WebView view,WebResourceRequest request){Uri uri=request.getUrl();if(OriginPolicy.trusted(uri.toString(),origin))return false;if(!request.isForMainFrame())return true;external(uri);return true;} + @Override public void onPageStarted(WebView view,String url,android.graphics.Bitmap icon){progress.setVisibility(View.VISIBLE);failure.setVisibility(View.GONE);} + @Override public void onPageFinished(WebView view,String url){progress.setVisibility(View.GONE);} + @Override public void onReceivedSslError(WebView view,android.webkit.SslErrorHandler handler,android.net.http.SslError error){handler.cancel();showFailure("The secure connection could not be verified. Check your connection and retry.");} + @Override public void onReceivedError(WebView view,WebResourceRequest request,WebResourceError error){if(request.isForMainFrame())showFailure("This page could not open. Your saved local data is kept. Check your connection, then tap Refresh.");} + }); + web.setWebChromeClient(new WebChromeClient(){ + @Override public boolean onShowFileChooser(WebView view,ValueCallback callback,FileChooserParams params){if(!OriginPolicy.trusted(web.getUrl(),origin))return false;if(chooser!=null)chooser.onReceiveValue(null);chooser=callback; + boolean images=Arrays.stream(params.getAcceptTypes()).anyMatch(v->v.contains("image")||v.contains("pdf")); + if(images)new AlertDialog.Builder(MainActivity.this).setTitle("Add a document").setItems(new String[]{"Choose a file or photo","Take a photo"},(d,n)->{if(n==1)takePhoto();else pickFile(params);}).setOnCancelListener(d->finishChooser(null)).show();else pickFile(params);return true;} + @Override public void onPermissionRequest(PermissionRequest request){runOnUiThread(()->request.deny());} + @Override public boolean onJsAlert(WebView view,String url,String message,JsResult result){new AlertDialog.Builder(MainActivity.this).setMessage(message).setPositiveButton("OK",(d,w)->result.confirm()).setOnCancelListener(d->result.cancel()).show();return true;} + @Override public boolean onJsConfirm(WebView view,String url,String message,JsResult result){new AlertDialog.Builder(MainActivity.this).setMessage(message).setPositiveButton("Continue",(d,w)->result.confirm()).setNegativeButton("Cancel",(d,w)->result.cancel()).setOnCancelListener(d->result.cancel()).show();return true;} + }); + web.setDownloadListener((url,userAgent,disposition,mime,length)->{if(url.startsWith("blob:")||url.startsWith("data:")){web.evaluateJavascript("window.AndroidFiles&&AndroidFiles.exportUrl("+JSONObject.quote(url)+","+JSONObject.quote(URLUtil.guessFileName(url,disposition,mime))+","+JSONObject.quote(mime==null?"application/octet-stream":mime)+")",null);}else external(Uri.parse(url));}); + LinearLayout toolbar=new LinearLayout(this);toolbar.setGravity(Gravity.CENTER);addButton(toolbar,"Home",()->web.loadUrl(origin+"/"));addButton(toolbar,"Refresh",()->web.reload());if(BuildConfig.SCREEN_CAPTURE){addButton(toolbar,"Record screen",this::chooseRecording);addButton(toolbar,"Recordings",this::recordings);}root.addView(toolbar); + if(state==null){String path="/";Uri deep=getIntent().getData();if(deep!=null&&OriginPolicy.trusted(deep.toString(),origin))path=deep.getEncodedPath()+(deep.getEncodedQuery()==null?"":"?"+deep.getEncodedQuery())+(deep.getEncodedFragment()==null?"":"#"+deep.getEncodedFragment());web.loadUrl(origin+path);}else web.restoreState(state); + } + private void post(JavaScriptReplyProxy reply,JSONObject data){runOnUiThread(()->{if(!isFinishing()&&web!=null&&OriginPolicy.trusted(web.getUrl(),origin))reply.postMessage(data.toString());});} + private void addButton(LinearLayout bar,String label,Runnable action){Button button=new Button(this);button.setText(label);button.setTextSize(12);button.setMinHeight(48);button.setOnClickListener(v->action.run());bar.addView(button,new LinearLayout.LayoutParams(0,-2,1));} + private void showFailure(String message){failure.setText(message);failure.setVisibility(View.VISIBLE);progress.setVisibility(View.GONE);} + private void external(Uri uri){String scheme=uri.getScheme();if(!Set.of("https","mailto","tel").contains(scheme==null?"":scheme))return;try{startActivity(new Intent(Intent.ACTION_VIEW,uri).addCategory(Intent.CATEGORY_BROWSABLE));}catch(ActivityNotFoundException e){showFailure("No app can open this link.");}} + private WebResourceResponse localResponse(Uri uri){ + if(!OriginPolicy.trusted(uri.toString(),origin))return null;String path=uri.getPath(); + if(BuildConfig.SCREEN_CAPTURE&&path!=null&&path.startsWith("/__native_recordings/")){String id=path.substring("/__native_recordings/".length());if(!OriginPolicy.recordingId(id))return empty(404);File file=new File(getFilesDir(),"recordings/"+id+".mp4");try{return new WebResourceResponse("video/mp4",null,200,"OK",Map.of("Cache-Control","no-store","Access-Control-Allow-Origin",origin),new FileInputStream(file));}catch(IOException e){return empty(404);}} + String asset=OriginPolicy.assetPath(path);if(asset==null)return empty(403);if(BuildConfig.SCREEN_CAPTURE&&asset.startsWith("v/"))asset="index.html"; + try{InputStream stream=getAssets().open("web/"+asset);String extension=android.webkit.MimeTypeMap.getFileExtensionFromUrl(asset);String mime=android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);if(asset.endsWith(".js"))mime="text/javascript";if(mime==null)mime="application/octet-stream";return new WebResourceResponse(mime,"UTF-8",200,"OK",Map.of("Cache-Control","no-store","X-Content-Type-Options","nosniff"),stream);}catch(IOException e){if(asset.startsWith("api/")||!BuildConfig.BUNDLED_OFFLINE)return null;return empty(404);} + } + private WebResourceResponse empty(int status){return new WebResourceResponse("text/plain","UTF-8",status,status==403?"Forbidden":"Not Found",Map.of(),new ByteArrayInputStream(new byte[0]));} + private void pickFile(WebChromeClient.FileChooserParams params){try{Intent intent=new Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*");String[] types=Arrays.stream(params.getAcceptTypes()).filter(s->!s.isBlank()).toArray(String[]::new);if(types.length>0)intent.putExtra(Intent.EXTRA_MIME_TYPES,types);intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE,params.getMode()==WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE);startActivityForResult(intent,PICK);}catch(ActivityNotFoundException e){finishChooser(null);showFailure("No document picker is available.");}} + private void takePhoto(){try{File directory=new File(getCacheDir(),"camera");directory.mkdirs();File photo=File.createTempFile("document-",".jpg",directory);cameraUri=FileProvider.getUriForFile(this,BuildConfig.APPLICATION_ID+".files",photo);Intent intent=new Intent(MediaStore.ACTION_IMAGE_CAPTURE).putExtra(MediaStore.EXTRA_OUTPUT,cameraUri).addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION|Intent.FLAG_GRANT_READ_URI_PERMISSION);startActivityForResult(intent,CAMERA);}catch(Exception e){finishChooser(null);showFailure("The camera could not open. Choose an existing photo instead.");}} + private void finishChooser(Uri[] values){if(chooser!=null){chooser.onReceiveValue(values);chooser=null;}cameraUri=null;} + private void offerExport(File file,String name,String mime){if(isFinishing())return;new AlertDialog.Builder(this).setTitle("Your file is ready").setItems(new String[]{"Save "+name,"Share file"},(d,n)->{if(n==1){Uri uri=FileProvider.getUriForFile(this,BuildConfig.APPLICATION_ID+".files",file);Intent share=new Intent(Intent.ACTION_SEND).setType(mime).putExtra(Intent.EXTRA_STREAM,uri).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);try{startActivity(Intent.createChooser(share,"Share file"));}catch(ActivityNotFoundException e){showFailure("No sharing app is available.");}}else{saveFile=file;saveMime=mime;try{startActivityForResult(new Intent(Intent.ACTION_CREATE_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType(mime.split(";")[0]).putExtra(Intent.EXTRA_TITLE,name),SAVE);}catch(ActivityNotFoundException e){showFailure("No file saver is available.");}}}).show();} + protected void chooseRecording(){showFailure("Native recording is not included in this build.");} + protected void recordings(){showFailure("Native recordings are not included in this build.");} + @Override protected void onActivityResult(int request,int result,Intent data){super.onActivityResult(request,result,data);if(request==PICK){if(result!=RESULT_OK||data==null){finishChooser(null);return;}ArrayList uris=new ArrayList<>();if(data.getClipData()!=null)for(int n=0;n{try(InputStream in=new FileInputStream(source);OutputStream out=getContentResolver().openOutputStream(destination,"w")){if(out==null)throw new IOException();byte[] buffer=new byte[32768];for(int n;(n=in.read(buffer))!=-1;)out.write(buffer,0,n);runOnUiThread(()->Toast.makeText(this,"File saved",Toast.LENGTH_SHORT).show());}catch(IOException e){runOnUiThread(()->showFailure("The file could not be saved. Your app data is unchanged."));}});} } + @Override protected void onSaveInstanceState(Bundle out){if(web!=null)web.saveState(out);super.onSaveInstanceState(out);} + @Override public void onBackPressed(){if(web!=null&&web.canGoBack())web.goBack();else super.onBackPressed();} + @Override protected void onDestroy(){if(chooser!=null)chooser.onReceiveValue(null);exports.abort();files.shutdown();if(web!=null){web.stopLoading();web.destroy();web=null;}super.onDestroy();} +} diff --git a/android/app/src/main/java/com/buttercode/mobile/OriginPolicy.java b/android/app/src/main/java/com/buttercode/mobile/OriginPolicy.java new file mode 100644 index 0000000..4d5516c --- /dev/null +++ b/android/app/src/main/java/com/buttercode/mobile/OriginPolicy.java @@ -0,0 +1,14 @@ +package com.buttercode.mobile; +import java.net.URI; +import java.util.Locale; +import java.util.UUID; +/** Native capabilities are available only to the packaged app's exact HTTPS origin. */ +public final class OriginPolicy { + public static final long MAX_EXPORT_BYTES=30L*1024*1024; + public static boolean trusted(String candidate,String origin){try{URI u=new URI(candidate),o=new URI(origin);return "https".equals(u.getScheme())&&u.getUserInfo()==null&&u.getPort()==o.getPort()&&o.getHost()!=null&&o.getHost().equalsIgnoreCase(u.getHost());}catch(Exception e){return false;}} + public static boolean recordingId(String id){try{return UUID.fromString(id).toString().equals(id);}catch(Exception e){return false;}} + public static String assetPath(String path){if(path==null||path.isEmpty()||path.equals("/"))return "index.html";String p=path.startsWith("/")?path.substring(1):path;if(p.contains("..")||p.contains("\\")||p.contains("%")||p.indexOf('\0')>=0)return null;return p;} + public static String safeName(String value){String clean=value==null?"export":value.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]","_").trim();if(clean.isEmpty()||clean.equals(".")||clean.equals(".."))clean="export";return clean.substring(0,Math.min(clean.length(),120));} + public static boolean exportMime(String value){return value!=null&&value.length()<=100&&!value.matches("(?s).*[\\r\\n\\x00].*")&&(value.equals("application/pdf")||value.equals("application/json")||value.equals("application/octet-stream")||value.equals("video/mp4")||value.equals("video/webm")||java.util.Set.of("text/plain","text/csv","text/markdown","text/vtt").contains(value.split(";",2)[0]));} + private OriginPolicy(){} +} diff --git a/android/app/src/main/res/drawable/app_icon.xml b/android/app/src/main/res/drawable/app_icon.xml new file mode 100644 index 0000000..1e713a1 --- /dev/null +++ b/android/app/src/main/res/drawable/app_icon.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3d21683 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1 @@ +InvoiceFlow ZA \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..5badc53 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1 @@ + diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..a05d7da --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1 @@ + diff --git a/android/app/src/test/java/com/buttercode/mobile/OriginPolicyTest.java b/android/app/src/test/java/com/buttercode/mobile/OriginPolicyTest.java new file mode 100644 index 0000000..6a11dd9 --- /dev/null +++ b/android/app/src/test/java/com/buttercode/mobile/OriginPolicyTest.java @@ -0,0 +1,8 @@ +package com.buttercode.mobile; +import org.junit.Test;import static org.junit.Assert.*; +public class OriginPolicyTest { + @Test public void exactOriginOnly(){String o="https://contractcheck.online";assertTrue(OriginPolicy.trusted(o+"/report",o));for(String s:new String[]{"http://contractcheck.online","https://contractcheck.online.evil.test","https://contractcheck.online@evil.test","https://evil.test@contractcheck.online","https://contractcheck.online:443","file:///etc/passwd","javascript:alert(1)","null"})assertFalse(s,OriginPolicy.trusted(s,o));} + @Test public void filesCannotEscapeTheirRoots(){for(String p:new String[]{"/../secret","/%2e%2e/secret","/a\\b","/a\u0000b"})assertNull(p,OriginPolicy.assetPath(p));assertEquals("index.html",OriginPolicy.assetPath("/"));assertEquals("assets/signs/stop.svg",OriginPolicy.assetPath("/assets/signs/stop.svg"));} + @Test public void exportNamesAndTypesAreBounded(){assertEquals(".._private.pdf",OriginPolicy.safeName("../private.pdf"));assertTrue(OriginPolicy.safeName("x".repeat(300)).length()<=120);assertFalse(OriginPolicy.exportMime("text/html;\r\nHeader:x"));assertTrue(OriginPolicy.exportMime("application/pdf"));assertFalse(OriginPolicy.exportMime("application/vnd.android.package-archive"));} + @Test public void recordingHandlesAreUnambiguous(){assertTrue(OriginPolicy.recordingId("11111111-1111-4111-8111-111111111111"));assertFalse(OriginPolicy.recordingId("../../recording"));assertFalse(OriginPolicy.recordingId("1-1-1-1-1"));} +} diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..381f56a --- /dev/null +++ b/android/build.gradle @@ -0,0 +1 @@ +plugins { id 'com.android.application' version '8.13.2' apply false } diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..691500e --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..9bbc975c742b298b441bfb90dbc124400a3751b9 GIT binary patch literal 43705 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 DHx0+F literal 0 HcmV?d00001 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..96d9743 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78 +networkTimeout=60000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..650172b --- /dev/null +++ b/android/gradlew @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu +ANDROID_PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +if [ -n "${JAVA_HOME:-}" ]; then ANDROID_JAVA="$JAVA_HOME/bin/java"; else ANDROID_JAVA=java; fi +exec "$ANDROID_JAVA" -classpath "$ANDROID_PROJECT_DIR/gradle/wrapper/gradle-wrapper.jar" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/android/host-support.js b/android/host-support.js new file mode 100644 index 0000000..c0f311c --- /dev/null +++ b/android/host-support.js @@ -0,0 +1,13 @@ +/* Packaged Android only. No filesystem or native capability is exposed to web browsers. */ +(()=>{ + if(!window.AppHost)return; + let sequence=0;const pending=new Map(); + AppHost.onmessage=e=>{let response;try{response=JSON.parse(e.data)}catch{return}const p=pending.get(response.id);if(!p)return;clearTimeout(p.timeout);pending.delete(response.id);response.ok?p.resolve(response.data):p.reject(Error(response.error||'Native file transfer failed.'))}; + function call(op,body={}){return new Promise((resolve,reject)=>{const id=String(++sequence),timeout=setTimeout(()=>{pending.delete(id);reject(Error('The Android file transfer timed out.'))},30000);pending.set(id,{resolve,reject,timeout});AppHost.postMessage(JSON.stringify({id,op,...body}))})} + async function exportUrl(url,name,mime){let token;try{const response=await fetch(url);if(!response.ok)throw Error('Could not read the exported file.');const blob=await response.blob();({token}=await call('exportBegin',{name:name||'export',mime:blob.type||mime||'application/octet-stream',size:blob.size}));let index=0;for(let offset=0;offset{});alert(e.message)}} + window.AndroidFiles={exportUrl};window.print=()=>call('print').catch(e=>alert(e.message));const handled=new WeakSet(); + function handle(anchor){if(!anchor||!anchor.hasAttribute('download')||! /^(blob:|data:)/.test(anchor.href))return false;if(!handled.has(anchor)){handled.add(anchor);exportUrl(anchor.href,anchor.download,'application/octet-stream')}return true} + const original=HTMLAnchorElement.prototype.click;HTMLAnchorElement.prototype.click=function(){if(!handle(this))return original.call(this)}; + document.addEventListener('click',e=>{const anchor=e.target.closest?.('a[download]');if(handle(anchor)){e.preventDefault();e.stopImmediatePropagation()}},true); + window.__ANDROID_APP__=true; +})(); diff --git a/android/play-store/CONSOLE-HANDOFF.md b/android/play-store/CONSOLE-HANDOFF.md new file mode 100644 index 0000000..9408c39 --- /dev/null +++ b/android/play-store/CONSOLE-HANDOFF.md @@ -0,0 +1,19 @@ +# Play Console handoff — new personal account + +1. Complete the owner's identity/contact/device verification shown by Play Console. Do not publish personal contact information by inference. +2. Confirm each permanent application ID and developer-owned upload signing key. Enable Play App Signing through the owner's controlled console flow; back up upload credentials privately. +3. Supply actual support email, publisher identity, policy URLs, target audience, countries and pricing. Review listing-draft.json and Data Safety engineering notes against the release binary and providers. +4. Upload a correctly signed release AAB to internal testing. Debug APKs and unsigned AABs are not production submissions. +5. Obtain real screenshots and, for ScreenNote, the foreground-service use-case demonstration. Complete applicable content-rating, Data Safety, ads, app access, financial features, billing and foreground-service forms accurately. +6. Set up a closed test for EACH app: at least 12 opted-in testers continuously for 14 days for this newly-created personal account, then apply for production access. Periods may overlap; do not invent feedback/tester participation. Internal testing does not satisfy the closed-test requirement. +7. Review tester feedback, pre-launch reports, crashes/ANRs and accessibility; fix/retest. Apply for production access and submit only after the owner reviews the concrete release. + +Source requirements checked 26 October 2026: +- https://support.google.com/googleplay/android-developer/answer/14151465 +- https://developer.android.com/google/play/requirements/target-sdk +- https://support.google.com/googleplay/android-developer/answer/10787469 +- https://support.google.com/googleplay/android-developer/answer/9876937 +- https://support.google.com/googleplay/android-developer/answer/9858738 +- https://developer.android.com/media/grow/media-projection + +No console setup, tester invitation, legal acceptance, signing-key upload or store submission has been performed by this source build. diff --git a/android/play-store/DEVICE-ACCEPTANCE.md b/android/play-store/DEVICE-ACCEPTANCE.md new file mode 100644 index 0000000..dbb957c --- /dev/null +++ b/android/play-store/DEVICE-ACCEPTANCE.md @@ -0,0 +1,16 @@ +# Required device acceptance + +Record device/model, Android/WebView version, APK SHA-256, build commit, steps and observed result. Do not tick based only on compilation. + +- First launch; home/navigation/back/rotation/keyboard; no clipped controls at phone/tablet widths. +- Cold launch without network: promised offline functions available, online functions fail with a useful retry and preserve local data. +- Create real app records, close/reopen, verify persistence. App update preserves the database; uninstall/clear-data warning is clear. +- Import valid/invalid/oversized files, deny/cancel picker/camera permissions; no crash or silent data loss. +- Export real PDF/CSV/backup, save through Android document picker, open the saved file in another app, share it, and verify cancellation is harmless. +- Sensitive content is not uploaded before the user's explicit upload/share action. Foreign pages cannot reach the native bridge. +- ScreenNote: fresh consent each capture, screen-only/mic, cancel/deny, notification Stop, duration/file cap, rotation, lock, background, task/process termination, playable MP4, native-to-editor import, publish/view/reply/delete synthetic cloud copy. +- InvoiceFlow: customer → invoice → PDF → payment → expense → reports → export/import backup; verify amounts and no duplicated payments. +- ContractCheck: multi-page PDF/photo upload, corrupt rejection, actual free results, entitlement/receipt restoration, exports; no accidental Paystack checkout in this Android test client. +- GreenLight: study → practice → Drive → review → mock → test-day preparation; persistence/offline progress, source links, no in-app PWA-install prompt. + +Current status: physical device execution pending. No connected Android phone/emulator is available in the local build environment. diff --git a/android/play-store/data-safety-working-notes.json b/android/play-store/data-safety-working-notes.json new file mode 100644 index 0000000..f41728d --- /dev/null +++ b/android/play-store/data-safety-working-notes.json @@ -0,0 +1,18 @@ +{ + "status": "Engineering inventory, not completed Google Play declarations", + "local": [ + "business details", + "customers", + "invoices/quotes", + "payments and expenses", + "explicit backups" + ], + "server": [ + "None intentionally added by this Android client; audit all production libraries and network calls before declaring no collection" + ], + "remaining": [ + "Verify actual release binary makes no unexpected network transfers", + "Privacy policy with actual publisher/support contact", + "Review financial-features declaration against invoicing functionality; do not claim lending/banking capabilities" + ] +} diff --git a/android/play-store/listing-draft.json b/android/play-store/listing-draft.json new file mode 100644 index 0000000..e95c97d --- /dev/null +++ b/android/play-store/listing-draft.json @@ -0,0 +1,13 @@ +{ + "status": "DRAFT \u2014 do not submit without release-gate review", + "title": "InvoiceFlow ZA", + "shortDescription": "Create invoices, track payments and manage expenses on your device.", + "fullDescription": "InvoiceFlow ZA\n\nManage customers, items, invoices, quotes and expenses.\nRecord payments and review outstanding balances.\nExport PDFs, CSV files and explicit backups using Android file controls.\nKeep business records on this device; no account is required.\n\nData saved inside this Android app is separate from records saved in your browser. Export backups before clearing app data or uninstalling.", + "category": "Business", + "supportEmail": null, + "privacyPolicyUrl": null, + "publisherIdentity": null, + "targetAudience": "Owner must choose and verify; not declared", + "countries": "Owner must confirm; not configured", + "screenshots": "Required: actual Android builds, no mocked product claims" +} diff --git a/android/prepare.mjs b/android/prepare.mjs new file mode 100644 index 0000000..bdff593 --- /dev/null +++ b/android/prepare.mjs @@ -0,0 +1,16 @@ +import {mkdir,cp,readFile,writeFile,readdir,rm} from 'node:fs/promises';import {createHash} from 'node:crypto';import {execFileSync} from 'node:child_process';import path from 'node:path';import {fileURLToPath} from 'node:url'; +const here=path.dirname(fileURLToPath(import.meta.url)),repo=path.dirname(here),name=path.basename(repo); +process.chdir(repo);const out=path.join(here,'app/src/main/assets/web');await rm(out,{recursive:true,force:true});await mkdir(out,{recursive:true}); +if(name==='ScreenNote'){execFileSync(process.execPath,['scripts/build.mjs'],{stdio:'inherit'});await cp('dist',out,{recursive:true})} +else if(name==='InvoiceFlow')await cp('dist',out,{recursive:true}); +else if(name==='ContractCheq'){ + await cp('public',out,{recursive:true});await mkdir(path.join(out,'vendor'),{recursive:true});const assets=(await import(path.join(repo,'server/vendorAssets.js'))).default;for(const [name,source]of Object.entries(assets))await cp(source,path.join(out,'vendor',name)); + const file=path.join(out,'index.html');let html=await readFile(file,'utf8');html=html.replace('','');await writeFile(file,html); +}else if(name==='Greenlight-k53'){ + for(const file of ['index.html','privacy.html','corrections.html','study-guide.html','icon.svg','icon-192.png','icon-512.png','manifest.webmanifest','assets'])await cp(file,path.join(out,file),{recursive:true}); +}else throw Error('Unknown Android product root.'); +await cp(path.join(here,'host-support.js'),path.join(out,'android-host.js')); +for(const file of await readdir(out)){if(!file.endsWith('.html'))continue;const full=path.join(out,file);let html=await readFile(full,'utf8');html=html.replace(/]*>/i,match=>match+'');if(name==='Greenlight-k53')html=html.replace('','');await writeFile(full,html)} +async function walk(root,prefix=''){let result=[];for(const entry of await readdir(root,{withFileTypes:true})){const rel=prefix+entry.name,full=path.join(root,entry.name);if(entry.isDirectory())result.push(...await walk(full,rel+'/'));else{const b=await readFile(full);result.push({path:rel,size:b.length,sha256:createHash('sha256').update(b).digest('hex')})}}return result} +const files=await walk(out);if(!files.some(f=>f.path==='index.html'))throw Error('Missing entry point'); +await writeFile(path.join(here,'asset-manifest.json'),JSON.stringify({product:name,sourceCommit:execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim(),files},null,2)+'\n');console.log('Packaged '+files.length+' local assets for '+name); diff --git a/android/release-check.mjs b/android/release-check.mjs new file mode 100644 index 0000000..e1c2be5 --- /dev/null +++ b/android/release-check.mjs @@ -0,0 +1,5 @@ +import {readFile} from 'node:fs/promises'; +const gates=JSON.parse(await readFile(new URL('./release-gates.json',import.meta.url))),listing=JSON.parse(await readFile(new URL('./play-store/listing-draft.json',import.meta.url))); +if(listing.title.length>30||listing.shortDescription.length>80||listing.fullDescription.length>4000)throw Error('Store listing exceeds Google Play text bounds.'); +const pending=Object.entries(gates).filter(([k,v])=>v===false).map(([k])=>k);for(const key of ['supportEmail','privacyPolicyUrl','publisherIdentity'])if(!listing[key])pending.push(key); +console.log(JSON.stringify({app:gates.app,targetSdk:gates.targetSdk,submissionReady:pending.length===0,pending},null,2));if(process.argv.includes('--submission')&&pending.length)process.exitCode=1; diff --git a/android/release-gates.json b/android/release-gates.json new file mode 100644 index 0000000..090c587 --- /dev/null +++ b/android/release-gates.json @@ -0,0 +1,16 @@ +{ + "app": "InvoiceFlow ZA", + "packageId": "za.co.buttercode.invoiceflow", + "packageIdStatus": "provisional until first Play upload", + "targetSdk": 36, + "minSdk": 26, + "versionCode": 1, + "signedRelease": false, + "physicalDeviceAcceptance": false, + "playConsoleSetup": false, + "privacyAndDataSafetyReviewed": false, + "closedTestingCompleted": false, + "productionApproved": false, + "billingReviewed": null, + "ugcModerationReviewed": null +} diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..84bfd27 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,4 @@ +pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } } +dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS); repositories { google(); mavenCentral() } } +rootProject.name = 'AndroidClient' +include ':app' diff --git a/dist/app.js b/dist/app.js index 47b1b9b..4557f29 100644 --- a/dist/app.js +++ b/dist/app.js @@ -76,7 +76,7 @@ const invForm=$('#invoice-form');if(invForm){invForm.addEventListener('input',e= const invoiceSearch=$('#invoice-search');if(invoiceSearch)invoiceSearch.oninput=()=>{search=invoiceSearch.value;$('#invoice-results').innerHTML=filteredInvoices()};const rf=$('#report-form');if(rf)rf.onsubmit=e=>{e.preventDefault();const start=$('#report-start').value,end=$('#report-end').value;if(!dateOK(start)||!dateOK(end)||endtoday()){toast('Choose a valid date range ending on or before today.');return}reportStart=start;reportEnd=end;render()}} modal.addEventListener('click',e=>{if(e.target===modal){const r=modal.getBoundingClientRect();if(e.clientXr.right||e.clientYr.bottom)modal.close()}}); window.addEventListener('beforeunload',e=>{if(route.page==='editor'){e.preventDefault();e.returnValue=''}}); -async function boot(){try{db=await openDB();try{const saved=localStorage.getItem('invoiceflow-business');if(/^business-[2-5]$/.test(saved||''))mode=saved}catch{}returnMode=mode;state=await load(mode);if(!state)state=emptyState();validateState(state);render();if(!('serviceWorker'in navigator)){offlineState='unavailable';updateOfflineStatus()}else{navigator.serviceWorker.register('./sw.js').then(async registration=>{const worker=registration.installing||registration.waiting;if(worker&&!['installed','activated'].includes(worker.state)){await new Promise((resolve,reject)=>{worker.addEventListener('statechange',()=>{if(['installed','activated'].includes(worker.state))resolve();else if(worker.state==='redundant')reject(Error('Offline cache installation failed.'))})})}if(registration.waiting)offerUpdate(registration);registration.addEventListener('updatefound',()=>{const next=registration.installing;next?.addEventListener('statechange',()=>{if(next.state==='installed'&&navigator.serviceWorker.controller)offerUpdate(registration)})});await navigator.serviceWorker.ready;const required=['./index.html','./app.js','./core.js','./pdf.js','./vendor/pdf-lib.js','./vendor/fontkit.js','./vendor/DejaVuSans.ttf'];const cached=await Promise.all(required.map(path=>caches.match(new URL(path,location.href).href)));if(cached.some(response=>!response))throw Error('Offline files are incomplete.');offlineReady=true;offlineState='ready';updateOfflineStatus()}).catch(()=>{offlineReady=false;offlineState='unavailable';updateOfflineStatus()})}}catch(e){app.innerHTML=`

We couldn’t open your workspace.

${esc(e.message)}

No records have been changed. Try reopening this app in a regular browser window. Do not clear site data if you already have records here.

`;$('#retry').onclick=()=>location.reload()}} +async function boot(){try{db=await openDB();try{const saved=localStorage.getItem('invoiceflow-business');if(/^business-[2-5]$/.test(saved||''))mode=saved}catch{}returnMode=mode;state=await load(mode);if(!state)state=emptyState();validateState(state);render();if(!(!globalThis.AppHost&&'serviceWorker'in navigator)){offlineState='unavailable';updateOfflineStatus()}else{navigator.serviceWorker.register('./sw.js').then(async registration=>{const worker=registration.installing||registration.waiting;if(worker&&!['installed','activated'].includes(worker.state)){await new Promise((resolve,reject)=>{worker.addEventListener('statechange',()=>{if(['installed','activated'].includes(worker.state))resolve();else if(worker.state==='redundant')reject(Error('Offline cache installation failed.'))})})}if(registration.waiting)offerUpdate(registration);registration.addEventListener('updatefound',()=>{const next=registration.installing;next?.addEventListener('statechange',()=>{if(next.state==='installed'&&navigator.serviceWorker.controller)offerUpdate(registration)})});await navigator.serviceWorker.ready;const required=['./index.html','./app.js','./core.js','./pdf.js','./vendor/pdf-lib.js','./vendor/fontkit.js','./vendor/DejaVuSans.ttf'];const cached=await Promise.all(required.map(path=>caches.match(new URL(path,location.href).href)));if(cached.some(response=>!response))throw Error('Offline files are incomplete.');offlineReady=true;offlineState='ready';updateOfflineStatus()}).catch(()=>{offlineReady=false;offlineState='unavailable';updateOfflineStatus()})}}catch(e){app.innerHTML=`

We couldn’t open your workspace.

${esc(e.message)}

No records have been changed. Try reopening this app in a regular browser window. Do not clear site data if you already have records here.

`;$('#retry').onclick=()=>location.reload()}} function taxFields(value){return state.business.nonVat?{vatRate:0,taxMode:'none'}:value==='inclusive'?{vatRate:1500,taxMode:'inclusive'}:value==='zero'?{vatRate:0,taxMode:'exclusive'}:value==='exempt'?{vatRate:0,taxMode:'none'}:{vatRate:1500,taxMode:'exclusive'}} function pricingFields(i){return `

${i?`Net ${money(breakdown(i).net)} · VAT ${money(breakdown(i).vat)}`:'Totals update as you type.'}

`} function documentBreakdown(i){const b=breakdown(i);return `
Subtotal${money(b.subtotal)}
${b.discount?`
Discount−${money(b.discount)}
`:''}
Value excluding VAT${money(b.net)}
VAT ${(i.vatRate||0)/100}%${money(b.vat)}
${i.deposit?`

Requested deposit: ${money(i.deposit)} (included in the total; received payments are recorded separately).

`:''}`} @@ -108,7 +108,7 @@ function encryptedBackupDialog(){modalForm('Encrypt your backup',`

Paste incoming transactions only, using date,reference,amount. Amounts are positive rand. No bank connection or automatic reconciliation. Each candidate must be reviewed before it becomes a payment.

`,async f=>{if(!globalThis.crypto?.subtle)throw Error('Bank matching needs HTTPS. Open the secure production app.');const rows=bankRows(f.get('csv'));const enriched=await Promise.all(rows.map(async row=>{const encoded=new TextEncoder().encode(JSON.stringify([row.date,row.reference.toLowerCase(),row.amount])),hash=[...new Uint8Array(await crypto.subtle.digest('SHA-256',encoded))].map(b=>b.toString(16).padStart(2,'0')).join('');return {...row,hash}}));modal.close();reviewBankRows(enriched)},'Review matches')} function reviewBankRows(rows){const available=rows.filter(row=>!state.invoices.some(i=>i.payments.some(p=>p.bankFingerprint===row.hash)));modalForm('Review bank matches',`

${rows.length-available.length} previously recorded rows excluded. Identical date, reference and amount are treated as one transaction; genuine duplicates can be recorded manually. Choose an invoice for each payment to record; leave unmatched rows on Skip.

${available.map((row,n)=>{const options=state.invoices.filter(i=>i.state==='issued'&&i.date<=row.date&&balance(i)>=row.amount),matched=options.filter(i=>row.reference.toUpperCase().includes(i.number));return `
`}).join('')}`,async f=>{const selected=available.map((row,n)=>({row,invoiceId:f.get('row-'+n)})).filter(v=>v.invoiceId);if(!selected.length)throw Error('Choose at least one invoice, or Cancel.');await commit(s=>{const seen=new Set();for(const {row,invoiceId} of selected){if(seen.has(row.hash))continue;recordBankPayment(s,invoiceId,{date:row.date,amount:row.amount,reference:row.reference},row.hash);seen.add(row.hash)}});modal.close();render();toast('Reviewed bank payments recorded.')},'Record selected payments')} function previewDocument(){const form=$('#invoice-form');if(!form.reportValidity())return;const f=new FormData(form),temp=structuredClone(state);const invoiceId=(route.quote?saveQuote:createInvoice)(temp,{id:route.id,customerId:f.get('customerId'),date:f.get('date'),due:f.get('due'),notes:f.get('notes'),lines:readLines(),discount:cents(f.get('discount')||'0'),deposit:cents(f.get('deposit')||'0'),template:f.get('template'),...taxFields(f.get('tax'))});validateState(temp);const invoice=(route.quote?temp.quotes:temp.invoices).find(i=>i.id===invoiceId);modalForm('Document preview · unsaved',invoiceDocument({...invoice,kind:route.quote?'quote':undefined}),async()=>{modal.close()},'Back to editor')} -Object.assign(actions,{businesses:()=>{if(route.page==='editor'&&!confirm('Switch businesses? Unsaved editor changes will be lost.'))return;return businessDialog()},'secure-backup':encryptedBackupDialog,'bank-import':bankImportDialog,'preview-document':previewDocument,install:async()=>{if(installPrompt){await installPrompt.prompt();installPrompt=null;return}modalForm('Install InvoiceFlow',`

On Android Chrome, open the browser menu and choose Install app or Add to Home screen. On iPhone/iPad Safari, use Share → Add to Home Screen.

A first online visit is needed to cache app files. Keep backups even when installed. This browser may already have the app installed or may not support installation.

`,async()=>modal.close(),'Done')}}); +Object.assign(actions,{businesses:()=>{if(route.page==='editor'&&!confirm('Switch businesses? Unsaved editor changes will be lost.'))return;return businessDialog()},'secure-backup':encryptedBackupDialog,'bank-import':bankImportDialog,'preview-document':previewDocument,install:async()=>{if(globalThis.AppHost){toast('InvoiceFlow is already installed.');return}if(installPrompt){await installPrompt.prompt();installPrompt=null;return}modalForm('Install InvoiceFlow',`

On Android Chrome, open the browser menu and choose Install app or Add to Home screen. On iPhone/iPad Safari, use Share → Add to Home Screen.

A first online visit is needed to cache app files. Keep backups even when installed. This browser may already have the app installed or may not support installation.

`,async()=>modal.close(),'Done')}}); function offerUpdate(registration){const existing=$('#apply-update');if(existing)return;const button=document.createElement('button');button.id='apply-update';button.className='btn small';button.textContent='Apply app update';button.onclick=()=>{if(route.page==='editor'&&!confirm('Apply update? Unsaved editor changes will be lost.'))return;const worker=registration.waiting;if(!worker)return;let reloaded=false;navigator.serviceWorker.addEventListener('controllerchange',()=>{if(!reloaded){reloaded=true;location.reload()}});worker.postMessage({type:'SKIP_WAITING'})};$('.side-bottom')?.prepend(button)} boot(); From da14ec751de8007a7f6e26dcd6d5269c90316c94 Mon Sep 17 00:00:00 2001 From: buttercode101 Date: Mon, 26 Oct 2026 11:50:49 +0200 Subject: [PATCH 2/5] Use AndroidX back gestures and verify packaged clients on phone and tablet emulators --- .github/workflows/android.yml | 23 +++++++++++++++++++ android/.gitignore | 1 + android/app/build.gradle | 2 ++ .../com/buttercode/mobile/LaunchTest.java | 17 ++++++++++++-- android/app/src/main/AndroidManifest.xml | 2 +- .../com/buttercode/mobile/MainActivity.java | 4 ++-- dist/app.js | 4 +++- 7 files changed, 47 insertions(+), 6 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index a97e532..4f55329 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -31,6 +31,28 @@ jobs: 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: | + ./android/gradlew -p android --no-daemon connectedDebugAndroidTest + mkdir -p android/emulator-evidence + adb shell run-as za.co.buttercode.invoiceflow cat files/evidence/launch-offline.png > android/emulator-evidence/phone-offline.png + adb shell wm size 1600x2560 + adb shell wm density 240 + ./android/gradlew -p android --no-daemon connectedDebugAndroidTest + adb shell run-as za.co.buttercode.invoiceflow cat files/evidence/launch-offline.png > android/emulator-evidence/tablet-offline.png - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 if: always() with: @@ -41,5 +63,6 @@ jobs: android/app/build/reports/ android/app/build/test-results/ android/asset-manifest.json + android/emulator-evidence/ if-no-files-found: error retention-days: 30 diff --git a/android/.gitignore b/android/.gitignore index e346670..d7ea03b 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -6,3 +6,4 @@ local.properties *.p12 app/src/main/assets/web/ asset-manifest.json +emulator-evidence/ diff --git a/android/app/build.gradle b/android/app/build.gradle index a761e27..9e363c9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -26,8 +26,10 @@ android { 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' } diff --git a/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java b/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java index 3d3f2e1..5bd8295 100644 --- a/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java +++ b/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java @@ -1,5 +1,18 @@ package com.buttercode.mobile; -import androidx.test.ext.junit.runners.AndroidJUnit4;import androidx.test.platform.app.InstrumentationRegistry;import org.junit.Test;import org.junit.runner.RunWith;import static org.junit.Assert.*; +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 { - @Test public void assetsArePackaged() throws Exception {try(java.io.InputStream in=InstrumentationRegistry.getInstrumentation().getTargetContext().getAssets().open("web/index.html")){assertTrue(in.read()!=-1);}} + private ActivityScenario launch(){Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();Intent intent=context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());assertNotNull(intent);return ActivityScenario.launch(intent);} + private String js(ActivityScenario scenario,String expression)throws Exception{CountDownLatch ready=new CountDownLatch(1);AtomicReference 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 scenario)throws Exception{for(int n=0;n<80;n++){String v=js(scenario,"document.readyState==='complete'&&!!document.querySelector('#app,#root')&&document.body.innerText.length>120");if("true".equals(v))return;Thread.sleep(250);}fail("The packaged app did not render usable content.");} + @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 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");});ready(scenario); + 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")); + File directory=new File(context.getFilesDir(),"evidence");directory.mkdirs();Bitmap image=InstrumentationRegistry.getInstrumentation().getUiAutomation().takeScreenshot();assertNotNull("No emulator screenshot",image);try(OutputStream out=new FileOutputStream(new File(directory,"launch-offline.png"))){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();} } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 95ca0b9..344b08c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/android/app/src/main/java/com/buttercode/mobile/MainActivity.java b/android/app/src/main/java/com/buttercode/mobile/MainActivity.java index bd3211a..f1af72c 100644 --- a/android/app/src/main/java/com/buttercode/mobile/MainActivity.java +++ b/android/app/src/main/java/com/buttercode/mobile/MainActivity.java @@ -4,7 +4,7 @@ import androidx.core.content.FileProvider;import androidx.webkit.*; import org.json.*;import java.io.*;import java.util.*;import java.util.concurrent.*; -public class MainActivity extends Activity { +public class MainActivity extends androidx.activity.ComponentActivity { protected WebView web;private LinearLayout root;private ProgressBar progress;private TextView failure; private ExportStore exports;private final ExecutorService files=Executors.newSingleThreadExecutor(); private ValueCallback chooser;private Uri cameraUri;private File saveFile;private String saveMime; @@ -51,6 +51,7 @@ public class MainActivity extends Activity { }); web.setDownloadListener((url,userAgent,disposition,mime,length)->{if(url.startsWith("blob:")||url.startsWith("data:")){web.evaluateJavascript("window.AndroidFiles&&AndroidFiles.exportUrl("+JSONObject.quote(url)+","+JSONObject.quote(URLUtil.guessFileName(url,disposition,mime))+","+JSONObject.quote(mime==null?"application/octet-stream":mime)+")",null);}else external(Uri.parse(url));}); LinearLayout toolbar=new LinearLayout(this);toolbar.setGravity(Gravity.CENTER);addButton(toolbar,"Home",()->web.loadUrl(origin+"/"));addButton(toolbar,"Refresh",()->web.reload());if(BuildConfig.SCREEN_CAPTURE){addButton(toolbar,"Record screen",this::chooseRecording);addButton(toolbar,"Recordings",this::recordings);}root.addView(toolbar); + getOnBackPressedDispatcher().addCallback(this,new androidx.activity.OnBackPressedCallback(true){@Override public void handleOnBackPressed(){web.evaluateJavascript("typeof window.handleAndroidBack==='function'&&!!window.handleAndroidBack()",handled->{if("true".equals(handled))return;if(web.canGoBack())web.goBack();else{setEnabled(false);getOnBackPressedDispatcher().onBackPressed();setEnabled(true);}});}}); if(state==null){String path="/";Uri deep=getIntent().getData();if(deep!=null&&OriginPolicy.trusted(deep.toString(),origin))path=deep.getEncodedPath()+(deep.getEncodedQuery()==null?"":"?"+deep.getEncodedQuery())+(deep.getEncodedFragment()==null?"":"#"+deep.getEncodedFragment());web.loadUrl(origin+path);}else web.restoreState(state); } private void post(JavaScriptReplyProxy reply,JSONObject data){runOnUiThread(()->{if(!isFinishing()&&web!=null&&OriginPolicy.trusted(web.getUrl(),origin))reply.postMessage(data.toString());});} @@ -72,6 +73,5 @@ private WebResourceResponse localResponse(Uri uri){ protected void recordings(){showFailure("Native recordings are not included in this build.");} @Override protected void onActivityResult(int request,int result,Intent data){super.onActivityResult(request,result,data);if(request==PICK){if(result!=RESULT_OK||data==null){finishChooser(null);return;}ArrayList uris=new ArrayList<>();if(data.getClipData()!=null)for(int n=0;n{try(InputStream in=new FileInputStream(source);OutputStream out=getContentResolver().openOutputStream(destination,"w")){if(out==null)throw new IOException();byte[] buffer=new byte[32768];for(int n;(n=in.read(buffer))!=-1;)out.write(buffer,0,n);runOnUiThread(()->Toast.makeText(this,"File saved",Toast.LENGTH_SHORT).show());}catch(IOException e){runOnUiThread(()->showFailure("The file could not be saved. Your app data is unchanged."));}});} } @Override protected void onSaveInstanceState(Bundle out){if(web!=null)web.saveState(out);super.onSaveInstanceState(out);} - @Override public void onBackPressed(){if(web!=null&&web.canGoBack())web.goBack();else super.onBackPressed();} @Override protected void onDestroy(){if(chooser!=null)chooser.onReceiveValue(null);exports.abort();files.shutdown();if(web!=null){web.stopLoading();web.destroy();web=null;}super.onDestroy();} } diff --git a/dist/app.js b/dist/app.js index 4557f29..40fa56d 100644 --- a/dist/app.js +++ b/dist/app.js @@ -76,7 +76,7 @@ const invForm=$('#invoice-form');if(invForm){invForm.addEventListener('input',e= const invoiceSearch=$('#invoice-search');if(invoiceSearch)invoiceSearch.oninput=()=>{search=invoiceSearch.value;$('#invoice-results').innerHTML=filteredInvoices()};const rf=$('#report-form');if(rf)rf.onsubmit=e=>{e.preventDefault();const start=$('#report-start').value,end=$('#report-end').value;if(!dateOK(start)||!dateOK(end)||endtoday()){toast('Choose a valid date range ending on or before today.');return}reportStart=start;reportEnd=end;render()}} modal.addEventListener('click',e=>{if(e.target===modal){const r=modal.getBoundingClientRect();if(e.clientXr.right||e.clientYr.bottom)modal.close()}}); window.addEventListener('beforeunload',e=>{if(route.page==='editor'){e.preventDefault();e.returnValue=''}}); -async function boot(){try{db=await openDB();try{const saved=localStorage.getItem('invoiceflow-business');if(/^business-[2-5]$/.test(saved||''))mode=saved}catch{}returnMode=mode;state=await load(mode);if(!state)state=emptyState();validateState(state);render();if(!(!globalThis.AppHost&&'serviceWorker'in navigator)){offlineState='unavailable';updateOfflineStatus()}else{navigator.serviceWorker.register('./sw.js').then(async registration=>{const worker=registration.installing||registration.waiting;if(worker&&!['installed','activated'].includes(worker.state)){await new Promise((resolve,reject)=>{worker.addEventListener('statechange',()=>{if(['installed','activated'].includes(worker.state))resolve();else if(worker.state==='redundant')reject(Error('Offline cache installation failed.'))})})}if(registration.waiting)offerUpdate(registration);registration.addEventListener('updatefound',()=>{const next=registration.installing;next?.addEventListener('statechange',()=>{if(next.state==='installed'&&navigator.serviceWorker.controller)offerUpdate(registration)})});await navigator.serviceWorker.ready;const required=['./index.html','./app.js','./core.js','./pdf.js','./vendor/pdf-lib.js','./vendor/fontkit.js','./vendor/DejaVuSans.ttf'];const cached=await Promise.all(required.map(path=>caches.match(new URL(path,location.href).href)));if(cached.some(response=>!response))throw Error('Offline files are incomplete.');offlineReady=true;offlineState='ready';updateOfflineStatus()}).catch(()=>{offlineReady=false;offlineState='unavailable';updateOfflineStatus()})}}catch(e){app.innerHTML=`

We couldn’t open your workspace.

${esc(e.message)}

No records have been changed. Try reopening this app in a regular browser window. Do not clear site data if you already have records here.

`;$('#retry').onclick=()=>location.reload()}} +async function boot(){try{db=await openDB();try{const saved=localStorage.getItem('invoiceflow-business');if(/^business-[2-5]$/.test(saved||''))mode=saved}catch{}returnMode=mode;state=await load(mode);if(!state)state=emptyState();validateState(state);render();if(globalThis.AppHost){offlineReady=true;offlineState='ready';updateOfflineStatus()}else if(!('serviceWorker'in navigator)){offlineState='unavailable';updateOfflineStatus()}else{navigator.serviceWorker.register('./sw.js').then(async registration=>{const worker=registration.installing||registration.waiting;if(worker&&!['installed','activated'].includes(worker.state)){await new Promise((resolve,reject)=>{worker.addEventListener('statechange',()=>{if(['installed','activated'].includes(worker.state))resolve();else if(worker.state==='redundant')reject(Error('Offline cache installation failed.'))})})}if(registration.waiting)offerUpdate(registration);registration.addEventListener('updatefound',()=>{const next=registration.installing;next?.addEventListener('statechange',()=>{if(next.state==='installed'&&navigator.serviceWorker.controller)offerUpdate(registration)})});await navigator.serviceWorker.ready;const required=['./index.html','./app.js','./core.js','./pdf.js','./vendor/pdf-lib.js','./vendor/fontkit.js','./vendor/DejaVuSans.ttf'];const cached=await Promise.all(required.map(path=>caches.match(new URL(path,location.href).href)));if(cached.some(response=>!response))throw Error('Offline files are incomplete.');offlineReady=true;offlineState='ready';updateOfflineStatus()}).catch(()=>{offlineReady=false;offlineState='unavailable';updateOfflineStatus()})}}catch(e){app.innerHTML=`

We couldn’t open your workspace.

${esc(e.message)}

No records have been changed. Try reopening this app in a regular browser window. Do not clear site data if you already have records here.

`;$('#retry').onclick=()=>location.reload()}} function taxFields(value){return state.business.nonVat?{vatRate:0,taxMode:'none'}:value==='inclusive'?{vatRate:1500,taxMode:'inclusive'}:value==='zero'?{vatRate:0,taxMode:'exclusive'}:value==='exempt'?{vatRate:0,taxMode:'none'}:{vatRate:1500,taxMode:'exclusive'}} function pricingFields(i){return `

${i?`Net ${money(breakdown(i).net)} · VAT ${money(breakdown(i).vat)}`:'Totals update as you type.'}

`} function documentBreakdown(i){const b=breakdown(i);return `
Subtotal${money(b.subtotal)}
${b.discount?`
Discount−${money(b.discount)}
`:''}
Value excluding VAT${money(b.net)}
VAT ${(i.vatRate||0)/100}%${money(b.vat)}
${i.deposit?`

Requested deposit: ${money(i.deposit)} (included in the total; received payments are recorded separately).

`:''}`} @@ -112,3 +112,5 @@ Object.assign(actions,{businesses:()=>{if(route.page==='editor'&&!confirm('Switc function offerUpdate(registration){const existing=$('#apply-update');if(existing)return;const button=document.createElement('button');button.id='apply-update';button.className='btn small';button.textContent='Apply app update';button.onclick=()=>{if(route.page==='editor'&&!confirm('Apply update? Unsaved editor changes will be lost.'))return;const worker=registration.waiting;if(!worker)return;let reloaded=false;navigator.serviceWorker.addEventListener('controllerchange',()=>{if(!reloaded){reloaded=true;location.reload()}});worker.postMessage({type:'SKIP_WAITING'})};$('.side-bottom')?.prepend(button)} boot(); + +if(globalThis.AppHost)window.handleAndroidBack=()=>{if(modal.open){modal.close();return true}if(route.page!=='overview'){goto('overview');return true}return false}; From 2a8f30bdfab49ddd56cfbb64fa6915592daa0ec2 Mon Sep 17 00:00:00 2001 From: buttercode101 Date: Mon, 26 Oct 2026 12:04:33 +0200 Subject: [PATCH 3/5] Preserve edits during native navigation and retain reliable emulator evidence --- .github/workflows/android.yml | 9 +-------- android/app/build.gradle | 2 ++ .../java/com/buttercode/mobile/LaunchTest.java | 13 +++++++++---- .../com/buttercode/mobile/MainActivity.java | 6 +++--- android/emulator-check.sh | 18 ++++++++++++++++++ android/host-support.js | 3 ++- android/prepare.mjs | 2 +- dist/app.js | 2 ++ 8 files changed, 38 insertions(+), 17 deletions(-) create mode 100755 android/emulator-check.sh diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 4f55329..dcde0b3 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -45,14 +45,7 @@ jobs: profile: pixel_6 disable-animations: true emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - script: | - ./android/gradlew -p android --no-daemon connectedDebugAndroidTest - mkdir -p android/emulator-evidence - adb shell run-as za.co.buttercode.invoiceflow cat files/evidence/launch-offline.png > android/emulator-evidence/phone-offline.png - adb shell wm size 1600x2560 - adb shell wm density 240 - ./android/gradlew -p android --no-daemon connectedDebugAndroidTest - adb shell run-as za.co.buttercode.invoiceflow cat files/evidence/launch-offline.png > android/emulator-evidence/tablet-offline.png + script: bash android/emulator-check.sh - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 if: always() with: diff --git a/android/app/build.gradle b/android/app/build.gradle index 9e363c9..05c5359 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -33,3 +33,5 @@ dependencies { androidTestImplementation 'androidx.test:core:1.6.1' androidTestImplementation 'androidx.test.ext:junit:1.2.1' } + +tasks.configureEach { if(name=='connectedDebugAndroidTest') outputs.upToDateWhen {false} } diff --git a/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java b/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java index 5bd8295..e0a29f9 100644 --- a/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java +++ b/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java @@ -3,16 +3,21 @@ @RunWith(AndroidJUnit4.class) public class LaunchTest { private ActivityScenario launch(){Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();Intent intent=context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());assertNotNull(intent);return ActivityScenario.launch(intent);} private String js(ActivityScenario scenario,String expression)throws Exception{CountDownLatch ready=new CountDownLatch(1);AtomicReference 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 scenario)throws Exception{for(int n=0;n<80;n++){String v=js(scenario,"document.readyState==='complete'&&!!document.querySelector('#app,#root')&&document.body.innerText.length>120");if("true".equals(v))return;Thread.sleep(250);}fail("The packaged app did not render usable content.");} - @Test public void assetsAndNativeBridgeLoadOffline()throws Exception{ + private void ready(ActivityScenario 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");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 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");});ready(scenario); + 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")); - File directory=new File(context.getFilesDir(),"evidence");directory.mkdirs();Bitmap image=InstrumentationRegistry.getInstrumentation().getUiAutomation().takeScreenshot();assertNotNull("No emulator screenshot",image);try(OutputStream out=new FileOutputStream(new File(directory,"launch-offline.png"))){assertTrue(image.compress(Bitmap.CompressFormat.PNG,100,out));}image.recycle(); + } } + 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();} } diff --git a/android/app/src/main/java/com/buttercode/mobile/MainActivity.java b/android/app/src/main/java/com/buttercode/mobile/MainActivity.java index f1af72c..80a3384 100644 --- a/android/app/src/main/java/com/buttercode/mobile/MainActivity.java +++ b/android/app/src/main/java/com/buttercode/mobile/MainActivity.java @@ -50,12 +50,12 @@ public class MainActivity extends androidx.activity.ComponentActivity { @Override public boolean onJsConfirm(WebView view,String url,String message,JsResult result){new AlertDialog.Builder(MainActivity.this).setMessage(message).setPositiveButton("Continue",(d,w)->result.confirm()).setNegativeButton("Cancel",(d,w)->result.cancel()).setOnCancelListener(d->result.cancel()).show();return true;} }); web.setDownloadListener((url,userAgent,disposition,mime,length)->{if(url.startsWith("blob:")||url.startsWith("data:")){web.evaluateJavascript("window.AndroidFiles&&AndroidFiles.exportUrl("+JSONObject.quote(url)+","+JSONObject.quote(URLUtil.guessFileName(url,disposition,mime))+","+JSONObject.quote(mime==null?"application/octet-stream":mime)+")",null);}else external(Uri.parse(url));}); - LinearLayout toolbar=new LinearLayout(this);toolbar.setGravity(Gravity.CENTER);addButton(toolbar,"Home",()->web.loadUrl(origin+"/"));addButton(toolbar,"Refresh",()->web.reload());if(BuildConfig.SCREEN_CAPTURE){addButton(toolbar,"Record screen",this::chooseRecording);addButton(toolbar,"Recordings",this::recordings);}root.addView(toolbar); + LinearLayout toolbar=new LinearLayout(this);toolbar.setGravity(Gravity.CENTER);addButton(toolbar,"Home",()->web.evaluateJavascript("typeof window.requestAndroidHome==='function'&&!!window.requestAndroidHome()",handled->{if(!"true".equals(handled))web.loadUrl(origin+"/");}));addButton(toolbar,"Refresh",()->new AlertDialog.Builder(this).setMessage("Refresh the app? Unsaved changes may be lost.").setPositiveButton("Refresh",(d,w)->web.reload()).setNegativeButton("Keep editing",null).show());if(BuildConfig.SCREEN_CAPTURE){addButton(toolbar,"Record screen",this::chooseRecording);addButton(toolbar,"Recordings",this::recordings);}root.addView(toolbar); getOnBackPressedDispatcher().addCallback(this,new androidx.activity.OnBackPressedCallback(true){@Override public void handleOnBackPressed(){web.evaluateJavascript("typeof window.handleAndroidBack==='function'&&!!window.handleAndroidBack()",handled->{if("true".equals(handled))return;if(web.canGoBack())web.goBack();else{setEnabled(false);getOnBackPressedDispatcher().onBackPressed();setEnabled(true);}});}}); if(state==null){String path="/";Uri deep=getIntent().getData();if(deep!=null&&OriginPolicy.trusted(deep.toString(),origin))path=deep.getEncodedPath()+(deep.getEncodedQuery()==null?"":"?"+deep.getEncodedQuery())+(deep.getEncodedFragment()==null?"":"#"+deep.getEncodedFragment());web.loadUrl(origin+path);}else web.restoreState(state); } - private void post(JavaScriptReplyProxy reply,JSONObject data){runOnUiThread(()->{if(!isFinishing()&&web!=null&&OriginPolicy.trusted(web.getUrl(),origin))reply.postMessage(data.toString());});} - private void addButton(LinearLayout bar,String label,Runnable action){Button button=new Button(this);button.setText(label);button.setTextSize(12);button.setMinHeight(48);button.setOnClickListener(v->action.run());bar.addView(button,new LinearLayout.LayoutParams(0,-2,1));} + private void post(JavaScriptReplyProxy reply,JSONObject data){runOnUiThread(()->{if(!isFinishing()&&web!=null&&OriginPolicy.trusted(web.getUrl(),origin)){try{reply.postMessage(data.toString());}catch(IllegalStateException ignored){/* The source document was closed. */}}});} + private void addButton(LinearLayout bar,String label,Runnable action){Button button=new Button(this);button.setText(label);button.setTextSize(12);button.setMinHeight((int)(48*getResources().getDisplayMetrics().density));button.setOnClickListener(v->action.run());bar.addView(button,new LinearLayout.LayoutParams(0,-2,1));} private void showFailure(String message){failure.setText(message);failure.setVisibility(View.VISIBLE);progress.setVisibility(View.GONE);} private void external(Uri uri){String scheme=uri.getScheme();if(!Set.of("https","mailto","tel").contains(scheme==null?"":scheme))return;try{startActivity(new Intent(Intent.ACTION_VIEW,uri).addCategory(Intent.CATEGORY_BROWSABLE));}catch(ActivityNotFoundException e){showFailure("No app can open this link.");}} private WebResourceResponse localResponse(Uri uri){ diff --git a/android/emulator-check.sh b/android/emulator-check.sh new file mode 100755 index 0000000..ddc2bed --- /dev/null +++ b/android/emulator-check.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +mkdir -p android/emulator-evidence +run_mode() { + local mode="$1" result=0 + ./android/gradlew -p android --no-daemon connectedDebugAndroidTest || result=$? + if ! adb pull /sdcard/Pictures/AndroidAcceptance android/emulator-evidence/; then + if [ "$result" = 0 ]; then return 1; fi + fi + if [ -d android/app/build/reports/androidTests/connected ]; then + cp -R android/app/build/reports/androidTests/connected "android/emulator-evidence/${mode}-reports" + fi + return "$result" +} +run_mode phone +adb shell wm size 1600x2560 +adb shell wm density 240 +run_mode tablet diff --git a/android/host-support.js b/android/host-support.js index c0f311c..0b2f9b6 100644 --- a/android/host-support.js +++ b/android/host-support.js @@ -6,8 +6,9 @@ function call(op,body={}){return new Promise((resolve,reject)=>{const id=String(++sequence),timeout=setTimeout(()=>{pending.delete(id);reject(Error('The Android file transfer timed out.'))},30000);pending.set(id,{resolve,reject,timeout});AppHost.postMessage(JSON.stringify({id,op,...body}))})} async function exportUrl(url,name,mime){let token;try{const response=await fetch(url);if(!response.ok)throw Error('Could not read the exported file.');const blob=await response.blob();({token}=await call('exportBegin',{name:name||'export',mime:blob.type||mime||'application/octet-stream',size:blob.size}));let index=0;for(let offset=0;offset{});alert(e.message)}} window.AndroidFiles={exportUrl};window.print=()=>call('print').catch(e=>alert(e.message));const handled=new WeakSet(); - function handle(anchor){if(!anchor||!anchor.hasAttribute('download')||! /^(blob:|data:)/.test(anchor.href))return false;if(!handled.has(anchor)){handled.add(anchor);exportUrl(anchor.href,anchor.download,'application/octet-stream')}return true} + function handle(anchor){if(!anchor||!anchor.hasAttribute('download')||! /^(blob:|data:)/.test(anchor.href))return false;if(!handled.has(anchor)){handled.add(anchor);exportUrl(anchor.href,anchor.download,'application/octet-stream').finally(()=>handled.delete(anchor))}return true} const original=HTMLAnchorElement.prototype.click;HTMLAnchorElement.prototype.click=function(){if(!handle(this))return original.call(this)}; + const dispatch=HTMLAnchorElement.prototype.dispatchEvent;HTMLAnchorElement.prototype.dispatchEvent=function(event){if(event.type==='click'&&handle(this))return false;return dispatch.call(this,event)}; document.addEventListener('click',e=>{const anchor=e.target.closest?.('a[download]');if(handle(anchor)){e.preventDefault();e.stopImmediatePropagation()}},true); window.__ANDROID_APP__=true; })(); diff --git a/android/prepare.mjs b/android/prepare.mjs index bdff593..7211350 100644 --- a/android/prepare.mjs +++ b/android/prepare.mjs @@ -10,7 +10,7 @@ else if(name==='ContractCheq'){ for(const file of ['index.html','privacy.html','corrections.html','study-guide.html','icon.svg','icon-192.png','icon-512.png','manifest.webmanifest','assets'])await cp(file,path.join(out,file),{recursive:true}); }else throw Error('Unknown Android product root.'); await cp(path.join(here,'host-support.js'),path.join(out,'android-host.js')); -for(const file of await readdir(out)){if(!file.endsWith('.html'))continue;const full=path.join(out,file);let html=await readFile(full,'utf8');html=html.replace(/]*>/i,match=>match+'');if(name==='Greenlight-k53')html=html.replace('','');await writeFile(full,html)} +for(const file of await readdir(out)){if(!file.endsWith('.html'))continue;const full=path.join(out,file);let html=await readFile(full,'utf8');html=html.replace(/]+(?:fonts\.googleapis\.com|fonts\.gstatic\.com)[^>]*>/gi,'');html=html.replace(/]*>/i,match=>match+'');if(name==='Greenlight-k53')html=html.replace('','');await writeFile(full,html)} async function walk(root,prefix=''){let result=[];for(const entry of await readdir(root,{withFileTypes:true})){const rel=prefix+entry.name,full=path.join(root,entry.name);if(entry.isDirectory())result.push(...await walk(full,rel+'/'));else{const b=await readFile(full);result.push({path:rel,size:b.length,sha256:createHash('sha256').update(b).digest('hex')})}}return result} const files=await walk(out);if(!files.some(f=>f.path==='index.html'))throw Error('Missing entry point'); await writeFile(path.join(here,'asset-manifest.json'),JSON.stringify({product:name,sourceCommit:execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim(),files},null,2)+'\n');console.log('Packaged '+files.length+' local assets for '+name); diff --git a/dist/app.js b/dist/app.js index 40fa56d..6992ff2 100644 --- a/dist/app.js +++ b/dist/app.js @@ -114,3 +114,5 @@ function offerUpdate(registration){const existing=$('#apply-update');if(existing boot(); if(globalThis.AppHost)window.handleAndroidBack=()=>{if(modal.open){modal.close();return true}if(route.page!=='overview'){goto('overview');return true}return false}; + +if(globalThis.AppHost)window.requestAndroidHome=()=>{goto('overview');return true}; From 1a782dd099c65e91c4e98c34ec9c479cd6da6163 Mon Sep 17 00:00:00 2001 From: Loopii Date: Mon, 26 Oct 2026 13:36:28 +0200 Subject: [PATCH 4/5] Add native-mode host API and de-hardcode offline acceptance evidence - host-support.js: expose window.AndroidNative (speak/stop) next to __ANDROID_APP__ so packaged web UIs can adapt; apps without a speech bridge reject the op harmlessly. - emulator-check.sh: force-stop the bundled launcher before each acceptance run (the software-GPU runner let it raise an isn't-responding ANR dialog over the offline screenshot) and fail the build when a system dialog is still on screen as evidence is collected. --- android/emulator-check.sh | 21 +++++++++++++++++++++ android/host-support.js | 6 +++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/android/emulator-check.sh b/android/emulator-check.sh index ddc2bed..9334229 100755 --- a/android/emulator-check.sh +++ b/android/emulator-check.sh @@ -1,8 +1,28 @@ #!/usr/bin/env bash set -euo pipefail mkdir -p android/emulator-evidence +# The CI runner uses a software GPU (swiftshader). Under it the bundled launcher can +# raise an "isn't responding" ANR dialog that overlays the acceptance screenshot and +# makes working offline behaviour look like an app failure. Stop the launcher before +# each run so the app is the foreground window, then fail the build if a system dialog +# is still present when the evidence is collected. +quieten_launcher() { + for pkg in com.google.android.apps.nexuslauncher com.android.launcher3; do + adb shell am force-stop "$pkg" >/dev/null 2>&1 || true + done +} +assert_no_system_dialog() { + local mode="$1" + adb shell uiautomator dump /sdcard/window-dump.xml >/dev/null 2>&1 || return 0 + if adb shell cat /sdcard/window-dump.xml 2>/dev/null | grep -qiE "isn.t responding|has stopped"; then + echo "::error::A system ANR dialog was on screen during ${mode} acceptance evidence; the screenshot is contaminated." + return 1 + fi + return 0 +} run_mode() { local mode="$1" result=0 + quieten_launcher ./android/gradlew -p android --no-daemon connectedDebugAndroidTest || result=$? if ! adb pull /sdcard/Pictures/AndroidAcceptance android/emulator-evidence/; then if [ "$result" = 0 ]; then return 1; fi @@ -10,6 +30,7 @@ run_mode() { if [ -d android/app/build/reports/androidTests/connected ]; then cp -R android/app/build/reports/androidTests/connected "android/emulator-evidence/${mode}-reports" fi + assert_no_system_dialog "$mode" || result=1 return "$result" } run_mode phone diff --git a/android/host-support.js b/android/host-support.js index 0b2f9b6..f0ce1dd 100644 --- a/android/host-support.js +++ b/android/host-support.js @@ -10,5 +10,9 @@ const original=HTMLAnchorElement.prototype.click;HTMLAnchorElement.prototype.click=function(){if(!handle(this))return original.call(this)}; const dispatch=HTMLAnchorElement.prototype.dispatchEvent;HTMLAnchorElement.prototype.dispatchEvent=function(event){if(event.type==='click'&&handle(this))return false;return dispatch.call(this,event)}; document.addEventListener('click',e=>{const anchor=e.target.closest?.('a[download]');if(handle(anchor)){e.preventDefault();e.stopImmediatePropagation()}},true); - window.__ANDROID_APP__=true; + const nativeCall=call; +window.AndroidNative={isNative:true, + speak:(text,rate)=>nativeCall('speak',{text:String(text||'').slice(0,3000),rate:Number(rate)||1}).catch(()=>{}), + stop:()=>nativeCall('stopSpeech',{}).catch(()=>{})}; +window.__ANDROID_APP__=true; })(); From c74a0ecb20edaaaa61629ab00351aa432453b693 Mon Sep 17 00:00:00 2001 From: Loopii Date: Mon, 26 Oct 2026 13:50:36 +0200 Subject: [PATCH 5/5] Require loaded content, not the loading shell, before offline evidence is captured The emulator screenshot was taken after the readiness predicate passed, yet showed 'Opening your workspace...'. The predicate could be satisfied by the loading shell, so the acceptance image did not demonstrate offline readiness even once the launcher ANR overlay was removed. Excluding the loading copy makes the screenshot prove what it claims. --- .../src/androidTest/java/com/buttercode/mobile/LaunchTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java b/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java index e0a29f9..3a13f1b 100644 --- a/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java +++ b/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java @@ -3,7 +3,7 @@ @RunWith(AndroidJUnit4.class) public class LaunchTest { private ActivityScenario launch(){Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();Intent intent=context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());assertNotNull(intent);return ActivityScenario.launch(intent);} private String js(ActivityScenario scenario,String expression)throws Exception{CountDownLatch ready=new CountDownLatch(1);AtomicReference 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 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");if("true".equals(v))return;Thread.sleep(250);}fail("The packaged app did not render usable content.");} + private void ready(ActivityScenario 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 scenario=launch()){