Version 2.80 is now available and it has lots of SAF file fixes and improvements-
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.
//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() + " android " + app.GetOSVersion()
+ " 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 = "⚠"; colour = C_KNOWN; nKnown++; }
else if( ok ) { icon = "✔"; colour = C_PASS; nPass++; }
else { icon = "✘"; colour = C_FAIL; nFail++; }
var s = "<font color='" + colour + "'>" + icon + "</font> " + name;
if( detail ) s += " <font color='" + C_DIM + "'>— " + Esc(detail) + "</font>";
Say( s );
}
function Esc( s )
{
return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
}
//----------------------------------------------------------------- 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" );
});
}