SAF file handling improvements in v2.80

54 views
Skip to first unread message

Dave

unread,
Sep 1, 2026, 5:05:15 PM (3 days ago) Sep 1
to DroidScript
Hi Guys,

Version 2.80 is now available and it has lots of SAF file fixes and improvements-  
  https://droidscript.org/download/v2.80/

With the help of Claud I generated an enhanced version of Alan's script which tests all the file functions and they all pass on my Android 14 Samsung device.

Do they all pass on yours?


//DroidScript file handling test suite.
//
//Runs the whole file/folder API three times over:
// 1. a normal path - app folder on primary storage, no SAF involved
// 2. a SAF uri - the content: tree uri from app.GetPermission()
// 3. a path from that uri - app.Uri2Path(), which exercises path -> SAF mapping
//
//The only interaction needed is granting folder access at the start, and even
//that is skipped if a suitable permission has already been granted.
//
//Change PERM_TYPE to "extsdcard" or "usb" to test removable storage, which is
//where the SAF paths are genuinely unreachable by java.io.File.

var PERM_TYPE = "internal"; //"internal" | "external" | "extsdcard" | "usb"
var TEST_DIR = "_filetests";

//Own the colours rather than inheriting the theme's, which may be unreadable
//against this background.
var C_BACK = "#12151a"; //page background
var C_TEXT = "#e8e8e8"; //normal text
var C_DIM = "#9aa0a6"; //paths and details
var C_PASS = "#5fd35f";
var C_FAIL = "#ff6b6b";
var C_KNOWN = "#ffb454";

var txt, scroll;
var lines = [];
var roots = [], tests = [], idx = 0;
var nPass = 0, nFail = 0, nKnown = 0, nSaid = 0;

//----------------------------------------------------------------- ui

function OnStart()
{
lay = app.CreateLayout( "linear", "Left,FillXY" );
lay.SetPadding( 0.02, 0.02, 0.02, 0.01 );
lay.SetBackColor( C_BACK );

scroll = app.CreateScroller( 1, 0.96 );
scroll.SetBackColor( C_BACK );
txt = app.CreateText( "", 1, -1, "Multiline,Html,Left" );
txt.SetTextSize( 12 );
txt.SetTextColor( C_TEXT );
scroll.AddChild( txt );
lay.AddChild( scroll );

app.AddLayout( lay );

Say( "<b>DroidScript file handling tests</b>" );
Say( "device: " + app.GetModel() + " &nbsp; android " + app.GetOSVersion()
+ " &nbsp; ds " + app.GetVersion() );
Say( "" );

Setup();
}

function Say( html )
{
//Note: do NOT wrap this in a <font> tag. Html.fromHtml applies the outer
//span last, so it would override the tick and cross colours inside.
//The base colour comes from txt.SetTextColor() instead.
lines.push( html );
txt.SetHtml( lines.join("<br>") );

//Only scroll now and then, each call is an animation (see ScrollEnd).
if( ++nSaid % 5 == 0 ) ScrollEnd();
}

//Scroll to the bottom.
//ScrollTo takes a FRACTION of the screen height, not pixels, and multiplies it
//by the display height internally - so a big number like 999999 overflows the
//int multiply and lands at a negative offset (black screen). Anything past the
//content gets clamped, so a small value is all we need.
function ScrollEnd()
{
try { scroll.ScrollTo( 0, 50 ); } catch(e) {}
}

function Mark( ok, name, detail )
{
var icon, colour;
if( ok == "known" ) { icon = "&#9888;"; colour = C_KNOWN; nKnown++; }
else if( ok ) { icon = "&#10004;"; colour = C_PASS; nPass++; }
else { icon = "&#10008;"; colour = C_FAIL; nFail++; }

var s = "<font color='" + colour + "'>" + icon + "</font> " + name;
if( detail ) s += " <font color='" + C_DIM + "'>&mdash; " + Esc(detail) + "</font>";
Say( s );
}

function Esc( s )
{
return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}

//----------------------------------------------------------------- assertions

function Assert( cond, msg )
{
if( !cond ) throw new Error( msg || "assertion failed" );
}

function AssertEq( got, want, what )
{
if( got !== want )
throw new Error( (what||"value") + ": expected " + JSON.stringify(want)
+ " but got " + JSON.stringify(got) );
}

//----------------------------------------------------------------- setup

function Setup()
{
//Always test a plain path in the app folder.
roots.push({ name:"normal path", base: app.GetPath() + "/" + TEST_DIR, saf:false });

//Re-use an existing grant if we have one, so re-runs need no interaction.
var granted = app.ListPermissions( "Storage" );
if( granted && granted.length > 0 )
{
var first = granted.split(",")[0];
if( first && first.indexOf("content:") == 0 ) {
Say( "using existing permission" );
OnPermission( first );
return;
}
}

Say( "<font color='" + C_KNOWN + "'>Pick a folder to grant access "
+ "(" + PERM_TYPE + ")</font>" );
app.GetPermission( PERM_TYPE, OnPermission );

//If the picker is cancelled the callback never comes, so fall back after a
//while and run the normal path tests anyway.
setTimeout( function() { OnPermission( null ); }, 60000 );
}

var permDone = false;

function OnPermission( uri )
{
if( permDone ) return;
permDone = true;

if( !uri || uri.indexOf("content:") != 0 ) {
Say( "<font color='" + C_FAIL + "'>no folder granted - "
+ "running the normal path tests only</font>" );
}
else {
Say( "uri: <font color='" + C_DIM + "'>" + Esc(uri) + "</font>" );
roots.push({ name:"saf uri", base: uri + "/" + TEST_DIR, saf:true });

var path = null;
try { path = app.Uri2Path( uri, "" ); } catch(e) {}
//Still a SAF backed location even though it is spelled as a path - the
//grant belongs to the uri, so java.io.File cannot reach it either.
if( path && path.indexOf("content:") != 0 )
roots.push({ name:"path from uri", base: path + "/" + TEST_DIR, saf:true });
else
Say( "<font color='" + C_KNOWN + "'>Uri2Path gave nothing usable, "
+ "skipping the path-from-uri pass</font>" );
}

Say( "" );
for( var i = 0; i < roots.length; i++ ) Build( roots[i] );
setTimeout( RunNext, 100 );
}

//----------------------------------------------------------------- runner

function T( root, name, fn )
{
tests.push({ root:root, name:name, fn:fn });
}

function RunNext()
{
if( idx >= tests.length ) { Finish(); return; }

var t = tests[idx++];

//Print a heading when we move on to the next root.
if( t.root && t.root.heading ) {
Say( "" );
Say( "<b>" + t.root.name + "</b> <font color='" + C_DIM + "'>"
+ Esc(t.root.base) + "</font>" );
t.root.heading = false;
}

try {
t.fn( t.root );
Mark( true, t.name );
}
catch( e ) {
if( e && e.known ) Mark( "known", t.name, e.message );
else Mark( false, t.name, e.message );
}

setTimeout( RunNext, 1 );
}

function Known( msg )
{
var e = new Error( msg );
e.known = true;
return e;
}

function Finish()
{
Say( "" );
Say( "<b>" + nPass + " passed, " + nFail + " failed, "
+ nKnown + " known limitations</b>" );
if( nFail == 0 ) Say( "<font color='" + C_PASS + "'><b>all good</b></font>" );
ScrollEnd();
}

//----------------------------------------------------------------- the tests

function Build( r )
{
r.heading = true;

var dir = r.base;
var sub = dir + "/sub";
var file = dir + "/test.txt";
var copy = dir + "/copy.txt";
var moved = dir + "/moved.txt";
var nest = sub + "/nested.txt";
var dir2 = r.base + "_copy";
var dir3 = r.base + "_renamed";
var bin = dir + "/data.bin";

var LONG = "Hello how are you? This is a longer piece of text.";
var SHORT = "Bye.";

//--- make sure we start clean (no assertions, just tidy up) ---
T( r, "clean up any previous run", function() {
try { app.DeleteFolder( dir ); } catch(e) {}
try { app.DeleteFolder( dir2 ); } catch(e) {}
try { app.DeleteFolder( dir3 ); } catch(e) {}
});

//--- folders ---
T( r, "MakeFolder", function() {
app.MakeFolder( dir );
Assert( app.FolderExists(dir), "FolderExists said false after MakeFolder" );
});

T( r, "IsFolder on a folder", function() {
AssertEq( app.IsFolder(dir), true, "IsFolder" );
});

T( r, "FileExists is false for a folder", function() {
AssertEq( app.FileExists(dir), false, "FileExists" );
});

//--- write / read ---
T( r, "WriteFile + ReadFile", function() {
app.WriteFile( file, LONG );
AssertEq( app.ReadFile(file), LONG, "content" );
});

T( r, "WriteFile shorter over longer (truncates)", function() {
app.WriteFile( file, SHORT );
AssertEq( app.ReadFile(file), SHORT, "content" );
});

T( r, "WriteFile append", function() {
app.WriteFile( file, "!", "append" );
AssertEq( app.ReadFile(file), SHORT + "!", "content" );
});

T( r, "FileExists on a file", function() {
AssertEq( app.FileExists(file), true, "FileExists" );
});

T( r, "IsFolder is false for a file", function() {
AssertEq( app.IsFolder(file), false, "IsFolder" );
});

T( r, "GetFileSize", function() {
var want = (SHORT + "!").length;
AssertEq( app.GetFileSize(file), want, "size" );
});

T( r, "GetFileDate", function() {
var d = app.GetFileDate( file );
Assert( d, "GetFileDate returned nothing" );
Assert( d.getTime && d.getTime() > 0, "date was not a real date" );
});

T( r, "ReplaceInFile", function() {
app.WriteFile( file, LONG );
app.ReplaceInFile( file, "Hello", "Goodbye" );
AssertEq( app.ReadFile(file), LONG.replace("Hello","Goodbye"), "content" );
});

T( r, "ReadFile after rewrite", function() {
app.WriteFile( file, LONG );
AssertEq( app.ReadFile(file), LONG, "content" );
});

T( r, "ReadFileData", function() {
var data;
try { data = app.ReadFileData( file, "text" ); }
catch(e) { throw new Error( "threw: " + e.message ); }
Assert( data != null, "returned nothing" );
});

//--- base64 round trip ---
T( r, "WriteFile/ReadFile base64", function() {
var b64 = "SGVsbG8gYmFzZTY0"; //"Hello base64"
app.WriteFile( bin, b64, "base64" );
AssertEq( app.ReadFile(bin), "Hello base64", "decoded content" );
});

//--- copy / rename / delete of files ---
T( r, "CopyFile", function() {
app.CopyFile( file, copy );
Assert( app.FileExists(copy), "copy does not exist" );
AssertEq( app.ReadFile(copy), LONG, "copied content" );
});

T( r, "CopyFile over a longer file truncates", function() {
app.WriteFile( copy, LONG + LONG );
app.WriteFile( file, SHORT );
app.CopyFile( file, copy );
AssertEq( app.ReadFile(copy), SHORT, "copied content" );
app.WriteFile( file, LONG );
});

T( r, "RenameFile", function() {
app.RenameFile( copy, moved );
AssertEq( app.FileExists(copy), false, "old name still exists" );
Assert( app.FileExists(moved), "new name does not exist" );
});

T( r, "DeleteFile", function() {
app.DeleteFile( moved );
AssertEq( app.FileExists(moved), false, "still exists after delete" );
});

//--- listing ---
T( r, "ListFolder", function() {
var list = app.ListFolder( dir, "", 0, "" );
Assert( list && list.length, "returned nothing" );
Assert( list.join(",").indexOf("test.txt") > -1,
"test.txt missing from " + list.join(",") );
});

T( r, "ListFolder with a filter", function() {
var list = app.ListFolder( dir, "test", 0, "" );
Assert( list && list.length >= 1, "filter returned nothing" );
});

//--- nested folders ---
T( r, "MakeFolder nested", function() {
app.MakeFolder( sub );
Assert( app.FolderExists(sub), "sub folder missing" );
app.WriteFile( nest, "nested" );
AssertEq( app.ReadFile(nest), "nested", "nested content" );
});

T( r, "WalkFolder", function() {
var res;
try { res = app.WalkFolder( dir, null, 0, 0, "alphasort" ); }
catch(e) { throw new Error( "threw: " + e.message ); }
Assert( res, "returned nothing" );
var keys = [];
for( var k in res ) keys.push(k);
Assert( keys.length > 0, "no folders in the result" );
});

T( r, "CopyFolder", function() {
app.CopyFolder( dir, dir2, true, "", "" );
Assert( app.FolderExists(dir2), "copied folder missing" );
AssertEq( app.ReadFile(dir2 + "/sub/nested.txt"), "nested",
"nested file in the copy" );
});

T( r, "RenameFolder", function() {
app.RenameFolder( dir2, dir3 );
Assert( app.FolderExists(dir3), "renamed folder missing" );
AssertEq( app.FolderExists(dir2), false, "old folder still exists" );
});

T( r, "DeleteFolder (with contents)", function() {
app.DeleteFolder( dir3 );
AssertEq( app.FolderExists(dir3), false, "folder still exists" );
});

//--- the File object (random access) ---
T( r, "CreateFile write + read", function() {
var f = app.CreateFile( file, "w" );
if( !f ) {
if( r.saf ) throw Known( "CreateFile has no SAF support (RandomAccessFile)" );
throw new Error( "CreateFile returned null" );
}
f.WriteData( "abcdef", "Text" );
f.Close();
AssertEq( app.ReadFile(file), "abcdef", "content" );
});

T( r, "CreateFile seek + length", function() {
var f = app.CreateFile( file, "rw" );
if( !f ) {
if( r.saf ) throw Known( "CreateFile has no SAF support (RandomAccessFile)" );
throw new Error( "CreateFile returned null" );
}
var len = f.GetLength();
f.Seek( 0 );
AssertEq( f.GetPointer(), 0, "pointer after seek" );
f.Close();
AssertEq( len, 6, "length" );
});

T( r, "CreateFile append", function() {
var f = app.CreateFile( file, "a" );
if( !f ) {
if( r.saf ) throw Known( "CreateFile has no SAF support (RandomAccessFile)" );
throw new Error( "CreateFile returned null" );
}
f.WriteData( "gh", "Text" );
f.Close();
AssertEq( app.ReadFile(file), "abcdefgh", "content" );
});

//--- uri helpers ---
if( r.base.indexOf("content:") == 0 ) {
T( r, "Uri2Path", function() {
var p = app.Uri2Path( dir, "" );
Assert( p && p.indexOf("content:") != 0,
"did not give a path, got " + p );
});
}

//--- tidy up ---
T( r, "DeleteFolder (cleanup)", function() {
app.DeleteFolder( dir );
AssertEq( app.FolderExists(dir), false, "test folder still exists" );
});
}










Ferhat

unread,
Sep 1, 2026, 5:54:32 PM (3 days ago) Sep 1
to DroidScript
Hi Dave,
  1. The WriteFile issue has been fixed.
    After granting SAF permission, WriteFile now works correctly and file contents can be updated without any problems.

  2. The SendIntent issue has been fixed.
    Videos can now be sent to WhatsApp for sharing without any problems.

  3. The Shortcut issue still persists.
    The Shortcut works correctly when it is created while the project is running inside DroidScript. However, after the project is built as an APK, the Shortcut no longer works.

After building the APK, the Shortcut is successfully added to the home screen. The specified name and icon are displayed correctly. However, when I tap the Shortcut, the app does not open properly and remains stuck on a blank screen. Pressing the back button does not exit this screen either. After some time, an "App has stopped" notification appears.


2 Eylül 2026 Çarşamba tarihinde saat 00:05:15 UTC+3 itibarıyla Dave şunları yazdı:

Dave

unread,
Sep 3, 2026, 12:38:17 PM (yesterday) Sep 3
to DroidScript
Re. the shortcut issue - did you remember to update your APKBuilder from the 'Plugins' menu?

Dave

unread,
Sep 3, 2026, 12:39:37 PM (yesterday) Sep 3
to DroidScript
Please give me a code snippet that reproduces the problem.

Ferhat

unread,
Sep 3, 2026, 4:08:04 PM (21 hours ago) Sep 3
to DroidScript

I updated APKBuilder in the Plugins section. I’m leaving an example SPK file below. It works flawlessly through DroidScript. However, when an APK is built, the content doesn’t appear, and after a while I get a “stopped working” error.




3 Eylül 2026 Perşembe tarihinde saat 19:39:37 UTC+3 itibarıyla Dave şunları yazdı:
Shortcut.spk
Reply all
Reply to author
Forward
0 new messages