<?xml version="1.0" encoding="iso-8859-1" ?>
<?xml-stylesheet href="blog.xsl" type="text/xsl" ?>
<blog>
	<datum>07.08.2026</datum>
	<headline>Das Storage Access Framework (SAF)</headline>
	<teaser>
		Das Storage Access Framework bei Android erlaubt es Benutzer:innen genauer festzulegen,
		wo Anwendungen Daten ablegen oder lesen dürfen. Dieser Blogbeitrag zeigt Ihnen,
		wie ich meine Anwendung umgestellt habe.
	</teaser>
	<para>
		Früher war es ja für den Androidentwickler ganz einfach, Dateien zu lesen und 
		schreiben. Im privaten Bereich einer Anwendung durfte sie schon immer alles und 
		anderen Anwendungen war es verboten, dort zu lesen oder zu schreiben. Wer im 
		öffentlichen Bereich lesen oder schreiben wollten, mußte dies im Manifest kund 
		tun und der Anwender wurde gefragt.
	</para>
	<para>
		Mit Android 4.4 (Apilevel 19) wurde das Storage Access Framework eingeführt. 
		Wenn eine Anwendung dieses benutzt hat, konnten die Benutzer:innen festlegen, wo die 
		Anwendung das Recht hat, Dateien zu lesen oder zu schreiben. Damals war das alles
		noch optional.
	</para>
	<para>
		Mit Android 11 (Apilevel 30), wurde die Verwendung des SAFs allerdings verpflichtend 
		eingeführt. Wer das nicht nutzen wollte, mußte stattdessen das Recht 
		MANAGE_EXTERNAL_STORAGE anfordern. Damit wird Ihre Anwendung im Google Play Store 
		nicht zugelassen, wenn es keine besonderen Gründe dafür gibt.
	</para>
	<para>
		Es ist also Zeit, sich zu überlegen, wie Ihre Anwendung angepasst werden könnte.
		Das ist vielleicht auch dann sinnvoll, wenn Sie nicht planen, Ihre App in den 
		Play Store zu bringen. Ich habe jedenfalls, meine Apps fit gemacht und zeige 
		Ihnen hier die wesentlichen Änderungen.
	</para>
	<para>
		Die erste Änderung, die ich gemacht habe, war die Einführung zweier Funktionen,
		um eine Datei zum Lesen bzw Schreiben zu öffnen;<pre>public OutputStream openOutputStream(boolean pub, String filename, boolean append) throws IOException
{
/*
	String uriString = getSharedPreferences(CONFIG_FILE, MODE_PRIVATE).getString(CONFIG_KEY, null);
	if (uriString != null)
	{
		try
		{
			Uri treeUri = Uri.parse(uriString);
			DocumentFile root = DocumentFile.fromTreeUri(this, treeUri);
			DocumentFile file = root.findFile(filename);
			if (file == null)
			{
				String mime;

				if( filename.endsWith(".gpx") )
					mime = "application/gpx+xml";
				else if( filename.endsWith(".xml") )
					mime = "text/xml";
				else if( filename.endsWith(".txt") )
					mime = "text/plain";
				else
					mime = "application/octet-stream";

				file = root.createFile(mime, filename);
			}
			if( file != null &amp;&amp; file.exists())
			{
				// "wa" steht für Write-Append
				return getContentResolver().openOutputStream(file.getUri(), append ? "wa" : "w");
			}
		}
		catch (java.lang.Exception e)
		{
			Log.e("GPS", "SAF write failed", e);
		}
	}
*/
	// fall back for old androids
	File file = getExternalFileName(filename);
	return new FileOutputStream(file, append);
}</pre>Den Teil, der SAF benutzt, habe ich auskommentiert, weil sonst in meiner 
build.gradle eine Abhängigkeit eingeführt hätte werden müssen.</para>
	<para>
		Das wichtigste war dann die Änderung meiner Funktionen beim Öffnen eines Ausgabestreams von
		<pre>m_rawFile = getExternalFileName(getRawTrackFileName());
m_rawFile.createNewFile();
m_rawFileOS = new FileOutputStream(m_rawFile, true);</pre>zu<pre>m_rawFileOS = (FileOutputStream)openOutputStream(getRawTrackFileName(), true);</pre>
		Beim Lesen der Datei war es ähnlich. Das erspare ich mir hier.
	</para>
	<para>
		So kann das commited und getested werden und Sie haben den ersten Schritt getan, 
		ohne die Funktionalität Ihrer Anwendung zu verändern.
	</para>
	<para>
		Sobald Sie sich entscheiden, SAF einzuführen, müssen Sie zu erst die Teile, die 
		noch auskommentiert sind, scharf stellen und die Abhängigkeit in build.gradle einführen.<pre>dependencies {
	implementation 'androidx.appcompat:appcompat:1.7.1'
	implementation 'androidx.documentfile:documentfile:1.0.1'	// &lt;- das ist neu
}</pre>Und Sie müssen beim Öffnen der Streams den Cast zu (FileOutputStream) bzw (FileInputStream) 
		entfernen und die Variablen entsprechend ändern.
	</para>
	<para>
		Als nächstes müssen Sie Ihren Benutzer:innen die Möglichkeit geben, den Ordner auszuwählen, 
		wo Sie Ihre Dateien speichern sollen. Dazu benötigen Sie zwei Funktionen, um den 
		Dialog zu starten und die Anwort der Benutzer:innen zu verarbeiten:<pre>public void selectStorageFolder()
{
	Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
	// Erlaubt dauerhaften Zugriff
	intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
			| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
			| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
	startActivityForResult(intent, REQUEST_CODE_OPEN_DIRECTORY);
}

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data)
{
	super.onActivityResult(requestCode, resultCode, data);

	if (requestCode == REQUEST_CODE_OPEN_DIRECTORY &amp;&amp; resultCode == RESULT_OK)
	{
		if (data != null &amp;&amp; data.getData() != null)
		{
			Uri treeUri = data.getData();

			// WICHTIG: Die Berechtigung dauerhaft beim System registrieren ("Persistable Permission")
			final int takeFlags = data.getFlags()
					&amp; (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);

			try
			{
				getContentResolver().takePersistableUriPermission(treeUri, takeFlags);

				// URI in SharedPreferences speichern
				getSharedPreferences(CONFIG_FILE, MODE_PRIVATE)
						.edit()
						.putString(CONFIG_KEY, treeUri.toString())
						.apply();

				// Activity neu starten oder Initialisierung fortsetzen
				recreate();
			}
			catch (SecurityException e)
			{
				Log.e("GPS", "Failed to take persistable permission", e);
			}
		}
	}
}</pre></para>
	<para>
		Dannach müssen Sie noch alle Stellen, die das File-Objekt verwenden, umstellen 
		und stattdessen FileDocument benutzen. Google hat leider mit der Brechstange 
		statt mit Hirn gearbeitet und File einfach alles verboten. Statt mit File.exists() 
		zu überprüfen, ob die Datei existiert, öffne ich einfach die Datei und reagiere
		auf eine Exception.<pre>File xmlFile = getExternalFileName(getXmlTrackFileName());
if( xmlFile.exists() )</pre>kann entfallen.
	</para>
	<para>
		File.delete funktioniert natürlich auch nicht. Wenn Sie also eine Datei löschen
		wollen, brauchen Sie eine Extrafunktion:<pre>private void deleteLocalFile(String filename)
{
	// Strategy 1: Standard File API (Works for private internal storage or legacy)
	File file = getExternalFileName(filename);
	if( file != null &amp;&amp; file.exists() )
	{
		if( file.delete() )
			return;
	}

	String uriString = getSharedPreferences(CONFIG_FILE, MODE_PRIVATE).getString(CONFIG_KEY, null);

	if( uriString != null )
	{
		try
		{
			Uri treeUri = Uri.parse(uriString);
			DocumentFile root = DocumentFile.fromTreeUri(this, treeUri);
			DocumentFile dFile = root.findFile(filename);

			if( dFile != null &amp;&amp; dFile.exists() )
			{
				if( dFile.delete() )
				{
					Log.d("GPS", "File deleted via SAF: " + filename);
				}				}
		}
		catch (Exception e)
		{
			Log.e("GPS", "SAF failed, trying legacy", e);
		}
	}
}</pre></para>
	<para>
		Bevor Sie auf einen Ordner zugreifen, den Sie mit SAF angefordert haben, sollten 
		Sie prüfen, ob die Rechte noch vorhanden sind, denn die Benutzer:innen könnten sie wieder
		entzogen haben.<pre>private boolean checkSafFolderPermissions( boolean writePermission )
{
	String uriString = getSharedPreferences(CONFIG_FILE, MODE_PRIVATE).getString(CONFIG_KEY, null);
	if (uriString == null)
		return false;
	Uri treeUri = Uri.parse(uriString);
	if (treeUri == null)
		return false;

	int modeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION;
	if( writePermission )
		modeFlags |= Intent.FLAG_GRANT_WRITE_URI_PERMISSION;

	try
	{
		// Diese Methode wirft keine Exception, wenn man keine Rechte hat,
		// aber man kann damit prüfen, ob der Zugriff noch valide ist.
		getContentResolver().takePersistableUriPermission(treeUri, modeFlags);
	}
	catch (SecurityException e)
	{
		return false;
	}

	// 2. Prüfung via DocumentFile (ist der Ordner noch vorhanden?)
	DocumentFile folder = DocumentFile.fromTreeUri(this, treeUri);
	return folder != null &amp;&amp; folder.exists()
			&amp;&amp; folder.canRead()
			&amp;&amp; (!writePermission || folder.canWrite());
}</pre></para>
	<para>
		Beim Start Ihrer App oder vor dem ersten Schreiben können Sie die Berechtigung 
		anfordern. Dazu habe ich diese Funktion eingeführt. Sie prüft nämlich gegebenfalls
		für ältere Androiden noch die herkömlichen Berechtigungen:<pre>public RequestCode requestStoragePermission( @DrawableRes int iconId, String title )
{
	if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
	{
		if( hasStorageFolder() )
			return RequestCode.OK;

		if( !Environment.isExternalStorageManager() )
		{
			displayStorageManagePermission();
			return RequestCode.REQUESTED;
		}
	}
	else if(
			ContextCompat.checkSelfPermission(this, READ_EXTERNAL_STORAGE)
					!= PackageManager.PERMISSION_GRANTED
					|| ContextCompat.checkSelfPermission(this, WRITE_EXTERNAL_STORAGE)
					!= PackageManager.PERMISSION_GRANTED
	)
	{
		// Suggestion: Request the permission instead of just failing
		ActivityCompat.requestPermissions(
				this,
				new String[]{READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE},
				STORAGE_PERMISSION_REQUEST_CODE
		);
		return RequestCode.DENIED;
	}
	else if( checkCallingOrSelfPermission("android.permission.READ_EXTERNAL_STORAGE") == PackageManager.PERMISSION_DENIED )
	{
		showMessage(iconId, title, "Read Permission Missing", true, null);
		return RequestCode.DENIED;
	}
	else if( checkCallingOrSelfPermission("android.permission.WRITE_EXTERNAL_STORAGE") == PackageManager.PERMISSION_DENIED )
	{
		showMessage( iconId, title, "Write Premission Missing", true, null);
		return RequestCode.DENIED;
	}
	return RequestCode.OK;
}</pre>Diese Funktion fordert keine Berechtigung an, wenn ein Ordner schon mal ausgewählt 
		wurde. Wenn Sie Spaß daran haben, könnten Sie noch prüfen, ob das Recht zu lesen 
		und zu schreiben auch noch vorhanden ist und ggfls. den Dialog zu Auswahl eines
		Ordner nochmal aufrufen. Da die Benutzer:innen das an anderer Stelle auch erledigen 
		können und sie aktiv das Recht entzogen haben, spare ich mir die Aktion.
	</para>
	<para>
		So, das waren die wesentlichen Änderungen, die erforderlich waren. Weshalb das 
		Projekt beinahe gescheitert wäre: Die Amateure bei Google erlauben es nicht, 
		mit dem Framework den Dokumentorder auszuwählen. Da lagen jetzt aber meine Dateien 
		von mehreren Jahren der Benutzung und meine App soll plötzlich keinen Zugriff
		mehr bekommen? Ich habe es dann aber trotzdem realisiert und die Benutzer:innen haben 
		nun die Wahl, wo ihre Daten gespeichert werden. Wenn sie die Daten wie bisher 
		im Dokumentordner speichern wollen und ihre Telefone dieses nicht auswählen lässt,
		können sie das Recht MANAGE_STORAGE gewähren und meine App kann dort schreiben, 
		wo es die Benutzer:innen wollen.  Und das Beste: Meine Samsunggeräte
		erlauben mir auch die Auswahl des Dokumentordners. Es funktioniert auch mit anderen 
		Geräten, solange das Recht der Storage Management Permission gewährt wird. Ein 
		Parallelbetrieb ist also möglich, meine App funktionier nun mit dem Android 9 
		von meinem ganz alten Tablett oder mit Android 16 auf den neueren Samsunggeräten 
		UND mit Android 16 des Emulators.
	</para>
	<para>
		Die Funktionen wurden alle der Datei 
		<a href="https://github.com/mgaeckler1964/GpsWayPoints/blob/main/src/at/gaeckler/gps/GpsActivity.java"
		target="_blank">GpsActivity.java</a> auf GitHub entnommen.
	</para>
</blog>