[libgdx] Runtime User Permissions on Android API 26+

Hi all, in my LibGDX application after changing my target Android API to 26 my app is crashing when writing using an external FileHandle. I had this working when targeting a lower API (20) by adding these to my AndroidManifest.xml:

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

But a few months Google changed the minimum API to 26, which requires handling runtime user permissions so the AndroidManifest entry is no longer enough to make it work.

I have no experience with Android specific code because everything I’ve needed to do up to this point I have been able to do entirely within LibGDX. Here is a tutorial I found for external read permission in Android, but I’m not sure how to make this work within a LigGDX project (I have not been able to find any LibGDX tutorials on this). Is this what I need to do and would this go in the AndroidLauncher.java or does it need a separate file within the Android project?
Code: https://codinginflow.com/tutorials/android/run-time-permission-request
Video Tutorial: https://www.youtube.com/watch?v=SMrB97JuIoM

Here is my simple LibGDX example which works if I target a lower API (20) but it crashes when I change it to API 26 because I’m not handling the runtime user permissions. In my example, clicking the button labeled “PRESS” causes a String to be saved to a text document via JSON. Then the string is read back out of the text file into a label and displayed on screen. The counter increases each time the button is clicked to show that a new save and load event has occurred.

If someone could help get my sample program working with API 26 (or later) or provide a different working example in LibGDX that I could download, run, and examine I would greatly appreciate it. Thanks.

Test.java

public class Test implements ApplicationListener {
	private Stage stage;
	private Window window;
	private Graphics graphics;
	
	@Override
	public void create () {
		stage = new Stage();
		stage.setViewport(new ScreenViewport(stage.getViewport().getCamera()));
		Gdx.input.setInputProcessor(stage);
	
		graphics = new Graphics();
		window = new Window(graphics);
		stage.addActor(window);
	}
	
	@Override
	public void render () {
		Gdx.gl.glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
		Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
		stage.act(Gdx.graphics.getRawDeltaTime());
		stage.draw();
	}
	
	@Override
	public void resize(int width, int height) {
		stage.getViewport().update(width, height, true);
	}
	
	@Override
	public void pause() { }
	@Override
	public void resume() { }
	
	@Override
	public void dispose () {
		stage.dispose();
		graphics.dispose();
	}
}

Window.java

public class Window extends Table {
	Graphics graphics;
	FileHandle handle;
	Label label;
	int count=0;
	
	public Window(Graphics graphics){
		setFillParent(true);
		this.graphics = graphics;
		handle = Gdx.files.getFileHandle("ZTEST/test.txt", getFileType());
		
		// label
		Label.LabelStyle labelStyle = new Label.LabelStyle();
		labelStyle.font = graphics.getFont();
		labelStyle.fontColor = Color.WHITE;
		label = new Label("", labelStyle);
		label.setAlignment(Align.center);
		add(label).size(graphics.programWidth*0.4f,graphics.programWidth*0.14f);
		label.debug();
		row().pad(graphics.programWidth*0.1f);
		
		// button
		TextButton.TextButtonStyle buttonStyle = new TextButton.TextButtonStyle();
		buttonStyle.font = graphics.getFont();
		buttonStyle.fontColor = Color.WHITE;
		final TextButton button = new TextButton("PRESS", buttonStyle);
		button.addListener(new ClickListener(){
			@Override
			public void clicked(InputEvent event, float x, float y){
				// SAVE TO DISK
				save(++count);
				
				// LOAD FROM DISK
				load();
			}
		});
		add(button).size(graphics.programWidth*0.4f,graphics.programWidth*0.14f);
		button.debug();
	}
	private Files.FileType getFileType(){
		switch(Gdx.app.getType()){
			case Desktop:
				return Files.FileType.Local;
			default:
				return Files.FileType.External;
		}
	}
	
	private void save(int count){
		Json json = new Json();
		json.setOutputType(JsonWriter.OutputType.json);
		handle.writeString(json.prettyPrint("save: "+count), false);
	}
	private void load(){
		if(handle.exists()){
			final Json json = new Json();
			label.setText(json.fromJson(null, handle.readString()).toString());
		}
	}
}

Graphics.java
(Creates the BitmapFont)

public class Graphics implements Disposable {
	public final int programWidth;
	BitmapFont font;
	
	public Graphics(){
		programWidth = (Gdx.graphics.getWidth() < Gdx.graphics.getHeight()) ? Gdx.graphics.getWidth() : Gdx.graphics.getHeight();
		font = generateNumberFont();
	}
	private BitmapFont generateNumberFont(){
		final FreeTypeFontGenerator generator = new FreeTypeFontGenerator(Gdx.files.internal("internal/arialbd.ttf"));
		final FreeTypeFontGenerator.FreeTypeFontParameter parameter = new FreeTypeFontGenerator.FreeTypeFontParameter();
		parameter.characters = "abcdefthijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890:";
		parameter.size = (int)(programWidth*0.08f);
		final BitmapFont font = generator.generateFont(parameter);
		generator.dispose();
		return font;
	}
	
	public BitmapFont getFont(){ return font; }
	
	@Override
	public void dispose() {
		font.dispose();
	}
}

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
	<manifest xmlns:android="http://schemas.android.com/apk/res/android"
	package="com.tekker.test" >
	
	<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
	<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
	
	<application
		android:allowBackup="true"
		android:icon="@drawable/ic_launcher"
		android:label="@string/app_name"
		android:theme="@style/GdxTheme" >
		<activity
			android:name="com.tekker.test.AndroidLauncher"
			android:label="@string/app_name" 
			android:screenOrientation="fullUser"
			android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize">
			<intent-filter>
				<action android:name="android.intent.action.MAIN" />
				<category android:name="android.intent.category.LAUNCHER" />
			</intent-filter>
		</activity>
	</application>
</manifest>