From 2d9c54d3ce1afb50267895f8bd2b5e43154987ec Mon Sep 17 00:00:00 2001 From: buttercode101 Date: Mon, 26 Oct 2026 11:41:37 +0200 Subject: [PATCH 1/5] Add Android internal client, native file controls and Play release preparation --- .github/workflows/android.yml | 47 +++++++++++ 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 ++++ .../buttercode/mobile/ScreenNoteActivity.java | 14 ++++ .../mobile/ScreenRecordingService.java | 31 +++++++ .../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 | 24 ++++++ 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 + public/app.js | 2 +- 31 files changed, 433 insertions(+), 1 deletion(-) 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/java/com/buttercode/mobile/ScreenNoteActivity.java create mode 100644 android/app/src/main/java/com/buttercode/mobile/ScreenRecordingService.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..ede1b69 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,47 @@ +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: Install locked web dependencies + run: npm ci + - 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..0264089 --- /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.screennote'; minSdk 26; targetSdk 36; versionCode 1; versionName '0.1.0-internal'; testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + buildConfigField 'String','APP_ORIGIN','"https://screennote-woad.vercel.app"' + buildConfigField 'boolean','SCREEN_CAPTURE','true' + buildConfigField 'boolean','BUNDLED_OFFLINE','false' + } + 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..6d4c070 --- /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/java/com/buttercode/mobile/ScreenNoteActivity.java b/android/app/src/main/java/com/buttercode/mobile/ScreenNoteActivity.java new file mode 100644 index 0000000..37982f2 --- /dev/null +++ b/android/app/src/main/java/com/buttercode/mobile/ScreenNoteActivity.java @@ -0,0 +1,14 @@ +package com.buttercode.mobile; +import android.Manifest;import android.app.*;import android.os.*;import android.content.*;import android.content.pm.PackageManager;import android.media.*;import android.media.projection.*;import android.net.Uri;import android.widget.*;import androidx.core.content.FileProvider;import java.io.*;import java.util.*; +public class ScreenNoteActivity extends MainActivity { + private boolean mic;private static final int AUDIO=81,SCREEN=82,NOTIFICATIONS=83; + @Override protected void chooseRecording(){if(ScreenRecordingService.active){new AlertDialog.Builder(this).setTitle("Recording in progress").setMessage("Stop and save your recording?").setPositiveButton("Stop recording",(d,w)->startService(new Intent(this,ScreenRecordingService.class).setAction(ScreenRecordingService.STOP))).setNegativeButton("Keep recording",null).show();return;} + new AlertDialog.Builder(this).setTitle("Record your screen").setMessage("Android will ask which screen or app to share. Recordings stay on this device until you publish. Up to 5 minutes / 25 MB. System audio and a camera bubble are not included in this first Android recorder.").setPositiveButton("Screen + microphone",(d,w)->{mic=true;if(checkSelfPermission(Manifest.permission.RECORD_AUDIO)!=PackageManager.PERMISSION_GRANTED)requestPermissions(new String[]{Manifest.permission.RECORD_AUDIO},AUDIO);else requestScreen();}).setNeutralButton("Screen only",(d,w)->{mic=false;requestScreen();}).setNegativeButton("Cancel",null).show(); + } + private void requestScreen(){if(Build.VERSION.SDK_INT>=33&&checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)!=PackageManager.PERMISSION_GRANTED&&!getSharedPreferences("capture",MODE_PRIVATE).getBoolean("notificationsAsked",false)){getSharedPreferences("capture",MODE_PRIVATE).edit().putBoolean("notificationsAsked",true).apply();requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS},NOTIFICATIONS);return;}startActivityForResult(getSystemService(MediaProjectionManager.class).createScreenCaptureIntent(),SCREEN);} + @Override public void onRequestPermissionsResult(int code,String[] names,int[] results){super.onRequestPermissionsResult(code,names,results);if(code==NOTIFICATIONS){requestScreen();return;}if(code==AUDIO&&results.length>0&&results[0]==PackageManager.PERMISSION_GRANTED)requestScreen();else if(code==AUDIO)Toast.makeText(this,"Microphone permission declined. You can choose Screen only.",Toast.LENGTH_LONG).show();} + @Override protected void onActivityResult(int code,int result,Intent data){super.onActivityResult(code,result,data);if(code==SCREEN&&result==RESULT_OK&&data!=null){Intent start=new Intent(this,ScreenRecordingService.class).putExtra("result",result).putExtra("consent",data).putExtra("microphone",mic);startForegroundService(start);Toast.makeText(this,"Recording starts now. Use the recording notification or Record screen to stop.",Toast.LENGTH_LONG).show();}} + @Override protected void onResume(){super.onResume();String error=getSharedPreferences("capture",MODE_PRIVATE).getString("error",null);if(error!=null){getSharedPreferences("capture",MODE_PRIVATE).edit().remove("error").apply();Toast.makeText(this,error,Toast.LENGTH_LONG).show();}} + @Override protected void recordings(){File dir=new File(getFilesDir(),"recordings");File[] values=dir.listFiles((d,n)->n.endsWith(".mp4")&&OriginPolicy.recordingId(n.substring(0,n.length()-4))&&!n.equals(ScreenRecordingService.activeFileName));if(values==null||values.length==0){Toast.makeText(this,"No native recordings yet. Stop a recording to save it here.",Toast.LENGTH_LONG).show();return;}Arrays.sort(values,(a,b)->Long.compare(b.lastModified(),a.lastModified()));String[] labels=Arrays.stream(values).map(f->new java.text.SimpleDateFormat("dd MMM HH:mm",Locale.getDefault()).format(new Date(f.lastModified()))+" · "+String.format(Locale.getDefault(),"%.1f MB",f.length()/1000000.0)).toArray(String[]::new);new AlertDialog.Builder(this).setTitle("Recordings on this device").setItems(labels,(d,n)->openRecording(values[n])).setNegativeButton("Close",null).show();} + private void openRecording(File file){new AlertDialog.Builder(this).setTitle("Your recording").setItems(new String[]{"Review and share in ScreenNote","Play recording","Share original MP4","Delete recording"},(d,n)->{String id=file.getName().substring(0,file.getName().length()-4);Uri uri=FileProvider.getUriForFile(this,BuildConfig.APPLICATION_ID+".files",file);if(n==0)web.loadUrl(BuildConfig.APP_ORIGIN+"/?nativeRecording="+id);else if(n==3)new AlertDialog.Builder(this).setMessage("Permanently delete this native recording? Imported copies and published videos are separate.").setPositiveButton("Delete",(a,b)->{if(!file.delete())Toast.makeText(this,"The recording could not be deleted.",Toast.LENGTH_LONG).show();}).setNegativeButton("Keep recording",null).show();else try{Intent intent=n==1?new Intent(Intent.ACTION_VIEW).setDataAndType(uri,"video/mp4"):new Intent(Intent.ACTION_SEND).setType("video/mp4").putExtra(Intent.EXTRA_STREAM,uri);intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);startActivity(n==1?intent:Intent.createChooser(intent,"Share recording"));}catch(ActivityNotFoundException e){Toast.makeText(this,"No app can open this video.",Toast.LENGTH_LONG).show();}}).show();} +} diff --git a/android/app/src/main/java/com/buttercode/mobile/ScreenRecordingService.java b/android/app/src/main/java/com/buttercode/mobile/ScreenRecordingService.java new file mode 100644 index 0000000..706b1a0 --- /dev/null +++ b/android/app/src/main/java/com/buttercode/mobile/ScreenRecordingService.java @@ -0,0 +1,31 @@ +package com.buttercode.mobile; +import android.app.*;import android.os.*;import android.content.*;import android.content.pm.ServiceInfo;import android.hardware.display.*;import android.media.*;import android.media.projection.*;import android.graphics.Point;import android.view.*;import java.io.*;import java.util.UUID; + +/** User-started capture only. START_NOT_STICKY: never record again without new consent. */ +public class ScreenRecordingService extends Service { + public static final String STOP="com.buttercode.mobile.STOP_CAPTURE";public static volatile boolean active=false;public static volatile String activeFileName=""; + private MediaProjection projection;private MediaRecorder recorder;private VirtualDisplay display;private File output;private boolean recording=false,finishing=false; + @Override public IBinder onBind(Intent intent){return null;} + @Override public int onStartCommand(Intent intent,int flags,int startId){ + if(intent==null||STOP.equals(intent.getAction())){finish();return START_NOT_STICKY;}if(active)return START_NOT_STICKY; + boolean mic=intent.getBooleanExtra("microphone",false);Intent consent=Build.VERSION.SDK_INT>=33?intent.getParcelableExtra("consent",Intent.class):intent.getParcelableExtra("consent");int result=intent.getIntExtra("result",Activity.RESULT_CANCELED); + if(consent==null||result!=Activity.RESULT_OK){stopSelf();return START_NOT_STICKY;} + try{ + NotificationManager nm=getSystemService(NotificationManager.class);nm.createNotificationChannel(new NotificationChannel("recording","Screen recording",NotificationManager.IMPORTANCE_LOW)); + PendingIntent stop=PendingIntent.getService(this,1,new Intent(this,ScreenRecordingService.class).setAction(STOP),PendingIntent.FLAG_UPDATE_CURRENT|PendingIntent.FLAG_IMMUTABLE); + PendingIntent open=PendingIntent.getActivity(this,2,new Intent(this,ScreenNoteActivity.class),PendingIntent.FLAG_UPDATE_CURRENT|PendingIntent.FLAG_IMMUTABLE); + Notification notice=new Notification.Builder(this,"recording").setContentTitle("ScreenNote is recording").setContentText("Tap Stop when you have finished. Up to 5 minutes / 25 MB.").setSmallIcon(android.R.drawable.presence_video_online).setOngoing(true).setContentIntent(open).addAction(new Notification.Action.Builder(null,"Stop recording",stop).build()).build(); + int types=0;if(Build.VERSION.SDK_INT>=29)types=ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION;if(mic&&Build.VERSION.SDK_INT>=30)types|=ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE; + if(Build.VERSION.SDK_INT>=29)startForeground(40,notice,types);else startForeground(40,notice); + MediaProjectionManager manager=getSystemService(MediaProjectionManager.class);projection=manager.getMediaProjection(result,consent);projection.registerCallback(new MediaProjection.Callback(){@Override public void onStop(){finish();}},new Handler(getMainLooper())); + File folder=new File(getFilesDir(),"recordings");folder.mkdirs();output=new File(folder,UUID.randomUUID()+".mp4");activeFileName=output.getName(); + Point size=new Point();((WindowManager)getSystemService(WINDOW_SERVICE)).getDefaultDisplay().getRealSize(size);float scale=Math.min(1f,1280f/Math.max(size.x,size.y));int width=Math.max(2,Math.round(size.x*scale)/2*2),height=Math.max(2,Math.round(size.y*scale)/2*2); + recorder=Build.VERSION.SDK_INT>=31?new MediaRecorder(this):new MediaRecorder();if(mic)recorder.setAudioSource(MediaRecorder.AudioSource.MIC);recorder.setVideoSource(MediaRecorder.VideoSource.SURFACE);recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);recorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264);recorder.setVideoSize(width,height);recorder.setVideoFrameRate(24);recorder.setVideoEncodingBitRate(1000000);if(mic){recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);recorder.setAudioEncodingBitRate(64000);recorder.setAudioSamplingRate(44100);}recorder.setOutputFile(output.getAbsolutePath());recorder.setMaxDuration(300000);recorder.setMaxFileSize(25L*1024*1024-65536);recorder.setOnInfoListener((r,what,extra)->{if(what==MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED||what==MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED)finish();});recorder.setOnErrorListener((r,what,extra)->finish());recorder.prepare(); + display=projection.createVirtualDisplay("ScreenNote",width,height,getResources().getDisplayMetrics().densityDpi,DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,recorder.getSurface(),null,null);recorder.start();recording=true;active=true; + }catch(Exception e){finish();getSharedPreferences("capture",MODE_PRIVATE).edit().putString("error","Recording could not start. Try again with a fresh screen-sharing permission.").apply();} + return START_NOT_STICKY; + } + private synchronized void finish(){if(finishing)return;finishing=true;boolean saved=false;try{if(recording){recorder.stop();saved=output!=null&&output.length()>0;}}catch(RuntimeException ignored){}recording=false;active=false;activeFileName="";if(display!=null){display.release();display=null;}if(recorder!=null){try{recorder.release();}catch(RuntimeException ignored){}recorder=null;}if(projection!=null){MediaProjection p=projection;projection=null;p.stop();}if(output!=null&&!saved)output.delete();if(saved)getSharedPreferences("capture",MODE_PRIVATE).edit().putString("latest",output.getName()).apply();stopForeground(STOP_FOREGROUND_REMOVE);stopSelf();} + @Override public void onDestroy(){finish();super.onDestroy();} + @Override public void onTaskRemoved(Intent intent){finish();super.onTaskRemoved(intent);} +} 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..aeb5777 --- /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..948d539 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1 @@ +ScreenNote \ 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..500e3ea --- /dev/null +++ b/android/play-store/data-safety-working-notes.json @@ -0,0 +1,24 @@ +{ + "status": "Engineering inventory, not completed Google Play declarations", + "local": [ + "native MP4 recordings", + "imported videos", + "editor metadata", + "private recovery keys", + "comment editing capabilities" + ], + "server": [ + "videos explicitly published by users", + "titles and supplied context", + "guest names and comments", + "anonymous reaction identifiers", + "normal hosting/security logs" + ], + "remaining": [ + "Terms acceptance before upload/comment", + "In-app reporting and blocking appropriate to anonymous shared-link discussions", + "Operator moderation workflow and real contact details", + "Stable public privacy-policy page naming actual publisher", + "Foreground-service declaration with a real recording demonstration" + ] +} diff --git a/android/play-store/listing-draft.json b/android/play-store/listing-draft.json new file mode 100644 index 0000000..4570721 --- /dev/null +++ b/android/play-store/listing-draft.json @@ -0,0 +1,13 @@ +{ + "status": "DRAFT \u2014 do not submit without release-gate review", + "title": "ScreenNote", + "shortDescription": "Record, review and share short videos with contextual conversations.", + "fullDescription": "ScreenNote\n\nRecord your Android screen with optional microphone audio.\nReview and trim videos before explicitly publishing a link.\nDiscuss specific moments using comments and threaded replies.\nKeep native recordings private on this device until you choose to share.\n\nThe first Android recorder supports screen-only and microphone capture, up to 5 minutes or 25 MB. System audio and camera overlays are not included. Online links use shared beta capacity and expire according to the selected period.", + "category": "Productivity", + "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..561c659 --- /dev/null +++ b/android/release-gates.json @@ -0,0 +1,16 @@ +{ + "app": "ScreenNote", + "packageId": "za.co.buttercode.screennote", + "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": false +} 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/public/app.js b/public/app.js index db69810..0a6826e 100644 --- a/public/app.js +++ b/public/app.js @@ -95,4 +95,4 @@ actions['beta-feedback']=()=>dialog('Report your test',`

Save this report loca document.addEventListener('keydown',e=>{if(e.target.closest('input,textarea,select,[contenteditable=true]')||modal.open)return;if(busy)return;if(e.altKey&&e.shiftKey){if(capture?.begun&&recording&&e.code==='KeyP'){e.preventDefault();actions.pause()}if(capture?.begun&&recording&&e.code==='KeyS'){e.preventDefault();actions.stop()}return}if(e.ctrlKey||e.metaKey||e.altKey)return;const p=$('#viewer-player')||$('#player');if(!p)return;if(e.code==='KeyK'){e.preventDefault();p.paused?p.play().catch(()=>{}):p.pause()}else if(e.code==='KeyJ'||e.code==='KeyL'){e.preventDefault();p.currentTime=Math.min(p.duration||0,Math.max(0,p.currentTime+(e.code==='KeyJ'?-5:5)))}else if(e.code==='KeyM'){e.preventDefault();p.muted=!p.muted}}); async function boot(){const match=location.pathname.match(/^\/v\/([a-f0-9-]{36})$/);if(match||location.pathname.startsWith('/v/')){await viewerPage(match?.[1]||location.pathname.slice(3));return}try{await db.open();libraryReady=true;clips=await db.all();home();checkSharing();const drafts=await db.all('drafts');if(drafts.some(d=>d.chunks.length)){dialog('A recording was interrupted',`

ScreenNote found saved recording chunks on this device. Recover the most recent partial recording? Some browsers may not be able to play an interrupted file.

`,async()=>{const d=drafts.filter(d=>d.chunks.length).sort((a,b)=>b.createdAt-a.createdAt)[0],blob=await fixed(new Blob(d.chunks,{type:d.chunks[0].type}),d.seconds);const info=await metadata(blob,d.seconds),c={id:randomId(),owner:secret(),title:'Recovered recording',blob,type:blob.type.split(';')[0],createdAt:Date.now(),...info};await db.finish(c,d.id);clips=await db.all();editor(c)},'Recover recording')}}catch(e){app.innerHTML=`

We couldn’t open your local library.

${esc(e.message)}

Use a regular browser with device storage enabled. Do not clear site data if you have recordings saved here.

`}} -boot(); +boot().then(async()=>{if(!globalThis.AppHost)return;const id=new URLSearchParams(location.search).get('nativeRecording');if(!/^[a-f0-9-]{36}$/.test(id||''))return;try{const response=await fetch('/__native_recordings/'+id);if(!response.ok)throw Error('This native recording is unavailable. Open Recordings to choose it again.');const blob=await response.blob();await importVideo(new File([blob],'Screen recording.mp4',{type:'video/mp4'}));history.replaceState(null,'','/')}catch(e){error(e)}}); From dec7dc3206f5dd52ab46e5bace632ca6f9115d87 Mon Sep 17 00:00:00 2001 From: buttercode101 Date: Mon, 26 Oct 2026 11:50:57 +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 ++-- public/app.js | 2 ++ 7 files changed, 46 insertions(+), 5 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index ede1b69..a15875d 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -33,6 +33,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.screennote 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.screennote cat files/evidence/launch-offline.png > android/emulator-evidence/tablet-offline.png - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 if: always() with: @@ -43,5 +65,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 0264089..649ccd3 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 6d4c070..13a63ce 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/public/app.js b/public/app.js index 0a6826e..8c9054f 100644 --- a/public/app.js +++ b/public/app.js @@ -96,3 +96,5 @@ document.addEventListener('keydown',e=>{if(e.target.closest('input,textarea,sele async function boot(){const match=location.pathname.match(/^\/v\/([a-f0-9-]{36})$/);if(match||location.pathname.startsWith('/v/')){await viewerPage(match?.[1]||location.pathname.slice(3));return}try{await db.open();libraryReady=true;clips=await db.all();home();checkSharing();const drafts=await db.all('drafts');if(drafts.some(d=>d.chunks.length)){dialog('A recording was interrupted',`

ScreenNote found saved recording chunks on this device. Recover the most recent partial recording? Some browsers may not be able to play an interrupted file.

`,async()=>{const d=drafts.filter(d=>d.chunks.length).sort((a,b)=>b.createdAt-a.createdAt)[0],blob=await fixed(new Blob(d.chunks,{type:d.chunks[0].type}),d.seconds);const info=await metadata(blob,d.seconds),c={id:randomId(),owner:secret(),title:'Recovered recording',blob,type:blob.type.split(';')[0],createdAt:Date.now(),...info};await db.finish(c,d.id);clips=await db.all();editor(c)},'Recover recording')}}catch(e){app.innerHTML=`

We couldn’t open your local library.

${esc(e.message)}

Use a regular browser with device storage enabled. Do not clear site data if you have recordings saved here.

`}} boot().then(async()=>{if(!globalThis.AppHost)return;const id=new URLSearchParams(location.search).get('nativeRecording');if(!/^[a-f0-9-]{36}$/.test(id||''))return;try{const response=await fetch('/__native_recordings/'+id);if(!response.ok)throw Error('This native recording is unavailable. Open Recordings to choose it again.');const blob=await response.blob();await importVideo(new File([blob],'Screen recording.mp4',{type:'video/mp4'}));history.replaceState(null,'','/')}catch(e){error(e)}}); + +if(globalThis.AppHost)window.handleAndroidBack=()=>{if(busy||recording)return true;if(modal.open){modal.close();return true}if(selected){home();return true}return false}; From a47398a9490e344ca8ac6cdca7922c9dea2a7a15 Mon Sep 17 00:00:00 2001 From: buttercode101 Date: Mon, 26 Oct 2026 12:04:42 +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 +- public/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 a15875d..052b834 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -47,14 +47,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.screennote 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.screennote 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 649ccd3..dc5b83f 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/public/app.js b/public/app.js index 8c9054f..558f800 100644 --- a/public/app.js +++ b/public/app.js @@ -98,3 +98,5 @@ async function boot(){const match=location.pathname.match(/^\/v\/([a-f0-9-]{36}) boot().then(async()=>{if(!globalThis.AppHost)return;const id=new URLSearchParams(location.search).get('nativeRecording');if(!/^[a-f0-9-]{36}$/.test(id||''))return;try{const response=await fetch('/__native_recordings/'+id);if(!response.ok)throw Error('This native recording is unavailable. Open Recordings to choose it again.');const blob=await response.blob();await importVideo(new File([blob],'Screen recording.mp4',{type:'video/mp4'}));history.replaceState(null,'','/')}catch(e){error(e)}}); if(globalThis.AppHost)window.handleAndroidBack=()=>{if(busy||recording)return true;if(modal.open){modal.close();return true}if(selected){home();return true}return false}; + +if(globalThis.AppHost)window.requestAndroidHome=()=>{if(!busy&&!recording)home();return true}; From 761fcb0f33d5df708ddc28f49005c0e70e87a091 Mon Sep 17 00:00:00 2001 From: Loopii Date: Mon, 26 Oct 2026 13:36:34 +0200 Subject: [PATCH 4/5] Add native-mode host API and de-hardcode offline acceptance evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. - app.js: accurate capture copy in the packaged app — the native recorder exists, so the UI no longer tells the user screen capture is unavailable 'in this browser'. --- 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 3d7c91c34fabd829a560027518a79a7ee5db54d4 Mon Sep 17 00:00:00 2001 From: Loopii Date: Mon, 26 Oct 2026 13:51:09 +0200 Subject: [PATCH 5/5] Actually include the native-aware capture copy (correction to the previous commit) The previous commit message on this branch claimed an app.js change that it did not contain: a 'git add' with a pathspec that does not exist in this repository failed atomically and its stderr was suppressed, so only android/emulator-check.sh and android/host-support.js were staged. This commit adds the real change. public/app.js: in the packaged app the recorder is native, so the panel no longer tells the user screen capture is unavailable 'in this browser'; it points at the Record screen control and the import path instead. Browser behaviour is unchanged. --- public/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app.js b/public/app.js index 558f800..a66519e 100644 --- a/public/app.js +++ b/public/app.js @@ -25,7 +25,7 @@ async function checkSharing(){if(healthChecking)return;healthChecking=true;const async function api(op,data,id){const r=await fetch('/api/clip?op='+op+(id?'&id='+encodeURIComponent(id):''),data?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data),signal:AbortSignal.timeout(65000)}:{signal:AbortSignal.timeout(65000)});let v;try{v=await r.json()}catch{throw Error('The sharing service did not respond. Your local copy is safe.')}if(!r.ok)throw Object.assign(Error(v.error||'Sharing failed. Keep your local copy and retry.'),{status:r.status,code:v.code});return v} function shell(content,page='My recordings',view=page){discussionUI?.destroy();discussionUI=null;const navigated=renderedView!==view;renderedView=view;markupUI?.destroy();markupUI=null;app.innerHTML=`
Your space ${page}${brand()}
Early access · local first
${esc(sharingIssue)}
${emergencyBlob?``:''}${content}
ScreenNote · A little more human.Your recordings stay here until you choose to publish.
`;if(navigated){window.scrollTo({top:0,left:0,behavior:'instant'});$('#main').focus({preventScroll:true})}} const isShared=c=>!!c.shared&&!c.revoked&&c.expiresAt>Date.now(); -function home(){if(recording||busy)return;const canRecord=globalThis.isSecureContext&&!!navigator.mediaDevices?.getDisplayMedia&&!!globalThis.MediaRecorder;selected=null;resetURL();shell(`
LESS BACK-AND-FORTH. MORE CLARITY.

A quick video.
A lot less explaining.

Show your screen. Add your voice. Send the link.
Let the conversation happen in its own time.

Free beta · Viewers need no account. Up to 5 minutes / 25 MB. Shared storage uses a 1 GB budget; capacity depends on video sizes.
YOUR NEXT GOOD EXPLANATION
${icon('play')}

Ready when you are.

A walkthrough, an update, or a little context.

Up to 5 minutes · 25 MB per shared clipReview first. Publish when ready.
MAKE YOURSELF CLEAR

${canRecord?'What are we recording?':'Bring your recording.'}

${canRecord?'Choose a source next, then prepare your recording before the countdown.':'Import a device recording, review it, then share when ready.'}

${option('mic','Microphone','Your voice, alongside your screen','mic',canRecord)}
${option('video','Camera bubble','Put a face to your message','camera',canRecord)}${option('sound','Shared-screen audio','When your browser offers it','system',canRecord)}${!canRecord?'

Screen capture is unavailable in this browser. Import an MP4 or WebM instead.

':''}

Your recordings ${clips.length}

Saved in this browser
${clips.length?clips.map(c=>`
${c.thumbnail?``:icon('video')}${formatTime(c.duration)}

${esc(isGeneratedTitle(c.title)?'Untitled recording':c.title)}

${c.cloudDeleted?'Uploaded video deleted':isShared(c)?'Shared link · expires '+new Date(c.expiresAt).toLocaleDateString():c.revoked?'Sharing stopped':c.shared?'Shared link expired':c.managementOnly?'Recovered management access':c.sample?'Sample clip · local only':'Only on this device'} · ${new Date(c.createdAt).toLocaleDateString()}
`).join(''):`

Your ideas belong here.

Record your first message, import a video, or explore with a real sample clip.

`}
`);$('#import-video').onchange=e=>{const file=e.target.files[0];if(file)run(async()=>{const status=$('#import-status');if(status)status.textContent='Reading and checking your video…';e.target.disabled=true;try{await importVideo(file)}finally{e.target.value='';e.target.disabled=false;if(status?.isConnected)status.textContent=''}})}} +function home(){if(recording||busy)return;const canRecord=globalThis.isSecureContext&&!!navigator.mediaDevices?.getDisplayMedia&&!!globalThis.MediaRecorder;selected=null;resetURL();shell(`
LESS BACK-AND-FORTH. MORE CLARITY.

A quick video.
A lot less explaining.

Show your screen. Add your voice. Send the link.
Let the conversation happen in its own time.

Free beta · Viewers need no account. Up to 5 minutes / 25 MB. Shared storage uses a 1 GB budget; capacity depends on video sizes.
YOUR NEXT GOOD EXPLANATION
${icon('play')}

Ready when you are.

A walkthrough, an update, or a little context.

Up to 5 minutes · 25 MB per shared clipReview first. Publish when ready.
MAKE YOURSELF CLEAR

${canRecord?'What are we recording?':'Bring your recording.'}

${canRecord?'Choose a source next, then prepare your recording before the countdown.':'Import a device recording, review it, then share when ready.'}

${option('mic','Microphone','Your voice, alongside your screen','mic',canRecord)}
${option('video','Camera bubble','Put a face to your message','camera',canRecord)}${option('sound','Shared-screen audio','When your browser offers it','system',canRecord)}${!canRecord?('

'+(window.__ANDROID_APP__?'Use Record screen below to capture, then bring the recording in here.':'Screen capture is unavailable in this browser. Import an MP4 or WebM instead.')+'

'):''}

Your recordings ${clips.length}

Saved in this browser
${clips.length?clips.map(c=>`
${c.thumbnail?``:icon('video')}${formatTime(c.duration)}

${esc(isGeneratedTitle(c.title)?'Untitled recording':c.title)}

${c.cloudDeleted?'Uploaded video deleted':isShared(c)?'Shared link · expires '+new Date(c.expiresAt).toLocaleDateString():c.revoked?'Sharing stopped':c.shared?'Shared link expired':c.managementOnly?'Recovered management access':c.sample?'Sample clip · local only':'Only on this device'} · ${new Date(c.createdAt).toLocaleDateString()}
`).join(''):`

Your ideas belong here.

Record your first message, import a video, or explore with a real sample clip.

`}
`);$('#import-video').onchange=e=>{const file=e.target.files[0];if(file)run(async()=>{const status=$('#import-status');if(status)status.textContent='Reading and checking your video…';e.target.disabled=true;try{await importVideo(file)}finally{e.target.value='';e.target.disabled=false;if(status?.isConnected)status.textContent=''}})}} function option(i,title,copy,id,enabled=true){return ``} function resetURL(){if(objectURL)URL.revokeObjectURL(objectURL);objectURL=null} function fitPlayer(video){if(!video)return;const fit=()=>{if(video.videoWidth&&video.videoHeight){video.style.aspectRatio=video.videoWidth+'/'+video.videoHeight;video.parentElement.style.setProperty('--media-ratio',video.videoWidth/video.videoHeight)}};video.addEventListener('loadedmetadata',fit);video.addEventListener('resize',fit);fit()}