Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
target: google_apis
profile: pixel_6
disable-animations: true
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
emulator-options: -no-window -gpu software -memory 2048 -cores 2 -no-snapshot -noaudio -no-boot-anim -camera-back none
script: bash android/emulator-check.sh
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
if: always()
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ ScreenNote lets you record or import a short explanation, review it privately, a

## Limits that matter

Clips are limited to five minutes and 25 MiB. Shared storage uses a 1,000,000,000-byte budget with temporary upload reservations; there is no fixed 16-slot cap. Available clip count depends on sizes, active reservations and provider quotas. Each clip allows up to 100 comments. Names are unverified; owner and author controls depend on private browser capabilities.
Clips are limited to ten minutes and 50 MB. Shared storage uses a 1,000,000,000-byte budget with temporary upload reservations; there is no fixed 16-slot cap. Available clip count depends on sizes, active reservations and provider quotas. Each clip allows up to 100 comments. Names are unverified; owner and author controls depend on private browser capabilities.

Local recordings are browser data, not a backup. Recovery-key files restore management capabilities, not video files. Issued media URLs may continue working for five minutes after sharing stops. Playback bubbles do not appear in native fullscreen or picture-in-picture. Phones without browser screen capture can import a device recording.
Local recordings are browser data, not a backup. Recovery-key files restore management capabilities, not video files. Issued media URLs may continue working for ten minutes after sharing stops. Playback bubbles do not appear in native fullscreen or picture-in-picture. Phones without browser screen capture can import a device recording.

## Quick start

Expand Down
2 changes: 1 addition & 1 deletion android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ plugins { id 'com.android.application' }
android {
namespace 'com.buttercode.mobile'
compileSdk 36
defaultConfig { applicationId 'za.co.buttercode.screennote'; minSdk 26; targetSdk 36; versionCode 2; versionName '0.1.1-internal'; testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
defaultConfig { applicationId 'za.co.buttercode.screennote'; minSdk 26; targetSdk 36; versionCode 4; versionName '0.5.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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
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 void nativeText(String text,boolean tap)throws Exception{for(int n=0;n<50;n++){android.view.accessibility.AccessibilityNodeInfo root=InstrumentationRegistry.getInstrumentation().getUiAutomation().getRootInActiveWindow();if(root!=null){java.util.List<android.view.accessibility.AccessibilityNodeInfo> found=root.findAccessibilityNodeInfosByText(text);for(android.view.accessibility.AccessibilityNodeInfo node:found){if(node.getText()==null||!text.equalsIgnoreCase(node.getText().toString()))continue;if(!tap)return;while(node!=null&&!node.isClickable())node=node.getParent();if(node!=null&&node.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK))return;}}Thread.sleep(100);}fail("Native control unavailable: "+text);}
private String nativeTree(android.view.accessibility.AccessibilityNodeInfo n){if(n==null)return "none";StringBuilder s=new StringBuilder();s.append("["+n.getClassName()+" text="+n.getText()+" description="+n.getContentDescription()+" clickable="+n.isClickable()+"]");for(int i=0;i<n.getChildCount();i++)s.append(nativeTree(n.getChild(i)));return s.toString();}
private ActivityScenario<MainActivity> launch(){Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();Intent intent=context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());assertNotNull(intent);return ActivityScenario.launch(intent);}
private String js(ActivityScenario<MainActivity> scenario,String expression)throws Exception{CountDownLatch ready=new CountDownLatch(1);AtomicReference<String> value=new AtomicReference<>();scenario.onActivity(a->a.web.evaluateJavascript(expression,v->{value.set(v);ready.countDown();}));assertTrue("WebView evaluation timed out",ready.await(10,TimeUnit.SECONDS));return value.get();}
private void ready(ActivityScenario<MainActivity> scenario)throws Exception{for(int n=0;n<80;n++){String v=js(scenario,"document.readyState==='complete'&&!!document.querySelector('#app h1,#root h1')&&document.body.innerText.length>120");if("true".equals(v))return;Thread.sleep(250);}fail("The packaged app did not render usable content.");}
Expand All @@ -25,7 +26,7 @@ private void saveScreenshot(ActivityScenario<MainActivity> scenario,Context cont
@androidx.test.filters.SdkSuppress(minSdkVersion=29) @Test public void integratedChromeTracksThemeAndDoesNotStealContentSpace()throws Exception{
Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();
try(ActivityScenario<MainActivity> scenario=launch()){
ready(scenario);
ready(scenario);js(scenario,"(()=>{const d=document.getElementById('record-setup');if(d)d.open=true})()");
for(int n=0;n<60&&!"1".equals(js(scenario,"document.querySelectorAll('[data-android-menu]').length"));n++)Thread.sleep(100);
assertEquals("1",js(scenario,"document.querySelectorAll('[data-android-menu]').length"));
scenario.onActivity(a->{android.view.ViewGroup parent=(android.view.ViewGroup)a.web.getParent();assertSame("No native toolbar may sit below the product",a.web,parent.getChildAt(parent.getChildCount()-1));});
Expand Down Expand Up @@ -59,11 +60,48 @@ private void saveScreenshot(ActivityScenario<MainActivity> scenario,Context cont
nativeText("Refresh page",true);nativeText("Refresh this page?",false);nativeText("Keep editing",true);
ready(scenario);
if(BuildConfig.SCREEN_CAPTURE){
js(scenario,"document.querySelector('[data-android-action=recordScreen]').click()");nativeText("Record your screen",false);nativeText("Cancel",true);
js(scenario,"document.querySelector('[data-android-action=recordScreen]').click()");nativeText("Recording quality",false);nativeText("Continue",true);nativeText("Record your screen",false);nativeText("Cancel",true);
assertEquals("true",js(scenario,"!!document.querySelector('[data-android-capture]')"));
}
}
}

@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();}
@androidx.test.filters.SdkSuppress(minSdkVersion=35) @Test public void nativeScreenRecordingSurvivesOneMinuteAndImports()throws Exception{
Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();
android.content.res.Configuration cfg=context.getResources().getConfiguration();
org.junit.Assume.assumeTrue("Long native capture runs once, in phone portrait mode",cfg.smallestScreenWidthDp<600&&cfg.orientation==android.content.res.Configuration.ORIENTATION_PORTRAIT);
try(ActivityScenario<MainActivity> scenario=launch()){
ready(scenario);context.getSharedPreferences("capture",Context.MODE_PRIVATE).edit().putBoolean("notificationsAsked",true).apply();js(scenario,"(()=>{const d=document.getElementById('record-setup');if(d)d.open=true;document.querySelector('[data-android-action=recordScreen]').click()})()");
nativeText("Recording quality",false);nativeText("Continue",true);nativeText("Screen only",true);
// Read the actual system dialog. Android 15 offers a scope selector; choose the entire test screen.
boolean consent=false;
for(int n=0;n<50&&!consent;n++){
android.view.accessibility.AccessibilityNodeInfo root=InstrumentationRegistry.getInstrumentation().getUiAutomation().getRootInActiveWindow();
if(root!=null){
java.util.List<android.view.accessibility.AccessibilityNodeInfo> scope=root.findAccessibilityNodeInfosByText("A single app");
if(!scope.isEmpty()){android.view.accessibility.AccessibilityNodeInfo node=scope.get(0);while(node!=null&&!node.isClickable())node=node.getParent();if(node!=null)node.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK);Thread.sleep(300);nativeText("Entire screen",true);Thread.sleep(300);}
root=InstrumentationRegistry.getInstrumentation().getUiAutomation().getRootInActiveWindow();
if(root!=null)for(String label:new String[]{"Start","Start now","Start recording"})for(android.view.accessibility.AccessibilityNodeInfo node:root.findAccessibilityNodeInfosByText(label))if(node.getText()!=null&&label.equalsIgnoreCase(node.getText().toString())){while(node!=null&&!node.isClickable())node=node.getParent();if(node!=null&&node.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK))consent=true;}
}
if(!consent)Thread.sleep(200);
}
if(!consent){saveScreenshot(scenario,context,"capture-consent-blocked");android.view.accessibility.AccessibilityNodeInfo root=InstrumentationRegistry.getInstrumentation().getUiAutomation().getRootInActiveWindow();System.out.println("CAPTURE_CONSENT_UI "+nativeTree(root));}assertTrue("Native screen-sharing consent was not completed",consent);
for(int n=0;n<60&&!ScreenRecordingService.active;n++)Thread.sleep(200);
assertTrue("Native recorder did not start",ScreenRecordingService.active);
String name=ScreenRecordingService.activeFileName;
// Real elapsed capture on the emulator. No synthetic MediaRecorder or file-size mocks.
Thread.sleep(65000);assertTrue("Recorder stopped before 65 seconds",ScreenRecordingService.active);
js(scenario,"document.querySelector('[data-android-action=recordScreen]').click()");nativeText("Stop recording",true);
for(int n=0;n<60&&ScreenRecordingService.active;n++)Thread.sleep(200);
File video=new File(new File(context.getFilesDir(),"recordings"),name);assertTrue(video.exists());assertTrue(video.length()>0);
android.media.MediaMetadataRetriever media=new android.media.MediaMetadataRetriever();media.setDataSource(video.getAbsolutePath());long duration=Long.parseLong(media.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_DURATION));assertTrue("Native video shorter than a minute",duration>=60000);assertNotNull(media.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH));media.release();
js(scenario,"document.querySelector('[data-android-action=openRecordings]').click()");nativeText("Recordings on this device",false);
// Import via the same WebView route the user opens, then verify a decoded, persistent local copy.
nativeText("Close",true);String id=name.substring(0,name.length()-4);scenario.onActivity(a->a.web.loadUrl(BuildConfig.APP_ORIGIN+"/?nativeRecording="+id));
boolean imported=false;for(int n=0;n<100;n++){if("true".equals(js(scenario,"!!document.querySelector('#player')&&document.querySelector('#player').duration>=60"))){imported=true;break;}Thread.sleep(200);}assertTrue("Native video did not import into the editor",imported);
assertEquals("false",js(scenario,"document.documentElement.scrollWidth>innerWidth+2"));saveScreenshot(scenario,context,"native-recording-import");
}finally{context.startService(new Intent(context,ScreenRecordingService.class).setAction(ScreenRecordingService.STOP));}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ final class ExportStore {
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.");
if(output!=null)throw new IOException("Finish the current export first.");if(size<1||size>OriginPolicy.exportLimit(contentType)||!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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
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 final long MAX_EXPORT_BYTES=250_000_000L;
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 long exportLimit(String mime){return "video/mp4".equals(mime)||"video/webm".equals(mime)?MAX_EXPORT_BYTES:30L*1024*1024;}
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(){}
}
Loading
Loading