diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..052b834 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,63 @@ +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 + - name: Enable emulator acceleration + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Android emulator acceptance + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d + with: + api-level: 35 + arch: x86_64 + target: google_apis + profile: pixel_6 + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + script: bash android/emulator-check.sh + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: android-internal-${{ github.sha }} + path: | + android/app/build/outputs/apk/debug/*.apk + android/app/build/outputs/bundle/release/*.aab + android/app/build/reports/ + android/app/build/test-results/ + android/asset-manifest.json + android/emulator-evidence/ + if-no-files-found: error + retention-days: 30 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..d7ea03b --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,9 @@ +.gradle/ +**/build/ +local.properties +*.jks +*.keystore +*.p12 +app/src/main/assets/web/ +asset-manifest.json +emulator-evidence/ diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..60c5f2d --- /dev/null +++ b/android/README.md @@ -0,0 +1,27 @@ +# Android internal client + +This project is a testable Android release candidate, not a claim of Play approval or physical device acceptance. See play-store/ for product-specific listing, data handling, acceptance and console handoff. Package IDs are provisional until first upload. Native app data is separate from Chrome/browser data; use explicit backup/import to migrate. + +## Reproducible build + +Install JDK 17 and an already licensed Android SDK (API 36 and build-tools 35.0.0), or use the pinned GitHub Android workflow with its runner-provided SDK. For ScreenNote/ContractCheck run npm ci first. No signing secrets are used by CI. + +``` +node android/prepare.mjs +./android/gradlew -p android assembleDebug testDebugUnitTest lintRelease bundleRelease +node android/release-check.mjs --submission +``` + +The final check deliberately fails while required release gates remain incomplete. A debug APK is installable for developer testing. The release AAB is UNSIGNED unless private signing configuration is provided and cannot be uploaded as-is. Pin/checksum data and the local-asset manifest tie builds to source. Do not ship a generic wrapper without completing the acceptance flows. + +For a controlled signed release provide ANDROID_UPLOAD_KEYSTORE_PATH, ANDROID_UPLOAD_STORE_PASSWORD, ANDROID_UPLOAD_KEY_ALIAS and ANDROID_UPLOAD_KEY_PASSWORD privately. Never commit/upload a key or password, print signing variables, put passwords in command-line arguments or publish signing material in CI artifacts. The developer must retain a private backup. Production signing and Play App Signing enrollment are owner-controlled release steps. + +## Native boundary + +Only the exact configured HTTPS origin and main frame can send native WebMessages. No wildcard origins or addJavascriptInterface bridge. External websites open in the system browser. Mixed content, cleartext and filesystem URL access are disabled. Native file exports are bounded and use the Android save/share chooser, with temporary content-URI permissions. No all-files-storage permission. Android backup is disabled; explicit product backups remain available. + +InvoiceFlow and GreenLight bundle first-launch offline content. ScreenNote bundles its editor and calls the existing online sharing API. ContractCheck bundles its frontend; analysis needs its existing server. Its Android test client gates new Paystack checkout pending a regional billing review. The website checkout is unaffected. + +ScreenNote alone includes the native recorder: fresh Android capture consent, foreground notification/stop control, screen-only or microphone audio, private MP4s, a 5-minute/25-MiB cap, recording list and explicit native-to-editor import. It never restarts capture without new consent after service/task termination. System audio and camera overlays are not implemented in this first native recorder and must not be advertised. Physical recording, interruption and media tests remain mandatory. + +No Play Console setup, invitations, payments, legal acceptance, upload signing, physical-device tests or publication are implied by this source build. diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..dc5b83f --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,37 @@ +plugins { id 'com.android.application' } +android { + namespace 'com.buttercode.mobile' + compileSdk 36 + defaultConfig { applicationId 'za.co.buttercode.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' + implementation 'androidx.activity:activity:1.10.1' + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test:runner:1.6.2' + androidTestImplementation 'androidx.test:core:1.6.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' +} + +tasks.configureEach { if(name=='connectedDebugAndroidTest') outputs.upToDateWhen {false} } diff --git a/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java b/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java new file mode 100644 index 0000000..e0a29f9 --- /dev/null +++ b/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java @@ -0,0 +1,23 @@ +package com.buttercode.mobile; +import androidx.test.ext.junit.runners.AndroidJUnit4;import androidx.test.platform.app.InstrumentationRegistry;import androidx.test.core.app.ActivityScenario;import android.content.*;import android.graphics.Bitmap;import java.io.*;import java.util.concurrent.*;import java.util.concurrent.atomic.*;import org.junit.Test;import org.junit.runner.RunWith;import static org.junit.Assert.*; +@RunWith(AndroidJUnit4.class) public class LaunchTest { + private ActivityScenario launch(){Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();Intent intent=context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());assertNotNull(intent);return ActivityScenario.launch(intent);} + private String js(ActivityScenario scenario,String expression)throws Exception{CountDownLatch ready=new CountDownLatch(1);AtomicReference value=new AtomicReference<>();scenario.onActivity(a->a.web.evaluateJavascript(expression,v->{value.set(v);ready.countDown();}));assertTrue("WebView evaluation timed out",ready.await(10,TimeUnit.SECONDS));return value.get();} + private void ready(ActivityScenario scenario)throws Exception{for(int n=0;n<80;n++){String v=js(scenario,"document.readyState==='complete'&&!!document.querySelector('#app h1,#root h1')&&document.body.innerText.length>120");if("true".equals(v))return;Thread.sleep(250);}fail("The packaged app did not render usable content.");} + @androidx.test.filters.SdkSuppress(minSdkVersion=29) @Test public void assetsAndNativeBridgeLoadOffline()throws Exception{ + Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();try(InputStream in=context.getAssets().open("web/index.html")){assertTrue(in.read()!=-1);} + try(ActivityScenario scenario=launch()){ + ready(scenario);assertEquals("true",js(scenario,"typeof window.AppHost!=='undefined'&&typeof window.AndroidFiles?.exportUrl==='function'")); + scenario.onActivity(a->{a.web.getSettings().setBlockNetworkLoads(true);a.web.loadUrl(BuildConfig.APP_ORIGIN+"/?offlineSmoke=1");});for(int n=0;n<80;n++){if("true".equals(js(scenario,"location.search.includes('offlineSmoke=1')&&document.readyState==='complete'&&!!document.querySelector('#app h1,#root h1')")))break;Thread.sleep(250);}ready(scenario); + saveScreenshot(context); + assertEquals("false",js(scenario,"document.body.innerText.includes('We couldn’t open your workspace')||document.body.innerText.includes('We couldn’t open your local library')")); + assertEquals("false",js(scenario,"document.documentElement.scrollWidth>innerWidth+2")); + + } + } + private void saveScreenshot(Context context)throws Exception{ + Bitmap image=InstrumentationRegistry.getInstrumentation().getUiAutomation().takeScreenshot();assertNotNull("No emulator screenshot",image); + android.content.ContentValues values=new android.content.ContentValues();values.put(android.provider.MediaStore.Images.Media.DISPLAY_NAME,BuildConfig.APPLICATION_ID+"-offline.png");values.put(android.provider.MediaStore.Images.Media.MIME_TYPE,"image/png");String mode=context.getResources().getConfiguration().smallestScreenWidthDp>=600?"tablet":"phone";values.put(android.provider.MediaStore.Images.Media.RELATIVE_PATH,"Pictures/AndroidAcceptance/"+mode);android.net.Uri uri=context.getContentResolver().insert(android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI,values);assertNotNull(uri);try(OutputStream out=context.getContentResolver().openOutputStream(uri)){assertNotNull(out);assertTrue(image.compress(Bitmap.CompressFormat.PNG,100,out));}image.recycle(); + } + @Test public void nativeExportRequiresExactSequentialBytes()throws Exception{Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();ExportStore store=new ExportStore(context.getCacheDir());String token=store.begin("invoice.pdf","application/pdf",4);try{store.chunk(token,1,"JVBERg==");fail("Out-of-order export accepted");}catch(IOException expected){}store.chunk(token,0,"JVBERg==");File file=store.finish(token);assertEquals(4,file.length());try(InputStream in=new FileInputStream(file)){assertEquals('%',in.read());}assertTrue(file.delete());store.abort();} +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..13a63ce --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/com/buttercode/mobile/ExportStore.java b/android/app/src/main/java/com/buttercode/mobile/ExportStore.java new file mode 100644 index 0000000..28584e6 --- /dev/null +++ b/android/app/src/main/java/com/buttercode/mobile/ExportStore.java @@ -0,0 +1,16 @@ +package com.buttercode.mobile; +import android.util.Base64; +import java.io.*;import java.util.UUID; +final class ExportStore { + private final File directory;private File active;private OutputStream output;private String token;private long expected,written;private int sequence; + String name,mime; + ExportStore(File root){directory=new File(root,"exports");directory.mkdirs();} + synchronized String begin(String fileName,String contentType,long size)throws IOException{ + if(output!=null)throw new IOException("Finish the current export first.");if(size<1||size>OriginPolicy.MAX_EXPORT_BYTES||!OriginPolicy.exportMime(contentType)||!contentType.matches("[a-zA-Z0-9!#$&^_.+/-]+(?:;[a-zA-Z0-9=_.+ -]+)?"))throw new IOException("Unsupported export type or size."); + long total=0;for(File f:directory.listFiles()==null?new File[0]:directory.listFiles()){if(System.currentTimeMillis()-f.lastModified()>7L*86400000)f.delete();else total+=f.length();}if(total+size>100L*1024*1024)throw new IOException("Export cache is full. Save your files and clear the export cache from Android settings."); + token=UUID.randomUUID().toString();name=OriginPolicy.safeName(fileName);mime=contentType;active=new File(directory,token+"-"+name);expected=size;written=0;sequence=0;output=new FileOutputStream(active);return token; + } + synchronized void chunk(String handle,int index,String encoded)throws IOException{if(output==null||!token.equals(handle)||sequence!=index||encoded.length()>90000)throw new IOException("Invalid export transfer.");byte[] bytes;try{bytes=Base64.decode(encoded,Base64.NO_WRAP);}catch(IllegalArgumentException e){throw new IOException("Invalid export data.");}if(bytes.length>65536||written+bytes.length>expected)throw new IOException("Export exceeds its declared size.");output.write(bytes);written+=bytes.length;sequence++;} + synchronized File finish(String handle)throws IOException{if(output==null||!token.equals(handle)||written!=expected)throw new IOException("Export is incomplete.");output.close();output=null;File completed=active;active=null;return completed;} + synchronized void abort(){try{if(output!=null)output.close();}catch(IOException ignored){}output=null;if(active!=null)active.delete();active=null;token=null;} +} diff --git a/android/app/src/main/java/com/buttercode/mobile/MainActivity.java b/android/app/src/main/java/com/buttercode/mobile/MainActivity.java new file mode 100644 index 0000000..80a3384 --- /dev/null +++ b/android/app/src/main/java/com/buttercode/mobile/MainActivity.java @@ -0,0 +1,77 @@ +package com.buttercode.mobile; + +import android.app.*;import android.os.*;import android.content.*;import android.content.pm.PackageManager;import android.graphics.Color;import android.net.Uri;import android.view.*;import android.webkit.*;import android.widget.*;import android.provider.MediaStore; +import androidx.core.content.FileProvider;import androidx.webkit.*; +import org.json.*;import java.io.*;import java.util.*;import java.util.concurrent.*; + +public class MainActivity extends androidx.activity.ComponentActivity { + protected WebView web;private LinearLayout root;private ProgressBar progress;private TextView failure; + private ExportStore exports;private final ExecutorService files=Executors.newSingleThreadExecutor(); + private ValueCallback chooser;private Uri cameraUri;private File saveFile;private String saveMime; + private static final int PICK=10,CAMERA=11,SAVE=12,MIC=13,PROJECTION=14; + private boolean microphone;private final String origin=BuildConfig.APP_ORIGIN; + @Override public void onCreate(Bundle state){super.onCreate(state);exports=new ExportStore(getCacheDir()); + root=new LinearLayout(this);root.setOrientation(LinearLayout.VERTICAL);root.setBackgroundColor(Color.rgb(247,246,242)); + root.setOnApplyWindowInsetsListener((v,insets)->{if(Build.VERSION.SDK_INT>=30){android.graphics.Insets bars=insets.getInsets(WindowInsets.Type.systemBars()|WindowInsets.Type.displayCutout());v.setPadding(bars.left,bars.top,bars.right,bars.bottom);}else v.setPadding(insets.getSystemWindowInsetLeft(),insets.getSystemWindowInsetTop(),insets.getSystemWindowInsetRight(),insets.getSystemWindowInsetBottom());return insets;}); + setContentView(root);getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); + progress=new ProgressBar(this,null,android.R.attr.progressBarStyleHorizontal);progress.setIndeterminate(true);root.addView(progress,new LinearLayout.LayoutParams(-1,4)); + failure=new TextView(this);failure.setPadding(20,16,20,16);failure.setTextColor(Color.rgb(140,35,20));failure.setVisibility(View.GONE);root.addView(failure); + web=new WebView(this);root.addView(web,new LinearLayout.LayoutParams(-1,0,1)); + WebSettings settings=web.getSettings();settings.setJavaScriptEnabled(true);settings.setDomStorageEnabled(true);settings.setAllowFileAccess(false);settings.setAllowContentAccess(false);settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);settings.setMediaPlaybackRequiresUserGesture(true);settings.setUserAgentString(settings.getUserAgentString()+" ButtercodeAndroid/1 "+BuildConfig.APPLICATION_ID); + CookieManager.getInstance().setAcceptThirdPartyCookies(web,false);WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG); + if(!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)){failure.setText("Update Android System WebView in Google Play, then reopen this app.");failure.setVisibility(View.VISIBLE);return;} + WebViewCompat.addWebMessageListener(web,"AppHost",Set.of(origin),(view,message,source,isMainFrame,reply)->{ + if(!isMainFrame||!OriginPolicy.trusted(source.toString(),origin)||!OriginPolicy.trusted(web.getUrl(),origin))return; + String raw=message.getData();if(raw==null||raw.length()>100000)return; + files.execute(()->{String id="";try{JSONObject body=new JSONObject(raw);id=body.getString("id");if(id.length()>80)throw new IOException("Invalid request.");String op=body.getString("op");Object data; + switch(op){case "exportBegin":data=new JSONObject().put("token",exports.begin(body.optString("name"),body.optString("mime"),body.optLong("size")));break; + case "exportChunk":exports.chunk(body.getString("token"),body.getInt("sequence"),body.getString("bytes"));data=true;break; + case "exportFinish":File completed=exports.finish(body.getString("token"));String mime=exports.mime,name=exports.name;runOnUiThread(()->offerExport(completed,name,mime));data=true;break; + case "exportAbort":exports.abort();data=true;break; + case "print":runOnUiThread(()->{android.print.PrintManager pm=(android.print.PrintManager)getSystemService(PRINT_SERVICE);pm.print(getString(com.buttercode.mobile.R.string.app_name),web.createPrintDocumentAdapter(getString(com.buttercode.mobile.R.string.app_name)),null);});data=true;break; + default:throw new IOException("Unsupported native operation.");} + post(reply,new JSONObject().put("id",id).put("ok",true).put("data",data)); + }catch(Exception e){try{post(reply,new JSONObject().put("id",id).put("ok",false).put("error",e instanceof IOException?e.getMessage():"The file transfer could not complete."));}catch(JSONException ignored){}}}); + }); + web.setWebViewClient(new WebViewClient(){ + @Override public WebResourceResponse shouldInterceptRequest(WebView view,WebResourceRequest request){return localResponse(request.getUrl());} + @Override public boolean shouldOverrideUrlLoading(WebView view,WebResourceRequest request){Uri uri=request.getUrl();if(OriginPolicy.trusted(uri.toString(),origin))return false;if(!request.isForMainFrame())return true;external(uri);return true;} + @Override public void onPageStarted(WebView view,String url,android.graphics.Bitmap icon){progress.setVisibility(View.VISIBLE);failure.setVisibility(View.GONE);} + @Override public void onPageFinished(WebView view,String url){progress.setVisibility(View.GONE);} + @Override public void onReceivedSslError(WebView view,android.webkit.SslErrorHandler handler,android.net.http.SslError error){handler.cancel();showFailure("The secure connection could not be verified. Check your connection and retry.");} + @Override public void onReceivedError(WebView view,WebResourceRequest request,WebResourceError error){if(request.isForMainFrame())showFailure("This page could not open. Your saved local data is kept. Check your connection, then tap Refresh.");} + }); + web.setWebChromeClient(new WebChromeClient(){ + @Override public boolean onShowFileChooser(WebView view,ValueCallback callback,FileChooserParams params){if(!OriginPolicy.trusted(web.getUrl(),origin))return false;if(chooser!=null)chooser.onReceiveValue(null);chooser=callback; + boolean images=Arrays.stream(params.getAcceptTypes()).anyMatch(v->v.contains("image")||v.contains("pdf")); + if(images)new AlertDialog.Builder(MainActivity.this).setTitle("Add a document").setItems(new String[]{"Choose a file or photo","Take a photo"},(d,n)->{if(n==1)takePhoto();else pickFile(params);}).setOnCancelListener(d->finishChooser(null)).show();else pickFile(params);return true;} + @Override public void onPermissionRequest(PermissionRequest request){runOnUiThread(()->request.deny());} + @Override public boolean onJsAlert(WebView view,String url,String message,JsResult result){new AlertDialog.Builder(MainActivity.this).setMessage(message).setPositiveButton("OK",(d,w)->result.confirm()).setOnCancelListener(d->result.cancel()).show();return true;} + @Override public boolean onJsConfirm(WebView view,String url,String message,JsResult result){new AlertDialog.Builder(MainActivity.this).setMessage(message).setPositiveButton("Continue",(d,w)->result.confirm()).setNegativeButton("Cancel",(d,w)->result.cancel()).setOnCancelListener(d->result.cancel()).show();return true;} + }); + web.setDownloadListener((url,userAgent,disposition,mime,length)->{if(url.startsWith("blob:")||url.startsWith("data:")){web.evaluateJavascript("window.AndroidFiles&&AndroidFiles.exportUrl("+JSONObject.quote(url)+","+JSONObject.quote(URLUtil.guessFileName(url,disposition,mime))+","+JSONObject.quote(mime==null?"application/octet-stream":mime)+")",null);}else external(Uri.parse(url));}); + LinearLayout toolbar=new LinearLayout(this);toolbar.setGravity(Gravity.CENTER);addButton(toolbar,"Home",()->web.evaluateJavascript("typeof window.requestAndroidHome==='function'&&!!window.requestAndroidHome()",handled->{if(!"true".equals(handled))web.loadUrl(origin+"/");}));addButton(toolbar,"Refresh",()->new AlertDialog.Builder(this).setMessage("Refresh the app? Unsaved changes may be lost.").setPositiveButton("Refresh",(d,w)->web.reload()).setNegativeButton("Keep editing",null).show());if(BuildConfig.SCREEN_CAPTURE){addButton(toolbar,"Record screen",this::chooseRecording);addButton(toolbar,"Recordings",this::recordings);}root.addView(toolbar); + getOnBackPressedDispatcher().addCallback(this,new androidx.activity.OnBackPressedCallback(true){@Override public void handleOnBackPressed(){web.evaluateJavascript("typeof window.handleAndroidBack==='function'&&!!window.handleAndroidBack()",handled->{if("true".equals(handled))return;if(web.canGoBack())web.goBack();else{setEnabled(false);getOnBackPressedDispatcher().onBackPressed();setEnabled(true);}});}}); + if(state==null){String path="/";Uri deep=getIntent().getData();if(deep!=null&&OriginPolicy.trusted(deep.toString(),origin))path=deep.getEncodedPath()+(deep.getEncodedQuery()==null?"":"?"+deep.getEncodedQuery())+(deep.getEncodedFragment()==null?"":"#"+deep.getEncodedFragment());web.loadUrl(origin+path);}else web.restoreState(state); + } + private void post(JavaScriptReplyProxy reply,JSONObject data){runOnUiThread(()->{if(!isFinishing()&&web!=null&&OriginPolicy.trusted(web.getUrl(),origin)){try{reply.postMessage(data.toString());}catch(IllegalStateException ignored){/* The source document was closed. */}}});} + private void addButton(LinearLayout bar,String label,Runnable action){Button button=new Button(this);button.setText(label);button.setTextSize(12);button.setMinHeight((int)(48*getResources().getDisplayMetrics().density));button.setOnClickListener(v->action.run());bar.addView(button,new LinearLayout.LayoutParams(0,-2,1));} + private void showFailure(String message){failure.setText(message);failure.setVisibility(View.VISIBLE);progress.setVisibility(View.GONE);} + private void external(Uri uri){String scheme=uri.getScheme();if(!Set.of("https","mailto","tel").contains(scheme==null?"":scheme))return;try{startActivity(new Intent(Intent.ACTION_VIEW,uri).addCategory(Intent.CATEGORY_BROWSABLE));}catch(ActivityNotFoundException e){showFailure("No app can open this link.");}} + private WebResourceResponse localResponse(Uri uri){ + if(!OriginPolicy.trusted(uri.toString(),origin))return null;String path=uri.getPath(); + if(BuildConfig.SCREEN_CAPTURE&&path!=null&&path.startsWith("/__native_recordings/")){String id=path.substring("/__native_recordings/".length());if(!OriginPolicy.recordingId(id))return empty(404);File file=new File(getFilesDir(),"recordings/"+id+".mp4");try{return new WebResourceResponse("video/mp4",null,200,"OK",Map.of("Cache-Control","no-store","Access-Control-Allow-Origin",origin),new FileInputStream(file));}catch(IOException e){return empty(404);}} + String asset=OriginPolicy.assetPath(path);if(asset==null)return empty(403);if(BuildConfig.SCREEN_CAPTURE&&asset.startsWith("v/"))asset="index.html"; + try{InputStream stream=getAssets().open("web/"+asset);String extension=android.webkit.MimeTypeMap.getFileExtensionFromUrl(asset);String mime=android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);if(asset.endsWith(".js"))mime="text/javascript";if(mime==null)mime="application/octet-stream";return new WebResourceResponse(mime,"UTF-8",200,"OK",Map.of("Cache-Control","no-store","X-Content-Type-Options","nosniff"),stream);}catch(IOException e){if(asset.startsWith("api/")||!BuildConfig.BUNDLED_OFFLINE)return null;return empty(404);} + } + private WebResourceResponse empty(int status){return new WebResourceResponse("text/plain","UTF-8",status,status==403?"Forbidden":"Not Found",Map.of(),new ByteArrayInputStream(new byte[0]));} + private void pickFile(WebChromeClient.FileChooserParams params){try{Intent intent=new Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*");String[] types=Arrays.stream(params.getAcceptTypes()).filter(s->!s.isBlank()).toArray(String[]::new);if(types.length>0)intent.putExtra(Intent.EXTRA_MIME_TYPES,types);intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE,params.getMode()==WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE);startActivityForResult(intent,PICK);}catch(ActivityNotFoundException e){finishChooser(null);showFailure("No document picker is available.");}} + private void takePhoto(){try{File directory=new File(getCacheDir(),"camera");directory.mkdirs();File photo=File.createTempFile("document-",".jpg",directory);cameraUri=FileProvider.getUriForFile(this,BuildConfig.APPLICATION_ID+".files",photo);Intent intent=new Intent(MediaStore.ACTION_IMAGE_CAPTURE).putExtra(MediaStore.EXTRA_OUTPUT,cameraUri).addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION|Intent.FLAG_GRANT_READ_URI_PERMISSION);startActivityForResult(intent,CAMERA);}catch(Exception e){finishChooser(null);showFailure("The camera could not open. Choose an existing photo instead.");}} + private void finishChooser(Uri[] values){if(chooser!=null){chooser.onReceiveValue(values);chooser=null;}cameraUri=null;} + private void offerExport(File file,String name,String mime){if(isFinishing())return;new AlertDialog.Builder(this).setTitle("Your file is ready").setItems(new String[]{"Save "+name,"Share file"},(d,n)->{if(n==1){Uri uri=FileProvider.getUriForFile(this,BuildConfig.APPLICATION_ID+".files",file);Intent share=new Intent(Intent.ACTION_SEND).setType(mime).putExtra(Intent.EXTRA_STREAM,uri).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);try{startActivity(Intent.createChooser(share,"Share file"));}catch(ActivityNotFoundException e){showFailure("No sharing app is available.");}}else{saveFile=file;saveMime=mime;try{startActivityForResult(new Intent(Intent.ACTION_CREATE_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType(mime.split(";")[0]).putExtra(Intent.EXTRA_TITLE,name),SAVE);}catch(ActivityNotFoundException e){showFailure("No file saver is available.");}}}).show();} + protected void chooseRecording(){showFailure("Native recording is not included in this build.");} + protected void recordings(){showFailure("Native recordings are not included in this build.");} + @Override protected void onActivityResult(int request,int result,Intent data){super.onActivityResult(request,result,data);if(request==PICK){if(result!=RESULT_OK||data==null){finishChooser(null);return;}ArrayList uris=new ArrayList<>();if(data.getClipData()!=null)for(int n=0;n{try(InputStream in=new FileInputStream(source);OutputStream out=getContentResolver().openOutputStream(destination,"w")){if(out==null)throw new IOException();byte[] buffer=new byte[32768];for(int n;(n=in.read(buffer))!=-1;)out.write(buffer,0,n);runOnUiThread(()->Toast.makeText(this,"File saved",Toast.LENGTH_SHORT).show());}catch(IOException e){runOnUiThread(()->showFailure("The file could not be saved. Your app data is unchanged."));}});} } + @Override protected void onSaveInstanceState(Bundle out){if(web!=null)web.saveState(out);super.onSaveInstanceState(out);} + @Override protected void onDestroy(){if(chooser!=null)chooser.onReceiveValue(null);exports.abort();files.shutdown();if(web!=null){web.stopLoading();web.destroy();web=null;}super.onDestroy();} +} diff --git a/android/app/src/main/java/com/buttercode/mobile/OriginPolicy.java b/android/app/src/main/java/com/buttercode/mobile/OriginPolicy.java new file mode 100644 index 0000000..4d5516c --- /dev/null +++ b/android/app/src/main/java/com/buttercode/mobile/OriginPolicy.java @@ -0,0 +1,14 @@ +package com.buttercode.mobile; +import java.net.URI; +import java.util.Locale; +import java.util.UUID; +/** Native capabilities are available only to the packaged app's exact HTTPS origin. */ +public final class OriginPolicy { + public static final long MAX_EXPORT_BYTES=30L*1024*1024; + public static boolean trusted(String candidate,String origin){try{URI u=new URI(candidate),o=new URI(origin);return "https".equals(u.getScheme())&&u.getUserInfo()==null&&u.getPort()==o.getPort()&&o.getHost()!=null&&o.getHost().equalsIgnoreCase(u.getHost());}catch(Exception e){return false;}} + public static boolean recordingId(String id){try{return UUID.fromString(id).toString().equals(id);}catch(Exception e){return false;}} + public static String assetPath(String path){if(path==null||path.isEmpty()||path.equals("/"))return "index.html";String p=path.startsWith("/")?path.substring(1):path;if(p.contains("..")||p.contains("\\")||p.contains("%")||p.indexOf('\0')>=0)return null;return p;} + public static String safeName(String value){String clean=value==null?"export":value.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]","_").trim();if(clean.isEmpty()||clean.equals(".")||clean.equals(".."))clean="export";return clean.substring(0,Math.min(clean.length(),120));} + public static boolean exportMime(String value){return value!=null&&value.length()<=100&&!value.matches("(?s).*[\\r\\n\\x00].*")&&(value.equals("application/pdf")||value.equals("application/json")||value.equals("application/octet-stream")||value.equals("video/mp4")||value.equals("video/webm")||java.util.Set.of("text/plain","text/csv","text/markdown","text/vtt").contains(value.split(";",2)[0]));} + private OriginPolicy(){} +} diff --git a/android/app/src/main/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/emulator-check.sh b/android/emulator-check.sh new file mode 100755 index 0000000..9334229 --- /dev/null +++ b/android/emulator-check.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail +mkdir -p android/emulator-evidence +# The CI runner uses a software GPU (swiftshader). Under it the bundled launcher can +# raise an "isn't responding" ANR dialog that overlays the acceptance screenshot and +# makes working offline behaviour look like an app failure. Stop the launcher before +# each run so the app is the foreground window, then fail the build if a system dialog +# is still present when the evidence is collected. +quieten_launcher() { + for pkg in com.google.android.apps.nexuslauncher com.android.launcher3; do + adb shell am force-stop "$pkg" >/dev/null 2>&1 || true + done +} +assert_no_system_dialog() { + local mode="$1" + adb shell uiautomator dump /sdcard/window-dump.xml >/dev/null 2>&1 || return 0 + if adb shell cat /sdcard/window-dump.xml 2>/dev/null | grep -qiE "isn.t responding|has stopped"; then + echo "::error::A system ANR dialog was on screen during ${mode} acceptance evidence; the screenshot is contaminated." + return 1 + fi + return 0 +} +run_mode() { + local mode="$1" result=0 + quieten_launcher + ./android/gradlew -p android --no-daemon connectedDebugAndroidTest || result=$? + if ! adb pull /sdcard/Pictures/AndroidAcceptance android/emulator-evidence/; then + if [ "$result" = 0 ]; then return 1; fi + fi + if [ -d android/app/build/reports/androidTests/connected ]; then + cp -R android/app/build/reports/androidTests/connected "android/emulator-evidence/${mode}-reports" + fi + assert_no_system_dialog "$mode" || result=1 + return "$result" +} +run_mode phone +adb shell wm size 1600x2560 +adb shell wm density 240 +run_mode tablet diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..691500e --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9bbc975 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..96d9743 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78 +networkTimeout=60000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..650172b --- /dev/null +++ b/android/gradlew @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu +ANDROID_PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +if [ -n "${JAVA_HOME:-}" ]; then ANDROID_JAVA="$JAVA_HOME/bin/java"; else ANDROID_JAVA=java; fi +exec "$ANDROID_JAVA" -classpath "$ANDROID_PROJECT_DIR/gradle/wrapper/gradle-wrapper.jar" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/android/host-support.js b/android/host-support.js new file mode 100644 index 0000000..f0ce1dd --- /dev/null +++ b/android/host-support.js @@ -0,0 +1,18 @@ +/* Packaged Android only. No filesystem or native capability is exposed to web browsers. */ +(()=>{ + if(!window.AppHost)return; + let sequence=0;const pending=new Map(); + AppHost.onmessage=e=>{let response;try{response=JSON.parse(e.data)}catch{return}const p=pending.get(response.id);if(!p)return;clearTimeout(p.timeout);pending.delete(response.id);response.ok?p.resolve(response.data):p.reject(Error(response.error||'Native file transfer failed.'))}; + function call(op,body={}){return new Promise((resolve,reject)=>{const id=String(++sequence),timeout=setTimeout(()=>{pending.delete(id);reject(Error('The Android file transfer timed out.'))},30000);pending.set(id,{resolve,reject,timeout});AppHost.postMessage(JSON.stringify({id,op,...body}))})} + async function exportUrl(url,name,mime){let token;try{const response=await fetch(url);if(!response.ok)throw Error('Could not read the exported file.');const blob=await response.blob();({token}=await call('exportBegin',{name:name||'export',mime:blob.type||mime||'application/octet-stream',size:blob.size}));let index=0;for(let offset=0;offset{});alert(e.message)}} + window.AndroidFiles={exportUrl};window.print=()=>call('print').catch(e=>alert(e.message));const handled=new WeakSet(); + function handle(anchor){if(!anchor||!anchor.hasAttribute('download')||! /^(blob:|data:)/.test(anchor.href))return false;if(!handled.has(anchor)){handled.add(anchor);exportUrl(anchor.href,anchor.download,'application/octet-stream').finally(()=>handled.delete(anchor))}return true} + const original=HTMLAnchorElement.prototype.click;HTMLAnchorElement.prototype.click=function(){if(!handle(this))return original.call(this)}; + const dispatch=HTMLAnchorElement.prototype.dispatchEvent;HTMLAnchorElement.prototype.dispatchEvent=function(event){if(event.type==='click'&&handle(this))return false;return dispatch.call(this,event)}; + document.addEventListener('click',e=>{const anchor=e.target.closest?.('a[download]');if(handle(anchor)){e.preventDefault();e.stopImmediatePropagation()}},true); + const nativeCall=call; +window.AndroidNative={isNative:true, + speak:(text,rate)=>nativeCall('speak',{text:String(text||'').slice(0,3000),rate:Number(rate)||1}).catch(()=>{}), + stop:()=>nativeCall('stopSpeech',{}).catch(()=>{})}; +window.__ANDROID_APP__=true; +})(); diff --git a/android/play-store/CONSOLE-HANDOFF.md b/android/play-store/CONSOLE-HANDOFF.md new file mode 100644 index 0000000..9408c39 --- /dev/null +++ b/android/play-store/CONSOLE-HANDOFF.md @@ -0,0 +1,19 @@ +# Play Console handoff — new personal account + +1. Complete the owner's identity/contact/device verification shown by Play Console. Do not publish personal contact information by inference. +2. Confirm each permanent application ID and developer-owned upload signing key. Enable Play App Signing through the owner's controlled console flow; back up upload credentials privately. +3. Supply actual support email, publisher identity, policy URLs, target audience, countries and pricing. Review listing-draft.json and Data Safety engineering notes against the release binary and providers. +4. Upload a correctly signed release AAB to internal testing. Debug APKs and unsigned AABs are not production submissions. +5. Obtain real screenshots and, for ScreenNote, the foreground-service use-case demonstration. Complete applicable content-rating, Data Safety, ads, app access, financial features, billing and foreground-service forms accurately. +6. Set up a closed test for EACH app: at least 12 opted-in testers continuously for 14 days for this newly-created personal account, then apply for production access. Periods may overlap; do not invent feedback/tester participation. Internal testing does not satisfy the closed-test requirement. +7. Review tester feedback, pre-launch reports, crashes/ANRs and accessibility; fix/retest. Apply for production access and submit only after the owner reviews the concrete release. + +Source requirements checked 26 October 2026: +- https://support.google.com/googleplay/android-developer/answer/14151465 +- https://developer.android.com/google/play/requirements/target-sdk +- https://support.google.com/googleplay/android-developer/answer/10787469 +- https://support.google.com/googleplay/android-developer/answer/9876937 +- https://support.google.com/googleplay/android-developer/answer/9858738 +- https://developer.android.com/media/grow/media-projection + +No console setup, tester invitation, legal acceptance, signing-key upload or store submission has been performed by this source build. diff --git a/android/play-store/DEVICE-ACCEPTANCE.md b/android/play-store/DEVICE-ACCEPTANCE.md new file mode 100644 index 0000000..dbb957c --- /dev/null +++ b/android/play-store/DEVICE-ACCEPTANCE.md @@ -0,0 +1,16 @@ +# Required device acceptance + +Record device/model, Android/WebView version, APK SHA-256, build commit, steps and observed result. Do not tick based only on compilation. + +- First launch; home/navigation/back/rotation/keyboard; no clipped controls at phone/tablet widths. +- Cold launch without network: promised offline functions available, online functions fail with a useful retry and preserve local data. +- Create real app records, close/reopen, verify persistence. App update preserves the database; uninstall/clear-data warning is clear. +- Import valid/invalid/oversized files, deny/cancel picker/camera permissions; no crash or silent data loss. +- Export real PDF/CSV/backup, save through Android document picker, open the saved file in another app, share it, and verify cancellation is harmless. +- Sensitive content is not uploaded before the user's explicit upload/share action. Foreign pages cannot reach the native bridge. +- ScreenNote: fresh consent each capture, screen-only/mic, cancel/deny, notification Stop, duration/file cap, rotation, lock, background, task/process termination, playable MP4, native-to-editor import, publish/view/reply/delete synthetic cloud copy. +- InvoiceFlow: customer → invoice → PDF → payment → expense → reports → export/import backup; verify amounts and no duplicated payments. +- ContractCheck: multi-page PDF/photo upload, corrupt rejection, actual free results, entitlement/receipt restoration, exports; no accidental Paystack checkout in this Android test client. +- GreenLight: study → practice → Drive → review → mock → test-day preparation; persistence/offline progress, source links, no in-app PWA-install prompt. + +Current status: physical device execution pending. No connected Android phone/emulator is available in the local build environment. diff --git a/android/play-store/data-safety-working-notes.json b/android/play-store/data-safety-working-notes.json new file mode 100644 index 0000000..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..7211350 --- /dev/null +++ b/android/prepare.mjs @@ -0,0 +1,16 @@ +import {mkdir,cp,readFile,writeFile,readdir,rm} from 'node:fs/promises';import {createHash} from 'node:crypto';import {execFileSync} from 'node:child_process';import path from 'node:path';import {fileURLToPath} from 'node:url'; +const here=path.dirname(fileURLToPath(import.meta.url)),repo=path.dirname(here),name=path.basename(repo); +process.chdir(repo);const out=path.join(here,'app/src/main/assets/web');await rm(out,{recursive:true,force:true});await mkdir(out,{recursive:true}); +if(name==='ScreenNote'){execFileSync(process.execPath,['scripts/build.mjs'],{stdio:'inherit'});await cp('dist',out,{recursive:true})} +else if(name==='InvoiceFlow')await cp('dist',out,{recursive:true}); +else if(name==='ContractCheq'){ + await cp('public',out,{recursive:true});await mkdir(path.join(out,'vendor'),{recursive:true});const assets=(await import(path.join(repo,'server/vendorAssets.js'))).default;for(const [name,source]of Object.entries(assets))await cp(source,path.join(out,'vendor',name)); + const file=path.join(out,'index.html');let html=await readFile(file,'utf8');html=html.replace('','');await writeFile(file,html); +}else if(name==='Greenlight-k53'){ + for(const file of ['index.html','privacy.html','corrections.html','study-guide.html','icon.svg','icon-192.png','icon-512.png','manifest.webmanifest','assets'])await cp(file,path.join(out,file),{recursive:true}); +}else throw Error('Unknown Android product root.'); +await cp(path.join(here,'host-support.js'),path.join(out,'android-host.js')); +for(const file of await readdir(out)){if(!file.endsWith('.html'))continue;const full=path.join(out,file);let html=await readFile(full,'utf8');html=html.replace(/]+(?:fonts\.googleapis\.com|fonts\.gstatic\.com)[^>]*>/gi,'');html=html.replace(/]*>/i,match=>match+'');if(name==='Greenlight-k53')html=html.replace('','');await writeFile(full,html)} +async function walk(root,prefix=''){let result=[];for(const entry of await readdir(root,{withFileTypes:true})){const rel=prefix+entry.name,full=path.join(root,entry.name);if(entry.isDirectory())result.push(...await walk(full,rel+'/'));else{const b=await readFile(full);result.push({path:rel,size:b.length,sha256:createHash('sha256').update(b).digest('hex')})}}return result} +const files=await walk(out);if(!files.some(f=>f.path==='index.html'))throw Error('Missing entry point'); +await writeFile(path.join(here,'asset-manifest.json'),JSON.stringify({product:name,sourceCommit:execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim(),files},null,2)+'\n');console.log('Packaged '+files.length+' local assets for '+name); diff --git a/android/release-check.mjs b/android/release-check.mjs new file mode 100644 index 0000000..e1c2be5 --- /dev/null +++ b/android/release-check.mjs @@ -0,0 +1,5 @@ +import {readFile} from 'node:fs/promises'; +const gates=JSON.parse(await readFile(new URL('./release-gates.json',import.meta.url))),listing=JSON.parse(await readFile(new URL('./play-store/listing-draft.json',import.meta.url))); +if(listing.title.length>30||listing.shortDescription.length>80||listing.fullDescription.length>4000)throw Error('Store listing exceeds Google Play text bounds.'); +const pending=Object.entries(gates).filter(([k,v])=>v===false).map(([k])=>k);for(const key of ['supportEmail','privacyPolicyUrl','publisherIdentity'])if(!listing[key])pending.push(key); +console.log(JSON.stringify({app:gates.app,targetSdk:gates.targetSdk,submissionReady:pending.length===0,pending},null,2));if(process.argv.includes('--submission')&&pending.length)process.exitCode=1; diff --git a/android/release-gates.json b/android/release-gates.json new file mode 100644 index 0000000..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..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()} @@ -95,4 +95,8 @@ 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)}}); + +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};