diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..dcde0b3 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,61 @@ +name: Android internal builds +on: + push: + branches: [android/playstore-prep] + pull_request: + paths: ['android/**', '.github/workflows/android.yml'] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: android-${{ github.ref }} + cancel-in-progress: true +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 + with: + distribution: temurin + java-version: '17' + - uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a + with: + gradle-version: '8.13' + - name: Check listing and release gates + run: node android/release-check.mjs + - name: Prepare pinned web assets + run: node android/prepare.mjs + - name: Compile and verify Android + run: ./android/gradlew -p android --no-daemon assembleDebug bundleRelease testDebugUnitTest lintRelease + - name: Enable emulator acceleration + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Android emulator acceptance + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d + with: + api-level: 35 + arch: x86_64 + target: google_apis + profile: pixel_6 + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + script: bash android/emulator-check.sh + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: android-internal-${{ github.sha }} + path: | + android/app/build/outputs/apk/debug/*.apk + android/app/build/outputs/bundle/release/*.aab + android/app/build/reports/ + android/app/build/test-results/ + android/asset-manifest.json + android/emulator-evidence/ + if-no-files-found: error + retention-days: 30 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..d7ea03b --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,9 @@ +.gradle/ +**/build/ +local.properties +*.jks +*.keystore +*.p12 +app/src/main/assets/web/ +asset-manifest.json +emulator-evidence/ 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..05c5359 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,37 @@ +plugins { id 'com.android.application' } +android { + namespace 'com.buttercode.mobile' + compileSdk 36 + defaultConfig { applicationId 'za.co.buttercode.invoiceflow'; minSdk 26; targetSdk 36; versionCode 1; versionName '0.1.0-internal'; testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + buildConfigField 'String','APP_ORIGIN','"https://invoiceflow-za.vercel.app"' + buildConfigField 'boolean','SCREEN_CAPTURE','false' + buildConfigField 'boolean','BUNDLED_OFFLINE','true' + } + buildFeatures { buildConfig true } + compileOptions { sourceCompatibility JavaVersion.VERSION_17; targetCompatibility JavaVersion.VERSION_17; coreLibraryDesugaringEnabled true } + signingConfigs { + upload { + if (System.getenv('ANDROID_UPLOAD_KEYSTORE_PATH')) { + storeFile file(System.getenv('ANDROID_UPLOAD_KEYSTORE_PATH')) + storePassword System.getenv('ANDROID_UPLOAD_STORE_PASSWORD') + keyAlias System.getenv('ANDROID_UPLOAD_KEY_ALIAS') + keyPassword System.getenv('ANDROID_UPLOAD_KEY_PASSWORD') + } + } + } + buildTypes { release { minifyEnabled false; if(System.getenv('ANDROID_UPLOAD_KEYSTORE_PATH')) signingConfig signingConfigs.upload } } + lint { abortOnError true } + testOptions { unitTests.returnDefaultValues = true } +} +dependencies { + implementation 'androidx.webkit:webkit:1.14.0' + implementation 'androidx.core:core:1.16.0' + implementation 'androidx.activity:activity:1.10.1' + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test:runner:1.6.2' + androidTestImplementation 'androidx.test:core:1.6.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' +} + +tasks.configureEach { if(name=='connectedDebugAndroidTest') outputs.upToDateWhen {false} } 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..3a13f1b --- /dev/null +++ b/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java @@ -0,0 +1,23 @@ +package com.buttercode.mobile; +import androidx.test.ext.junit.runners.AndroidJUnit4;import androidx.test.platform.app.InstrumentationRegistry;import androidx.test.core.app.ActivityScenario;import android.content.*;import android.graphics.Bitmap;import java.io.*;import java.util.concurrent.*;import java.util.concurrent.atomic.*;import org.junit.Test;import org.junit.runner.RunWith;import static org.junit.Assert.*; +@RunWith(AndroidJUnit4.class) public class LaunchTest { + private ActivityScenario 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&&!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()){ + ready(scenario);assertEquals("true",js(scenario,"typeof window.AppHost!=='undefined'&&typeof window.AndroidFiles?.exportUrl==='function'")); + scenario.onActivity(a->{a.web.getSettings().setBlockNetworkLoads(true);a.web.loadUrl(BuildConfig.APP_ORIGIN+"/?offlineSmoke=1");});for(int n=0;n<80;n++){if("true".equals(js(scenario,"location.search.includes('offlineSmoke=1')&&document.readyState==='complete'&&!!document.querySelector('#app h1,#root h1')")))break;Thread.sleep(250);}ready(scenario); + saveScreenshot(context); + assertEquals("false",js(scenario,"document.body.innerText.includes('We couldn’t open your workspace')||document.body.innerText.includes('We couldn’t open your local library')")); + assertEquals("false",js(scenario,"document.documentElement.scrollWidth>innerWidth+2")); + + } + } + private void saveScreenshot(Context context)throws Exception{ + Bitmap image=InstrumentationRegistry.getInstrumentation().getUiAutomation().takeScreenshot();assertNotNull("No emulator screenshot",image); + android.content.ContentValues values=new android.content.ContentValues();values.put(android.provider.MediaStore.Images.Media.DISPLAY_NAME,BuildConfig.APPLICATION_ID+"-offline.png");values.put(android.provider.MediaStore.Images.Media.MIME_TYPE,"image/png");String mode=context.getResources().getConfiguration().smallestScreenWidthDp>=600?"tablet":"phone";values.put(android.provider.MediaStore.Images.Media.RELATIVE_PATH,"Pictures/AndroidAcceptance/"+mode);android.net.Uri uri=context.getContentResolver().insert(android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI,values);assertNotNull(uri);try(OutputStream out=context.getContentResolver().openOutputStream(uri)){assertNotNull(out);assertTrue(image.compress(Bitmap.CompressFormat.PNG,100,out));}image.recycle(); + } + @Test public void nativeExportRequiresExactSequentialBytes()throws Exception{Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();ExportStore store=new ExportStore(context.getCacheDir());String token=store.begin("invoice.pdf","application/pdf",4);try{store.chunk(token,1,"JVBERg==");fail("Out-of-order export accepted");}catch(IOException expected){}store.chunk(token,0,"JVBERg==");File file=store.finish(token);assertEquals(4,file.length());try(InputStream in=new FileInputStream(file)){assertEquals('%',in.read());}assertTrue(file.delete());store.abort();} +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..344b08c --- /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..80a3384 --- /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 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; + 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.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)){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){ + 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 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/emulator-check.sh b/android/emulator-check.sh new file mode 100755 index 0000000..9334229 --- /dev/null +++ b/android/emulator-check.sh @@ -0,0 +1,39 @@ +#!/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 + fi + 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 +adb shell wm size 1600x2560 +adb shell wm density 240 +run_mode tablet 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 0000000..9bbc975 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ 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..f0ce1dd --- /dev/null +++ b/android/host-support.js @@ -0,0 +1,18 @@ +/* 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').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); + 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; +})(); 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..7211350 --- /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(/]+(?: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/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..6992ff2 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){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).

`:''}`} @@ -108,7 +108,11 @@ 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(); + +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};