Initial commit

This commit is contained in:
2026-02-02 04:50:13 +01:00
commit 5b11698731
22592 changed files with 7677434 additions and 0 deletions

View File

@@ -0,0 +1,519 @@
package org.scummvm.scummvm;
import android.content.Context;
import android.net.Uri;
import android.os.Build;
import android.os.ParcelFileDescriptor;
import android.util.Log;
import androidx.annotation.RequiresApi;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.OutputStream;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.scummvm.scummvm.zip.ZipFile;
public class BackupManager {
public static final int ERROR_CANCELLED = 1;
public static final int ERROR_NO_ERROR = 0;
public static final int ERROR_INVALID_BACKUP = -1;
public static final int ERROR_INVALID_SAVES = -2;
@RequiresApi(api = Build.VERSION_CODES.N)
public static int exportBackup(Context context, Uri output) {
try (ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(output, "wt")) {
if (pfd == null) {
return ERROR_INVALID_BACKUP;
}
return exportBackup(context, new FileOutputStream(pfd.getFileDescriptor()));
} catch(FileNotFoundException ignored) {
return ERROR_INVALID_BACKUP;
} catch(IOException ignored) {
return ERROR_INVALID_BACKUP;
}
}
public static int exportBackup(Context context, File output) {
try {
return exportBackup(context, new FileOutputStream(output, false));
} catch(FileNotFoundException ignored) {
return ERROR_INVALID_BACKUP;
}
}
@RequiresApi(api = Build.VERSION_CODES.N)
public static int importBackup(ScummVMActivity context, Uri input) {
try (ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(input, "r")) {
if (pfd == null) {
return ERROR_INVALID_BACKUP;
}
return importBackup(context, new FileInputStream(pfd.getFileDescriptor()));
} catch(FileNotFoundException ignored) {
return ERROR_INVALID_BACKUP;
} catch(IOException ignored) {
return ERROR_INVALID_BACKUP;
}
}
public static int importBackup(ScummVMActivity context, File input) {
try {
return importBackup(context, new FileInputStream(input));
} catch(FileNotFoundException ignored) {
return ERROR_INVALID_BACKUP;
}
}
private static int exportBackup(Context context, FileOutputStream output) {
File configuration = new File(context.getFilesDir(), "scummvm.ini");
Map<String, Map<String, String>> parsedIniMap;
try (FileReader reader = new FileReader(configuration)) {
parsedIniMap = INIParser.parse(reader);
} catch(FileNotFoundException ignored) {
parsedIniMap = null;
} catch(IOException ignored) {
parsedIniMap = null;
}
if (parsedIniMap == null) {
return ERROR_INVALID_BACKUP;
}
ZipOutputStream zos = new ZipOutputStream(output);
try (FileInputStream stream = new FileInputStream(configuration)) {
ZipEntry entry = new ZipEntry("scummvm.ini");
entry.setSize(configuration.length());
entry.setTime(configuration.lastModified());
zos.putNextEntry(entry);
copyStream(zos, stream);
zos.closeEntry();
} catch(FileNotFoundException ignored) {
return ERROR_INVALID_BACKUP;
} catch(IOException ignored) {
return ERROR_INVALID_BACKUP;
}
try {
ZipEntry entry;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
entry = new ZipEntry("saf");
zos.putNextEntry(entry);
if (!exportTrees(context, zos)) {
return ERROR_INVALID_BACKUP;
}
zos.closeEntry();
}
entry = new ZipEntry("saves/");
zos.putNextEntry(entry);
zos.closeEntry();
} catch(FileNotFoundException ignored) {
return ERROR_INVALID_BACKUP;
} catch(IOException ignored) {
return ERROR_INVALID_BACKUP;
}
File savesPath = INIParser.getPath(parsedIniMap, "scummvm", "savepath",
new File(context.getFilesDir(), "saves"));
boolean ret = exportSaves(context, savesPath, zos, "saves/");
if (!ret) {
try {
zos.close();
} catch(IOException ignored) {
}
return ERROR_INVALID_BACKUP;
}
HashSet<File> savesPaths = new HashSet<>();
savesPaths.add(savesPath);
int sectionId = -1;
for (String section : parsedIniMap.keySet()) {
sectionId++;
if ("scummvm".equals(section)) {
continue;
}
savesPath = INIParser.getPath(parsedIniMap, section, "savepath", null);
if (savesPath == null) {
continue;
}
if (savesPaths.contains(savesPath)) {
continue;
}
savesPaths.add(savesPath);
String folderName = "saves-" + sectionId + "/";
ZipEntry entry = new ZipEntry(folderName);
try {
zos.putNextEntry(entry);
zos.closeEntry();
} catch(IOException ignored) {
return ERROR_INVALID_BACKUP;
}
ret = exportSaves(context, savesPath, zos, folderName);
if (!ret) {
break;
}
}
try {
zos.close();
} catch(IOException ignored) {
return ERROR_INVALID_BACKUP;
}
return ret ? ERROR_NO_ERROR : ERROR_INVALID_SAVES;
}
@RequiresApi(api = Build.VERSION_CODES.N)
static private boolean exportTrees(Context context, ZipOutputStream zos) throws IOException {
ObjectOutputStream oos = new ObjectOutputStream(zos);
// Version 1
oos.writeInt(1);
SAFFSTree[] trees = SAFFSTree.getTrees(context);
oos.writeInt(trees.length);
for (SAFFSTree tree : trees) {
oos.writeObject(tree.getTreeName());
oos.writeObject(tree.getTreeId());
oos.writeObject(tree.getTreeDocumentUri().toString());
}
// Don't close as it would close the underlying ZipOutputStream
oos.flush();
return true;
}
static private boolean exportSaves(Context context, File folder, ZipOutputStream zos, String folderName) {
SAFFSTree.PathResult pr;
try {
pr = SAFFSTree.fullPathToNode(context, folder.getPath(), false);
} catch (FileNotFoundException e) {
return false;
}
// This version check is only to make Android Studio linter happy
if (pr == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
// This is a standard filesystem path
File[] children = folder.listFiles();
if (children == null) {
return true;
}
Arrays.sort(children);
for (File f: children) {
if ("timestamps".equals(f.getName())) {
continue;
}
try (FileInputStream stream = new FileInputStream(f)) {
ZipEntry entry = new ZipEntry(folderName + f.getName());
entry.setSize(f.length());
entry.setTime(f.lastModified());
zos.putNextEntry(entry);
copyStream(zos, stream);
zos.closeEntry();
} catch(FileNotFoundException ignored) {
return false;
} catch(IOException ignored) {
return false;
}
}
return true;
}
// This is a SAF fake mount point
SAFFSTree.SAFFSNode[] children = pr.tree.getChildren(pr.node);
if (children == null) {
return false;
}
Arrays.sort(children);
for (SAFFSTree.SAFFSNode child : children) {
if ((child._flags & SAFFSTree.SAFFSNode.DIRECTORY) != 0) {
continue;
}
String component = child._path.substring(child._path.lastIndexOf('/') + 1);
if ("timestamps".equals(component)) {
continue;
}
try (ParcelFileDescriptor pfd = pr.tree.createFileDescriptor(child, "r")) {
ZipEntry entry = new ZipEntry(folderName + component);
zos.putNextEntry(entry);
copyStream(zos, new ParcelFileDescriptor.AutoCloseInputStream(pfd));
zos.closeEntry();
} catch(FileNotFoundException ignored) {
return false;
} catch(IOException ignored) {
return false;
}
}
return true;
}
private static int importBackup(ScummVMActivity context, FileInputStream input) {
ZipFile zf;
try {
zf = new ZipFile(input);
} catch(IOException ignored) {
return ERROR_INVALID_BACKUP;
}
// Load configuration
org.scummvm.scummvm.zip.ZipEntry ze = zf.getEntry("scummvm.ini");
if (ze == null) {
// No configuration file, not a backup
return ERROR_INVALID_BACKUP;
}
// Avoid using tmp suffix as it's used by atomic file support
File configurationTmp = new File(context.getFilesDir(), "scummvm.ini.tmp2");
try (FileOutputStream os = new FileOutputStream(configurationTmp);
InputStream is = zf.getInputStream(ze)) {
copyStream(os, is);
} catch(FileNotFoundException ignored) {
return ERROR_INVALID_BACKUP;
} catch(IOException ignored) {
return ERROR_INVALID_BACKUP;
}
// Load the new configuration to know where to put saves
Map<String, Map<String, String>> parsedIniMap;
try (FileReader reader = new FileReader(configurationTmp)) {
parsedIniMap = INIParser.parse(reader);
} catch(FileNotFoundException ignored) {
parsedIniMap = null;
} catch(IOException ignored) {
parsedIniMap = null;
}
if (parsedIniMap == null) {
configurationTmp.delete();
return ERROR_INVALID_BACKUP;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
// Restore the missing SAF trees
ze = zf.getEntry("saf");
if (ze == null) {
// No configuration file, not a backup
return ERROR_INVALID_BACKUP;
}
try (InputStream is = zf.getInputStream(ze)) {
if (importTrees(context, is) == ERROR_INVALID_BACKUP) {
// Try to continue except obvious error
return ERROR_INVALID_BACKUP;
}
} catch(FileNotFoundException ignored) {
return ERROR_INVALID_BACKUP;
} catch(IOException ignored) {
return ERROR_INVALID_BACKUP;
} catch (ClassNotFoundException e) {
return ERROR_INVALID_BACKUP;
}
}
// Move the configuration back now that we know it's parsable and that SAF is set up
Log.i(ScummVM.LOG_TAG, "Writing new ScummVM configuration");
File configuration = new File(context.getFilesDir(), "scummvm.ini");
if (!configurationTmp.renameTo(configuration)) {
try (FileOutputStream os = new FileOutputStream(configuration);
FileInputStream is = new FileInputStream(configurationTmp)) {
copyStream(os, is);
} catch(FileNotFoundException ignored) {
return ERROR_INVALID_BACKUP;
} catch(IOException ignored) {
return ERROR_INVALID_BACKUP;
}
configurationTmp.delete();
}
File savesPath = INIParser.getPath(parsedIniMap, "scummvm", "savepath",
new File(context.getFilesDir(), "saves"));
boolean ret = importSaves(context, savesPath, zf, "saves/");
if (!ret) {
try {
zf.close();
} catch(IOException ignored) {
}
return ERROR_INVALID_BACKUP;
}
HashSet<File> savesPaths = new HashSet<>();
savesPaths.add(savesPath);
int sectionId = -1;
for (String section : parsedIniMap.keySet()) {
sectionId++;
if ("scummvm".equals(section)) {
continue;
}
savesPath = INIParser.getPath(parsedIniMap, section, "savepath", null);
if (savesPath == null) {
continue;
}
if (savesPaths.contains(savesPath)) {
continue;
}
savesPaths.add(savesPath);
String folderName = "saves-" + sectionId + "/";
ret = importSaves(context, savesPath, zf, folderName);
if (!ret) {
break;
}
}
try {
zf.close();
} catch(IOException ignored) {
}
return ret ? ERROR_NO_ERROR : ERROR_INVALID_SAVES;
}
@RequiresApi(api = Build.VERSION_CODES.N)
static private int importTrees(ScummVMActivity context, InputStream is) throws IOException, ClassNotFoundException {
boolean failed = false;
ObjectInputStream ois = new ObjectInputStream(is);
// Version 1
if (ois.readInt() != 1) {
// Invalid version
return ERROR_INVALID_BACKUP;
}
for (int length = ois.readInt(); length > 0; length--) {
String treeName = (String)ois.readObject();
String treeId = (String)ois.readObject();
String treeUri = (String)ois.readObject();
if (SAFFSTree.findTree(context, treeId) != null) {
continue;
}
Uri uri = context.selectWithNativeUI(true, true, Uri.parse(treeUri), treeName, null, null);
if (uri == null) {
failed = true;
continue;
}
// Register the new selected tree
SAFFSTree.newTree(context, uri);
}
ois.close();
return failed ? ERROR_CANCELLED : ERROR_NO_ERROR;
}
static private boolean importSaves(Context context, File folder, ZipFile zf, String folderName) {
SAFFSTree.PathResult pr;
try {
pr = SAFFSTree.fullPathToNode(context, folder.getPath(), true);
} catch (FileNotFoundException e) {
return false;
}
// This version check is only to make Android Studio linter happy
if (pr == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
// This is a standard filesystem path
if (!folder.isDirectory() && !folder.mkdirs()) {
return false;
}
Enumeration<? extends org.scummvm.scummvm.zip.ZipEntry> entries = zf.entries();
while (entries.hasMoreElements()) {
org.scummvm.scummvm.zip.ZipEntry entry = entries.nextElement();
String name = entry.getName();
if (!name.startsWith(folderName)) {
continue;
}
// Get the base name (this avoids directory traversal)
name = name.substring(name.lastIndexOf("/") + 1);
if (name.isEmpty() || "timestamps".equals(name)) {
continue;
}
File f = new File(folder, name);
try (InputStream is = zf.getInputStream(entry);
FileOutputStream os = new FileOutputStream(f)) {
copyStream(os, is);
} catch(FileNotFoundException ignored) {
return false;
} catch(IOException ignored) {
return false;
}
}
return true;
}
// This is a SAF fake mount point
Enumeration<? extends org.scummvm.scummvm.zip.ZipEntry> entries = zf.entries();
while (entries.hasMoreElements()) {
org.scummvm.scummvm.zip.ZipEntry entry = entries.nextElement();
String name = entry.getName();
if (!name.startsWith(folderName)) {
continue;
}
// Get the base name (this avoids directory traversal)
name = name.substring(name.lastIndexOf("/") + 1);
if (name.isEmpty() || "timestamps".equals(name)) {
continue;
}
SAFFSTree.SAFFSNode child = pr.tree.getChild(pr.node, name);
if (child == null) {
child = pr.tree.createFile(pr.node, name);
}
if (child == null) {
return false;
}
try (InputStream is = zf.getInputStream(entry);
ParcelFileDescriptor pfd = pr.tree.createFileDescriptor(child, "wt")) {
copyStream(new FileOutputStream(pfd.getFileDescriptor()), is);
} catch(FileNotFoundException ignored) {
return false;
} catch(IOException ignored) {
return false;
}
}
return true;
}
public static void copyStream(OutputStream out, InputStream in) throws IOException {
byte[] buffer = new byte[4096];
int sz;
while((sz = in.read(buffer)) != -1) {
out.write(buffer, 0, sz);
}
}
}

View File

@@ -0,0 +1,566 @@
package org.scummvm.scummvm;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ShortcutInfo;
import android.content.pm.ShortcutManager;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Insets;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Icon;
import android.media.AudioAttributes;
import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioTrack;
import android.view.DisplayCutout;
import android.view.View;
import android.view.Window;
import android.view.WindowInsets;
import android.view.WindowInsetsController;
import android.view.accessibility.AccessibilityEvent;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
class CompatHelpers {
static class HideSystemStatusBar {
public static void hide(final Window window) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
HideSystemStatusBarR.hide(window);
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) {
HideSystemStatusBarKitKat.hide(window);
} else {
HideSystemStatusBarJellyBean.hide(window);
}
}
@RequiresApi(android.os.Build.VERSION_CODES.JELLY_BEAN)
@SuppressWarnings("deprecation")
private static class HideSystemStatusBarJellyBean {
public static void hide(final Window window) {
View view = window.getDecorView();
view.setSystemUiVisibility(
view.getSystemUiVisibility() |
View.SYSTEM_UI_FLAG_LOW_PROFILE);
}
}
@RequiresApi(android.os.Build.VERSION_CODES.KITKAT)
@SuppressWarnings("deprecation")
private static class HideSystemStatusBarKitKat {
public static void hide(final Window window) {
View view = window.getDecorView();
view.setSystemUiVisibility(
(view.getSystemUiVisibility() & ~View.SYSTEM_UI_FLAG_IMMERSIVE) |
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY |
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
View.SYSTEM_UI_FLAG_FULLSCREEN);
}
}
@RequiresApi(android.os.Build.VERSION_CODES.R)
private static class HideSystemStatusBarR {
public static void hide(final Window window) {
WindowInsetsController insetsController = window.getInsetsController();
insetsController.hide(WindowInsets.Type.statusBars() | WindowInsets.Type.navigationBars());
insetsController.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
}
}
static class SystemInsets {
public interface SystemInsetsListener {
void systemInsetsUpdated(int[] gestureInsets, int[] systemInsets, int[] cutoutInsets);
}
public static void registerSystemInsetsListener(View v, SystemInsetsListener l) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
v.setOnApplyWindowInsetsListener(new OnApplyWindowInsetsListenerR(l));
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
v.setOnApplyWindowInsetsListener(new OnApplyWindowInsetsListenerQ(l));
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
v.setOnApplyWindowInsetsListener(new OnApplyWindowInsetsListenerP(l));
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
v.setOnApplyWindowInsetsListener(new OnApplyWindowInsetsListenerLollipop(l));
} else {
// Not available
int[] gestureInsets = new int[] { 0, 0, 0, 0 };
int[] systemInsets = new int[] { 0, 0, 0, 0 };
int[] cutoutInsets = new int[] { 0, 0, 0, 0 };
l.systemInsetsUpdated(gestureInsets, systemInsets, cutoutInsets);
}
}
@RequiresApi(android.os.Build.VERSION_CODES.LOLLIPOP)
@SuppressWarnings("deprecation")
private static class OnApplyWindowInsetsListenerLollipop implements View.OnApplyWindowInsetsListener {
final private SystemInsetsListener l;
public OnApplyWindowInsetsListenerLollipop(SystemInsetsListener l) {
this.l = l;
}
@Override
public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
// No system gestures inset before Android Q
int[] gestureInsets = new int[] {
insets.getStableInsetLeft(),
insets.getStableInsetTop(),
insets.getStableInsetRight(),
insets.getStableInsetBottom()
};
int[] systemInsets = new int[] {
insets.getSystemWindowInsetLeft(),
insets.getSystemWindowInsetTop(),
insets.getSystemWindowInsetRight(),
insets.getSystemWindowInsetBottom()
};
// No cutouts before Android P
int[] cutoutInsets = new int[] { 0, 0, 0, 0 };
l.systemInsetsUpdated(gestureInsets, systemInsets, cutoutInsets);
return v.onApplyWindowInsets(insets);
}
}
@RequiresApi(android.os.Build.VERSION_CODES.P)
@SuppressWarnings("deprecation")
private static class OnApplyWindowInsetsListenerP implements View.OnApplyWindowInsetsListener {
final private SystemInsetsListener l;
public OnApplyWindowInsetsListenerP(SystemInsetsListener l) {
this.l = l;
}
@Override
public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
// No system gestures inset before Android Q
int[] gestureInsets = new int[] {
insets.getStableInsetLeft(),
insets.getStableInsetTop(),
insets.getStableInsetRight(),
insets.getStableInsetBottom()
};
int[] systemInsets = new int[] {
insets.getSystemWindowInsetLeft(),
insets.getSystemWindowInsetTop(),
insets.getSystemWindowInsetRight(),
insets.getSystemWindowInsetBottom()
};
int[] cutoutInsets;
DisplayCutout cutout = insets.getDisplayCutout();
if (cutout == null) {
cutoutInsets = new int[] { 0, 0, 0, 0 };
} else {
cutoutInsets = new int[] {
cutout.getSafeInsetLeft(),
cutout.getSafeInsetTop(),
cutout.getSafeInsetRight(),
cutout.getSafeInsetBottom()
};
}
l.systemInsetsUpdated(gestureInsets, systemInsets, cutoutInsets);
return v.onApplyWindowInsets(insets);
}
}
@RequiresApi(android.os.Build.VERSION_CODES.Q)
@SuppressWarnings("deprecation")
private static class OnApplyWindowInsetsListenerQ implements View.OnApplyWindowInsetsListener {
final private SystemInsetsListener l;
public OnApplyWindowInsetsListenerQ(SystemInsetsListener l) {
this.l = l;
}
@Override
public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
Insets insetsStruct = insets.getSystemGestureInsets();
int[] gestureInsets = new int[] {
insetsStruct.left,
insetsStruct.top,
insetsStruct.right,
insetsStruct.bottom,
};
insetsStruct = insets.getSystemWindowInsets();
int[] systemInsets = new int[] {
insetsStruct.left,
insetsStruct.top,
insetsStruct.right,
insetsStruct.bottom,
};
int[] cutoutInsets;
DisplayCutout cutout = insets.getDisplayCutout();
if (cutout == null) {
cutoutInsets = new int[] { 0, 0, 0, 0 };
} else {
cutoutInsets = new int[] {
cutout.getSafeInsetLeft(),
cutout.getSafeInsetTop(),
cutout.getSafeInsetRight(),
cutout.getSafeInsetBottom()
};
}
l.systemInsetsUpdated(gestureInsets, systemInsets, cutoutInsets);
return v.onApplyWindowInsets(insets);
}
}
@RequiresApi(android.os.Build.VERSION_CODES.R)
private static class OnApplyWindowInsetsListenerR implements View.OnApplyWindowInsetsListener {
final private SystemInsetsListener l;
public OnApplyWindowInsetsListenerR(SystemInsetsListener l) {
this.l = l;
}
@Override
public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
Insets insetsStruct = insets.getInsetsIgnoringVisibility(WindowInsets.Type.systemGestures());
int[] gestureInsets = new int[] {
insetsStruct.left,
insetsStruct.top,
insetsStruct.right,
insetsStruct.bottom,
};
insetsStruct = insets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars());
int[] systemInsets = new int[] {
insetsStruct.left,
insetsStruct.top,
insetsStruct.right,
insetsStruct.bottom,
};
int[] cutoutInsets;
DisplayCutout cutout = insets.getDisplayCutout();
if (cutout == null) {
cutoutInsets = new int[] { 0, 0, 0, 0 };
} else {
cutoutInsets = new int[] {
cutout.getSafeInsetLeft(),
cutout.getSafeInsetTop(),
cutout.getSafeInsetRight(),
cutout.getSafeInsetBottom()
};
}
l.systemInsetsUpdated(gestureInsets, systemInsets, cutoutInsets);
return v.onApplyWindowInsets(insets);
}
}
}
static class AudioTrackCompat {
public static class AudioTrackCompatReturn {
public AudioTrack audioTrack;
public int bufferSize;
}
public static AudioTrackCompatReturn make(int sample_rate, int buffer_size) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
return AudioTrackCompatM.make(sample_rate, buffer_size);
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
return AudioTrackCompatLollipop.make(sample_rate, buffer_size);
} else {
return AudioTrackCompatOld.make(sample_rate, buffer_size);
}
}
/**
* Support for Android KitKat or lower
*/
@SuppressWarnings("deprecation")
private static class AudioTrackCompatOld {
public static AudioTrackCompatReturn make(int sample_rate, int buffer_size) {
AudioTrackCompatReturn ret = new AudioTrackCompatReturn();
ret.audioTrack = new AudioTrack(
AudioManager.STREAM_MUSIC,
sample_rate,
AudioFormat.CHANNEL_OUT_STEREO,
AudioFormat.ENCODING_PCM_16BIT,
buffer_size,
AudioTrack.MODE_STREAM);
ret.bufferSize = buffer_size;
return ret;
}
}
@RequiresApi(android.os.Build.VERSION_CODES.LOLLIPOP)
private static class AudioTrackCompatLollipop {
public static AudioTrackCompatReturn make(int sample_rate, int buffer_size) {
AudioTrackCompatReturn ret = new AudioTrackCompatReturn();
ret.audioTrack = new AudioTrack(
new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_GAME)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build(),
new AudioFormat.Builder()
.setSampleRate(sample_rate)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO).build(),
buffer_size,
AudioTrack.MODE_STREAM,
AudioManager.AUDIO_SESSION_ID_GENERATE);
ret.bufferSize = buffer_size;
return ret;
}
}
@RequiresApi(android.os.Build.VERSION_CODES.M)
private static class AudioTrackCompatM {
public static AudioTrackCompatReturn make(int sample_rate, int buffer_size) {
AudioTrackCompatReturn ret = new AudioTrackCompatReturn();
ret.audioTrack = new AudioTrack(
new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_GAME)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build(),
new AudioFormat.Builder()
.setSampleRate(sample_rate)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO).build(),
buffer_size,
AudioTrack.MODE_STREAM,
AudioManager.AUDIO_SESSION_ID_GENERATE);
// Keep track of the actual obtained audio buffer size, if supported.
// We just requested 16 bit PCM stereo pcm so there are 4 bytes per frame.
ret.bufferSize = ret.audioTrack.getBufferSizeInFrames() * 4;
return ret;
}
}
}
static class AccessibilityEventConstructor {
public static AccessibilityEvent make(int eventType) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
return AccessibilityEventConstructorR.make(eventType);
} else {
return AccessibilityEventConstructorOld.make(eventType);
}
}
@SuppressWarnings("deprecation")
private static class AccessibilityEventConstructorOld {
public static AccessibilityEvent make(int eventType) {
return AccessibilityEvent.obtain(eventType);
}
}
@RequiresApi(android.os.Build.VERSION_CODES.R)
private static class AccessibilityEventConstructorR {
public static AccessibilityEvent make(int eventType) {
return new AccessibilityEvent(eventType);
}
}
}
static class ShortcutCreator {
public static Intent createShortcutResultIntent(@NonNull Context context, String id, @NonNull Intent intent, @NonNull String label, @Nullable Drawable icon, @DrawableRes int fallbackIconId) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N_MR1) {
return ShortcutCreatorN_MR1.createShortcutResultIntent(context, id, intent, label, icon, fallbackIconId);
} else {
return ShortcutCreatorOld.createShortcutResultIntent(context, id, intent, label, icon, fallbackIconId);
}
}
public static void pushDynamicShortcut(@NonNull Context context, String id, @NonNull Intent intent, @NonNull String label, @Nullable Drawable icon, @DrawableRes int fallbackIconId) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
ShortcutCreatorR.pushDynamicShortcut(context, id, intent, label, icon, fallbackIconId);
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N_MR1) {
ShortcutCreatorN_MR1.pushDynamicShortcut(context, id, intent, label, icon, fallbackIconId);
}
// No support for older versions
}
@SuppressWarnings("deprecation")
private static class ShortcutCreatorOld {
public static Intent createShortcutResultIntent(@NonNull Context context, String ignoredId, @NonNull Intent intent, @NonNull String label, @Nullable Drawable icon, @DrawableRes int fallbackIconId) {
Intent result = new Intent();
if (icon == null) {
icon = DrawableCompat.getDrawable(context, fallbackIconId);
}
Objects.requireNonNull(icon);
Bitmap bmIcon = drawableToBitmap(icon);
addToIntent(result, intent, label, bmIcon);
return result;
}
public static void addToIntent(Intent result, @NonNull Intent intent, @NonNull String label, @NonNull Bitmap icon) {
result.putExtra(Intent.EXTRA_SHORTCUT_INTENT, intent);
result.putExtra(Intent.EXTRA_SHORTCUT_NAME, label);
result.putExtra(Intent.EXTRA_SHORTCUT_ICON, icon);
}
}
@RequiresApi(android.os.Build.VERSION_CODES.N_MR1)
private static class ShortcutCreatorN_MR1 {
public static ShortcutInfo createShortcutInfo(Context context, String id, @NonNull Intent intent, @NonNull String label, @Nullable Icon icon) {
ShortcutInfo.Builder builder = new ShortcutInfo.Builder(context, id);
builder.setIntent(intent);
builder.setShortLabel(label);
builder.setIcon(icon);
HashSet<String> categories = new HashSet<>(1);
categories.add("actions.intent.START_GAME_EVENT");
builder.setCategories(categories);
return builder.build();
}
public static Intent createShortcutResultIntent(Context context, String id, @NonNull Intent intent, @NonNull String label, @Nullable Drawable icon, @DrawableRes int fallbackIconId) {
Bitmap bm;
Icon ic;
if (icon != null) {
bm = drawableToBitmap(icon);
ic = Icon.createWithBitmap(bm);
} else {
icon = DrawableCompat.getDrawable(context, fallbackIconId);
Objects.requireNonNull(icon);
bm = drawableToBitmap(icon);
ic = Icon.createWithResource(context, fallbackIconId);
}
ShortcutInfo si = createShortcutInfo(context, id, intent, label, ic);
Intent result = null;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
final ShortcutManager shortcutManager = context.getSystemService(ShortcutManager.class);
result = shortcutManager.createShortcutResultIntent(si);
}
if (result == null) {
result = new Intent();
}
ShortcutCreatorOld.addToIntent(result, intent, label, bm);
return result;
}
public static void pushDynamicShortcut(@NonNull Context context, String id, @NonNull Intent intent, @NonNull String label, @Nullable Drawable icon, @DrawableRes int fallbackIconId) {
Icon ic;
if (icon != null) {
ic = Icon.createWithBitmap(drawableToBitmap(icon));
} else {
ic = Icon.createWithResource(context, fallbackIconId);
}
ShortcutInfo si = createShortcutInfo(context, id, intent, label, ic);
final ShortcutManager shortcutManager = context.getSystemService(ShortcutManager.class);
if (shortcutManager.isRateLimitingActive()) {
return;
}
List<ShortcutInfo> shortcuts = shortcutManager.getDynamicShortcuts();
// Sort shortcuts by rank, timestamp and id
Collections.sort(shortcuts, new Comparator<ShortcutInfo>() {
@Override
public int compare(ShortcutInfo a, ShortcutInfo b) {
int ret = Integer.compare(a.getRank(), b.getRank());
if (ret != 0) {
return ret;
}
ret = Long.compare(a.getLastChangedTimestamp(), b.getLastChangedTimestamp());
if (ret != 0) {
return ret;
}
return a.getId().compareTo(b.getId());
}
});
// In old Android versions, only 4 shortcuts are displayed but 5 maximum are supported
// Problem: the last one added is not displayed... so stick to 4
int maxSize = Math.min(shortcutManager.getMaxShortcutCountPerActivity(), 4);
if (shortcuts.size() >= maxSize) {
int toRemove = shortcuts.size() - maxSize + 1;
ArrayList<String> toRemoveList = new ArrayList<>(toRemove);
// Remove the lowest rank, oldest shortcut if we need it
for(ShortcutInfo oldSi : shortcuts) {
if (oldSi.getId().equals(id)) {
// We will update it: no need to make space
toRemoveList.clear();
break;
}
if (toRemove > 0) {
toRemoveList.add(oldSi.getId());
toRemove -= 1;
}
}
shortcutManager.removeDynamicShortcuts(toRemoveList);
}
shortcuts = new ArrayList<>(1);
shortcuts.add(si);
shortcutManager.addDynamicShortcuts(shortcuts);
shortcutManager.reportShortcutUsed(id);
}
}
@RequiresApi(android.os.Build.VERSION_CODES.R)
private static class ShortcutCreatorR {
public static void pushDynamicShortcut(@NonNull Context context, String id, @NonNull Intent intent, @NonNull String label, @Nullable Drawable icon, @DrawableRes int fallbackIconId) {
Icon ic;
if (icon != null) {
ic = Icon.createWithBitmap(drawableToBitmap(icon));
} else {
ic = Icon.createWithResource(context, fallbackIconId);
}
ShortcutInfo si = ShortcutCreatorN_MR1.createShortcutInfo(context, id, intent, label, ic);
final ShortcutManager shortcutManager = context.getSystemService(ShortcutManager.class);
shortcutManager.pushDynamicShortcut(si);
// pushDynamicShortcut already reports usage
}
}
private static Bitmap drawableToBitmap(@NonNull Drawable drawable) {
// We resize to 128x128 to avoid having too big bitmaps for Binder
if (drawable instanceof BitmapDrawable) {
Bitmap bm = ((BitmapDrawable)drawable).getBitmap();
bm = Bitmap.createScaledBitmap(bm, 128, 128, true);
return bm.copy(bm.getConfig(), false);
}
Bitmap bitmap = Bitmap.createBitmap(128, 128, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
drawable.draw(canvas);
// Create an immutable bitmap
return bitmap.copy(bitmap.getConfig(), false);
}
}
static class DrawableCompat {
public static Drawable getDrawable(@NonNull Context context, @DrawableRes int id) throws Resources.NotFoundException {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
return DrawableCompatLollipop.getDrawable(context, id);
} else {
return DrawableCompatOld.getDrawable(context, id);
}
}
@RequiresApi(android.os.Build.VERSION_CODES.LOLLIPOP)
private static class DrawableCompatLollipop {
@SuppressLint("UseCompatLoadingForDrawables")
public static Drawable getDrawable(@NonNull Context context, @DrawableRes int id) throws Resources.NotFoundException {
return context.getDrawable(id);
}
}
@SuppressWarnings("deprecation")
private static class DrawableCompatOld {
@SuppressLint("UseCompatLoadingForDrawables")
public static Drawable getDrawable(@NonNull Context context, @DrawableRes int id) throws Resources.NotFoundException {
return context.getResources().getDrawable(id);
}
}
}
}

View File

@@ -0,0 +1,860 @@
/*
* Copyright (C) 2008-2009 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package org.scummvm.scummvm;
import android.content.Context;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.content.res.XmlResourceParser;
import android.graphics.drawable.Drawable;
import android.text.TextUtils;
import android.util.DisplayMetrics;
import android.util.Log;
import android.util.TypedValue;
import android.util.Xml;
import androidx.annotation.XmlRes;
import org.xmlpull.v1.XmlPullParserException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.StringTokenizer;
/**
* Loads an XML description of a keyboard and stores the attributes of the keys. A keyboard
* consists of rows of keys.
* <p>The layout file for a keyboard contains XML that looks like the following snippet:</p>
* <pre>
* &lt;Keyboard
* android:keyWidth="%10p"
* android:keyHeight="50px"
* android:horizontalGap="2px"
* android:verticalGap="2px" &gt;
* &lt;Row android:keyWidth="32px" &gt;
* &lt;Key android:keyLabel="A" /&gt;
* ...
* &lt;/Row&gt;
* ...
* &lt;/Keyboard&gt;
* </pre>
*/
public class CustomKeyboard {
static final String LOG_TAG = "CustomKeyboard";
// Keyboard XML Tags
private static final String TAG_KEYBOARD = "CustomKeyboard";
private static final String TAG_ROW = "CustomRow";
private static final String TAG_KEY = "CustomKey";
public static final int EDGE_LEFT = 0x01;
public static final int EDGE_RIGHT = 0x02;
public static final int EDGE_TOP = 0x04;
public static final int EDGE_BOTTOM = 0x08;
public static final int KEYCODE_SHIFT = -1;
public static final int KEYCODE_MODE_CHANGE = -2;
public static final int KEYCODE_CANCEL = -3;
public static final int KEYCODE_DONE = -4;
public static final int KEYCODE_DELETE = -5;
public static final int KEYCODE_ALT = -6;
/** Keyboard label **/
private CharSequence mLabel;
/** Horizontal gap default for all rows */
private int mDefaultHorizontalGap;
/** Default key width */
private int mDefaultWidth;
/** Default key height */
private int mDefaultHeight;
/** Default gap between rows */
private int mDefaultVerticalGap;
/** Is the keyboard in the shifted state */
private boolean mShifted;
/** Key instance for the shift key, if present */
private CustomKey[] mShiftKeys = { null, null };
/** Key index for the shift key, if present */
private int[] mShiftKeyIndices = {-1, -1};
/** Current key width, while loading the keyboard */
private int mKeyWidth;
/** Current key height, while loading the keyboard */
private int mKeyHeight;
/** Total height of the keyboard, including the padding and keys */
// @UnsupportedAppUsage
private int mTotalHeight;
/**
* Total width of the keyboard, including left side gaps and keys, but not any gaps on the
* right side.
*/
// @UnsupportedAppUsage
private int mTotalWidth;
/** List of keys in this keyboard */
private List<CustomKey> mKeys;
/** List of modifier keys such as Shift & Alt, if any */
// @UnsupportedAppUsage
private List<CustomKey> mModifierKeys;
/** Width of the screen available to fit the keyboard */
private int mDisplayWidth;
/** Height of the screen */
private int mDisplayHeight;
/** Keyboard mode, or zero, if none. */
private int mKeyboardMode;
// Variables for pre-computing nearest keys.
private static final int GRID_WIDTH = 10;
private static final int GRID_HEIGHT = 5;
private static final int GRID_SIZE = GRID_WIDTH * GRID_HEIGHT;
private int mCellWidth;
private int mCellHeight;
private int[][] mGridNeighbors;
private int mProximityThreshold;
/** Number of key widths from current touch point to search for nearest keys. */
private static float SEARCH_DISTANCE = 1.8f;
private ArrayList<CustomRow> rows = new ArrayList<CustomRow>();
/**
* Container for keys in the keyboard. All keys in a row are at the same Y-coordinate.
* Some of the key size defaults can be overridden per row from what the {@link CustomKeyboard}
* defines.
*/
public static class CustomRow {
/** Default width of a key in this row. */
public int defaultWidth;
/** Default height of a key in this row. */
public int defaultHeight;
/** Default horizontal gap between keys in this row. */
public int defaultHorizontalGap;
/** Vertical gap following this row. */
public int verticalGap;
ArrayList<CustomKey> mKeys = new ArrayList<>();
/**
* Edge flags for this row of keys. Possible values that can be assigned are
* {@link CustomKeyboard#EDGE_TOP EDGE_TOP} and {@link CustomKeyboard#EDGE_BOTTOM EDGE_BOTTOM}
*/
public int rowEdgeFlags;
/** The keyboard mode for this row */
public int mode;
private CustomKeyboard parent;
public CustomRow(CustomKeyboard parent) {
this.parent = parent;
}
public CustomRow(Resources res, CustomKeyboard parent, XmlResourceParser parser) {
this.parent = parent;
TypedArray a = res.obtainAttributes(Xml.asAttributeSet(parser), R.styleable.CustomKeyboard);
defaultWidth = getDimensionOrFraction(a, R.styleable.CustomKeyboard_keyWidth, parent.mDisplayWidth, parent.mDefaultWidth);
defaultHeight = getDimensionOrFraction(a, R.styleable.CustomKeyboard_keyHeight, parent.mDisplayHeight, parent.mDefaultHeight);
defaultHorizontalGap = getDimensionOrFraction(a, R.styleable.CustomKeyboard_horizontalGap, parent.mDisplayWidth, parent.mDefaultHorizontalGap);
verticalGap = getDimensionOrFraction(a, R.styleable.CustomKeyboard_verticalGap, parent.mDisplayHeight, parent.mDefaultVerticalGap);
a.recycle();
a = res.obtainAttributes(Xml.asAttributeSet(parser), R.styleable.CustomKeyboard_CustomRow);
rowEdgeFlags = a.getInt(R.styleable.CustomKeyboard_CustomRow_rowEdgeFlags, 0);
mode = a.getResourceId(R.styleable.CustomKeyboard_CustomRow_keyboardMode, 0);
a.recycle();
}
} // end of: static class CustomRow
/**
* Class for describing the position and characteristics of a single key in the keyboard.
*
*/
public static class CustomKey {
/**
* All the key codes (unicode or custom code) that this key could generate, zero'th
* being the most important.
*/
public int[] codes;
/** Label to display */
public CharSequence label;
/** Icon to display instead of a label. Icon takes precedence over a label */
public Drawable icon;
/** Preview version of the icon, for the preview popup */
public Drawable iconPreview;
/** Width of the key, not including the gap */
public int width;
/** Height of the key, not including the gap */
public int height;
/** The horizontal gap before this key */
public int gap;
/** Whether this key is sticky, i.e., a toggle key */
public boolean sticky;
/** X coordinate of the key in the keyboard layout */
public int x;
/** Y coordinate of the key in the keyboard layout */
public int y;
/** The current pressed state of this key */
public boolean pressed;
/** If this is a sticky key, is it on? */
public boolean on;
/** Text to output when pressed. This can be multiple characters, like ".com" */
public CharSequence text;
/** Popup characters */
public CharSequence popupCharacters;
/**
* Flags that specify the anchoring to edges of the keyboard for detecting touch events
* that are just out of the boundary of the key. This is a bit mask of
* {@link CustomKeyboard#EDGE_LEFT}, {@link CustomKeyboard#EDGE_RIGHT}, {@link CustomKeyboard#EDGE_TOP} and
* {@link CustomKeyboard#EDGE_BOTTOM}.
*/
public int edgeFlags;
/** Whether this is a modifier key, such as Shift or Alt */
public boolean modifier;
/** The keyboard that this key belongs to */
private CustomKeyboard keyboard;
/**
* If this key pops up a mini keyboard, this is the resource id for the XML layout for that
* keyboard.
*/
public int popupResId;
/** Whether this key repeats itself when held down */
public boolean repeatable;
private final static int[] KEY_STATE_NORMAL_ON = {
android.R.attr.state_checkable,
android.R.attr.state_checked
};
private final static int[] KEY_STATE_PRESSED_ON = {
android.R.attr.state_pressed,
android.R.attr.state_checkable,
android.R.attr.state_checked
};
private final static int[] KEY_STATE_NORMAL_OFF = {
android.R.attr.state_checkable
};
private final static int[] KEY_STATE_PRESSED_OFF = {
android.R.attr.state_pressed,
android.R.attr.state_checkable
};
private final static int[] KEY_STATE_NORMAL = { };
private final static int[] KEY_STATE_PRESSED = {
android.R.attr.state_pressed
};
/** Create an empty key with no attributes. */
public CustomKey(CustomRow parent) {
keyboard = parent.parent;
height = parent.defaultHeight;
width = parent.defaultWidth;
gap = parent.defaultHorizontalGap;
edgeFlags = parent.rowEdgeFlags;
}
/** Create a key with the given top-left coordinate and extract its attributes from
* the XML parser.
* @param res resources associated with the caller's context
* @param parent the row that this key belongs to. The row must already be attached to
* a {@link CustomKeyboard}.
* @param x the x coordinate of the top-left
* @param y the y coordinate of the top-left
* @param parser the XML parser containing the attributes for this key
*/
public CustomKey(Resources res, CustomRow parent, int x, int y, XmlResourceParser parser) {
this(parent);
this.x = x;
this.y = y;
// obtainAttributes (AttributeSet set, int[] attrs)
// Retrieve a set of basic attribute values from an AttributeSet, not performing styling of them using a theme and/or style resources.
TypedArray a = res.obtainAttributes(Xml.asAttributeSet(parser), R.styleable.CustomKeyboard);
width = getDimensionOrFraction(a, R.styleable.CustomKeyboard_keyWidth, keyboard.mDisplayWidth, parent.defaultWidth);
height = getDimensionOrFraction(a, R.styleable.CustomKeyboard_keyHeight, keyboard.mDisplayHeight, parent.defaultHeight);
gap = getDimensionOrFraction(a, R.styleable.CustomKeyboard_horizontalGap, keyboard.mDisplayWidth, parent.defaultHorizontalGap);
// Log.d(LOG_TAG, "from CustomKeyboard: wid: " +width + " heigh: " + height + " gap: " + gap );
a.recycle();
a = res.obtainAttributes(Xml.asAttributeSet(parser), R.styleable.CustomKeyboard_CustomKey);
this.x += gap;
TypedValue codesValue = new TypedValue();
a.getValue(R.styleable.CustomKeyboard_CustomKey_codes, codesValue);
if (codesValue.type == TypedValue.TYPE_INT_DEC || codesValue.type == TypedValue.TYPE_INT_HEX) {
// Log.d(LOG_TAG, "Key codes is INT or HEX");
codes = new int[] { codesValue.data };
} else if (codesValue.type == TypedValue.TYPE_STRING) {
// Log.d(LOG_TAG, "Key codes is String");
codes = parseCSV(codesValue.string.toString());
}
iconPreview = a.getDrawable(R.styleable.CustomKeyboard_CustomKey_iconPreview);
if (iconPreview != null) {
iconPreview.setBounds(0, 0, iconPreview.getIntrinsicWidth(), iconPreview.getIntrinsicHeight());
}
popupCharacters = a.getText(R.styleable.CustomKeyboard_CustomKey_popupCharacters);
popupResId = a.getResourceId(R.styleable.CustomKeyboard_CustomKey_popupKeyboard, 0);
repeatable = a.getBoolean(R.styleable.CustomKeyboard_CustomKey_isRepeatable, false);
modifier = a.getBoolean(R.styleable.CustomKeyboard_CustomKey_isModifier, false);
sticky = a.getBoolean(R.styleable.CustomKeyboard_CustomKey_isSticky, false);
edgeFlags = a.getInt(R.styleable.CustomKeyboard_CustomKey_keyEdgeFlags, 0);
edgeFlags |= parent.rowEdgeFlags;
icon = a.getDrawable(R.styleable.CustomKeyboard_CustomKey_keyIcon);
if (icon != null) {
icon.setBounds(0, 0, icon.getIntrinsicWidth(), icon.getIntrinsicHeight());
}
label = a.getText(R.styleable.CustomKeyboard_CustomKey_keyLabel);
// Log.d(LOG_TAG, "Key label is " + label);
text = a.getText(R.styleable.CustomKeyboard_CustomKey_keyOutputText);
if (codes == null && !TextUtils.isEmpty(label)) {
codes = new int[] { label.charAt(0) };
}
a.recycle();
}
/**
* Informs the key that it has been pressed, in case it needs to change its appearance or
* state.
* @see #onReleased(boolean)
*/
public void onPressed() {
pressed = !pressed;
}
/**
* Changes the pressed state of the key.
*
* <p>Toggled state of the key will be flipped when all the following conditions are
* fulfilled:</p>
*
* <ul>
* <li>This is a sticky key, that is, {@link #sticky} is {@code true}.
* <li>The parameter {@code inside} is {@code true}.
* <li>{@link android.os.Build.VERSION#SDK_INT} is greater than
* {@link android.os.Build.VERSION_CODES#LOLLIPOP_MR1}.
* </ul>
*
* @param inside whether the finger was released inside the key. Works only on Android M and
* later. See the method document for details.
* @see #onPressed()
*/
public void onReleased(boolean inside) {
pressed = !pressed;
if (sticky && inside) {
on = !on;
}
}
int[] parseCSV(String value) {
int count = 0;
int lastIndex = 0;
if (value.length() > 0) {
count++;
while ((lastIndex = value.indexOf(",", lastIndex + 1)) > 0) {
count++;
}
}
int[] values = new int[count];
count = 0;
StringTokenizer st = new StringTokenizer(value, ",");
while (st.hasMoreTokens()) {
try {
values[count++] = Integer.parseInt(st.nextToken());
} catch (NumberFormatException nfe) {
Log.e(LOG_TAG, "Error parsing keycodes " + value);
}
}
return values;
}
/**
* Detects if a point falls inside this key.
* @param x the x-coordinate of the point
* @param y the y-coordinate of the point
* @return whether or not the point falls inside the key. If the key is attached to an edge,
* it will assume that all points between the key and the edge are considered to be inside
* the key.
*/
public boolean isInside(int x, int y) {
boolean leftEdge = (edgeFlags & EDGE_LEFT) > 0;
boolean rightEdge = (edgeFlags & EDGE_RIGHT) > 0;
boolean topEdge = (edgeFlags & EDGE_TOP) > 0;
boolean bottomEdge = (edgeFlags & EDGE_BOTTOM) > 0;
if ((x >= this.x || (leftEdge && x <= this.x + this.width))
&& (x < this.x + this.width || (rightEdge && x >= this.x))
&& (y >= this.y || (topEdge && y <= this.y + this.height))
&& (y < this.y + this.height || (bottomEdge && y >= this.y))) {
return true;
} else {
return false;
}
}
/**
* Returns the square of the distance between the center of the key and the given point.
* @param x the x-coordinate of the point
* @param y the y-coordinate of the point
* @return the square of the distance of the point from the center of the key
*/
public int squaredDistanceFrom(int x, int y) {
int xDist = this.x + width / 2 - x;
int yDist = this.y + height / 2 - y;
return xDist * xDist + yDist * yDist;
}
/**
* Returns the drawable state for the key, based on the current state and type of the key.
* @return the drawable state of the key.
* @see android.graphics.drawable.StateListDrawable#setState(int[])
*/
public int[] getCurrentDrawableState() {
int[] states = KEY_STATE_NORMAL;
if (on) {
if (pressed) {
states = KEY_STATE_PRESSED_ON;
} else {
states = KEY_STATE_NORMAL_ON;
}
} else {
if (sticky) {
if (pressed) {
states = KEY_STATE_PRESSED_OFF;
} else {
states = KEY_STATE_NORMAL_OFF;
}
} else {
if (pressed) {
states = KEY_STATE_PRESSED;
}
}
}
return states;
}
} // end of: static class CustomKey
/**
* Creates a keyboard from the given xml key layout file.
* @param context the application or service context
* @param xmlLayoutResId the resource file that contains the keyboard layout and keys.
*/
public CustomKeyboard(Context context, int xmlLayoutResId) {
this(context, xmlLayoutResId, 0);
}
/**
* Creates a keyboard from the given xml key layout file. Weeds out rows
* that have a keyboard mode defined but don't match the specified mode.
* @param context the application or service context
* @param xmlLayoutResId the resource file that contains the keyboard layout and keys.
* @param modeId keyboard mode identifier
* @param width sets width of keyboard
* @param height sets height of keyboard
*/
public CustomKeyboard(Context context, @XmlRes int xmlLayoutResId, int modeId, int width, int height) {
mDisplayWidth = width;
mDisplayHeight = height;
mDefaultHorizontalGap = 0;
mDefaultWidth = mDisplayWidth / 10;
mDefaultVerticalGap = 0;
mDefaultHeight = mDefaultWidth;
mKeys = new ArrayList<CustomKey>();
mModifierKeys = new ArrayList<CustomKey>();
mKeyboardMode = modeId;
loadKeyboard(context, context.getResources().getXml(xmlLayoutResId));
}
/**
* Creates a keyboard from the given xml key layout file. Weeds out rows
* that have a keyboard mode defined but don't match the specified mode.
* @param context the application or service context
* @param xmlLayoutResId the resource file that contains the keyboard layout and keys.
* @param modeId keyboard mode identifier
*/
public CustomKeyboard(Context context, @XmlRes int xmlLayoutResId, int modeId) {
DisplayMetrics dm = context.getResources().getDisplayMetrics();
mDisplayWidth = dm.widthPixels;
mDisplayHeight = dm.heightPixels;
// Log.v(LOG_TAG, "keyboard's display metrics:" + dm);
mDefaultHorizontalGap = 0;
mDefaultWidth = mDisplayWidth / 10;
mDefaultVerticalGap = 0;
mDefaultHeight = mDefaultWidth;
mKeys = new ArrayList<CustomKey>();
mModifierKeys = new ArrayList<CustomKey>();
mKeyboardMode = modeId;
// Log.v(LOG_TAG, "Resource ID is " + xmlLayoutResId + " and parser is null?" + ((context.getResources().getXml(xmlLayoutResId) == null) ? "yes" : "no"));
loadKeyboard(context, context.getResources().getXml(xmlLayoutResId));
}
/**
* <p>Creates a blank keyboard from the given resource file and populates it with the specified
* characters in left-to-right, top-to-bottom fashion, using the specified number of columns.
* </p>
* <p>If the specified number of columns is -1, then the keyboard will fit as many keys as
* possible in each row.</p>
* @param context the application or service context
* @param layoutTemplateResId the layout template file, containing no keys.
* @param characters the list of characters to display on the keyboard. One key will be created
* for each character.
* @param columns the number of columns of keys to display. If this number is greater than the
* number of keys that can fit in a row, it will be ignored. If this number is -1, the
* keyboard will fit as many keys as possible in each row.
*/
public CustomKeyboard(Context context, int layoutTemplateResId, CharSequence characters, int columns, int horizontalPadding) {
this(context, layoutTemplateResId);
int x = 0;
int y = 0;
int column = 0;
mTotalWidth = 0;
CustomRow row = new CustomRow(this);
row.defaultHeight = mDefaultHeight;
row.defaultWidth = mDefaultWidth;
row.defaultHorizontalGap = mDefaultHorizontalGap;
row.verticalGap = mDefaultVerticalGap;
row.rowEdgeFlags = EDGE_TOP | EDGE_BOTTOM;
final int maxColumns = columns == -1 ? Integer.MAX_VALUE : columns;
for (int i = 0; i < characters.length(); i++) {
char c = characters.charAt(i);
if (column >= maxColumns || x + mDefaultWidth + horizontalPadding > mDisplayWidth) {
x = 0;
y += mDefaultVerticalGap + mDefaultHeight;
column = 0;
}
final CustomKey key = new CustomKey(row);
key.x = x;
key.y = y;
key.label = String.valueOf(c);
key.codes = new int[] { c };
column++;
x += key.width + key.gap;
mKeys.add(key);
row.mKeys.add(key);
if (x > mTotalWidth) {
mTotalWidth = x;
}
}
mTotalHeight = y + mDefaultHeight;
rows.add(row);
}
// @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023)
final void resize(int newWidth, int newHeight) {
int numRows = rows.size();
for (int rowIndex = 0; rowIndex < numRows; ++rowIndex) {
CustomRow row = rows.get(rowIndex);
int numKeys = row.mKeys.size();
int totalGap = 0;
int totalWidth = 0;
for (int keyIndex = 0; keyIndex < numKeys; ++keyIndex) {
CustomKey key = row.mKeys.get(keyIndex);
if (keyIndex > 0) {
totalGap += key.gap;
}
totalWidth += key.width;
}
if (totalGap + totalWidth > newWidth) {
int x = 0;
float scaleFactor = (float)(newWidth - totalGap) / totalWidth;
for (int keyIndex = 0; keyIndex < numKeys; ++keyIndex) {
CustomKey key = row.mKeys.get(keyIndex);
key.width *= scaleFactor;
key.x = x;
x += key.width + key.gap;
}
}
}
mTotalWidth = newWidth;
// TODO: This does not adjust the vertical placement according to the new size.
// The main problem in the previous code was horizontal placement/size, but we should
// also recalculate the vertical sizes/positions when we get this resize call.
}
public List<CustomKey> getKeys() {
return mKeys;
}
public List<CustomKey> getModifierKeys() {
return mModifierKeys;
}
protected int getHorizontalGap() {
return mDefaultHorizontalGap;
}
protected void setHorizontalGap(int gap) {
mDefaultHorizontalGap = gap;
}
protected int getVerticalGap() {
return mDefaultVerticalGap;
}
protected void setVerticalGap(int gap) {
mDefaultVerticalGap = gap;
}
protected int getKeyHeight() {
return mDefaultHeight;
}
protected void setKeyHeight(int height) {
mDefaultHeight = height;
}
protected int getKeyWidth() {
return mDefaultWidth;
}
protected void setKeyWidth(int width) {
mDefaultWidth = width;
}
/**
* Returns the total height of the keyboard
* @return the total height of the keyboard
*/
public int getHeight() {
return mTotalHeight;
}
public int getMinWidth() {
return mTotalWidth;
}
public boolean setShifted(boolean shiftState) {
for (CustomKey shiftKey : mShiftKeys) {
if (shiftKey != null) {
shiftKey.on = shiftState;
}
}
if (mShifted != shiftState) {
mShifted = shiftState;
return true;
}
return false;
}
public boolean isShifted() {
return mShifted;
}
/**
*/
public int[] getShiftKeyIndices() {
return mShiftKeyIndices;
}
public int getShiftKeyIndex() {
return mShiftKeyIndices[0];
}
private void computeNearestNeighbors() {
// Round-up so we don't have any pixels outside the grid
mCellWidth = (getMinWidth() + GRID_WIDTH - 1) / GRID_WIDTH;
mCellHeight = (getHeight() + GRID_HEIGHT - 1) / GRID_HEIGHT;
mGridNeighbors = new int[GRID_SIZE][];
int[] indices = new int[mKeys.size()];
final int gridWidth = GRID_WIDTH * mCellWidth;
final int gridHeight = GRID_HEIGHT * mCellHeight;
for (int x = 0; x < gridWidth; x += mCellWidth) {
for (int y = 0; y < gridHeight; y += mCellHeight) {
int count = 0;
for (int i = 0; i < mKeys.size(); i++) {
final CustomKey key = mKeys.get(i);
if (key.squaredDistanceFrom(x, y) < mProximityThreshold
|| key.squaredDistanceFrom(x + mCellWidth - 1, y) < mProximityThreshold
|| key.squaredDistanceFrom(x + mCellWidth - 1, y + mCellHeight - 1) < mProximityThreshold
|| key.squaredDistanceFrom(x, y + mCellHeight - 1) < mProximityThreshold) {
indices[count++] = i;
}
}
int [] cell = new int[count];
System.arraycopy(indices, 0, cell, 0, count);
mGridNeighbors[(y / mCellHeight) * GRID_WIDTH + (x / mCellWidth)] = cell;
}
}
}
/**
* Returns the indices of the keys that are closest to the given point.
* @param x the x-coordinate of the point
* @param y the y-coordinate of the point
* @return the array of integer indices for the nearest keys to the given point. If the given
* point is out of range, then an array of size zero is returned.
*/
public int[] getNearestKeys(int x, int y) {
if (mGridNeighbors == null) computeNearestNeighbors();
if (x >= 0 && x < getMinWidth() && y >= 0 && y < getHeight()) {
int index = (y / mCellHeight) * GRID_WIDTH + (x / mCellWidth);
if (index < GRID_SIZE) {
return mGridNeighbors[index];
}
}
return new int[0];
}
protected CustomRow createRowFromXml(Resources res, XmlResourceParser parser) {
return new CustomRow(res, this, parser);
}
protected CustomKey createKeyFromXml(Resources res, CustomRow parent, int x, int y, XmlResourceParser parser) {
return new CustomKey(res, parent, x, y, parser);
}
private void loadKeyboard(Context context, XmlResourceParser parser) {
boolean inKey = false;
boolean inRow = false;
boolean leftMostKey = false;
int row = 0;
int x = 0;
int y = 0;
CustomKey key = null;
CustomRow currentRow = null;
Resources res = context.getResources();
boolean skipRow = false;
try {
int event;
while ((event = parser.next()) != XmlResourceParser.END_DOCUMENT) {
if (event == XmlResourceParser.START_TAG) {
String tag = parser.getName();
if (TAG_ROW.equals(tag)) {
// Log.d(LOG_TAG, "TAG ROW");
inRow = true;
x = 0;
currentRow = createRowFromXml(res, parser);
rows.add(currentRow);
skipRow = currentRow.mode != 0 && currentRow.mode != mKeyboardMode;
if (skipRow) {
skipToEndOfRow(parser);
inRow = false;
}
} else if (TAG_KEY.equals(tag)) {
// Log.d(LOG_TAG, "TAG KEY");
inKey = true;
key = createKeyFromXml(res, currentRow, x, y, parser);
mKeys.add(key);
if (key.codes != null) {
if (key.codes[0] == KEYCODE_SHIFT) {
// Find available shift key slot and put this shift key in it
for (int i = 0; i < mShiftKeys.length; i++) {
if (mShiftKeys[i] == null) {
mShiftKeys[i] = key;
mShiftKeyIndices[i] = mKeys.size()-1;
break;
}
}
mModifierKeys.add(key);
} else if (key.codes[0] == KEYCODE_ALT) {
mModifierKeys.add(key);
}
currentRow.mKeys.add(key);
}
} else if (TAG_KEYBOARD.equals(tag)) {
parseKeyboardAttributes(res, parser);
}
} else if (event == XmlResourceParser.END_TAG) {
if (inKey) {
inKey = false;
x += key.gap + key.width;
if (x > mTotalWidth) {
mTotalWidth = x;
}
} else if (inRow) {
inRow = false;
y += currentRow.verticalGap;
y += currentRow.defaultHeight;
row++;
} else {
// TODO: error or extend?
}
}
}
} catch (Exception e) {
Log.e(LOG_TAG, "Parse error:" + e);
e.printStackTrace();
}
mTotalHeight = y - mDefaultVerticalGap;
}
private void skipToEndOfRow(XmlResourceParser parser) throws XmlPullParserException, IOException {
int event;
while ((event = parser.next()) != XmlResourceParser.END_DOCUMENT) {
if (event == XmlResourceParser.END_TAG && parser.getName().equals(TAG_ROW)) {
break;
}
}
}
private void parseKeyboardAttributes(Resources res, XmlResourceParser parser) {
TypedArray a = res.obtainAttributes(Xml.asAttributeSet(parser), R.styleable.CustomKeyboard);
mDefaultWidth = getDimensionOrFraction(a, R.styleable.CustomKeyboard_keyWidth, mDisplayWidth, mDisplayWidth / 10);
mDefaultHeight = getDimensionOrFraction(a, R.styleable.CustomKeyboard_keyHeight, mDisplayHeight, 50);
mDefaultHorizontalGap = getDimensionOrFraction(a, R.styleable.CustomKeyboard_horizontalGap, mDisplayWidth, 0);
mDefaultVerticalGap = getDimensionOrFraction(a, R.styleable.CustomKeyboard_verticalGap, mDisplayHeight, 0);
mProximityThreshold = (int) (mDefaultWidth * SEARCH_DISTANCE);
mProximityThreshold = mProximityThreshold * mProximityThreshold; // Square it for comparison
a.recycle();
}
static int getDimensionOrFraction(TypedArray a, int index, int base, int defValue) {
TypedValue value = a.peekValue(index);
if (value == null)
return defValue;
if (value.type == TypedValue.TYPE_DIMENSION) {
return a.getDimensionPixelOffset(index, defValue);
} else if (value.type == TypedValue.TYPE_FRACTION) {
// Round it to avoid values like 47.9999 from getting truncated
return Math.round(a.getFraction(index, base, base, defValue));
}
return defValue;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
package org.scummvm.scummvm;
import android.text.SpannableStringBuilder;
public class EditableAccommodatingLatinIMETypeNullIssues extends SpannableStringBuilder {
EditableAccommodatingLatinIMETypeNullIssues(CharSequence source) {
super(source);
}
//This character must be ignored by your onKey() code.
public static final CharSequence ONE_UNPROCESSED_CHARACTER = "\\";
@Override
public SpannableStringBuilder replace(final int spannableStringStart, final int spannableStringEnd, CharSequence replacementSequence, int replacementStart, int replacementEnd) {
if (replacementEnd > replacementStart) {
//In this case, there is something in the replacementSequence that the IME
// is attempting to replace part of the editable with.
//We don't really care about whatever might already be in the editable;
// we only care about making sure that SOMETHING ends up in it,
// so that the backspace key will continue to work.
// So, start by zeroing out whatever is there to begin with.
super.replace(0, length(), "", 0, 0);
//We DO care about preserving the new stuff that is replacing the stuff in the
// editable, because this stuff might be sent to us as a keyDown event. So, we
// insert the new stuff (typically, a single character) into the now-empty editable,
// and return the result to the caller.
return super.replace(0, 0, replacementSequence, replacementStart, replacementEnd);
} else if (spannableStringEnd > spannableStringStart) {
//In this case, there is NOTHING in the replacementSequence, and something is
// being replaced in the editable.
// This is characteristic of a DELETION.
// So, start by zeroing out whatever is being replaced in the editable.
super.replace(0, length(), "", 0, 0);
//And now, we will place our ONE_UNPROCESSED_CHARACTER into the editable buffer, and return it.
return super.replace(0, 0, ONE_UNPROCESSED_CHARACTER, 0, 1);
}
// In this case, NOTHING is being replaced in the editable. This code assumes that there
// is already something there. This assumption is probably OK because in our
// InputConnectionAccommodatingLatinIMETypeNullIssues.getEditable() method
// we PLACE a ONE_UNPROCESSED_CHARACTER into the newly-created buffer. So if there
// is nothing replacing the identified part
// of the editable, and no part of the editable that is being replaced, then we just
// leave whatever is in the editable ALONE,
// and we can be confident that there will be SOMETHING there. This call to super.replace()
// in that case will be a no-op, except
// for the value it returns.
return super.replace(spannableStringStart, spannableStringEnd,
replacementSequence, replacementStart, replacementEnd);
}
}

View File

@@ -0,0 +1,407 @@
package org.scummvm.scummvm;
import android.annotation.TargetApi;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.text.Editable;
import android.text.InputType;
import android.text.Selection;
import android.util.AttributeSet;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.PointerIcon;
import android.view.SurfaceView;
import android.view.inputmethod.BaseInputConnection;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
public class EditableSurfaceView extends SurfaceView {
final Context _context;
final boolean _allowHideSystemMousePointer;
private boolean _mouseIsInCapturedState;
public EditableSurfaceView(Context context) {
super(context);
_context = context;
_mouseIsInCapturedState = false;
_allowHideSystemMousePointer = true;
}
public EditableSurfaceView(Context context, AttributeSet attrs) {
super(context, attrs);
_context = context;
_mouseIsInCapturedState = false;
_allowHideSystemMousePointer = true;
}
public EditableSurfaceView(Context context,
AttributeSet attrs,
int defStyle) {
super(context, attrs, defStyle);
_context = context;
_mouseIsInCapturedState = false;
_allowHideSystemMousePointer = true;
}
@Override
public boolean onCheckIsTextEditor() {
return false;
}
private class MyInputConnection extends BaseInputConnection {
// The false second argument puts the BaseInputConnection into "dummy" mode, which is also required in order for the raw key events to be sent to your view.
// In the BaseInputConnection code, you can find several comments such as the following: "only if dummy mode, a key event is sent for the new text and the current editable buffer cleared."
// Reference: https://stackoverflow.com/a/7386854
public MyInputConnection() {
super(EditableSurfaceView.this, false);
}
// Bug fixes for backspace behavior in TYPE_NULL input type
// ref: https://stackoverflow.com/a/19980975
// This holds the Editable text buffer that the LatinIME mistakenly *thinks*
// that it is editing, even though the views that employ this class are
// completely driven by key events.
Editable _myEditable = null;
//This method is called by the IME whenever the view that returned an
// instance of this class to the IME from its onCreateInputConnection()
// gains focus.
@Override
public Editable getEditable() {
// Some versions of the Google Keyboard (LatinIME) were delivered with a
// bug that causes KEYCODE_DEL to no longer be generated once the number
// of KEYCODE_DEL taps equals the number of other characters that have
// been typed. This bug was reported here as issue 62306.
//
// As of this writing (1/7/2014), it is fixed in the AOSP code, but that
// fix has not yet been released. Even when it is released, there will
// be many devices having versions of the Google Keyboard that include the bug
// in the wild for the indefinite future. Therefore, a workaround is required.
//
// This is a workaround for that bug which just jams a single garbage character
// into the internal buffer that the keyboard THINKS it is editing even
// though we have specified TYPE_NULL which *should* cause LatinIME to
// generate key events regardless of what is in that buffer. We have other
// code that attempts to ensure as the user edites that there is always
// one character remaining.
//
// The problem arises because when this unseen buffer becomes empty, the IME
// thinks that there is nothing left to delete, and therefore stops
// generating KEYCODE_DEL events, even though the app may still be very
// interested in receiving them.
//
// So, for example, if the user taps in ABCDE and then positions the
// (app-based) cursor to the left of A and taps the backspace key three
// times without any evident effect on the letters (because the app's own
// UI code knows that there are no letters to the left of the
// app-implemented cursor), and then moves the cursor to the right of the
// E and hits backspace five times, then, after E and D have been deleted,
// no more KEYCODE_DEL events will be generated by the IME because the
// unseen buffer will have become empty from five letter key taps followed
// by five backspace key taps (as the IME is unaware of the app-based cursor
// movements performed by the user).
//
// In other words, if your app is processing KEYDOWN events itself, and
// maintaining its own cursor and so on, and not telling the IME anything
// about the user's cursor position, this buggy processing of the hidden
// buffer will stop KEYCODE_DEL events when your app actually needs them -
// in whatever Android releases incorporate this LatinIME bug.
//
// By creating this garbage characters in the Editable that is initially
// returned to the IME here, we make the IME think that it still has
// something to delete, which causes it to keep generating KEYCODE_DEL
// events in response to backspace key presses.
//
// A specific keyboard version that I tested this on which HAS this
// problem but does NOT have the "KEYCODE_DEL completely gone" (issue 42904)
// problem that is addressed by the deleteSurroundingText() override below
// (the two problems are not both present in a single version) is
// 2.0.19123.914326a, tested running on a Nexus7 2012 tablet.
// There may be other versions that have issue 62306.
//
// A specific keyboard version that I tested this on which does NOT have
// this problem but DOES have the "KEYCODE_DEL completely gone" (issue
// 42904) problem that is addressed by the deleteSurroundingText()
// override below is 1.0.1800.776638, tested running on the Nexus10
// tablet. There may be other versions that also have issue 42904.
//
// The bug that this addresses was first introduced as of AOSP commit tag
// 4.4_r0.9, and the next RELEASED Android version after that was
// android-4.4_r1, which is the first release of Android 4.4. So, 4.4 will
// be the first Android version that would have included, in the original
// RELEASED version, a Google Keyboard for which this bug was present.
//
// Note that this bug was introduced exactly at the point that the OTHER bug
// (the one that is addressed in deleteSurroundingText(), below) was first
// FIXED.
//
// Despite the fact that the above are the RELEASES associated with the bug,
// the fact is that any 4.x Android release could have been upgraded by the
// user to a later version of Google Keyboard than was present when the
// release was originally installed to the device. I have checked the
// www.archive.org snapshots of the Google Keyboard listing page on the Google
// Play store, and all released updates listed there (which go back to early
// June of 2013) required Android 4.0 and up, so we can be pretty sure that
// this bug is not present in any version earlier than 4.0 (ICS), which means
// that we can limit this fix to API level 14 and up. And once the LatinIME
// problem is fixed, we can limit the scope of this workaround to end as of
// the last release that included the problem, since we can assume that
// users will not upgrade Google Keyboard to an EARLIER version than was
// originally included in their Android release.
//
// The bug that this addresses was FIXED but NOT RELEASED as of this AOSP
// commit:
//https://android.googlesource.com/platform/packages/inputmethods/LatinIME/+
// /b41bea65502ce7339665859d3c2c81b4a29194e4/java/src/com/android
// /inputmethod/latin/LatinIME.java
// so it can be assumed to affect all of KitKat released thus far
// (up to 4.4.2), and could even affect beyond KitKat, although I fully
// expect it to be incorporated into the next release *after* API level 19.
//
// When it IS released, this method should be changed to limit it to no
// higher than API level 19 (assuming that the fix is released before API
// level 20), just in order to limit the scope of this fix, since poking
// 1024 characters into the Editable object returned here is of course a
// kluge. But right now the safest thing is just to not have an upper limit
// on the application of this kluge, since the fix for the problem it
// addresses has not yet been released (as of 1/7/2014).
if(Build.VERSION.SDK_INT >= 14) {
if (_myEditable == null) {
_myEditable = new EditableAccommodatingLatinIMETypeNullIssues(EditableAccommodatingLatinIMETypeNullIssues.ONE_UNPROCESSED_CHARACTER);
Selection.setSelection(_myEditable, 1);
} else {
int _myEditableLength = _myEditable.length();
if (_myEditableLength == 0) {
// I actually HAVE seen this be zero on the Nexus 10 with the keyboard
// that came with Android 4.4.2
// On the Nexus 10 4.4.2 if I tapped away from the view and then back to it, the
// _myEditable would come back as null and I would create a new one. This is also
// what happens on other devices (e.g., the Nexus 6 with 4.4.2,
// which has a slightly later version of the Google Keyboard). But for the
// Nexus 10 4.4.2, the keyboard had a strange behavior
// when I tapped on the rack, and then tapped Done on the keyboard to close it,
// and then tapped on the rack AGAIN. In THAT situation,
// the _myEditable would NOT be set to NULL but its LENGTH would be ZERO. So, I
// just append to it in that situation.
_myEditable.append(EditableAccommodatingLatinIMETypeNullIssues.ONE_UNPROCESSED_CHARACTER);
Selection.setSelection(_myEditable, 1);
}
}
return _myEditable;
}
else {
//Default behavior for keyboards that do not require any fix
return super.getEditable();
}
}
// This method is called INSTEAD of generating a KEYCODE_DEL event, by
// versions of Latin IME that have the bug described in Issue 42904.
@Override
public boolean deleteSurroundingText(int beforeLength, int afterLength) {
// If targetSdkVersion is set to anything AT or ABOVE API level 16
// then for the GOOGLE KEYBOARD versions DELIVERED
// with Android 4.1.x, 4.2.x or 4.3.x, NO KEYCODE_DEL EVENTS WILL BE
// GENERATED BY THE GOOGLE KEYBOARD (LatinIME) EVEN when TYPE_NULL
// is being returned as the InputType by your view from its
// onCreateInputMethod() override, due to a BUG in THOSE VERSIONS.
//
// When TYPE_NULL is specified (as this entire class assumes is being done
// by the views that use it, what WILL be generated INSTEAD of a KEYCODE_DEL
// is a deleteSurroundingText(1,0) call. So, by overriding this
// deleteSurroundingText() method, we can fire the KEYDOWN/KEYUP events
// ourselves for KEYCODE_DEL. This provides a workaround for the bug.
//
// The specific AOSP RELEASES involved are 4.1.1_r1 (the very first 4.1
// release) through 4.4_r0.8 (the release just prior to Android 4.4).
// This means that all of KitKat should not have the bug and will not
// need this workaround.
//
// Although 4.0.x (ICS) did not have this bug, it was possible to install
// later versions of the keyboard as an app on anything running 4.0 and up,
// so those versions are also potentially affected.
//
// The first version of separately-installable Google Keyboard shown on the
// Google Play store site by www.archive.org is Version 1.0.1869.683049,
// on June 6, 2013, and that version (and probably other, later ones)
// already had this bug.
//
//Since this required at least 4.0 to install, I believe that the bug will
// not be present on devices running versions of Android earlier than 4.0.
//
// AND, it should not be present on versions of Android at 4.4 and higher,
// since users will not "upgrade" to a version of Google Keyboard that
// is LOWER than the one they got installed with their version of Android
// in the first place, and the bug will have been fixed as of the 4.4 release.
//
// The above scope of the bug is reflected in the test below, which limits
// the application of the workaround to Android versions between 4.0.x and 4.3.x.
//
// UPDATE: A popular third party keyboard was found that exhibits this same issue. It
// was not fixed at the same time as the Google Play keyboard, and so the bug in that case
// is still in place beyond API LEVEL 19. So, even though the Google Keyboard fixed this
// as of level 19, we cannot take out the fix based on that version number. And so I've
// removed the test for an upper limit on the version; the fix will remain in place ad
// infinitum - but only when TYPE_NULL is used, so it *should* be harmless even when
// the keyboard does not have the problem...
if ((Build.VERSION.SDK_INT >= 14) // && (Build.VERSION.SDK_INT < 19)
&& (beforeLength == 1 && afterLength == 0)) {
//Send Backspace key down and up events to replace the ones omitted
// by the LatinIME keyboard.
return super.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL))
&& super.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL));
} else {
//Really, I can't see how this would be invoked, given that we're using
// TYPE_NULL, for non-buggy versions, but in order to limit the impact
// of this change as much as possible (i.e., to versions at and above 4.0)
// I am using the original behavior here for non-affected versions.
return super.deleteSurroundingText(beforeLength, afterLength);
}
}
// @Override
// public boolean performEditorAction(int actionCode) {
// if (actionCode == EditorInfo.IME_ACTION_DONE) {
// InputMethodManager imm = (InputMethodManager)
// getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
// imm.hideSoftInputFromWindow(getWindowToken(), 0);
// }
//
// // Sends enter key
// return super.performEditorAction(actionCode);
// }
}
@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
outAttrs.initialCapsMode = 0;
outAttrs.initialSelEnd = outAttrs.initialSelStart = -1;
outAttrs.actionLabel = null;
// Per the documentation for InputType.TYPE_NULL:
// "This should be interpreted to mean that the target input connection is not rich,
// it can not process and show things like candidate text nor retrieve the current text,
// so the input method will need to run in a limited 'generate key events' mode."
// Reference: https://stackoverflow.com/a/7386854
// We lose auto-complete, but that is ok, because we *really* want direct input key handling
outAttrs.inputType = InputType.TYPE_NULL;
// IME_FLAG_NO_EXTRACT_UI used to specify that the IME does not need to show its extracted text UI. Extract UI means the fullscreen editing mode.
// IME_ACTION_NONE Bits of IME_MASK_ACTION: there is no available action.
// TODO should we have a IME_ACTION_DONE?
outAttrs.imeOptions = (EditorInfo.IME_ACTION_NONE |
EditorInfo.IME_FLAG_NO_EXTRACT_UI);
return new MyInputConnection();
}
public void showSystemMouseCursor(boolean show) {
//Log.d(ScummVM.LOG_TAG, "captureMouse::showSystemMouseCursor2 " + show);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
// Android N (Nougat) is Android 7.0
//SurfaceView main_surface = findViewById(R.id.main_surface);
int type = show ? PointerIcon.TYPE_ARROW : PointerIcon.TYPE_NULL;
// https://stackoverflow.com/a/55482761
//Log.d(ScummVM.LOG_TAG, "captureMouse::showSystemMouseCursor3a");
setPointerIcon(PointerIcon.getSystemIcon(_context, type));
} else {
/* Currently hiding the system mouse cursor is only
supported on OUYA. If other systems provide similar
intents, please add them here as well */
Intent intent =
new Intent(show?
"tv.ouya.controller.action.SHOW_CURSOR" :
"tv.ouya.controller.action.HIDE_CURSOR");
//Log.d(ScummVM.LOG_TAG, "captureMouse::showSystemMouseCursor3b");
_context.sendBroadcast(intent);
}
}
// This re-inforces the code for hiding the system mouse.
// We already had code for this in ScummVMActivity (see showSystemMouseCursor())
// so this might be redundant
//
// It applies on devices running Android 7 and above
// https://stackoverflow.com/a/55482761
// https://developer.android.com/reference/android/view/PointerIcon.html
//
@TargetApi(24)
@Override
public PointerIcon onResolvePointerIcon(MotionEvent me, int pointerIndex) {
if (_allowHideSystemMousePointer) {
if (_mouseIsInCapturedState) {
return PointerIcon.getSystemIcon(_context, PointerIcon.TYPE_NULL);
} else {
return PointerIcon.getSystemIcon(_context, PointerIcon.TYPE_ARROW);
}
} else {
return PointerIcon.getSystemIcon(_context, PointerIcon.TYPE_ARROW);
}
}
public void captureMouse(final boolean capture) {
if ((!_mouseIsInCapturedState && ((ScummVMActivity)_context).isKeyboardOverlayShown() && capture)) {
//Log.d(ScummVM.LOG_TAG, "captureMouse::returned - keyboard is shown");
return;
}
if ((capture && _mouseIsInCapturedState) ||
(!capture && ! _mouseIsInCapturedState)) {
//Log.d(ScummVM.LOG_TAG, "captureMouse::returned - nothing to do");
return;
}
if (capture) {
// setFocusableInTouchMode(true);
// setFocusable(true);
// requestFocus();
_mouseIsInCapturedState = true;
//Log.d(ScummVM.LOG_TAG, "captureMouse::_mouseIsInCapturedState");
} else {
//Log.d(ScummVM.LOG_TAG, "captureMouse::no _mouseIsInCapturedState");
_mouseIsInCapturedState = false;
}
showSystemMouseCursor(!capture);
// trying capturing the pointer resulted in firing TrackBallEvent instead of HoverEvent for our SurfaceView
// also the behavior was inconsistent when resuming to the app from a pause
// if (_allowHideSystemMousePointer) {
// // for API 26 and above
// if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
// Log.d(ScummVM.LOG_TAG, "captureMouse::CODES 0");
// if (capture) {
// postDelayed( new Runnable() {
// public void run()
// {
// Log.v(ScummVM.LOG_TAG, "captureMouse::requestPointerCapture() delayed");
//// if (!hasPointerCapture()) {
// requestPointerCapture();
//// showSystemMouseCursor(!capture);
//// }
// }
// }, 50 );
// } else {
// if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
// postDelayed(new Runnable() {
// public void run() {
// Log.v(ScummVM.LOG_TAG, "captureMouse::releasePointerCapture()");
//// if (hasPointerCapture()) {
// releasePointerCapture();
//// showSystemMouseCursor(!capture);
//// }
// }
// }, 50);
// }
// }
// } else {
// Log.d(ScummVM.LOG_TAG, "captureMouse::NO CODES 0 showSystemMouseCursor " + !capture);
// showSystemMouseCursor(!capture);
// }
// }
}
}

View File

@@ -0,0 +1,536 @@
package org.scummvm.scummvm;
import android.content.Context;
import android.os.Build;
import android.os.Environment;
import android.text.TextUtils;
import android.util.Log;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Scanner;
import java.util.regex.Pattern;
/**
* Contains helper methods to get list of available media
*/
public class ExternalStorage {
public static final String SD_CARD = "sdCard";
public static final String EXTERNAL_SD_CARD = "externalSdCard";
// User data is an system wide folder (typically, but maybe not always, "/data/") where apps can store data in their own subfolder.
// While this folder does exists in newer Android OS versions it is most likely not *directly* usable.
public static final String DATA_DIRECTORY = "User data (System Wide)";
// Internal App Data folder is a folder that is guaranteed to always be available for access.
// Only the app (here ScummVM) can write and read under this folder.
// It is used to store configuration file(s), log file(s), the default saved games folder, the default icons folder, and distribution data files.
// The folder's contents are kept kept upon upgrading to a compatible newer version of the app.
// It is wiped when downgrading, uninstalling or explicitly cleaning the application's data from the Android System Apps menu options.
// The storage for this folder is assigned from internal device storage (ie. not external physical SD Card).
public static final String DATA_DIRECTORY_INT = "ScummVM data (Internal)";
// External App Data folder is a folder that is NOT guaranteed to always be available for access.
// Only the app (here ScummVM) can write under this folder, but other apps have read access to the folder's contents.
// The folder's contents are kept upon upgrading to a compatible newer version of the app.
// It is wiped when downgrading, uninstalling or explicitly cleaning the application's data from the Android System Apps menu options.
public static final String DATA_DIRECTORY_EXT = "ScummVM data (External)";
// Find candidate removable sd card paths
// Code reference: https://stackoverflow.com/a/54411385
private static final String ANDROID_DIR = File.separator + "Android";
private static String ancestor(File dir) {
// getExternalFilesDir() and getExternalStorageDirectory()
// may return something app-specific like:
// /storage/sdcard1/Android/data/com.mybackuparchives.android/files
// so we want the great-great-grandparent folder.
if (dir == null) {
return null;
} else {
String path = dir.getAbsolutePath();
int i = path.indexOf(ANDROID_DIR);
if (i == -1) {
return path;
} else {
return path.substring(0, i);
}
}
}
// Pattern that SD card device should match
private static final Pattern
devicePattern = Pattern.compile("/dev/(block/.*vold.*|fuse)|/mnt/.*");
// Pattern that SD card mount path should match
private static final Pattern
pathPattern = Pattern.compile("/(mnt|storage|external_sd|extsd|_ExternalSD|Removable|.*MicroSD).*", Pattern.CASE_INSENSITIVE);
// Pattern that the mount path should not match.
//' emulated' indicates an internal storage location, so skip it.
// 'asec' is an encrypted package file, decrypted and mounted as a directory.
private static final Pattern
pathAntiPattern = Pattern.compile(".*(/secure|/asec|/emulated).*");
// These are expected fs types, including vfat. tmpfs is not OK.
// fuse can be removable SD card (as on Moto E or Asus ZenPad), or can be internal (Huawei G610).
private static final Pattern
fsTypePattern = Pattern.compile(".*(fat|msdos|ntfs|ext[34]|fuse|sdcard|esdfs).*");
/** Common paths for microSD card. **/
private static final String[] commonPaths = {
// Some of these taken from
// https://stackoverflow.com/questions/13976982/removable-storage-external-sdcard-path-by-manufacturers
// These are roughly in order such that the earlier ones, if they exist, are more sure
// to be removable storage than the later ones.
"/mnt/Removable/MicroSD",
"/storage/removable/" + SD_CARD + "1", // !< Sony Xperia Z1
"/Removable/MicroSD", // Asus ZenPad C
"/removable/microsd",
"/external_sd", // Samsung
"/_ExternalSD", // some LGs
"/storage/extSdCard", // later Samsung
"/storage/extsdcard", // Main filesystem is case-sensitive; FAT isn't.
"/mnt/extsd", // some Chinese tablets, e.g. Zeki
"/storage/" + SD_CARD + "1", // If this exists it's more likely than sdcard0 to be removable.
"/mnt/extSdCard",
"/mnt/" + SD_CARD + "/external_sd",
"/mnt/external_sd",
"/storage/external_SD",
"/storage/ext_sd", // HTC One Max
"/mnt/" + SD_CARD + "/_ExternalSD",
"/mnt/" + SD_CARD + "-ext",
"/" + SD_CARD + "2", // HTC One M8s
"/" + SD_CARD + "1", // Sony Xperia Z
"/mnt/media_rw/" + SD_CARD + "1", // 4.4.2 on CyanogenMod S3
"/mnt/" + SD_CARD, // This can be built-in storage (non-removable).
"/" + SD_CARD,
"/storage/" + SD_CARD +"0",
"/emmc",
"/mnt/emmc",
"/" + SD_CARD + "/sd",
"/mnt/" + SD_CARD + "/bpemmctest",
"/mnt/external1",
"/data/sdext4",
"/data/sdext3",
"/data/sdext2",
"/data/sdext",
"/storage/microsd" //ASUS ZenFone 2
// If we ever decide to support USB OTG storage, the following paths could be helpful:
// An LG Nexus 5 apparently uses usb://1002/UsbStorage/ as a URI to access an SD
// card over OTG cable. Other models, like Galaxy S5, use /storage/UsbDriveA
// "/mnt/usb_storage",
// "/mnt/UsbDriveA",
// "/mnt/UsbDriveB",
};
/** Find path to removable SD card. */
public static LinkedHashSet<File> findSdCardPath() {
String[] mountFields;
BufferedReader bufferedReader = null;
String lineRead;
// Possible SD card paths
LinkedHashSet<File> candidatePaths = new LinkedHashSet<>();
// Build a list of candidate paths, roughly in order of preference. That way if
// we can't definitively detect removable storage, we at least can pick a more likely
// candidate.
// Could do: use getExternalStorageState(File path), with and without an argument, when
// available. With an argument is available since API level 21.
// This may not be necessary, since we also check whether a directory exists and has contents,
// which would fail if the external storage state is neither MOUNTED nor MOUNTED_READ_ONLY.
// I moved hard-coded paths toward the end, but we need to make sure we put the ones in
// backwards order that are returned by the OS. And make sure the iterators respect
// the order!
// This is because when multiple "external" storage paths are returned, it's always (in
// experience, but not guaranteed by documentation) with internal/emulated storage
// first, removable storage second.
// Add value of environment variables as candidates, if set:
// EXTERNAL_STORAGE, SECONDARY_STORAGE, EXTERNAL_SDCARD_STORAGE
// But note they are *not* necessarily *removable* storage! Especially EXTERNAL_STORAGE.
// And they are not documented (API) features. Typically useful only for old versions of Android.
String val = System.getenv("SECONDARY_STORAGE");
if (!TextUtils.isEmpty(val)) {
addPath(val, candidatePaths);
}
val = System.getenv("EXTERNAL_SDCARD_STORAGE");
if (!TextUtils.isEmpty(val)) {
addPath(val, candidatePaths);
}
// Get listing of mounted devices with their properties.
ArrayList<File> mountedPaths = new ArrayList<>();
try {
// Note: Despite restricting some access to /proc (https://stackoverflow.com/a/38728738/423105),
// Android 7.0 does *not* block access to /proc/mounts, according to our test on George's Alcatel A30 GSM.
bufferedReader = new BufferedReader(new FileReader("/proc/mounts"));
// Iterate over each line of the mounts listing.
while ((lineRead = bufferedReader.readLine()) != null) {
// Log.d(ScummVM.LOG_TAG, "\nMounts line: " + lineRead);
mountFields = lineRead.split(" ");
// columns: device, mountpoint, fs type, options... Example:
// /dev/block/vold/179:97 /storage/sdcard1 vfat rw,dirsync,nosuid,nodev,noexec,relatime,uid=1000,gid=1015,fmask=0002,dmask=0002,allow_utime=0020,codepage=cp437,iocharset=iso8859-1,shortname=mixed,utf8,errors=remount-ro 0 0
String device = mountFields[0], path = mountFields[1], fsType = mountFields[2];
// The device, path, and fs type must conform to expected patterns.
// mtdblock is internal, I'm told.
// Check for disqualifying patterns in the path.
// If this mounts line fails our tests, skip it.
if (!(devicePattern.matcher(device).matches()
&& pathPattern.matcher(path).matches()
&& fsTypePattern.matcher(fsType).matches())
|| device.contains("mtdblock")
|| pathAntiPattern.matcher(path).matches()
) {
continue;
}
// TODO maybe: check options to make sure it's mounted RW?
// The answer at https://stackoverflow.com/a/13648873/423105 does.
// But it hasn't seemed to be necessary so far in my testing.
// This line met the criteria so far, so add it to candidate list.
addPath(path, mountedPaths);
}
} catch (IOException ignored) { }
finally {
if (bufferedReader != null) {
try {
bufferedReader.close();
} catch (IOException ignored) { }
}
}
// Append the paths from mount table to candidate list, in reverse order.
if (!mountedPaths.isEmpty()) {
// See https://stackoverflow.com/a/5374346/423105 on why the following is necessary.
// Basically, .toArray() needs its parameter to know what type of array to return.
File[] mountedPathsArray = mountedPaths.toArray(new File[0]);
addAncestors(mountedPathsArray, candidatePaths);
}
// Add hard-coded known common paths to candidate list:
addStrings(commonPaths, candidatePaths);
// If the above doesn't work we could try the following other options, but in my experience they
// haven't added anything helpful yet.
// getExternalFilesDir() and getExternalStorageDirectory() typically something app-specific like
// /storage/sdcard1/Android/data/com.mybackuparchives.android/files
// so we want the great-great-grandparent folder.
// TODO Note, This method was deprecated in API level 29.
// To improve user privacy, direct access to shared/external storage devices is deprecated.
// When an app targets Build.VERSION_CODES.Q, the path returned from this method is no longer directly accessible to apps.
// Apps can continue to access content stored on shared/external storage by migrating to
// alternatives such as Context#getExternalFilesDir(String), MediaStore, or Intent#ACTION_OPEN_DOCUMENT.
//
// This may be non-removable.
Log.d(ScummVM.LOG_TAG, "Environment.getExternalStorageDirectory():");
addPath(ancestor(Environment.getExternalStorageDirectory()), candidatePaths);
// TODO maybe use getExternalStorageState(File path), with and without an argument,
// when available. With an argument is available since API level 21.
// This may not be necessary, since we also check whether a directory exists,
// which would fail if the external storage state is neither MOUNTED nor MOUNTED_READ_ONLY.
// A "public" external storage directory. But in my experience it doesn't add anything helpful.
// Note that you can't pass null, or you'll get an NPE.
final File publicDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC);
// Take the parent, because we tend to get a path like /pathTo/sdCard/Music.
if (publicDirectory.getParentFile() != null) {
addPath(publicDirectory.getParentFile().getAbsolutePath(), candidatePaths);
}
// EXTERNAL_STORAGE: may not be removable.
val = System.getenv("EXTERNAL_STORAGE");
if (!TextUtils.isEmpty(val)) {
addPath(val, candidatePaths);
}
if (candidatePaths.isEmpty()) {
Log.w(ScummVM.LOG_TAG, "No removable microSD card found.");
return candidatePaths;
} else {
Log.i(ScummVM.LOG_TAG, "\nFound potential removable storage locations: " + candidatePaths);
}
// Accept or eliminate candidate paths if we can determine whether they're removable storage.
// In Lollipop and later, we can check isExternalStorageRemovable() status on each candidate.
if (Build.VERSION.SDK_INT >= 21) {
Iterator<File> itf = candidatePaths.iterator();
while (itf.hasNext()) {
File dir = itf.next();
// handle illegalArgumentException if the path is not a valid storage device.
try {
if (Environment.isExternalStorageRemovable(dir)) {
Log.i(ScummVM.LOG_TAG, dir.getPath() + " is removable external storage");
addPath(dir.getAbsolutePath(), candidatePaths);
} else if (Environment.isExternalStorageEmulated(dir)) {
Log.d(ScummVM.LOG_TAG, "Removing emulated external storage dir " + dir);
itf.remove();
}
} catch (IllegalArgumentException e) {
Log.d(ScummVM.LOG_TAG, "isRemovable(" + dir.getPath() + "): not a valid storage device.", e);
}
}
}
// Continue trying to accept or eliminate candidate paths based on whether they're removable storage.
// On pre-Lollipop, we only have singular externalStorage. Check whether it's removable.
if (Build.VERSION.SDK_INT >= 9) {
File externalStorage = Environment.getExternalStorageDirectory();
Log.d(ScummVM.LOG_TAG, String.format(Locale.ROOT, "findSDCardPath: getExternalStorageDirectory = %s", externalStorage.getPath()));
if (Environment.isExternalStorageRemovable()) {
// Make sure this is a candidate.
// TODO: Does this contains() work? Should we be canonicalizing paths before comparing?
if (candidatePaths.contains(externalStorage)) {
Log.d(ScummVM.LOG_TAG, "Using externalStorage dir " + externalStorage);
// return externalStorage;
addPath(externalStorage.getAbsolutePath(), candidatePaths);
}
} else if (Build.VERSION.SDK_INT >= 11 && Environment.isExternalStorageEmulated()) {
Log.d(ScummVM.LOG_TAG, "Removing emulated external storage dir " + externalStorage);
candidatePaths.remove(externalStorage);
}
}
return candidatePaths;
}
/** Add each path to the collection. */
private static void addStrings(String[] newPaths, LinkedHashSet<File> candidatePaths) {
for (String path : newPaths) {
addPath(path, candidatePaths);
}
}
/** Add ancestor of each File to the collection. */
private static void addAncestors(File[] files, LinkedHashSet<File> candidatePaths) {
for (int i = files.length - 1; i >= 0; i--) {
addPath(ancestor(files[i]), candidatePaths);
}
}
/**
* Add a new candidate directory path to our list, if it's not obviously wrong.
* Supply path as either String or File object.
* @param strNew path of directory to add
*/
private static void addPath(String strNew, Collection<File> paths) {
// If one of the arguments is null, fill it in from the other.
if (!TextUtils.isEmpty(strNew)) {
File fileNew = new File(strNew);
if (!paths.contains(fileNew) &&
// Check for paths known not to be removable SD card.
// The antipattern check can be redundant, depending on where this is called from.
!pathAntiPattern.matcher(strNew).matches()) {
// Eliminate candidate if not a directory or not fully accessible.
if (fileNew.exists() && fileNew.isDirectory() && fileNew.canExecute()) {
Log.d(ScummVM.LOG_TAG, " Adding candidate path " + strNew);
paths.add(fileNew);
} else {
Log.d(ScummVM.LOG_TAG, String.format(Locale.ROOT, " Invalid path %s: exists: %b isDir: %b canExec: %b canRead: %b",
strNew, fileNew.exists(), fileNew.isDirectory(), fileNew.canExecute(), fileNew.canRead()));
}
}
}
}
/**
* @return True if the external storage is available. False otherwise.
*/
public static boolean isAvailable() {
String state = Environment.getExternalStorageState();
return Environment.MEDIA_MOUNTED.equals(state) || Environment.MEDIA_MOUNTED_READ_ONLY.equals(state);
}
public static String getSdCardPath() {
return Environment.getExternalStorageDirectory().getPath() + "/";
}
/**
* @return True if the external storage is writable. False otherwise.
*/
public static boolean isWritable() {
String state = Environment.getExternalStorageState();
return Environment.MEDIA_MOUNTED.equals(state);
}
/**
* @return list of locations available. Odd elements are names, even are paths
*/
public static List<String> getAllStorageLocations(Context ctx) {
List<String> map = new ArrayList<>(20);
List<String> mMounts = new ArrayList<>(10);
List<String> mVold = new ArrayList<>(10);
mMounts.add("/mnt/sdcard");
mVold.add("/mnt/sdcard");
try {
File mountFile = new File("/proc/mounts");
if (mountFile.exists()) {
Scanner scanner = new Scanner(mountFile);
while (scanner.hasNext()) {
String line = scanner.nextLine();
if (line.startsWith("/dev/block/vold/")) {
String[] lineElements = line.split(" ");
String element = lineElements[1];
// don't add the default mount path
// it's already in the list.
if (!element.equals("/mnt/sdcard"))
mMounts.add(element);
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
try {
File voldFile = new File("/system/etc/vold.fstab");
if (voldFile.exists()){
Scanner scanner = new Scanner(voldFile);
while (scanner.hasNext()) {
String line = scanner.nextLine();
if (line.startsWith("dev_mount")) {
String[] lineElements = line.split(" ");
String element = lineElements[2];
if (element.contains(":"))
element = element.substring(0, element.indexOf(":"));
if (!element.equals("/mnt/sdcard"))
mVold.add(element);
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
for (int i = 0; i < mMounts.size(); i++) {
String mount = mMounts.get(i);
if (!mVold.contains(mount))
mMounts.remove(i--);
}
mVold.clear();
List<String> mountHash = new ArrayList<>(10);
for (String mount : mMounts) {
File root = new File(mount);
if (root.exists() && root.isDirectory() && root.canRead()) {
File[] list = root.listFiles();
StringBuilder hash = new StringBuilder("[");
if (list != null) {
for (File f : list) {
hash.append(f.getName().hashCode()).append(":").append(f.length()).append(", ");
}
}
hash.append("]");
if (!mountHash.contains(hash.toString())) {
String key = SD_CARD + "_" + (map.size() / 2);
if (map.size() == 0) {
key = SD_CARD;
} else if (map.size() == 2) {
key = EXTERNAL_SD_CARD;
}
mountHash.add(hash.toString());
map.add(key);
map.add(root.getAbsolutePath());
}
}
}
mMounts.clear();
if (Environment.getDataDirectory() != null
&& !TextUtils.isEmpty(Environment.getDataDirectory().getAbsolutePath())) {
File dataFilePath = new File(Environment.getDataDirectory().getAbsolutePath());
if (dataFilePath.exists() && dataFilePath.isDirectory()) {
map.add(DATA_DIRECTORY);
map.add(Environment.getDataDirectory().getAbsolutePath());
}
}
map.add(DATA_DIRECTORY_INT);
map.add(ctx.getFilesDir().getPath());
if (ctx.getExternalFilesDir(null) != null) {
map.add(DATA_DIRECTORY_EXT);
map.add(ctx.getExternalFilesDir(null).getPath());
}
// Now go through the external storage
if (isAvailable()) { // we can read the External Storage...
// Retrieve the primary External Storage:
File primaryExternalStorage = Environment.getExternalStorageDirectory();
// Retrieve the External Storages root directory:
String externalStorageRootDir;
if ((externalStorageRootDir = primaryExternalStorage.getParent()) == null) { // no parent...
String key = primaryExternalStorage.getAbsolutePath();
if (!map.contains(key)) {
map.add(key); // Make name as directory
map.add(key);
}
} else {
File externalStorageRoot = new File(externalStorageRootDir);
File[] files = externalStorageRoot.listFiles();
if (files != null) {
for (final File file : files) {
// Check if it is a real directory (not a USB drive)...
if (file.isDirectory()
&& file.canRead()
&& file.listFiles() != null
&& (file.listFiles().length > 0)) {
String key = file.getAbsolutePath();
if (!map.contains(key)) {
map.add(key); // Make name as directory
map.add(key);
}
}
}
}
}
}
// Get candidates for removable external storage
LinkedHashSet<File> candidateRemovableSdCardPaths = findSdCardPath();
for (final File file : candidateRemovableSdCardPaths) {
String key = file.getAbsolutePath();
if (!map.contains(key)) {
map.add(key); // Make name as directory
map.add(key);
}
}
return map;
}
}

View File

@@ -0,0 +1,346 @@
package org.scummvm.scummvm;
import android.util.Log;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.Reader;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* INI file parser modeled after config manager one in C++ side
*/
public class INIParser {
private final static String LOG_TAG = "INIParser";
// This class can not be instantiated
private INIParser() {
}
public static Map<String, Map<String, String>> parse(Reader reader) throws IOException {
Map<String, Map<String, String>> ret = new LinkedHashMap<>();
BufferedReader lineReader = new BufferedReader(reader);
Map<String, String> domain = null;
int lineno = 0;
String line;
while ((line = lineReader.readLine()) != null) {
lineno++;
if (lineno == 1 && line.startsWith("\357\273\277")) {
line = line.substring(3);
}
if (line.isEmpty()) {
continue;
}
final char firstChar = line.charAt(0);
/* Unlike C++ parser, we ignore comments for simplicity */
if (firstChar == '#') {
continue;
}
if (firstChar == '[') {
int i;
for(i = 1; i < line.length(); i++) {
final char c = line.charAt(i);
if (c > 127) {
break;
}
if (!Character.isLetterOrDigit(c) && c != '-' && c != '_') {
break;
}
}
if (i == line.length()) {
return null;
}
if (line.charAt(i) != ']') {
return null;
}
String domainName = line.substring(1, i);
domain = new LinkedHashMap<>();
ret.put(domainName, domain);
continue;
}
int i;
for (i = 0; i < line.length(); i++) {
final char c = line.charAt(i);
if (!isSpace(c)) {
break;
}
}
if (i == line.length()) {
continue;
}
if (domain == null) {
return null;
}
int equal = line.indexOf('=');
if (equal == -1) {
return null;
}
String key = line.substring(i, equal);
String value = line.substring(equal + 1);
key = trim(key);
value = trim(value);
domain.put(key, value);
}
return ret;
}
public static String get(Map<String, Map<String, String>> ini, String section, String key, String defaultValue) {
if (ini == null) {
return defaultValue;
}
Map<String, String> s = ini.get(section);
if (s == null) {
return defaultValue;
}
String value = s.get(key);
if (value == null) {
return defaultValue;
}
return value;
}
public static File getPath(Map<String, Map<String, String>> ini, String section, String key, File defaultValue) {
if (ini == null) {
return defaultValue;
}
Map<String, String> s = ini.get(section);
if (s == null) {
return defaultValue;
}
String value = s.get(key);
if (value == null) {
return defaultValue;
}
// Path components are escaped and puny encoded, undo this
File path = new File(""); // Create an abstract empty path
for(String component : value.split("/")) {
component = decodePathComponent(component);
path = new File(path, component);
}
return path;
}
private static String decodePathComponent(String component) {
if (!component.startsWith("xn--")) {
return component;
}
String decoded = punycodeDecode(component);
if (component == decoded) {
return component;
}
StringBuilder result = new StringBuilder(decoded);
int i = result.indexOf("\u0081");
while(i != -1 && i + 1 < result.length()) {
char c = decoded.charAt(i + 1);
if (c != 0x79) {
result.setCharAt(i, (char)(c - 0x80));
}
result.deleteCharAt(i + 1);
i = result.indexOf("\u0081", i + 1);
}
return result.toString();
}
/* Java isWhitespace is more inclusive than C one */
private static boolean isSpace(char c) {
return (c == ' ' || c == '\f' || c == '\n' || c == '\r' || c == '\t' || c == '\013');
}
/* Java trim is more strict than C one */
private static String trim(String s) {
int begin, end;
for(begin = 0; begin < s.length(); begin++) {
if (!isSpace(s.charAt(begin))) {
break;
}
}
for(end = s.length() - 1; end > begin; end--) {
if (!isSpace(s.charAt(end))) {
break;
}
}
return s.substring(begin, end + 1);
}
// punycode parameters, see https://datatracker.ietf.org/doc/html/rfc3492#section-5
private static final int BASE = 36;
private static final int TMIN = 1;
private static final int TMAX = 26;
private static final int SKEW = 38;
private static final int DAMP = 700;
private static final int INITIAL_N = 0x80;
private static final int INITIAL_BIAS = 72;
private static final int SMAX = 2147483647; // maximum Unicode code point
private static String punycodeDecode(String src) {
// Check for prefix
if (!src.startsWith("xn--")) {
return src;
}
// Check if it is ASCII
for (int i = 0; i < src.length(); i++) {
int c = src.charAt(i);
if (c > 0x7F) {
return src;
}
}
src = src.substring(4);
int tail = src.length();
int startInsert = src.lastIndexOf('-', tail) + 1;
while(true) {
// Check the insertions string and chop off invalid characters
int i;
for(i = startInsert; i < tail; i++) {
char c = src.charAt(i);
if (!((c >= '0' && c <= '9') ||
(c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z'))) {
break;
}
}
if (i == tail) {
// All good
break;
}
if (src.charAt(i) == '.') {
// Assume it's an extension, stop there
tail = i;
break;
}
if (startInsert == 0) {
// That was all invalid
return src;
}
// Look for previous dash
tail = startInsert;
startInsert = src.lastIndexOf('-', tail) + 1;
// Check again
}
// Do punycode work
StringBuilder dest = new StringBuilder(src.substring(0, startInsert > 0 ? startInsert - 1 : 0));
int di = dest.length();
int i = 0;
int n = INITIAL_N;
int bias = INITIAL_BIAS;
for (int si = startInsert; si < tail; di++) {
int org_i = i;
for (int w = 1, k = BASE; true; k += BASE) {
if (si >= tail) {
Log.w(LOG_TAG, "punycode_decode: incorrect digit for string: " + src);
return src;
}
int digit = decodeDigit(src.charAt(si));
si++;
if (digit == SMAX) {
Log.w(LOG_TAG, "punycode_decode: incorrect digit2 for string: " + src);
return src;
}
if (digit > (SMAX - i) / w) {
// OVERFLOW
Log.w(LOG_TAG, "punycode_decode: overflow1 for string: " + src);
return src;
}
i += digit * w;
int t;
if (k <= bias) {
t = TMIN;
} else if (k >= bias + TMAX) {
t = TMAX;
} else {
t = k - bias;
}
if (digit < t) {
break;
}
if (w > SMAX / (BASE - t)) {
// OVERFLOW
Log.w(LOG_TAG, "punycode_decode: overflow2 for string: "+ src);
return src;
}
w *= BASE - t;
}
bias = adaptBias(i - org_i, di + 1, org_i == 0);
if (i / (di + 1) > SMAX - n) {
// OVERFLOW
Log.w(LOG_TAG, "punycode_decode: overflow3 for string: " + src);
return src;
}
n += i / (di + 1);
i %= (di + 1);
dest.insert(i, Character.toChars(n));
i++;
}
// Re-add tail
dest.append(src.substring(tail));
return dest.toString();
}
private static int decodeDigit(char c) {
if (c >= '0' && c <= '9') {
return 26 + c - '0';
} else if (c >= 'a' && c <= 'z') {
return c - 'a';
} else if (c >= 'A' && c <= 'Z') {
return c - 'A';
} else {
return SMAX;
}
}
private static int adaptBias(int delta, int nPoints, boolean isFirst) {
int k;
delta /= isFirst ? DAMP : 2;
delta += delta / nPoints;
// while delta > 455: delta /= 35
for (k = 0; delta > ((BASE - TMIN) * TMAX) / 2; k += BASE) {
delta /= (BASE - TMIN);
}
return k + (((BASE - TMIN + 1) * delta) / (delta + SKEW));
}
}

View File

@@ -0,0 +1,168 @@
package org.scummvm.scummvm;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
public class LedView extends View {
public static final int DEFAULT_LED_COLOR = 0xffff0000;
private static final int BLINK_TIME = 30; // ms
private boolean _state;
private Runnable _blink;
private Paint _painter;
private int _radius;
private int _centerX;
private int _centerY;
public LedView(Context context) {
this(context, true, DEFAULT_LED_COLOR);
}
public LedView(Context context, boolean state) {
this(context, state, DEFAULT_LED_COLOR);
}
public LedView(Context context, boolean state, int color) {
super(context);
_state = state;
init(color);
}
public LedView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public LedView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context, attrs, defStyleAttr, 0);
}
@RequiresApi(android.os.Build.VERSION_CODES.LOLLIPOP)
public LedView(
Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init(context, attrs, defStyleAttr, defStyleRes);
}
private void init(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
TypedArray a = context.getTheme().obtainStyledAttributes(
attrs,
R.styleable.LedView,
defStyleAttr, defStyleRes);
try {
_state = a.getBoolean(R.styleable.LedView_state, true);
int color = a.getColor(R.styleable.LedView_color, DEFAULT_LED_COLOR);
init(color);
} finally {
a.recycle();
}
}
private void init(int color) {
_painter = new Paint();
_painter.setStyle(Paint.Style.FILL);
if (isInEditMode()) {
_painter.setStrokeWidth(2);
_painter.setStyle(_state ? Paint.Style.FILL : Paint.Style.STROKE);
}
_painter.setColor(color);
_painter.setAntiAlias(true);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int minw = getPaddingLeft() + getPaddingRight() + getSuggestedMinimumWidth();
int w = resolveSizeAndState(minw, widthMeasureSpec, 0);
int minh = MeasureSpec.getSize(w) - getPaddingLeft() - getPaddingRight() +
getPaddingBottom() + getPaddingTop();
int h = resolveSizeAndState(minh, heightMeasureSpec, 0);
setMeasuredDimension(w, h);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
int xpad = (getPaddingLeft() + getPaddingRight());
int ypad = (getPaddingTop() + getPaddingBottom());
int ww = w - xpad;
int hh = h - ypad;
_radius = Math.min(ww, hh) / 2 - 2;
_centerX = w / 2;
_centerY = h / 2;
}
@Override
protected void onDraw(@NonNull Canvas canvas) {
super.onDraw(canvas);
if (!_state && !isInEditMode()) {
return;
}
canvas.drawCircle(_centerX, _centerY, _radius, _painter);
}
public void on() {
setState(true);
}
public void off() {
setState(false);
}
public void setState(boolean state) {
if (_blink != null) {
removeCallbacks(_blink);
_blink = null;
}
if (_state == state) {
return;
}
_state = state;
invalidate();
}
public void blinkOnce() {
if (_blink != null) {
return;
}
boolean oldState = _state;
_state = !oldState;
invalidate();
_blink = new Runnable() {
private boolean _ran;
@Override
public void run() {
if (_ran) {
_blink = null;
return;
}
_ran = true;
_state = oldState;
invalidate();
postDelayed(this, BLINK_TIME);
}
};
postDelayed(_blink, BLINK_TIME);
}
}

View File

@@ -0,0 +1,250 @@
package org.scummvm.scummvm;
import android.annotation.SuppressLint;
import android.os.Build;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
/**
* Contains helper methods for mouse/hover events that were introduced in Android 4.0.
*/
public class MouseHelper implements View.OnHoverListener {
//private final View.OnHoverListener _listener;
private final ScummVM _scummvm;
private boolean _rmbPressed;
private boolean _lmbPressed;
private boolean _mmbPressed;
private boolean _bmbPressed;
private boolean _fmbPressed;
private boolean _srmbPressed;
private boolean _smmbPressed;
//
// Class initialization fails when this throws an exception.
// Checking hover availability is done on static class initialization for Android 1.6 compatibility.
//
static {
try {
Class.forName("android.view.View$OnHoverListener");
} catch (Exception ex) {
throw new RuntimeException(ex);
}
}
/**
* Calling this forces class initialization
*/
public static void checkHoverAvailable() {}
public MouseHelper(ScummVM scummvm) {
_scummvm = scummvm;
//_listener = createListener();
}
// private View.OnHoverListener createListener() {
// return new View.OnHoverListener() {
// @Override
// public boolean onHover(View view, MotionEvent e) {
// Log.d(ScummVM.LOG_TAG, "onHover mouseEvent");
// return onMouseEvent(e, true);
// }
// };
// }
@Override
public boolean onHover(View view, MotionEvent motionEvent) {
//Log.d(ScummVM.LOG_TAG, "onHover mouseEvent");
return onMouseEvent(motionEvent, true);
// return false;
}
// public void attach(SurfaceView main_surface) {
// main_surface.setOnHoverListener(_listener);
// }
// isTrackball is a subcase of isMouse (meaning isMouse will also return true)
public static boolean isTrackball(KeyEvent e) {
if (e == null) {
return false;
}
int source = e.getSource();
return ((source & InputDevice.SOURCE_TRACKBALL) == InputDevice.SOURCE_TRACKBALL) ||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && ((source & InputDevice.SOURCE_MOUSE_RELATIVE) == InputDevice.SOURCE_MOUSE_RELATIVE));
}
// isTrackball is a subcase of isMouse (meaning isMouse will also return true)
public static boolean isTrackball(MotionEvent e) {
if (e == null) {
return false;
}
//int source = e.getSource();
InputDevice device = e.getDevice();
if (device == null) {
return false;
}
int sources = device.getSources();
return ((sources & InputDevice.SOURCE_TRACKBALL) == InputDevice.SOURCE_TRACKBALL) ||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && ((sources & InputDevice.SOURCE_MOUSE_RELATIVE) == InputDevice.SOURCE_MOUSE_RELATIVE));
}
// "Checking against SOURCE_STYLUS only indicates "an input device is capable of obtaining input
// from a stylus. To determine whether a given touch event was produced by a stylus, examine
// the tool type returned by MotionEvent#getToolType(int) for each individual pointer."
// https://developer.android.com/reference/android/view/InputDevice#SOURCE_STYLUS
public static boolean isStylus(MotionEvent e){
if (e == null) {
return false;
}
for(int idx = 0; idx < e.getPointerCount(); idx++) {
if (e.getToolType(idx) == MotionEvent.TOOL_TYPE_STYLUS)
return true;
}
return false;
}
public static boolean isMouse(KeyEvent e) {
if (e == null) {
return false;
}
int source = e.getSource();
//Log.d(ScummVM.LOG_TAG, "isMouse keyEvent source: " + source);
// SOURCE_MOUSE_RELATIVE is sent when mouse is detected as trackball
// TODO: why does this happen? Do we need to also check for SOURCE_TRACKBALL here?
return ((source & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE)
|| ((source & InputDevice.SOURCE_STYLUS) == InputDevice.SOURCE_STYLUS)
|| ((source & InputDevice.SOURCE_TOUCHPAD) == InputDevice.SOURCE_TOUCHPAD)
|| isTrackball(e);
}
public static boolean isMouse(MotionEvent e) {
if (e == null) {
return false;
}
InputDevice device = e.getDevice();
if (device == null) {
return false;
}
int sources = device.getSources();
// SOURCE_MOUSE_RELATIVE is sent when mouse is detected as trackball
// TODO: why does this happen? Do we need to also check for SOURCE_TRACKBALL here?
// TODO: should these all be checks against TOOL_TYPEs instead of SOURCEs?
return ((sources & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE)
|| ((sources & InputDevice.SOURCE_TOUCHPAD) == InputDevice.SOURCE_TOUCHPAD)
|| isStylus(e)
|| isTrackball(e);
}
private boolean handleButton(MotionEvent e, boolean mbPressed, int mask, int downEvent, int upEvent) {
boolean mbDown = (e.getButtonState() & mask) == mask;
if ((e.getSource() & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE
&& (e.getButtonState() & MotionEvent.BUTTON_BACK) == MotionEvent.BUTTON_BACK) {
mbDown = (mask == MotionEvent.BUTTON_SECONDARY);
}
if (mbDown) {
if (!mbPressed) {
// mouse button was pressed just now
//Log.d(ScummVM.LOG_TAG, "handleButton mbDown, not mbPressed, mask = " + mask);
_scummvm.pushEvent(downEvent, (int)e.getX(), (int)e.getY(), e.getButtonState(), 0, 0, 0);
}
return true;
} else {
if (mbPressed) {
//Log.d(ScummVM.LOG_TAG, "handleButton not mbDown, mbPressed, mask = " + mask);
// mouse button was released just now
_scummvm.pushEvent(upEvent, (int)e.getX(), (int)e.getY(), e.getButtonState(), 0, 0, 0);
}
return false;
}
}
@SuppressLint("InlinedApi")
public boolean onMouseEvent(MotionEvent e, boolean hover) {
_scummvm.pushEvent(ScummVMEvents.JE_MOUSE_MOVE,
(int) e.getX(),
(int) e.getY(),
0,
0, 0, 0);
if (e.getActionMasked() == MotionEvent.ACTION_SCROLL) {
// The call is coming from ScummVMEvents, from a GenericMotionEvent (scroll wheel movement)
// TODO Do we want the JE_MOUSE_MOVE event too in this case?
int eventJEWheelUpDown = ScummVMEvents.JE_MOUSE_WHEEL_UP;
if (e.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0.0f) {
eventJEWheelUpDown = ScummVMEvents.JE_MOUSE_WHEEL_DOWN;
}
//Log.d(ScummVM.LOG_TAG, "onMouseEvent Wheel Up/Down = " + eventJEWheelUpDown);
_scummvm.pushEvent(eventJEWheelUpDown,
(int) e.getX(),
(int) e.getY(),
0,
0, 0, 0);
} else {
int buttonState = e.getButtonState();
//Log.d(ScummVM.LOG_TAG, "onMouseEvent buttonState = " + buttonState);
boolean lmbDown = (buttonState & MotionEvent.BUTTON_PRIMARY) == MotionEvent.BUTTON_PRIMARY;
if (!hover && e.getActionMasked() != MotionEvent.ACTION_UP && buttonState == 0) {
// On some device types, ButtonState is 0 even when tapping on the touch-pad or using the stylus on the screen etc.
lmbDown = true;
}
if (lmbDown) {
if (!_lmbPressed) {
// left mouse button was pressed just now
_scummvm.pushEvent(ScummVMEvents.JE_LMB_DOWN, (int)e.getX(), (int)e.getY(), e.getButtonState(), 0, 0, 0);
}
_lmbPressed = true;
} else {
if (_lmbPressed) {
// left mouse button was released just now
_scummvm.pushEvent(ScummVMEvents.JE_LMB_UP, (int)e.getX(), (int)e.getY(), e.getButtonState(), 0, 0, 0);
}
_lmbPressed = false;
}
_rmbPressed = handleButton(e, _rmbPressed, MotionEvent.BUTTON_SECONDARY, ScummVMEvents.JE_RMB_DOWN, ScummVMEvents.JE_RMB_UP);
_mmbPressed = handleButton(e, _mmbPressed, MotionEvent.BUTTON_TERTIARY, ScummVMEvents.JE_MMB_DOWN, ScummVMEvents.JE_MMB_UP);
_bmbPressed = handleButton(e, _bmbPressed, MotionEvent.BUTTON_BACK, ScummVMEvents.JE_BMB_DOWN, ScummVMEvents.JE_BMB_UP);
_fmbPressed = handleButton(e, _fmbPressed, MotionEvent.BUTTON_FORWARD, ScummVMEvents.JE_FMB_DOWN, ScummVMEvents.JE_FMB_UP);
// Lint warning for BUTTON_STYLUS... "
// Field requires API level 23 (current min is 16): android.view.MotionEvent#BUTTON_STYLUS_PRIMARY"
// Field requires API level 23 (current min is 16): android.view.MotionEvent#BUTTON_STYLUS_SECONDARY"
// We suppress it:
//
// https://stackoverflow.com/a/48588149
_srmbPressed = handleButton(e, _srmbPressed, MotionEvent.BUTTON_STYLUS_PRIMARY, ScummVMEvents.JE_RMB_DOWN, ScummVMEvents.JE_RMB_UP);
_smmbPressed = handleButton(e, _smmbPressed, MotionEvent.BUTTON_STYLUS_SECONDARY, ScummVMEvents.JE_MMB_DOWN, ScummVMEvents.JE_MMB_UP);
}
return true;
}
}

View File

@@ -0,0 +1,419 @@
package org.scummvm.scummvm;
import static org.scummvm.scummvm.ScummVMEvents.JE_MOUSE_WHEEL_DOWN;
import static org.scummvm.scummvm.ScummVMEvents.JE_MOUSE_WHEEL_UP;
import static org.scummvm.scummvm.ScummVMEvents.JE_MULTI;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.view.MotionEvent;
import androidx.annotation.NonNull;
import java.lang.ref.WeakReference;
public class MultitouchHelper {
private final ScummVM _scummvm;
private boolean _candidateStartOfMultitouchSession;
// a flag indicating whether we are in multitouch mode (more than one fingers down)
private boolean _multitouchMode;
// within a multitouch session (until a cancel event or no more multiple fingers down) the IDs for each finger-pointer is persisted and is consistent across events
// we can use the ids to track and utilize the movement of a specific finger (while ignoring the rest)
// Currently, we are using the last finger down, as the finger that moves the cursor
private int _firstPointerId;
private int _secondPointerId;
private int _thirdPointerId;
private int _cachedActionEventOnPointer2DownX;
private int _cachedActionEventOnPointer2DownY;
//
private int _cachedActionEventOnPointer1DownX;
private int _cachedActionEventOnPointer1DownY;
// The "level" of multitouch that is detected.
// We do not support downgrading a level, ie. if a three finger multitouch is detected,
// then raising one finger will void the multitouch session,
// rather than revert to two fingers multitouch.
// Similarly we do not support upgrading a level, ie. if we are already handling a two finger multitouch,
// then putting down another finger will void the session,
// rather than upgrade it to three fingers multitouch.
// INFO for this purpose we need to allow some limited time limit (delay _kLevelDecisionDelayMs) before deciding
// if the user did a two finger multitouch or intents to do a three finger multitouch
// Valid values for _multitouchLevel: MULTITOUCH_UNDECIDED, MULTITOUCH_TWO_FINGERS, MULTITOUCH_THREE_FINGERS
private final int MULTITOUCH_UNDECIDED = 0;
private final int MULTITOUCH_TWO_FINGERS = 2;
private final int MULTITOUCH_THREE_FINGERS = 3;
private int _multitouchLevel;
private final int _kLevelDecisionDelayMs = 400; // in milliseconds
private final int _kTouchMouseWheelDecisionDelayMs = 260; // in milliseconds - NOTE: Keep it significantly lower than _kLevelDecisionDelayMs
// messages for MultitouchHelperHandler
final static int MSG_MT_DECIDE_MULTITOUCH_SESSION_TIMEDOUT = 1;
final static int MSG_MT_UPGRADE_TO_LEVEL_3_TIMEDOUT = 2;
final static int MSG_MT_CHECK_FOR_TOUCH_MOUSE_WHEEL_TIMEDOUT = 3;
final private MultitouchHelper.MultitouchHelperHandler _multiTouchLevelUpgradeHandler = new MultitouchHelper.MultitouchHelperHandler(this);
// Scroll handling variables (calling it "Mouse Wheel" as SCROLL event on Android touch interface refers to moving around a finger on the touch surfaces)
private final int TOUCH_MOUSE_WHEEL_UNDECIDED = 0;
private final int TOUCH_MOUSE_WHEEL_ACTIVE = 1;
private final int TOUCH_MOUSE_WHEEL_NOT_HAPPENING = 3;
// Both fingers need to have moved up or down to enter the scroll (mouse wheel) mode
// The difference from the original positions (for both fingers respectively) is averaged and compared to the threshold below
// The decision to enter scroll mode happens as long as there are two fingers down but we're still undecided,
// (ie. undecided about whether this is a two finger event OR a third finger will follow OR it is a scroll event)
private final int MOVE_THRESHOLD_FOR_TOUCH_MOUSE_WHEEL_DECISION = 20;
private final int MOVE_THRESHOLD_FOR_SEND_TOUCH_MOUSE_WHEEL_EVENT = 30;
// 0: Undecided for scrolling (mouse wheel)
// 1: "scrolling" mode active
// 2: no "scrolling" (mouse wheel) (decided)
// Scrolling (mouse wheel) mode is mutually exclusive with the rest of the multi-touch modes,
// we can either send mouse wheel events or mouse click events in a multitouch session.
private int _touchMouseWheelDecisionLevel = 0;
// constructor
public MultitouchHelper(ScummVM scummvm) {
_scummvm = scummvm;
_multitouchMode = false;
_multitouchLevel = MULTITOUCH_UNDECIDED;
_candidateStartOfMultitouchSession = false;
_touchMouseWheelDecisionLevel = TOUCH_MOUSE_WHEEL_UNDECIDED;
resetPointers();
}
public void resetPointers() {
_firstPointerId = -1;
_secondPointerId = -1;
_thirdPointerId = -1;
_cachedActionEventOnPointer1DownX = -1;
_cachedActionEventOnPointer1DownY = -1;
_cachedActionEventOnPointer2DownX = -1;
_cachedActionEventOnPointer2DownY = -1;
}
public boolean isMultitouchMode() {
return _multitouchMode;
}
public int getMultitouchLevel() {
return _multitouchLevel;
}
public int getTouchMouseWheelDecisionLevel() {
return _touchMouseWheelDecisionLevel;
}
public boolean isTouchMouseWheel() { return getTouchMouseWheelDecisionLevel() == TOUCH_MOUSE_WHEEL_ACTIVE; }
public void setMultitouchMode(boolean enabledFlg) {
_multitouchMode = enabledFlg;
}
public void setMultitouchLevel(int mtlevel) {
_multitouchLevel = mtlevel;
}
public void setTouchMouseWheelDecisionLevel(int scrlevel) {
_touchMouseWheelDecisionLevel = scrlevel;
}
// TODO Maybe for consistency purposes, maybe sent all (important) UP events that were not sent, when ending a multitouch session?
public boolean handleMotionEvent(final MotionEvent event) {
// constants from APIv5:
// (action & ACTION_POINTER_INDEX_MASK) >> ACTION_POINTER_INDEX_SHIFT
//final int pointer = (action & 0xff00) >> 8;
final int maskedAction = event.getActionMasked();
int pointerIndex = -1;
int actionEventX;
int actionEventY;
if (maskedAction == MotionEvent.ACTION_DOWN) {
// start of a multitouch session! one finger down -- this is sent for the first pointer who touches the screen
resetPointers();
setMultitouchLevel(MULTITOUCH_UNDECIDED);
setTouchMouseWheelDecisionLevel(TOUCH_MOUSE_WHEEL_UNDECIDED);
setMultitouchMode(false);
_candidateStartOfMultitouchSession = true;
_multiTouchLevelUpgradeHandler.clear();
pointerIndex = 0;
_firstPointerId = event.getPointerId(pointerIndex);
_cachedActionEventOnPointer1DownX = (int) event.getX(pointerIndex);;
_cachedActionEventOnPointer1DownY = (int) event.getY(pointerIndex);;
return false;
} else if (maskedAction == MotionEvent.ACTION_CANCEL) {
resetPointers();
setMultitouchLevel(MULTITOUCH_UNDECIDED);
setTouchMouseWheelDecisionLevel(TOUCH_MOUSE_WHEEL_UNDECIDED);
setMultitouchMode(false);
_multiTouchLevelUpgradeHandler.clear();
return true;
} else if (maskedAction == MotionEvent.ACTION_OUTSIDE) {
return false;
}
if (event.getPointerCount() > 1 && event.getPointerCount() < 4) {
// a multi-touch event
if (_candidateStartOfMultitouchSession && event.getPointerCount() > 1) {
_candidateStartOfMultitouchSession = false; // reset this flag
setMultitouchMode(true);
}
if (isMultitouchMode()) {
if (maskedAction == MotionEvent.ACTION_POINTER_DOWN) {
pointerIndex = event.getActionIndex();
if (event.getPointerCount() == 2) {
_secondPointerId = event.getPointerId(pointerIndex);
if (getMultitouchLevel() == MULTITOUCH_UNDECIDED) {
_multiTouchLevelUpgradeHandler.removeMessages(MSG_MT_UPGRADE_TO_LEVEL_3_TIMEDOUT);
_multiTouchLevelUpgradeHandler.removeMessages(MSG_MT_CHECK_FOR_TOUCH_MOUSE_WHEEL_TIMEDOUT);
if (pointerIndex != -1) {
_cachedActionEventOnPointer2DownX = (int) event.getX(pointerIndex);
_cachedActionEventOnPointer2DownY = (int) event.getY(pointerIndex);
} else {
_cachedActionEventOnPointer2DownX = -1;
_cachedActionEventOnPointer2DownY = -1;
}
// Allow for some time before deciding a two finger touch event, since the user might be going for a three finger touch event
_multiTouchLevelUpgradeHandler.sendMessageDelayed(_multiTouchLevelUpgradeHandler.obtainMessage(MSG_MT_UPGRADE_TO_LEVEL_3_TIMEDOUT), _kLevelDecisionDelayMs);
// Also allow for (less) time to check if this is a two-finger "mouse-wheel" event
_multiTouchLevelUpgradeHandler.sendMessageDelayed(_multiTouchLevelUpgradeHandler.obtainMessage(MSG_MT_CHECK_FOR_TOUCH_MOUSE_WHEEL_TIMEDOUT), _kTouchMouseWheelDecisionDelayMs);
// Return as event "handled" here
// while we wait for the decision to be made for the level of multitouch (two or three)
//
return true;
}
// Don't return here
// We want to handle the case whereby we were in multitouch level 2, and we got a new pointer down event with 2 pointers count
// This is the case where the user keeps one finger down and taps the second finger
// This behavior should count as multiple right clicks (one for each new "tap" (ACTION_POINTER_DOWN event))
// for user friendliness / control intuitiveness
} else if (event.getPointerCount() == 3) {
_thirdPointerId = event.getPointerId(pointerIndex);
if (getMultitouchLevel() == MULTITOUCH_UNDECIDED) {
setMultitouchLevel(MULTITOUCH_THREE_FINGERS);
_multiTouchLevelUpgradeHandler.removeMessages(MSG_MT_CHECK_FOR_TOUCH_MOUSE_WHEEL_TIMEDOUT);
setTouchMouseWheelDecisionLevel(TOUCH_MOUSE_WHEEL_NOT_HAPPENING);
}
}
} else {
if (event.getPointerCount() == 2) {
// we prioritize the second pointer/ finger
pointerIndex = event.findPointerIndex(_secondPointerId);
if (pointerIndex != -1) {
actionEventX = (int)event.getX(pointerIndex);
actionEventY = (int)event.getY(pointerIndex);
} else {
actionEventX = -1;
actionEventY = -1;
}
if (getMultitouchLevel() == MULTITOUCH_UNDECIDED) {
// Fast trigger an ACTION_POINTER_DOWN if:
// - we were not yet decided on which level to use
// AND either:
// - a finger got up (from 3 to 2, shouldn't happen) or
// - our main finger (second finger down) moved from cached position
if (maskedAction == MotionEvent.ACTION_POINTER_UP
|| (maskedAction == MotionEvent.ACTION_MOVE
&& (actionEventX != _cachedActionEventOnPointer2DownX
|| actionEventY != _cachedActionEventOnPointer2DownY))) {
setMultitouchLevel(MULTITOUCH_TWO_FINGERS);
_multiTouchLevelUpgradeHandler.removeMessages(MSG_MT_UPGRADE_TO_LEVEL_3_TIMEDOUT);
// Checking if we can decide on a touch mouse wheel mode session
int firstPointerIndex = event.findPointerIndex(_firstPointerId);
int actionEventFirstPointerCoordY = -1;
if (firstPointerIndex != -1) {
actionEventFirstPointerCoordY = (int) event.getY(firstPointerIndex);
}
if (maskedAction == MotionEvent.ACTION_MOVE) {
// Decide Scroll (touch mouse wheel) if:
// - we were not yet decided on which level to use
// - and two fingers are down (but not because we went from 3 to 2)
// - and it's a move event
// - and the movement distance of both fingers on y axis is around >= MOVE_THRESHOLD_FOR_TOUCH_MOUSE_WHEEL_DECISION
// (plus some other qualifying checks to determine significant and similar movement on both fingers)
// NOTE the movementOfFinger2onY and movementOfFinger1onY gets higher (on subsequent events)
// if the user keeps moving their fingers (in the same direction),
// since it's in reference to the starting points for the fingers
int movementOfFinger2onY = actionEventY - _cachedActionEventOnPointer2DownY;
int movementOfFinger1onY = actionEventFirstPointerCoordY - _cachedActionEventOnPointer1DownY;
int absMovementOfFinger2onY = Math.abs(movementOfFinger2onY);
int absMovementOfFinger1onY = Math.abs(movementOfFinger1onY);
int absDiffOfMovementOfFingersOnY = Math.abs(movementOfFinger2onY - movementOfFinger1onY);
if (getTouchMouseWheelDecisionLevel() == TOUCH_MOUSE_WHEEL_UNDECIDED
&& (movementOfFinger2onY > 0 && movementOfFinger1onY > 0) || (movementOfFinger2onY < 0 && movementOfFinger1onY < 0)
&& absDiffOfMovementOfFingersOnY < MOVE_THRESHOLD_FOR_TOUCH_MOUSE_WHEEL_DECISION ) {
if ((absMovementOfFinger2onY + absMovementOfFinger1onY) / 2 >= MOVE_THRESHOLD_FOR_TOUCH_MOUSE_WHEEL_DECISION) {
setTouchMouseWheelDecisionLevel(TOUCH_MOUSE_WHEEL_ACTIVE);
_multiTouchLevelUpgradeHandler.removeMessages(MSG_MT_CHECK_FOR_TOUCH_MOUSE_WHEEL_TIMEDOUT);
} else {
// ignore this move event but don't forward it (return true as "event handled")
// there's still potential to be a scroll (touch mouse wheel) event, with accumulated movement
//
// Also downgrade the multitouch level to undecided to re-enter this code segment next time
// (the "countdown" for three-finger touch decision is not resumed)
setMultitouchLevel(MULTITOUCH_UNDECIDED);
return true;
}
} else {
setMultitouchLevel(MULTITOUCH_UNDECIDED);
return true;
}
} else {
setTouchMouseWheelDecisionLevel(TOUCH_MOUSE_WHEEL_NOT_HAPPENING);
_multiTouchLevelUpgradeHandler.removeMessages(MSG_MT_CHECK_FOR_TOUCH_MOUSE_WHEEL_TIMEDOUT);
}
// End of: Checking if we can decide on a touch mouse wheel mode session
if (getTouchMouseWheelDecisionLevel() != TOUCH_MOUSE_WHEEL_ACTIVE) {
// send the missing pointer down event first, before sending the actual current move event below
_scummvm.pushEvent(JE_MULTI,
event.getPointerCount(),
MotionEvent.ACTION_POINTER_DOWN,
actionEventX,
actionEventY,
0, 0);
}
}
}
} else if (event.getPointerCount() == 3) {
// we prioritize the third pointer/ finger
pointerIndex = event.findPointerIndex(_thirdPointerId);
}
}
// if (pointerIndex == -1) {
// Log.d(ScummVM.LOG_TAG,"Warning: pointerIndex == -1 and getPointerCount = " + event.getPointerCount());
// }
if (pointerIndex != -1) {
actionEventX = (int)event.getX(pointerIndex);
actionEventY = (int)event.getY(pointerIndex);
} else {
actionEventX = -1;
actionEventY = -1;
}
// we are only concerned for events with fingers down equal to the decided level of multitouch session
if (getMultitouchLevel() == event.getPointerCount()) {
if ((isTouchMouseWheel()) ) {
if (maskedAction == MotionEvent.ACTION_MOVE && pointerIndex == event.findPointerIndex(_secondPointerId)) {
// The co-ordinates sent with the event are the original touch co-ordinates of the first finger
// The mouse cursor should not move around while touch-mouse-wheel scrolling
// Also for simplification we only use the move events for the second finger
// and skip non-significant movements.
int movementOfFinger2onY = actionEventY - _cachedActionEventOnPointer2DownY;
if (Math.abs(movementOfFinger2onY) > MOVE_THRESHOLD_FOR_SEND_TOUCH_MOUSE_WHEEL_EVENT) {
_scummvm.pushEvent((movementOfFinger2onY > 0 ) ? JE_MOUSE_WHEEL_UP : JE_MOUSE_WHEEL_DOWN,
_cachedActionEventOnPointer1DownX,
_cachedActionEventOnPointer1DownY,
1, // This will indicate to the event handling code in native that it comes from touch interface
0,
0, 0);
_cachedActionEventOnPointer2DownY = actionEventY;
}
} // otherwise don't push an event in this case and return true
} else {
// arg1 will be the number of fingers down in the MULTI event we send to events.cpp
// arg2 is the event action
// arg3 and arg4 are the X,Y coordinates for the "active pointer" index, which is the last finger down
// (the second in two-fingers touch, or the third in a three-fingers touch mode)
_scummvm.pushEvent(JE_MULTI,
event.getPointerCount(),
event.getAction(),
actionEventX,
actionEventY,
0, 0);
}
}
}
return true;
} else if (event.getPointerCount() >= 4) {
// ignore if more than 3 fingers down. Mark as multitouch "handled" (return true)
return true;
} else if (event.getPointerCount() == 1 && isMultitouchMode() ) {
// We were already in a Multitouch session, but we're left with one finger down now
// Keep ignoring events for single pointer until we exit multitouch mode "session"
// this is to catch the case of being left with only one finger still touching the surface
// after lifting the rest of the fingers that were touching the surface
return true;
} else {
// one finger, no active multitouch mode "session". Mark as unhandled.
return false;
}
}
// Custom handler code (to avoid mem leaks, see warning "This Handler Class Should Be Static Or Leaks Might Occur”) based on:
// https://stackoverflow.com/a/27826094
public static class MultitouchHelperHandler extends Handler {
private final WeakReference<MultitouchHelper> mListenerReference;
public MultitouchHelperHandler(MultitouchHelper listener) {
super(Looper.getMainLooper());
mListenerReference = new WeakReference<>(listener);
}
@Override
public synchronized void handleMessage(@NonNull Message msg) {
MultitouchHelper listener = mListenerReference.get();
if(listener != null) {
listener.handle_MTHH_Message(msg);
}
}
public void clear() {
this.removeCallbacksAndMessages(null);
}
}
private void handle_MTHH_Message(final Message msg) {
if ((msg.what == MSG_MT_UPGRADE_TO_LEVEL_3_TIMEDOUT && getMultitouchLevel() == MULTITOUCH_UNDECIDED)
|| (msg.what == MSG_MT_CHECK_FOR_TOUCH_MOUSE_WHEEL_TIMEDOUT
&& (getMultitouchLevel() == MULTITOUCH_UNDECIDED
|| (getMultitouchLevel() == 2 && getTouchMouseWheelDecisionLevel() == TOUCH_MOUSE_WHEEL_UNDECIDED)))) {
// Either:
// - window of allowing upgrade to level 3 (three fingers) timed out
// - or window of allowing time for checking for scroll (touch mouse wheel) timed out
// decide level 2 (two fingers).
setMultitouchLevel(MULTITOUCH_TWO_FINGERS);
setTouchMouseWheelDecisionLevel(TOUCH_MOUSE_WHEEL_NOT_HAPPENING);
// send the delayed pointer down event
_scummvm.pushEvent(JE_MULTI,
2,
MotionEvent.ACTION_POINTER_DOWN,
_cachedActionEventOnPointer2DownX,
_cachedActionEventOnPointer2DownY,
0, 0);
}
}
public void clearEventHandler() {
_multiTouchLevelUpgradeHandler.clear();
}
}

View File

@@ -0,0 +1,5 @@
package org.scummvm.scummvm;
public interface OnKeyboardVisibilityListener {
void onVisibilityChanged(boolean visible);
}

View File

@@ -0,0 +1,797 @@
package org.scummvm.scummvm;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.UriPermission;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.ParcelFileDescriptor;
import android.provider.DocumentsContract;
import android.system.OsConstants;
import android.util.Log;
import androidx.annotation.RequiresApi;
import java.io.FileNotFoundException;
import java.lang.ref.SoftReference;
import java.util.ArrayDeque;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicLong;
/**
* SAF primitives for C++ FSNode
*/
@RequiresApi(api = Build.VERSION_CODES.N)
public class SAFFSTree {
@RequiresApi(api = Build.VERSION_CODES.BASE)
public interface IOBusyListener {
void onIOBusy(float ratio);
}
private static class IOTime {
long start;
long end;
long duration;
}
// Declare us as busy when I/O waits took more than 90% in 2 secs
private static final long IO_BUSINESS_TIMESPAN = 2000;
private static final long IO_BUSINESS_THRESHOLD = 1800;
private static ConcurrentLinkedQueue<IOTime> _lastIOs;
private static IOBusyListener _listener;
private static HashMap<String, SAFFSTree> _trees;
// This map will store the references of all our objects used
// by the native side.
// This avoids overflowing JNI will a pile of global references
private static ConcurrentHashMap<Long, SAFFSNode> _nodes;
// This atomic variable will generate unique identifiers for our objects
private static AtomicLong _idCounter;
@RequiresApi(api = Build.VERSION_CODES.BASE)
public static void setIOBusyListener(IOBusyListener l) {
if (_lastIOs == null) {
_lastIOs = new ConcurrentLinkedQueue<>();
}
_listener = l;
}
private static void reportIO(long start, long end) {
if (_listener == null) {
return;
}
// Register this new query
IOTime entry = new IOTime();
entry.start = start;
entry.end = end;
entry.duration = end - start;
_lastIOs.add(entry);
long deadline = end - IO_BUSINESS_TIMESPAN;
long duration = 0;
// Remove outdated entries and compute the time spent in I/Os
Iterator<IOTime> it = _lastIOs.iterator();
while (it.hasNext()) {
entry = it.next();
//Log.d(ScummVM.LOG_TAG, "ENTRY <" + Long.toString(entry.start) + " " + Long.toString(entry.end) + " " + Long.toString(entry.duration) + ">");
if (entry.end <= deadline) {
// entry is too old
it.remove();
} else if (entry.start < deadline) {
// This entry crossed the deadline
duration += entry.end - deadline;
} else {
duration += entry.duration;
}
}
//Log.d(ScummVM.LOG_TAG, "SUM: " + Long.toString(duration) + " DEADLINE WAS: " + Long.toString(deadline));
if (duration >= IO_BUSINESS_THRESHOLD && _listener != null) {
_listener.onIOBusy((float)duration / IO_BUSINESS_TIMESPAN);
}
}
private static void loadSAFTrees(Context context) {
final ContentResolver resolver = context.getContentResolver();
// As this function is called before starting to emit nodes,
// we can take the opportunity to setup the reference related stuff here
if (_nodes == null) {
_nodes = new ConcurrentHashMap<>();
_idCounter = new AtomicLong();
}
_trees = new HashMap<>();
for (UriPermission permission : resolver.getPersistedUriPermissions()) {
final Uri uri = permission.getUri();
if (!DocumentsContract.isTreeUri(uri)) {
continue;
}
SAFFSTree tree = new SAFFSTree(context, uri);
_trees.put(tree.getTreeId(), tree);
}
}
public static SAFFSTree newTree(Context context, Uri uri) {
if (_trees == null) {
loadSAFTrees(context);
}
SAFFSTree tree = new SAFFSTree(context, uri);
_trees.put(tree.getTreeId(), tree);
return tree;
}
public static SAFFSTree[] getTrees(Context context) {
if (_trees == null) {
loadSAFTrees(context);
}
return _trees.values().toArray(new SAFFSTree[0]);
}
public static SAFFSTree findTree(Context context, String name) {
if (_trees == null) {
loadSAFTrees(context);
}
return _trees.get(name);
}
public static class PathResult {
public final SAFFSTree tree;
public final SAFFSNode node;
PathResult(SAFFSTree tree, SAFFSNode node) {
this.tree = tree;
this.node = node;
}
}
/**
* Resolves a ScummVM virtual path to SAF objects if it's in the SAF domain.
* Returns null otherwise and throws a FileNotFoundException if the SAF path doesn't exist.
*/
@RequiresApi(api = Build.VERSION_CODES.BASE)
public static PathResult fullPathToNode(Context context, String path, boolean createDirIfNotExists) throws FileNotFoundException {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N ||
!path.startsWith("/saf/")) {
return null;
}
// This is a SAF fake mount point
int slash = path.indexOf('/', 5);
if (slash == -1) {
slash = path.length();
}
String treeName = path.substring(5, slash);
String innerPath = path.substring(slash);
SAFFSTree tree = SAFFSTree.findTree(context, treeName);
if (tree == null) {
throw new FileNotFoundException();
}
SAFFSNode node = tree.pathToNode(innerPath, createDirIfNotExists);
if (node == null) {
throw new FileNotFoundException();
}
return new PathResult(tree, node);
}
@RequiresApi(api = Build.VERSION_CODES.BASE)
public static void clearCaches() {
if (_trees == null) {
return;
}
for (SAFFSTree tree : _trees.values()) {
tree.clearCache();
}
}
/** @noinspection unused
* This version is used by the C++ side
*/
public static void addNodeRef(long nodeId) {
assert(nodeId != 0);
SAFFSNode node = _nodes.get(nodeId);
assert(node != null);
long newId = node.addRef();
assert(newId == nodeId);
}
/** @noinspection unused
* This version is used by the C++ side
*/
public static void decNodeRef(long nodeId) {
assert(nodeId != 0);
SAFFSNode node = _nodes.get(nodeId);
assert(node != null);
node.decRef();
}
/** @noinspection unused
* This version is used by the C++ side
*/
public static SAFFSNode refToNode(long nodeId) {
assert(nodeId != 0);
return _nodes.get(nodeId);
}
public static class SAFFSNode implements Comparable<SAFFSNode> {
public static final int DIRECTORY = 0x01;
public static final int WRITABLE = 0x02;
public static final int READABLE = 0x04;
public static final int DELETABLE = 0x08;
public static final int REMOVABLE = 0x10;
public SAFFSNode _parent;
public String _path;
public String _documentId;
public int _flags;
private HashMap<String, SoftReference<SAFFSNode>> _children;
private boolean _dirty;
private int _refCnt; // Reference counter for the native side
private long _id; // Identifier for the native side
private SAFFSNode reset(SAFFSNode parent, String path, String documentId, int flags) {
_parent = parent;
_path = path;
_documentId = documentId;
_flags = flags;
_children = null;
return this;
}
private static int computeFlags(String mimeType, int flags) {
int ourFlags = 0;
if (DocumentsContract.Document.MIME_TYPE_DIR.equals(mimeType)) {
ourFlags |= SAFFSNode.DIRECTORY;
}
if ((flags & (DocumentsContract.Document.FLAG_SUPPORTS_WRITE | DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE)) != 0) {
ourFlags |= SAFFSNode.WRITABLE;
}
if ((flags & DocumentsContract.Document.FLAG_VIRTUAL_DOCUMENT) == 0) {
ourFlags |= SAFFSNode.READABLE;
}
if ((flags & DocumentsContract.Document.FLAG_SUPPORTS_DELETE) != 0) {
ourFlags |= SAFFSNode.DELETABLE;
}
if ((flags & DocumentsContract.Document.FLAG_SUPPORTS_REMOVE) != 0) {
ourFlags |= SAFFSNode.REMOVABLE;
}
return ourFlags;
}
@Override
public int compareTo(SAFFSNode o) {
if (o == null) {
throw new NullPointerException();
}
return _path.compareTo(o._path);
}
public synchronized long addRef() {
_refCnt += 1;
if (_refCnt > 1) {
return _id;
}
assert(_refCnt == 1);
if (_id == 0) {
_id = _idCounter.incrementAndGet();
}
_nodes.put(_id, this);
return _id;
}
public synchronized void decRef() {
if (_refCnt == 1) {
SAFFSNode tmp = _nodes.remove(_id);
assert(tmp == this);
}
_refCnt -= 1;
}
}
private final Context _context;
private final Uri _treeUri;
private final SAFFSNode _root;
private final String _treeName;
public SAFFSTree(Context context, Uri treeUri) {
_context = context;
_treeUri = treeUri;
_root = new SAFFSNode().reset(null, "", DocumentsContract.getTreeDocumentId(treeUri), 0);
// Update flags and get name
String treeName = stat(_root);
if (treeName == null) {
// The tree likely got deleted
// Use the document ID instead as this will let the user do some cleanup
treeName = DocumentsContract.getTreeDocumentId(treeUri);
}
_treeName = treeName;
}
public String getTreeId() {
return Uri.encode(DocumentsContract.getTreeDocumentId(_treeUri));
}
public String getTreeName() {
return _treeName;
}
public Uri getTreeDocumentUri() {
return DocumentsContract.buildDocumentUriUsingTree(_treeUri, _root._documentId);
}
private void clearCache() {
ArrayDeque<SAFFSNode> stack = new ArrayDeque<>();
stack.push(_root);
while (!stack.isEmpty()) {
SAFFSNode node = stack.pop();
node._dirty = true;
if (node._children == null) {
continue;
}
for (SoftReference<SAFFSNode> ref : node._children.values()) {
node = ref.get();
if (node != null) {
stack.push(node);
}
}
}
}
public SAFFSNode pathToNode(String path, boolean createDirIfNotExists) {
String[] components = path.split("/");
SAFFSNode node = _root;
for (String component : components) {
if (component.isEmpty() || ".".equals(component)) {
continue;
}
if ("..".equals(component)) {
if (node._parent != null) {
node = node._parent;
}
continue;
}
SAFFSNode newNode = getChild(node, component);
if (newNode == null && createDirIfNotExists) {
newNode = createDirectory(node, component);
}
if (newNode == null) {
return null;
}
node = newNode;
}
return node;
}
public SAFFSNode[] getChildren(SAFFSNode node) {
Collection<SAFFSNode> results = null;
if (node._children != null && !node._dirty) {
results = new ArrayDeque<>();
for (SoftReference<SAFFSNode> ref : node._children.values()) {
if (ref == null) {
continue;
}
SAFFSNode newnode = ref.get();
if (newnode == null) {
// Some reference went stale: refresh
results = null;
break;
}
results.add(newnode);
}
}
if (results == null) {
try {
results = fetchChildren(node);
} catch (Exception e) {
Log.w(ScummVM.LOG_TAG, "Failed to get children: " + e);
return null;
}
}
return results.toArray(new SAFFSNode[0]);
}
/** @noinspection unused
* This version is used by the C++ side
*/
public SAFFSNode[] getChildren(long nodeId) {
SAFFSNode node = _nodes.get(nodeId);
assert(node != null);
return getChildren(node);
}
public Collection<SAFFSNode> fetchChildren(SAFFSNode node) {
final ContentResolver resolver = _context.getContentResolver();
final Uri searchUri = DocumentsContract.buildChildDocumentsUriUsingTree(_treeUri, node._documentId);
final ArrayDeque<SAFFSNode> results = new ArrayDeque<>();
// Keep the old children around to reuse them: this will help to keep one SAFFSNode instance for each node
HashMap<String, SoftReference<SAFFSNode>> oldChildren = node._children;
node._children = null;
// When _children will be set, it will be clean
node._dirty = false;
HashMap<String, SoftReference<SAFFSNode>> newChildren = new HashMap<>();
Cursor c = null;
long startIO = System.currentTimeMillis();
try {
c = resolver.query(searchUri, new String[] { DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_FLAGS }, null, null, null);
if (c == null) {
return results;
}
while (c.moveToNext()) {
final String displayName = c.getString(0);
final String documentId = c.getString(1);
final String mimeType = c.getString(2);
final int flags = c.getInt(3);
final int ourFlags = SAFFSNode.computeFlags(mimeType, flags);
SAFFSNode newnode = null;
SoftReference<SAFFSNode> oldnodeRef;
if (oldChildren != null) {
oldnodeRef = oldChildren.remove(displayName);
if (oldnodeRef != null) {
newnode = oldnodeRef.get();
}
}
if (newnode == null) {
newnode = new SAFFSNode();
}
newnode.reset(node, node._path + "/" + displayName, documentId, ourFlags);
newChildren.put(displayName, new SoftReference<>(newnode));
results.add(newnode);
}
// Success: store the cache
node._children = newChildren;
} finally {
if (c != null) {
c.close();
}
long endIO = System.currentTimeMillis();
reportIO(startIO, endIO);
}
return results;
}
public SAFFSNode getChild(SAFFSNode node, String name) {
//This variable is used to hold a strong reference on every children nodes
//noinspection unused
Collection<SAFFSNode> children;
if (node._children == null || node._dirty) {
try {
//noinspection UnusedAssignment
children = fetchChildren(node);
} catch (Exception e) {
Log.w(ScummVM.LOG_TAG, "Failed to get children: " + e);
return null;
}
}
SoftReference<SAFFSNode> ref = node._children.get(name);
if (ref == null) {
return null;
}
SAFFSNode newnode = ref.get();
if (newnode != null) {
return newnode;
}
// Node reference was stale, force a refresh
try {
//noinspection UnusedAssignment
children = fetchChildren(node);
} catch (Exception e) {
Log.w(ScummVM.LOG_TAG, "Failed to get children: " + e);
return null;
}
ref = node._children.get(name);
if (ref == null) {
return null;
}
newnode = ref.get();
if (newnode == null) {
Log.e(ScummVM.LOG_TAG, "Failed to keep a reference on object");
}
return newnode;
}
/** @noinspection unused
* This version is used by the C++ side
*/
public SAFFSNode getChild(long nodeId, String name) {
SAFFSNode node = _nodes.get(nodeId);
assert(node != null);
return getChild(node, name);
}
public SAFFSNode createDirectory(SAFFSNode node, String name) {
return createDocument(node, name, DocumentsContract.Document.MIME_TYPE_DIR);
}
/** @noinspection unused
* This version is used by the C++ side
*/
public SAFFSNode createDirectory(long nodeId, String name) {
SAFFSNode node = _nodes.get(nodeId);
assert(node != null);
return createDirectory(node, name);
}
public SAFFSNode createFile(SAFFSNode node, String name) {
return createDocument(node, name, "application/octet-stream");
}
/** @noinspection unused
* This version is used by the C++ side
*/
public SAFFSNode createFile(long nodeId, String name) {
SAFFSNode node = _nodes.get(nodeId);
assert(node != null);
return createFile(node, name);
}
public int createReadStream(SAFFSNode node) {
return createStream(node, "r");
}
/** @noinspection unused
* This version is used by the C++ side
*/
public int createReadStream(long nodeId) {
SAFFSNode node = _nodes.get(nodeId);
assert(node != null);
return createReadStream(node);
}
public int createWriteStream(SAFFSNode node) {
return createStream(node, "wt");
}
/** @noinspection unused
* This version is used by the C++ side
*/
public int createWriteStream(long nodeId) {
SAFFSNode node = _nodes.get(nodeId);
assert(node != null);
return createWriteStream(node);
}
public int removeNode(SAFFSNode node) {
final ContentResolver resolver = _context.getContentResolver();
final Uri uri = DocumentsContract.buildDocumentUriUsingTree(_treeUri, node._documentId);
if ((node._flags & SAFFSNode.REMOVABLE) != 0) {
final Uri parentUri = DocumentsContract.buildDocumentUriUsingTree(_treeUri, node._parent._documentId);
long startIO = System.currentTimeMillis();
try {
if (!DocumentsContract.removeDocument(resolver, uri, parentUri)) {
return OsConstants.EIO;
}
} catch(FileNotFoundException e) {
return OsConstants.ENOENT;
} finally {
long endIO = System.currentTimeMillis();
reportIO(startIO, endIO);
}
} else if ((node._flags & SAFFSNode.DELETABLE) != 0) {
long startIO = System.currentTimeMillis();
try {
if (!DocumentsContract.deleteDocument(resolver, uri)) {
return OsConstants.EIO;
}
} catch(FileNotFoundException e) {
return OsConstants.ENOENT;
} finally {
long endIO = System.currentTimeMillis();
reportIO(startIO, endIO);
}
} else {
return OsConstants.EPERM;
}
// Cleanup node
node._parent._dirty = true;
node.reset(null, null, null, 0);
return 0;
}
/** @noinspection unused
* This version is used by the C++ side
*/
public int removeNode(long nodeId) {
SAFFSNode node = _nodes.get(nodeId);
assert(node != null);
return removeNode(node);
}
/** @noinspection unused
* This version is used by the C++ side
*/
public void removeTree() {
final ContentResolver resolver = _context.getContentResolver();
String treeId = getTreeId();
resolver.releasePersistableUriPermission(_treeUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
if (_trees == null || _trees.remove(treeId) == null) {
loadSAFTrees(_context);
}
}
private SAFFSNode createDocument(SAFFSNode node, String name, String mimeType) {
final ContentResolver resolver = _context.getContentResolver();
final Uri parentUri = DocumentsContract.buildDocumentUriUsingTree(_treeUri, node._documentId);
Uri newDocUri;
// Make sure _children is OK
if (node._children == null || node._dirty) {
try {
fetchChildren(node);
} catch (Exception e) {
Log.w(ScummVM.LOG_TAG, "Failed to get children: " + e);
return null;
}
}
long startIO = System.currentTimeMillis();
try {
newDocUri = DocumentsContract.createDocument(resolver, parentUri, mimeType, name);
} catch(FileNotFoundException e) {
return null;
} finally {
long endIO = System.currentTimeMillis();
reportIO(startIO, endIO);
}
if (newDocUri == null) {
return null;
}
final String documentId = DocumentsContract.getDocumentId(newDocUri);
SAFFSNode newnode = null;
SoftReference<SAFFSNode> oldnodeRef = node._children.remove(name);
if (oldnodeRef != null) {
newnode = oldnodeRef.get();
}
if (newnode == null) {
newnode = new SAFFSNode();
}
newnode.reset(node, node._path + "/" + name, documentId, 0);
// Update flags
final String realName = stat(newnode);
if (realName == null) {
return null;
}
// Unlikely but...
if (!realName.equals(name)) {
node._children.remove(realName);
newnode._path = node._path + "/" + realName;
}
node._children.put(realName, new SoftReference<>(newnode));
return newnode;
}
public ParcelFileDescriptor createFileDescriptor(SAFFSNode node, String mode) {
final ContentResolver resolver = _context.getContentResolver();
final Uri uri = DocumentsContract.buildDocumentUriUsingTree(_treeUri, node._documentId);
ParcelFileDescriptor pfd;
long startIO = System.currentTimeMillis();
try {
pfd = resolver.openFileDescriptor(uri, mode);
} catch(FileNotFoundException e) {
return null;
} finally {
long endIO = System.currentTimeMillis();
reportIO(startIO, endIO);
}
return pfd;
}
private int createStream(SAFFSNode node, String mode) {
ParcelFileDescriptor pfd = createFileDescriptor(node, mode);
if (pfd == null) {
return -1;
}
return pfd.detachFd();
}
private String stat(SAFFSNode node) {
final ContentResolver resolver = _context.getContentResolver();
final Uri uri = DocumentsContract.buildDocumentUriUsingTree(_treeUri, node._documentId);
Cursor c = null;
long startIO = System.currentTimeMillis();
try {
c = resolver.query(uri, new String[] { DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE, DocumentsContract.Document.COLUMN_FLAGS }, null, null, null);
if (c == null) {
return null;
}
if (c.moveToNext()) {
final String displayName = c.getString(0);
final String mimeType = c.getString(1);
final int flags = c.getInt(2);
node._flags = SAFFSNode.computeFlags(mimeType, flags);
return displayName;
}
} catch (Exception e) {
Log.w(ScummVM.LOG_TAG, "Failed query: " + e);
} finally {
if (c != null) {
try {
c.close();
} catch (Exception ignored) {
}
}
long endIO = System.currentTimeMillis();
reportIO(startIO, endIO);
}
// We should never end up here
// If we do, a tree or a file got likely removed
return null;
}
}

View File

@@ -0,0 +1,573 @@
package org.scummvm.scummvm;
import android.content.res.AssetManager;
import android.graphics.PixelFormat;
import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioTrack;
import android.util.Log;
import android.view.SurfaceHolder;
import androidx.annotation.NonNull;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Scanner;
import javax.microedition.khronos.egl.EGL10;
import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.egl.EGLContext;
import javax.microedition.khronos.egl.EGLDisplay;
import javax.microedition.khronos.egl.EGLSurface;
import javax.microedition.khronos.opengles.GL10;
public abstract class ScummVM implements SurfaceHolder.Callback,
CompatHelpers.SystemInsets.SystemInsetsListener, Runnable {
public static final int SHOW_ON_SCREEN_MENU = 1;
public static final int SHOW_ON_SCREEN_INPUT_MODE = 2;
final protected static String LOG_TAG = "ScummVM";
final private AssetManager _asset_manager;
final private Object _sem_surface;
final private MyScummVMDestroyedCallback _svm_destroyed_callback;
private EGL10 _egl;
private EGLDisplay _egl_display = EGL10.EGL_NO_DISPLAY;
private EGLConfig _egl_config;
private EGLContext _egl_context = EGL10.EGL_NO_CONTEXT;
private EGLSurface _egl_surface = EGL10.EGL_NO_SURFACE;
private SurfaceHolder _surface_holder;
private int bitsPerPixel;
private AudioTrack _audio_track;
private int _sample_rate = 0;
private int _buffer_size = 0;
private boolean _assetsUpdated;
private String[] _args;
private native void create(AssetManager asset_manager,
EGL10 egl,
EGLDisplay egl_display,
AudioTrack audio_track,
int sample_rate,
int buffer_size,
boolean assetsUpdated);
private native void destroy();
private native void setSurface(int width, int height, int bpp);
private native int main(String[] args);
// pause the engine and all native threads
final public native void setPause(boolean pause);
// Feed an event to ScummVM. Safe to call from other threads.
final public native void pushEvent(int type, int arg1, int arg2, int arg3,
int arg4, int arg5, int arg6);
// Update the 3D touch controls
final public native void setupTouchMode(int oldValue, int newValue);
final public native void updateTouch(int action, int ptr, int x, int y);
final public native void syncVirtkeyboardState(boolean newState);
final public native String getNativeVersionInfo();
// CompatHelpers.WindowInsets.SystemInsetsListener interface
@Override
final public native void systemInsetsUpdated(int[] gestureInsets, int[] systemInsets, int[] cutoutInsets);
// Callbacks from C++ peer instance
abstract protected void getDPI(float[] values);
abstract protected void displayMessageOnOSD(String msg);
abstract protected void openUrl(String url);
abstract protected boolean hasTextInClipboard();
abstract protected String getTextFromClipboard();
abstract protected boolean setTextInClipboard(String text);
abstract protected boolean isConnectionLimited();
abstract protected void setWindowCaption(String caption);
abstract protected void showVirtualKeyboard(boolean enable);
abstract protected void showOnScreenControls(int enableMask);
abstract protected void setTouchMode(int touchMode);
abstract protected int getTouchMode();
abstract protected void setOrientation(int orientation);
abstract protected String getScummVMBasePath();
abstract protected String getScummVMConfigPath();
abstract protected String getScummVMLogPath();
abstract protected void setCurrentGame(String target);
abstract protected String[] getSysArchives();
abstract protected String[] getAllStorageLocations();
abstract protected String[] getAllStorageLocationsNoPermissionRequest();
abstract protected SAFFSTree getNewSAFTree(boolean write, String initialURI, String prompt);
abstract protected SAFFSTree[] getSAFTrees();
abstract protected SAFFSTree findSAFTree(String name);
abstract protected int exportBackup(String prompt);
abstract protected int importBackup(String prompt, String path);
public ScummVM(AssetManager asset_manager, SurfaceHolder holder, final MyScummVMDestroyedCallback scummVMDestroyedCallback) {
_asset_manager = asset_manager;
_sem_surface = new Object();
_svm_destroyed_callback = scummVMDestroyedCallback;
holder.addCallback(this);
}
final public String getInstallingScummVMVersionInfo() {
return getNativeVersionInfo();
}
// SurfaceHolder callback
final public void surfaceCreated(SurfaceHolder holder) {
Log.d(LOG_TAG, "surfaceCreated");
// no need to do anything, surfaceChanged() will be called in any case
}
// SurfaceHolder callback
final public void surfaceChanged(SurfaceHolder holder, int format,
int width, int height) {
PixelFormat pixelFormat = new PixelFormat();
PixelFormat.getPixelFormatInfo(format, pixelFormat);
bitsPerPixel = pixelFormat.bitsPerPixel;
Log.d(LOG_TAG, String.format(Locale.ROOT, "surfaceChanged: %dx%d (%d: %dbpp)",
width, height, format, bitsPerPixel));
// store values for the native code
// make sure to do it before notifying the lock
// as it leads to a race condition otherwise
setSurface(width, height, bitsPerPixel);
synchronized(_sem_surface) {
_surface_holder = holder;
_sem_surface.notifyAll();
}
}
// SurfaceHolder callback
final public void surfaceDestroyed(SurfaceHolder holder) {
Log.d(LOG_TAG, "surfaceDestroyed");
synchronized(_sem_surface) {
_surface_holder = null;
_sem_surface.notifyAll();
}
// Don't call when EGL is not init:
// this avoids polluting the static variables with obsolete values
if (_egl != null) {
// clear values for the native code
setSurface(0, 0, 0);
}
}
final public void setAssetsUpdated(boolean assetsUpdated) {
_assetsUpdated = assetsUpdated;
}
final public void setArgs(String[] args) {
_args = args;
}
final public void run() {
try {
// wait for the surfaceChanged callback
synchronized(_sem_surface) {
while (_surface_holder == null)
_sem_surface.wait();
}
initAudio();
initEGL();
} catch (Exception e) {
deinitEGL();
deinitAudio();
throw new RuntimeException("Error preparing the ScummVM thread", e);
}
create(_asset_manager, _egl, _egl_display,
_audio_track, _sample_rate, _buffer_size,
_assetsUpdated);
int res = main(_args);
destroy();
deinitEGL();
deinitAudio();
// Don't exit force-ably here!
if (_svm_destroyed_callback != null) {
_svm_destroyed_callback.handle(res);
}
}
private void initEGL() throws Exception {
_egl = (EGL10)EGLContext.getEGL();
_egl_display = _egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY);
int[] version = new int[2];
_egl.eglInitialize(_egl_display, version);
Log.d(LOG_TAG, String.format(Locale.ROOT, "EGL version %d.%d initialized", version[0], version[1]));
int[] num_config = new int[1];
_egl.eglGetConfigs(_egl_display, null, 0, num_config);
final int numConfigs = num_config[0];
if (numConfigs <= 0)
throw new IllegalArgumentException("No EGL configs");
EGLConfig[] configs = new EGLConfig[numConfigs];
_egl.eglGetConfigs(_egl_display, configs, numConfigs, num_config);
// Android's eglChooseConfig is busted in several versions and
// devices so we have to filter/rank the configs ourselves.
_egl_config = chooseEglConfig(configs, version);
int EGL_CONTEXT_CLIENT_VERSION = 0x3098;
int[] attrib_list = { EGL_CONTEXT_CLIENT_VERSION, 2,
EGL10.EGL_NONE };
_egl_context = _egl.eglCreateContext(_egl_display, _egl_config,
EGL10.EGL_NO_CONTEXT, attrib_list);
if (_egl_context == EGL10.EGL_NO_CONTEXT)
throw new Exception(String.format(Locale.ROOT, "Failed to create context: 0x%x",
_egl.eglGetError()));
}
// Callback from C++ peer instance
final protected EGLSurface initSurface() throws Exception {
_egl_surface = _egl.eglCreateWindowSurface(_egl_display, _egl_config,
_surface_holder, null);
if (_egl_surface == EGL10.EGL_NO_SURFACE)
throw new Exception(String.format(Locale.ROOT,
"eglCreateWindowSurface failed: 0x%x", _egl.eglGetError()));
_egl.eglMakeCurrent(_egl_display, _egl_surface, _egl_surface,
_egl_context);
GL10 gl = (GL10)_egl_context.getGL();
Log.i(LOG_TAG, String.format(Locale.ROOT, "Using EGL %s (%s); GL %s/%s (%s)",
_egl.eglQueryString(_egl_display, EGL10.EGL_VERSION),
_egl.eglQueryString(_egl_display, EGL10.EGL_VENDOR),
gl.glGetString(GL10.GL_VERSION),
gl.glGetString(GL10.GL_RENDERER),
gl.glGetString(GL10.GL_VENDOR)));
return _egl_surface;
}
// Callback from C++ peer instance
final protected void deinitSurface() {
if (_egl_display != EGL10.EGL_NO_DISPLAY) {
_egl.eglMakeCurrent(_egl_display, EGL10.EGL_NO_SURFACE,
EGL10.EGL_NO_SURFACE, EGL10.EGL_NO_CONTEXT);
if (_egl_surface != EGL10.EGL_NO_SURFACE)
_egl.eglDestroySurface(_egl_display, _egl_surface);
}
_egl_surface = EGL10.EGL_NO_SURFACE;
}
// Callback from C++ peer instance
final protected int eglVersion() {
String version = _egl.eglQueryString(_egl_display, EGL10.EGL_VERSION);
if (version == null) {
// 1.0
return 0x00010000;
}
Scanner versionScan = new Scanner(version).useLocale(Locale.ROOT).useDelimiter("[ .]");
int versionInt = versionScan.nextInt() << 16;
versionInt |= versionScan.nextInt() & 0xffff;
return versionInt;
}
private void deinitEGL() {
if (_egl_display != EGL10.EGL_NO_DISPLAY) {
_egl.eglMakeCurrent(_egl_display, EGL10.EGL_NO_SURFACE,
EGL10.EGL_NO_SURFACE, EGL10.EGL_NO_CONTEXT);
if (_egl_surface != EGL10.EGL_NO_SURFACE)
_egl.eglDestroySurface(_egl_display, _egl_surface);
if (_egl_context != EGL10.EGL_NO_CONTEXT)
_egl.eglDestroyContext(_egl_display, _egl_context);
_egl.eglTerminate(_egl_display);
}
_egl_surface = EGL10.EGL_NO_SURFACE;
_egl_context = EGL10.EGL_NO_CONTEXT;
_egl_config = null;
_egl_display = EGL10.EGL_NO_DISPLAY;
_egl = null;
}
private void initAudio() throws Exception {
_sample_rate = AudioTrack.getNativeOutputSampleRate(AudioManager.STREAM_MUSIC);
_buffer_size = AudioTrack.getMinBufferSize(_sample_rate,
AudioFormat.CHANNEL_OUT_STEREO,
AudioFormat.ENCODING_PCM_16BIT);
// ~50ms
int buffer_size_want = (_sample_rate * 2 * 2 / 20) & ~1023;
if (_buffer_size < buffer_size_want) {
Log.w(LOG_TAG, String.format(Locale.ROOT,
"adjusting audio buffer size (was: %d)", _buffer_size));
_buffer_size = buffer_size_want;
}
Log.i(LOG_TAG, String.format(Locale.ROOT, "Using %d bytes buffer for %dHz audio",
_buffer_size, _sample_rate));
CompatHelpers.AudioTrackCompat.AudioTrackCompatReturn audioTrackRet =
CompatHelpers.AudioTrackCompat.make(_sample_rate, _buffer_size);
_audio_track = audioTrackRet.audioTrack;
_buffer_size = audioTrackRet.bufferSize;
if (_audio_track.getState() != AudioTrack.STATE_INITIALIZED)
throw new Exception(
String.format(Locale.ROOT, "Error initializing AudioTrack: %d",
_audio_track.getState()));
}
private void deinitAudio() {
if (_audio_track != null)
_audio_track.release();
_audio_track = null;
_buffer_size = 0;
_sample_rate = 0;
}
private static final int[] s_eglAttribs = {
EGL10.EGL_CONFIG_ID,
EGL10.EGL_BUFFER_SIZE,
EGL10.EGL_RED_SIZE,
EGL10.EGL_GREEN_SIZE,
EGL10.EGL_BLUE_SIZE,
EGL10.EGL_ALPHA_SIZE,
EGL10.EGL_CONFIG_CAVEAT,
EGL10.EGL_DEPTH_SIZE,
EGL10.EGL_LEVEL,
EGL10.EGL_MAX_PBUFFER_WIDTH,
EGL10.EGL_MAX_PBUFFER_HEIGHT,
EGL10.EGL_MAX_PBUFFER_PIXELS,
EGL10.EGL_NATIVE_RENDERABLE,
EGL10.EGL_NATIVE_VISUAL_ID,
EGL10.EGL_NATIVE_VISUAL_TYPE,
EGL10.EGL_SAMPLE_BUFFERS,
EGL10.EGL_SAMPLES,
EGL10.EGL_STENCIL_SIZE,
EGL10.EGL_SURFACE_TYPE,
EGL10.EGL_TRANSPARENT_TYPE,
EGL10.EGL_TRANSPARENT_RED_VALUE,
EGL10.EGL_TRANSPARENT_GREEN_VALUE,
EGL10.EGL_TRANSPARENT_BLUE_VALUE,
EGL10.EGL_RENDERABLE_TYPE
};
final private static int EGL_OPENGL_ES_BIT = 1;
final private static int EGL_OPENGL_ES2_BIT = 4;
final private class EglAttribs {
LinkedHashMap<Integer, Integer> _lhm;
public EglAttribs(EGLConfig config) {
_lhm = new LinkedHashMap<>(s_eglAttribs.length);
int[] value = new int[1];
// prevent throwing IllegalArgumentException
if (_egl_display == null || config == null) {
return;
}
for (int i : s_eglAttribs) {
_egl.eglGetConfigAttrib(_egl_display, config, i, value);
_lhm.put(i, value[0]);
}
}
private int weightBits(int attr, int size) {
final int value = get(attr);
int score = 0;
if (value == size || (size > 0 && value > size))
score += 10;
// penalize for wasted bits
if (value > size)
score -= value - size;
return score;
}
public int weight() {
int score = 10000;
if (get(EGL10.EGL_CONFIG_CAVEAT) != EGL10.EGL_NONE)
score -= 1000;
// If there is a config with EGL_OPENGL_ES2_BIT it must be favored
// This attribute can only be checked with EGL 1.3 but it may be present on older versions
if ((get(EGL10.EGL_RENDERABLE_TYPE) & EGL_OPENGL_ES2_BIT) > 0)
score += 5000;
// less MSAA is better
score -= get(EGL10.EGL_SAMPLES) * 100;
// Must be at least 565, but then smaller is better
score += weightBits(EGL10.EGL_RED_SIZE, 5);
score += weightBits(EGL10.EGL_GREEN_SIZE, 6);
score += weightBits(EGL10.EGL_BLUE_SIZE, 5);
score += weightBits(EGL10.EGL_ALPHA_SIZE, 0);
// Prefer 24 bits depth
score += weightBits(EGL10.EGL_DEPTH_SIZE, 24);
score += weightBits(EGL10.EGL_STENCIL_SIZE, 8);
return score;
}
@NonNull
public String toString() {
String s;
if (get(EGL10.EGL_ALPHA_SIZE) > 0)
s = String.format(Locale.ROOT, "[%d] RGBA%d%d%d%d",
get(EGL10.EGL_CONFIG_ID),
get(EGL10.EGL_RED_SIZE),
get(EGL10.EGL_GREEN_SIZE),
get(EGL10.EGL_BLUE_SIZE),
get(EGL10.EGL_ALPHA_SIZE));
else
s = String.format(Locale.ROOT, "[%d] RGB%d%d%d",
get(EGL10.EGL_CONFIG_ID),
get(EGL10.EGL_RED_SIZE),
get(EGL10.EGL_GREEN_SIZE),
get(EGL10.EGL_BLUE_SIZE));
if (get(EGL10.EGL_DEPTH_SIZE) > 0)
s += String.format(Locale.ROOT, " D%d", get(EGL10.EGL_DEPTH_SIZE));
if (get(EGL10.EGL_STENCIL_SIZE) > 0)
s += String.format(Locale.ROOT, " S%d", get(EGL10.EGL_STENCIL_SIZE));
if (get(EGL10.EGL_SAMPLES) > 0)
s += String.format(Locale.ROOT, " MSAAx%d", get(EGL10.EGL_SAMPLES));
if ((get(EGL10.EGL_SURFACE_TYPE) & EGL10.EGL_WINDOW_BIT) > 0)
s += " W";
if ((get(EGL10.EGL_SURFACE_TYPE) & EGL10.EGL_PBUFFER_BIT) > 0)
s += " P";
if ((get(EGL10.EGL_SURFACE_TYPE) & EGL10.EGL_PIXMAP_BIT) > 0)
s += " X";
if ((get(EGL10.EGL_RENDERABLE_TYPE) & EGL_OPENGL_ES_BIT) > 0)
s += " ES";
if ((get(EGL10.EGL_RENDERABLE_TYPE) & EGL_OPENGL_ES2_BIT) > 0)
s += " ES2";
switch (get(EGL10.EGL_CONFIG_CAVEAT)) {
case EGL10.EGL_NONE:
break;
case EGL10.EGL_SLOW_CONFIG:
s += " SLOW";
break;
case EGL10.EGL_NON_CONFORMANT_CONFIG:
s += " NON_CONFORMANT";
default:
s += String.format(Locale.ROOT, " unknown CAVEAT 0x%x",
get(EGL10.EGL_CONFIG_CAVEAT));
}
return s;
}
public Integer get(Integer key) {
if (_lhm.containsKey(key) && _lhm.get(key) != null) {
return _lhm.get(key);
} else {
return 0;
}
}
}
private EGLConfig chooseEglConfig(EGLConfig[] configs, int[] version) {
EGLConfig res = configs[0];
int bestScore = -1;
Log.d(LOG_TAG, "EGL configs:");
for (EGLConfig config : configs) {
if (config != null) {
boolean good = true;
EglAttribs attr = new EglAttribs(config);
// must have
if ((attr.get(EGL10.EGL_SURFACE_TYPE) & EGL10.EGL_WINDOW_BIT) == 0)
good = false;
if (version[0] >= 2 ||
(version[0] == 1 && version[1] >= 3)) {
// EGL_OPENGL_ES2_BIT is only supported since EGL 1.3
if ((attr.get(EGL10.EGL_RENDERABLE_TYPE) & EGL_OPENGL_ES2_BIT) == 0)
good = false;
}
if (attr.get(EGL10.EGL_BUFFER_SIZE) < bitsPerPixel)
good = false;
// Force a config with a depth buffer and a stencil buffer when rendering directly on backbuffer
if ((attr.get(EGL10.EGL_DEPTH_SIZE) == 0) || (attr.get(EGL10.EGL_STENCIL_SIZE) == 0))
good = false;
int score = attr.weight();
Log.d(LOG_TAG, String.format(Locale.ROOT, "%s (%d, %s)", attr.toString(), score, good ? "OK" : "NOK"));
if (!good) {
continue;
}
if (score > bestScore) {
res = config;
bestScore = score;
}
}
}
if (bestScore < 0)
Log.e(LOG_TAG,
"Unable to find an acceptable EGL config, expect badness.");
Log.d(LOG_TAG, String.format(Locale.ROOT, "Chosen EGL config: %s",
new EglAttribs(res).toString()));
return res;
}
static {
// // For grabbing with gdb...
// final boolean sleep_for_debugger = false;
// if (sleep_for_debugger) {
// try {
// Thread.sleep(20 * 1000);
// } catch (InterruptedException ignored) {
// }
// }
System.loadLibrary("scummvm");
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,488 @@
package org.scummvm.scummvm;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.ParcelFileDescriptor;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.Log;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.EditorInfo;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import org.scummvm.scummvm.zip.ZipEntry;
import org.scummvm.scummvm.zip.ZipFile;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class ShortcutCreatorActivity extends Activity implements CompatHelpers.SystemInsets.SystemInsetsListener {
final protected static String LOG_TAG = "ShortcutCreatorActivity";
private IconsCache _cache;
static void pushShortcut(Context context, String gameId, Intent intent) {
Map<String, Map<String, String>> parsedIniMap;
try (FileReader reader = new FileReader(new File(context.getFilesDir(), "scummvm.ini"))) {
parsedIniMap = INIParser.parse(reader);
} catch(FileNotFoundException ignored) {
parsedIniMap = null;
} catch(IOException ignored) {
parsedIniMap = null;
}
if (parsedIniMap == null) {
return;
}
Game game = Game.loadGame(parsedIniMap, gameId);
if (game == null) {
return;
}
FileInputStream defaultStream = openFile(new File(context.getFilesDir(), "gui-icons.dat"));
File iconsPath = INIParser.getPath(parsedIniMap, "scummvm", "iconspath",
new File(context.getFilesDir(), "icons"));
FileInputStream[] packsStream = openFiles(context, iconsPath, "gui-icons.*\\.dat");
IconsCache cache = new IconsCache(context, defaultStream, packsStream);
final Drawable icon = cache.getGameIcon(game);
CompatHelpers.ShortcutCreator.pushDynamicShortcut(context, game.getTarget(), intent, game.getDescription(), icon, R.drawable.ic_no_game_icon);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.shortcut_creator_activity);
CompatHelpers.SystemInsets.registerSystemInsetsListener(findViewById(R.id.shortcut_creator_root), this);
// We are only here to create a shortcut
if (!Intent.ACTION_CREATE_SHORTCUT.equals(getIntent().getAction())) {
finish();
return;
}
List<Game> games;
Map<String, Map<String, String>> parsedIniMap;
try (FileReader reader = new FileReader(new File(getFilesDir(), "scummvm.ini"))) {
parsedIniMap = INIParser.parse(reader);
} catch(FileNotFoundException ignored) {
parsedIniMap = null;
} catch(IOException ignored) {
parsedIniMap = null;
}
if (parsedIniMap == null) {
Toast.makeText(this, R.string.ini_parsing_error, Toast.LENGTH_LONG).show();
finish();
return;
}
games = Game.loadGames(parsedIniMap);
FileInputStream defaultStream = openFile(new File(getFilesDir(), "gui-icons.dat"));
File iconsPath = INIParser.getPath(parsedIniMap, "scummvm", "iconspath",
new File(getFilesDir(), "icons"));
FileInputStream[] packsStream = openFiles(this, iconsPath, "gui-icons.*\\.dat");
_cache = new IconsCache(this, defaultStream, packsStream);
final GameAdapter listAdapter = new GameAdapter(this, games, _cache);
ListView listView = findViewById(R.id.shortcut_creator_games_list);
listView.setAdapter(listAdapter);
listView.setEmptyView(findViewById(R.id.shortcut_creator_games_list_empty));
listView.setOnItemClickListener(_gameClicked);
EditText searchEdit = findViewById(R.id.shortcut_creator_search_edit);
searchEdit.addTextChangedListener(new TextWatcher() {
@Override
public void onTextChanged(CharSequence cs, int arg1, int arg2, int arg3) {
listAdapter.getFilter().filter(cs.toString());
}
@Override
public void beforeTextChanged(CharSequence arg0, int arg1, int arg2,
int arg3) {
}
@Override
public void afterTextChanged(Editable arg0) {
}
});
if (games.isEmpty()) {
searchEdit.setVisibility(View.GONE);
}
setResult(RESULT_CANCELED);
}
@Override
public void systemInsetsUpdated(int[] gestureInsets, int[] systemInsets, int[] cutoutInsets) {
LinearLayout root = findViewById(R.id.shortcut_creator_root);
// Ignore bottom as we have our list which can overflow
root.setPadding(
Math.max(systemInsets[0], cutoutInsets[0]),
Math.max(systemInsets[1], cutoutInsets[1]),
Math.max(systemInsets[2], cutoutInsets[2]), 0);
}
static private FileInputStream openFile(File path) {
try {
return new FileInputStream(path);
} catch (FileNotFoundException e) {
return null;
}
}
static private FileInputStream[] openFiles(Context context, File basePath, String regex) {
SAFFSTree.PathResult pr;
try {
pr = SAFFSTree.fullPathToNode(context, basePath.getPath(), false);
} catch (FileNotFoundException e) {
return new FileInputStream[0];
}
// This version check is only to make Android Studio linter happy
if (pr == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
// This is a standard filesystem path
File[] children = basePath.listFiles((dir, name) -> name.matches(regex));
if (children == null) {
return new FileInputStream[0];
}
Arrays.sort(children);
FileInputStream[] ret = new FileInputStream[children.length];
int i = 0;
for (File f: children) {
ret[i] = openFile(f);
i += 1;
}
return ret;
}
// This is a SAF fake mount point
SAFFSTree.SAFFSNode[] children = pr.tree.getChildren(pr.node);
if (children == null) {
return new FileInputStream[0];
}
Arrays.sort(children);
ArrayList<FileInputStream> ret = new ArrayList<>();
for (SAFFSTree.SAFFSNode child : children) {
if ((child._flags & SAFFSTree.SAFFSNode.DIRECTORY) != 0) {
continue;
}
String component = child._path.substring(child._path.lastIndexOf('/') + 1);
if (!component.matches(regex)) {
continue;
}
ParcelFileDescriptor pfd = pr.tree.createFileDescriptor(child, "r");
if (pfd == null) {
continue;
}
ret.add(new ParcelFileDescriptor.AutoCloseInputStream(pfd));
}
return ret.toArray(new FileInputStream[0]);
}
private final OnItemClickListener _gameClicked = new OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> a, View v, int position, long id) {
Game game = (Game)a.getItemAtPosition(position);
final Drawable icon = _cache.getGameIcon(game);
// Display a customization dialog to let the user change (shorten?) the title
AlertDialog.Builder builder = new AlertDialog.Builder(ShortcutCreatorActivity.this);
builder.setTitle("Title");
View fragment = LayoutInflater.from(ShortcutCreatorActivity.this).inflate(R.layout.shortcut_creator_customize, null);
final EditText desc = fragment.findViewById(R.id.shortcut_creator_customize_game_description);
desc.setText(game.getDescription());
final ImageView iconView = fragment.findViewById(R.id.shortcut_creator_customize_game_icon);
Drawable displayedIcon = icon;
if (displayedIcon == null) {
displayedIcon = CompatHelpers.DrawableCompat.getDrawable(ShortcutCreatorActivity.this, R.drawable.ic_no_game_icon);
}
iconView.setImageDrawable(displayedIcon);
builder.setView(fragment);
builder.setPositiveButton(android.R.string.ok, (dialog, which) -> {
dialog.dismiss();
String label = desc.getText().toString();
// Generate an id which depends on the user description
// Without this, if the user changes the description but already has the same shortcut (also in the dynamic ones), the other label will be reused
String shortcutId = game.getTarget() + String.format("-%08x", label.hashCode());
Intent shortcut = new Intent(Intent.ACTION_MAIN, Uri.fromParts("scummvm", game.getTarget(), null),
ShortcutCreatorActivity.this, SplashActivity.class);
Intent result = CompatHelpers.ShortcutCreator.createShortcutResultIntent(ShortcutCreatorActivity.this, shortcutId, shortcut,
desc.getText().toString(), icon, R.drawable.ic_no_game_icon);
setResult(RESULT_OK, result);
finish();
});
builder.setNegativeButton(android.R.string.cancel, (dialog, which) ->
dialog.cancel());
final AlertDialog dialog = builder.create();
desc.setOnEditorActionListener((TextView tv, int actionId, KeyEvent event) -> {
if (actionId == EditorInfo.IME_ACTION_DONE) {
dialog.getButton(DialogInterface.BUTTON_POSITIVE).performClick();
return true;
}
return false;
});
dialog.show();
}
};
private static class Game {
@NonNull
private final String _target;
private final String _engineid;
private final String _gameid;
@NonNull
private final String _description;
private Game(@NonNull String target, String engineid, String gameid, @NonNull String description) {
_target = target;
_engineid = engineid;
_gameid = gameid;
_description = description;
}
@NonNull
public String getTarget() {
return _target;
}
@NonNull
public String getDescription() {
return _description;
}
public Collection<String> getIconCandidates() {
if (_engineid == null) {
return new ArrayList<>();
}
ArrayList<String> ret = new ArrayList<>();
if (_gameid != null) {
ret.add(String.format("icons/%s-%s.png", _engineid, _gameid).toLowerCase());
}
ret.add(String.format("icons/%s.png", _engineid).toLowerCase());
return ret;
}
public static Game loadGame(@NonNull Map<String, Map<String, String>> parsedIniMap, String target) {
Map<String, String> domain = parsedIniMap.get(target);
if (domain == null) {
return null;
}
String engineid = domain.get("engineid");
String gameid = domain.get("gameid");
String description = domain.get("description");
if (description == null) {
return null;
}
return new Game(target, engineid, gameid, description);
}
public static List<Game> loadGames(@NonNull Map<String, Map<String, String>> parsedIniMap) {
List<Game> games = new ArrayList<>();
for (Map.Entry<String, Map<String, String>> entry : parsedIniMap.entrySet()) {
final String domain = entry.getKey();
if (domain == null ||
"scummvm".equals(domain) ||
"cloud".equals(domain) ||
"keymapper".equals(domain)) {
continue;
}
String engineid = entry.getValue().get("engineid");
String gameid = entry.getValue().get("gameid");
String description = entry.getValue().get("description");
if (description == null) {
continue;
}
games.add(new Game(domain, engineid, gameid, description));
}
return games;
}
@NonNull
@Override
public String toString() {
return _description;
}
}
private static class IconsCache {
/**
* This kind of mimics Common::generateZipSet
*/
private final Context _context;
private final Map<String, byte[]> _icons = new LinkedHashMap<String, byte[]>(16,0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, byte[]> eldest) {
return size() > 128;
}
};
private static final byte[] _noIconSentinel = new byte[0];
private final List<ZipFile> _zipFiles = new ArrayList<>();
public IconsCache(Context context,
FileInputStream defaultStream,
FileInputStream[] packsStream) {
_context = context;
for (int i = packsStream.length - 1; i >= 0; i--) {
final FileInputStream packStream = packsStream[i];
if (packStream == null) {
continue;
}
try {
ZipFile zf = new ZipFile(packStream);
_zipFiles.add(zf);
} catch (IOException e) {
Log.e(LOG_TAG, "Error while loading pack ZipFile: " + i, e);
}
}
if (defaultStream != null) {
try {
ZipFile zf = new ZipFile(defaultStream);
_zipFiles.add(zf);
} catch (IOException e) {
Log.e(LOG_TAG, "Error while loading default ZipFile", e);
}
}
}
public Drawable getGameIcon(Game game) {
for (String name : game.getIconCandidates()) {
byte[] data = _icons.get(name);
if (data == null) {
data = loadIcon(name);
}
if (data == _noIconSentinel) {
continue;
}
Bitmap bitmap = BitmapFactory.decodeByteArray(data, 0, data.length);
if (bitmap == null) {
continue;
}
return new BitmapDrawable(_context.getResources(), bitmap);
}
return null;
}
private byte[] loadIcon(String name) {
int zfi = 0;
for(ZipFile zf : _zipFiles) {
final ZipEntry ze = zf.getEntry(name);
if (ze == null) {
zfi++;
continue;
}
int sz = (int) ze.getSize();
byte[] buffer = new byte[sz];
try (InputStream is = zf.getInputStream(ze)) {
if (is.read(buffer) != buffer.length) {
throw new IOException();
}
} catch (IOException e) {
Log.e(LOG_TAG, "Error while uncompressing: " + name + " from zip file " + zfi, e);
zfi++;
continue;
}
_icons.put(name, buffer);
return buffer;
}
// Register failure
_icons.put(name, _noIconSentinel);
return _noIconSentinel;
}
}
private static class GameAdapter extends ArrayAdapter<Game> {
private final IconsCache _cache;
public GameAdapter(Context context,
List<Game> items,
IconsCache cache) {
super(context, 0, items);
Collections.sort(items, (lhs, rhs) -> lhs.getDescription().compareToIgnoreCase(rhs.getDescription()));
_cache = cache;
}
@NonNull
@Override
public View getView(int position, View convertView, @NonNull ViewGroup parent)
{
if (convertView == null) {
convertView = LayoutInflater.from(getContext()).inflate(R.layout.shortcut_creator_game_list_item, parent, false);
}
final Game game = getItem(position);
assert game != null;
final TextView desc = convertView.findViewById(R.id.shortcut_creator_game_item_description);
desc.setText(game.getDescription());
final ImageView iconView = convertView.findViewById(R.id.shortcut_creator_game_item_icon);
Drawable icon = _cache.getGameIcon(game);
if (icon == null) {
icon = CompatHelpers.DrawableCompat.getDrawable(getContext(), R.drawable.ic_no_game_icon);
}
iconView.setImageDrawable(icon);
return convertView;
}
}
}

View File

@@ -0,0 +1,93 @@
package org.scummvm.scummvm;
import android.Manifest;
import android.app.Activity;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.widget.Toast;
import androidx.annotation.NonNull;
public class SplashActivity extends Activity {
/**
* Ids to identify an external storage read (and write) request.
* They are app-defined int constants. The callback method gets the result of the request.
* Ie. returned in the Activity's onRequestPermissionsResult()
*/
private static final int MY_PERMISSION_ALL = 110;
private static final String[] MY_PERMISSIONS_STR_LIST = {
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
};
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
&& Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
&& (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED
|| checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED)
) {
// In Tiramisu (API 33) and above, READ and WRITE external storage permissions have no effect,
// and they are automatically denied -- onRequestPermissionsResult() will be called without user's input
requestPermissions(MY_PERMISSIONS_STR_LIST, MY_PERMISSION_ALL);
} else {
Intent next = new Intent(this, ScummVMActivity.class);
next.fillIn(getIntent(), Intent.FILL_IN_ACTION | Intent.FILL_IN_DATA);
startActivity(next);
finish();
}
}
@Override
public void onResume() {
super.onResume();
CompatHelpers.HideSystemStatusBar.hide(getWindow());
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
if (requestCode == MY_PERMISSION_ALL) {
int numOfReqPermsGranted = 0;
// If request is canceled, the result arrays are empty.
for (int i = 0; i < grantResults.length; ++i) {
if (grantResults[i] == PackageManager.PERMISSION_GRANTED) {
Log.i(ScummVM.LOG_TAG, permissions[i] + " permission was granted at Runtime");
++numOfReqPermsGranted;
} else {
Log.i(ScummVM.LOG_TAG, permissions[i] + " permission was denied at Runtime");
}
}
if (numOfReqPermsGranted != grantResults.length) {
// permission denied! We won't be able to make use of functionality depending on this permission.
Toast.makeText(this, "Until permission is granted, some storage locations may be inaccessible for r/w!", Toast.LENGTH_SHORT)
.show();
}
}
startActivity(new Intent(this, ScummVMActivity.class));
finish();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) {
CompatHelpers.HideSystemStatusBar.hide(getWindow());
}
}
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
}
}

View File

@@ -0,0 +1,74 @@
package org.scummvm.scummvm;
// Based on code from: https://stackoverflow.com/a/11024200
public class Version implements Comparable<Version> {
private final String versionOnlyDigits;
private final String versionDescription;
public final String getDescription() {
return this.versionDescription;
}
public final String get() {
return this.versionOnlyDigits;
}
public Version(String version) {
if(version == null) {
this.versionOnlyDigits = "0";
this.versionDescription = "0";
} else {
this.versionDescription = version;
// cleanup from any non-digit characters in the version string
final String strippedVersion = version.replaceAll("[^\\d.]", "");
if (!strippedVersion.matches("[0-9]+(\\.[0-9]+)*")) {
this.versionOnlyDigits = "0";
} else {
this.versionOnlyDigits = strippedVersion;
}
}
}
// Here a version is considered "dirty" if it contains other characters in the description string than the expected digits (and dots) of a "clean" proper version
// eg. 2.3.0pre or 2.3.0git or 2.3.0git9272-gc71ac4748b are dirty
// 2.3.0 is NOT dirty
public boolean isDirty() {
return (versionOnlyDigits.compareTo(versionDescription) != 0);
}
@Override public int compareTo(Version that) {
if(that == null)
return 1;
String[] thisParts = this.get().split("\\.");
String[] thatParts = that.get().split("\\.");
int length = Math.max(thisParts.length, thatParts.length);
try {
for (int i = 0; i < length; i++) {
int thisPart = i < thisParts.length ?
Integer.parseInt(thisParts[i]) : 0;
int thatPart = i < thatParts.length ?
Integer.parseInt(thatParts[i]) : 0;
if (thisPart < thatPart)
return -1;
if (thisPart > thatPart)
return 1;
}
} catch (NumberFormatException e) {
return 1;
}
return 0;
}
@Override public boolean equals(Object that) {
if(this == that)
return true;
if(that == null)
return false;
if(this.getClass() != that.getClass())
return false;
return this.compareTo((Version) that) == 0;
}
}

View File

@@ -0,0 +1,59 @@
package org.scummvm.scummvm.net;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class HTTPManager {
protected ExecutorService _executor;
protected ArrayBlockingQueue<Runnable> _queue;
protected boolean _empty;
/** @noinspection unused
* Called from JNI (main ScummVM thread)
*/
public HTTPManager() {
TLSSocketFactory.init();
_executor = Executors.newCachedThreadPool();
// Use a capacity to make sure the queue is checked on a regular basis
_queue = new ArrayBlockingQueue<>(50);
_empty = true;
}
/** @noinspection unused
* Called from JNI (main ScummVM thread)
*/
public void startRequest(HTTPRequest request) {
request._manager = this;
_executor.execute(request);
}
/** @noinspection unused
* Called from JNI (main ScummVM thread)
*/
public void poll() {
Runnable r;
while((r = _queue.poll()) != null) {
r.run();
}
// The read is never synchronized but at least we ensure here that we don't miss any event
synchronized(this) {
_empty = _queue.isEmpty();
}
}
// Called from workers
void enqueue(Runnable r) {
while(true) {
try {
_queue.put(r);
synchronized(this) {
_empty = false;
}
return;
} catch (InterruptedException ignored) {
}
}
}
}

View File

@@ -0,0 +1,360 @@
package org.scummvm.scummvm.net;
import android.os.Build;
import android.os.ParcelFileDescriptor;
import android.util.Log;
import org.scummvm.scummvm.SAFFSTree;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.SequenceInputStream;
import java.math.BigInteger;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.Charset;
import java.security.SecureRandom;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.Vector;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
public class HTTPRequest implements Runnable {
final static String LOG_TAG = "ScummVM.HTTP";
private static final int DEFAULT_BUFFER_SIZE = 16384;
HTTPManager _manager;
protected String _method;
protected AtomicReference<String> _url;
protected TreeMap<String, String> _requestHeaders;
protected InputStream _uploadBuffer;
protected int _uploadBufferLength;
protected long _nativePointer;
protected AtomicBoolean _cancelled;
protected native void gotHeaders(long nativePointer, String[] headers);
protected native void gotData(long nativePointer, byte[] data, int size, int totalSize);
protected native void finished(long nativePointer, int errorCode, String errorMsg);
/** @noinspection unused
* Called from JNI
*/
public HTTPRequest(long nativePointer, String url, String[] requestHeaders, byte[] uploadBuffer, boolean uploading, boolean usingPatch, boolean post) {
init(nativePointer, url);
setupUploadBuffer(uploadBuffer, uploading, usingPatch, post);
setupHeaders(requestHeaders);
}
/** @noinspection unused
* Called from JNI
*/
public HTTPRequest(long nativePointer, String url, String[] requestHeaders, String[] formFields, String[] formFiles) {
init(nativePointer, url);
setupMultipartForm(formFields, formFiles);
setupHeaders(requestHeaders);
}
private void init(long nativePointer, String url) {
_nativePointer = nativePointer;
_url = new AtomicReference<>(url);
_requestHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
_cancelled = new AtomicBoolean(false);
_method = "GET";
}
private void setupUploadBuffer(byte[] uploadBuffer, boolean uploading, boolean usingPatch, boolean post) {
_uploadBuffer = null;
_uploadBufferLength = 0;
if (uploading) {
if (uploadBuffer == null) {
uploadBuffer = new byte[0];
}
_uploadBuffer = new ByteArrayInputStream(uploadBuffer);
_uploadBufferLength = uploadBuffer.length;
_method = "PUT";
return;
}
if (usingPatch) {
_method = "PATCH";
return;
}
if (uploadBuffer != null && uploadBuffer.length > 0) {
_uploadBuffer = new ByteArrayInputStream(uploadBuffer);
_uploadBufferLength = uploadBuffer.length;
post = true;
}
if (post) {
_method = "POST";
_requestHeaders.put("content-type", "application/x-www-form-urlencoded");
}
}
private void setupMultipartForm(String[] formFields, String[] formFiles) {
if ((formFields.length & 1) != 0) {
throw new IllegalArgumentException("formFields has odd length");
}
if ((formFiles.length & 1) != 0) {
throw new IllegalArgumentException("formFiles has odd length");
}
SecureRandom rnd = new SecureRandom();
String boundary = "ScummVM-Boundary-" + (new BigInteger(128, rnd)).toString(10);
int contentLength = 0;
Vector<InputStream> bodyParts = new Vector<>(formFiles.length * 2 + 2);
_method = "POST";
_requestHeaders.put("content-type", String.format("multipart/form-data; boundary=%s", boundary));
StringBuilder formFieldsContent = new StringBuilder();
for (int i = 0; i < formFields.length; i += 2) {
formFieldsContent.append(String.format("\r\n--%s\r\nContent-Disposition: form-data; name=\"%s\"\r\n\r\n", boundary, formFields[i]));
formFieldsContent.append(formFields[i+1]);
}
for (int i = 0; i < formFiles.length; i += 2) {
File file = new File(formFiles[i+1]);
formFieldsContent.append(String.format("\r\n--%s\r\nContent-Disposition: form-data; name=\"%s\"; filename=\"%s\"\r\n\r\n", boundary, formFiles[i], file.getName()));
byte[] textContent = formFieldsContent.toString().getBytes(Charset.defaultCharset());
bodyParts.add(new ByteArrayInputStream(textContent));
if (contentLength >= 0) {
try {
contentLength = Math.addExact(contentLength, textContent.length);
} catch (ArithmeticException e) {
contentLength = -1;
}
}
formFieldsContent = new StringBuilder();
try {
SAFFSTree.PathResult pr = SAFFSTree.fullPathToNode(null, file.getPath(), false);
if (pr == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
bodyParts.add(new FileInputStream(file));
long length = file.length();
if (!file.isFile() || length >= Integer.MAX_VALUE) {
contentLength = -1;
} else if (contentLength >= 0) {
try {
contentLength = Math.addExact(contentLength, (int)length);
} catch (ArithmeticException e) {
contentLength = -1;
}
}
} else {
ParcelFileDescriptor pfd = pr.tree.createFileDescriptor(pr.node, "r");
bodyParts.add(new ParcelFileDescriptor.AutoCloseInputStream(pfd));
contentLength = -1;
}
} catch (FileNotFoundException ignored) {
// We can't trigger an error now: we will make sure we call finished later with an error
bodyParts.add(null);
break;
}
}
// Now we only have to close the multipart with an ending boundary
formFieldsContent.append(String.format("\r\n--%s--\r\n", boundary));
byte[] textContent = formFieldsContent.toString().getBytes(Charset.defaultCharset());
bodyParts.add(new ByteArrayInputStream(textContent));
if (contentLength >= 0) {
try {
contentLength = Math.addExact(contentLength, textContent.length);
} catch (ArithmeticException e) {
contentLength = -1;
}
}
_uploadBuffer = new SequenceInputStream(bodyParts.elements());
_uploadBufferLength = contentLength;
}
private void setupHeaders(String[] requestHeaders) {
if ((requestHeaders.length & 1) != 0) {
throw new IllegalArgumentException("requestHeaders has odd length");
}
for(int i = 0; i < requestHeaders.length; i += 2) {
if (requestHeaders[i] == null) {
// If there were invalid headers passed in native code
// we end up with null entries at the end of the array
return;
}
_requestHeaders.put(requestHeaders[i], requestHeaders[i+1]);
}
}
/** @noinspection unused
* Called from JNI
*/
public void cancel() {
_cancelled.set(true);
// Don't notify the native object if we got cancelled: it may have been reused
_nativePointer = 0;
}
public String getURL() {
return _url.get();
}
private void cleanup() {
if (_uploadBuffer != null) {
try {
_uploadBuffer.close();
} catch (IOException ignored) {
}
}
}
// Runs on HTTPManager thread pool
@Override
public void run() {
if (_cancelled.get()) {
cleanup();
return;
}
URL url;
HttpURLConnection urlConnection;
try {
url = new URL(_url.get());
Log.d(LOG_TAG, String.format("Will make HTTP request to %s with method %s", url, _method));
urlConnection = (HttpURLConnection) url.openConnection();
urlConnection.setRequestMethod(_method);
} catch (IOException e) {
final String errorMsg = e.getMessage();
_manager.enqueue(() -> finished(_nativePointer, -1, errorMsg));
cleanup();
return;
}
if (_cancelled.get()) {
cleanup();
return;
}
urlConnection.setInstanceFollowRedirects(true);
for (Map.Entry<String, String> e : _requestHeaders.entrySet()) {
urlConnection.addRequestProperty(e.getKey(), e.getValue());
}
if (_uploadBuffer != null) {
urlConnection.setDoOutput(true);
if (_uploadBufferLength != -1) {
urlConnection.setFixedLengthStreamingMode(_uploadBufferLength);
}
}
try {
urlConnection.connect();
if (_cancelled.get()) {
urlConnection.disconnect();
cleanup();
return;
}
if (_uploadBuffer != null) {
try (OutputStream out = urlConnection.getOutputStream()) {
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
int read;
while ((read = _uploadBuffer.read(buffer, 0, DEFAULT_BUFFER_SIZE)) >= 0) {
out.write(buffer, 0, read);
}
} catch (NullPointerException e) {
// We failed to open some file when building the buffer
_manager.enqueue(() -> finished(_nativePointer, -1, "Can't open file"));
cleanup();
return;
}
}
if (_cancelled.get()) {
urlConnection.disconnect();
cleanup();
return;
}
Map<String, java.util.List<String>> headersField = urlConnection.getHeaderFields();
if (_cancelled.get()) {
urlConnection.disconnect();
cleanup();
return;
}
final Vector<String> headers = new Vector<>(headersField.size() * 2);
for (Map.Entry<String, java.util.List<String>> e : headersField.entrySet()) {
String key = e.getKey();
if (key == null) {
// The status line is placed in the map with a null key: ignore it
continue;
}
List<String> values = e.getValue();
headers.add(key.toLowerCase(Locale.ROOT));
headers.add(values.get(values.size() - 1));
}
_manager.enqueue(() -> gotHeaders(_nativePointer, headers.toArray(new String[0])));
int contentLength = urlConnection.getContentLength();
InputStream in = urlConnection.getInputStream();
if (_cancelled.get()) {
cleanup();
return;
}
boolean finished = false;
while(!finished) {
final byte[] inputData = new byte[DEFAULT_BUFFER_SIZE];
int offset = 0;
while(offset < DEFAULT_BUFFER_SIZE) {
final int size = in.read(inputData, offset, DEFAULT_BUFFER_SIZE - offset);
if (size == -1) {
finished = true;
break;
}
if (_cancelled.get()) {
cleanup();
return;
}
offset += size;
}
final int offset_ = offset;
_manager.enqueue(() -> gotData(_nativePointer, inputData, offset_, contentLength));
}
// Update URL field
url = urlConnection.getURL();
_url.set(url.toExternalForm());
final int responseCode = urlConnection.getResponseCode();
_manager.enqueue(() -> finished(_nativePointer, responseCode, null));
} catch (FileNotFoundException e) {
// The server returned an error, return the error code and no data
int responseCode = -1;
try {
responseCode = urlConnection.getResponseCode();
} catch (IOException ignored) {
}
final int responseCode_ = responseCode;
final String errorMsg = e.getMessage();
_manager.enqueue(() -> finished(_nativePointer, responseCode_, errorMsg));
cleanup();
} catch (IOException e) {
Log.w(LOG_TAG, "Error when making HTTP request", e);
final String errorMsg = e.getMessage();
_manager.enqueue(() -> finished(_nativePointer, -1, errorMsg));
cleanup();
} finally {
urlConnection.disconnect();
}
}
}

View File

@@ -0,0 +1,109 @@
package org.scummvm.scummvm.net;
import android.util.Base64;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Locale;
/*
* Inspiration taken from http://blog.novoj.net/2016/02/29/how-to-make-apache-httpclient-trust-lets-encrypt-certificate-authority/
*/
class LETrustManager implements X509TrustManager {
private static final String[] derLECerts = {
/* ISRG Root X1 */ "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygch77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6UA5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sWT8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UCB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUvKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkqhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZLubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVcoyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPAmRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=",
/* ISRG Root X2 */ "MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBTZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZIzj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdWtL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1/q4AaOeMSQ+2b1tbFfLn",
};
private static LETrustManager instance;
private final X509TrustManager _systemTrustManager;
private final X509TrustManager _leTrustManager;
static SSLContext getContext() throws NoSuchAlgorithmException, KeyManagementException {
try {
if (instance == null) {
instance = new LETrustManager();
}
} catch (NoSuchAlgorithmException e) {
e.printStackTrace(System.err);
return SSLContext.getDefault();
} catch (KeyStoreException e) {
e.printStackTrace(System.err);
return SSLContext.getDefault();
} catch (CertificateException e) {
e.printStackTrace(System.err);
return SSLContext.getDefault();
} catch (IOException e) {
e.printStackTrace(System.err);
return SSLContext.getDefault();
}
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, new TrustManager[]{instance}, null);
return sslContext;
}
public LETrustManager() throws NoSuchAlgorithmException, KeyStoreException, CertificateException, IOException {
final TrustManagerFactory mainTrustFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
mainTrustFactory.init((KeyStore)null);
this._systemTrustManager = (X509TrustManager)mainTrustFactory.getTrustManagers()[0];
KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
ks.load(null);
CertificateFactory cf = CertificateFactory.getInstance("X.509");
int i = 1;
for (String derCert : derLECerts) {
ByteArrayInputStream is = new ByteArrayInputStream(Base64.decode(derCert, Base64.DEFAULT));
Certificate cert = cf.generateCertificate(is);
ks.setCertificateEntry(String.format(Locale.getDefault(), "%d", i), cert);
i++;
}
final TrustManagerFactory leTrustFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
leTrustFactory.init(ks);
this._leTrustManager = (X509TrustManager)leTrustFactory.getTrustManagers()[0];
}
@Override
public void checkClientTrusted(final X509Certificate[] x509Certificates, final String authType) throws CertificateException {
// LE doesn't issue client certificates
_systemTrustManager.checkClientTrusted(x509Certificates, authType);
}
@Override
public void checkServerTrusted(final X509Certificate[] x509Certificates, final String authType) throws CertificateException {
try {
_systemTrustManager.checkServerTrusted(x509Certificates, authType);
} catch(CertificateException ignored) {
this._leTrustManager.checkServerTrusted(x509Certificates, authType);
}
}
@Override
public X509Certificate[] getAcceptedIssuers() {
X509Certificate[] systemAccepted = this._systemTrustManager.getAcceptedIssuers();
X509Certificate[] leAccepted = this._leTrustManager.getAcceptedIssuers();
X509Certificate[] allAccepted = Arrays.copyOf(systemAccepted, systemAccepted.length + leAccepted.length);
System.arraycopy(leAccepted, 0, allAccepted, systemAccepted.length, leAccepted.length);
return allAccepted;
}
}

View File

@@ -0,0 +1,267 @@
package org.scummvm.scummvm.net;
import android.util.Log;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.MalformedURLException;
import java.net.Proxy;
import java.net.ProxySelector;
import java.net.Socket;
import java.net.SocketAddress;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.List;
import java.util.Locale;
import javax.net.ssl.SSLSocketFactory;
/** @noinspection unused*/
public class SSocket {
final static String LOG_TAG = "ScummVM";
protected Socket _socket;
protected int _buffer = -2;
public SSocket(String url_) {
final URL url;
try {
url = new URL(url_);
} catch (MalformedURLException e) {
throw new RuntimeException(e);
}
String scheme = url.getProtocol().toLowerCase();
if (!scheme.equals("http") && !scheme.equals("https")) {
throw new RuntimeException("Unsupported protocol");
}
String host = url.getHost();
if (host == null) {
throw new RuntimeException("Missing host name");
}
if (host.contains(":")) {
host = String.format("[%s]", host);
}
int port = url.getPort();
if (port == -1) {
port = url.getDefaultPort();
}
Socket socket;
try {
socket = proxyConnect(url, host, port);
if (scheme.equals("https")) {
SSLSocketFactory ssf = new TLSSocketFactory();
socket = ssf.createSocket(socket, host, port, true);
}
_socket = socket;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private static Socket proxyConnect(URL url, String host, int port) throws IOException {
Socket ret;
final URI uri;
try {
uri = url.toURI();
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
SocketAddress sa = new InetSocketAddress(host, port);
final ProxySelector proxySelector = ProxySelector.getDefault();
final List<Proxy> proxies = proxySelector.select(uri);
IOException lastExc = null;
for (Proxy proxy : proxies) {
final Proxy.Type proxyType = proxy.type();
try {
if (proxyType != Proxy.Type.HTTP) {
ret = new Socket(proxy);
ret.connect(sa);
return ret;
}
// HTTP proxy with Socket is not supported on Android
// Let's do it ourselves with a CONNECT method
// First, resolve the proxy address: it's not resolved in Proxy
InetSocketAddress proxyAddress = (InetSocketAddress)proxy.address();
InetAddress addr = proxyAddress.getAddress();
String proxyHost;
if (addr != null) {
proxyHost = addr.getHostName();
} else {
proxyHost = proxyAddress.getHostName();
}
int proxyPort = proxyAddress.getPort();
proxyAddress = new InetSocketAddress(proxyHost, proxyPort);
ret = new Socket();
ret.connect(proxyAddress);
proxyHTTPConnect(ret, host, port);
return ret;
} catch (IOException e) {
Log.e(LOG_TAG, "Got an exception while connecting", e);
if (proxy.address() != null) {
proxySelector.connectFailed(uri, proxy.address(), e);
}
lastExc = e;
}
}
if (lastExc == null) {
throw new RuntimeException("No proxy specified");
}
throw lastExc;
}
private static void proxyHTTPConnect(Socket socket, String host, int port) throws IOException {
String requestLine = String.format(Locale.ROOT, "CONNECT %s:%d HTTP/1.0\r\n\r\n", host, port);
socket.getOutputStream().write(requestLine.getBytes());
byte[] buffer = readLine(socket);
// HTTP/1.x SP 2xx SP
if (buffer.length < 13 ||
buffer[0] != 'H' ||
buffer[1] != 'T' ||
buffer[2] != 'T' ||
buffer[3] != 'P' ||
buffer[4] != '/' ||
buffer[5] != '1' ||
buffer[6] != '.' ||
(buffer[7] != '0' && buffer[7] != '1') ||
buffer[8] != ' ' ||
buffer[9] != '2' ||
!Character.isDigit(buffer[10]) ||
!Character.isDigit(buffer[11]) ||
buffer[12] != ' ') {
throw new IOException("Invalid proxy reply");
}
for (int i = 0; i < 64 && buffer.length > 0; i++) {
buffer = readLine(socket);
}
if (buffer.length > 0) {
throw new IOException("Invalid proxy reply: too much headers");
}
}
private static byte[] readLine(Socket socket) throws IOException {
InputStream is = socket.getInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int b;
while (true) {
b = is.read();
if (b == -1) {
return baos.toByteArray();
}
if (b == '\r') {
continue;
}
if (b == '\n') {
return baos.toByteArray();
}
baos.write(b);
}
}
public int ready() {
if (_buffer != -2) {
// We have at least one byte or an EOF
return 1;
}
try {
// Set receive timeout to something ridiculously low to mimic a non-blocking socket
_socket.setSoTimeout(1);
_buffer = _socket.getInputStream().read();
return 1;
} catch (SocketTimeoutException e) {
// Nothing was ready to consume
return 0;
} catch (IOException e) {
Log.e(LOG_TAG, "Got an exception while checking ready status", e);
// Make it like if there was something ready
return 1;
}
}
public int send(byte[] data) {
try {
// Setup unlimited read timeout to allow for SSL exchanges to work
_socket.setSoTimeout(0);
_socket.getOutputStream().write(data);
return data.length;
} catch (IOException e) {
Log.e(LOG_TAG, "Got an exception while sending socket data", e);
// This likely failed
return 0;
}
}
public int recv(byte[] data) {
if (data.length == 0) {
return 0;
}
if (_buffer == -1) {
_buffer = -2;
return -1;
}
int offset = 0;
if (_buffer != -2) {
data[0] = (byte)_buffer;
offset = 1;
_buffer = -2;
}
try {
int recvd = 0;
long end = System.currentTimeMillis() + 5000;
while (true) {
try {
// Allow for some timeout but not too much
_socket.setSoTimeout(500);
recvd = _socket.getInputStream().read(data, offset, data.length - offset);
break;
} catch (SocketTimeoutException e1) {
if (System.currentTimeMillis() >= end) {
break;
}
}
}
if (offset == 0) {
// Nothing was buffered
return recvd;
}
if (recvd == -1) {
// Buffer the EOF and return the previous buffered data;
_buffer = -1;
return offset;
}
return offset + recvd;
} catch (IOException e) {
Log.e(LOG_TAG, "Got an exception while receiving socket data", e);
return offset;
}
}
public void close() {
try {
_socket.close();
} catch (IOException e) {
Log.e(LOG_TAG, "Got an exception while closing socket", e);
}
_socket = null;
}
}

View File

@@ -0,0 +1,122 @@
package org.scummvm.scummvm.net;
/*
* Customized from https://blog.dev-area.net/2015/08/13/android-4-1-enable-tls-1-1-and-tls-1-2/
* Added TLS1.3 support and keep old protocols enabled for maximum compatibility
*/
import java.io.IOException;
import java.net.InetAddress;
import java.net.Socket;
import java.net.UnknownHostException;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
/**
* @author fkrauthan
*/
public class TLSSocketFactory extends SSLSocketFactory {
private final SSLSocketFactory _factory;
private static String[] _protocols;
private static boolean _init;
public static void init() {
if (_init) {
return;
}
try {
HttpsURLConnection.setDefaultSSLSocketFactory(new TLSSocketFactory());
} catch (RuntimeException ignored) {
}
_init = true;
}
public TLSSocketFactory() {
SSLContext context = null;
try {
context = LETrustManager.getContext();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
} catch (KeyManagementException e) {
throw new RuntimeException(e);
}
_factory = context.getSocketFactory();
}
@Override
public String[] getDefaultCipherSuites() {
return _factory.getDefaultCipherSuites();
}
@Override
public String[] getSupportedCipherSuites() {
return _factory.getSupportedCipherSuites();
}
@Override
public Socket createSocket() throws IOException {
return enableTLSOnSocket(_factory.createSocket());
}
@Override
public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
return enableTLSOnSocket(_factory.createSocket(s, host, port, autoClose));
}
@Override
public Socket createSocket(String host, int port) throws IOException, UnknownHostException {
return enableTLSOnSocket(_factory.createSocket(host, port));
}
@Override
public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException, UnknownHostException {
return enableTLSOnSocket(_factory.createSocket(host, port, localHost, localPort));
}
@Override
public Socket createSocket(InetAddress host, int port) throws IOException {
return enableTLSOnSocket(_factory.createSocket(host, port));
}
@Override
public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException {
return enableTLSOnSocket(_factory.createSocket(address, port, localAddress, localPort));
}
private Socket enableTLSOnSocket(Socket socket) {
if(socket instanceof SSLSocket) {
SSLSocket sslSocket = (SSLSocket)socket;
if (_protocols == null) {
String[] newProtocols = {"TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3"};
// Build the list of protocols to enable
Set<String> protocols = new HashSet<>(Arrays.asList(sslSocket.getEnabledProtocols()));
Set<String> supported = new HashSet<>(Arrays.asList(sslSocket.getSupportedProtocols()));
for (String protocol : newProtocols) {
if (protocols.contains(protocol)) {
continue;
}
if (!supported.contains(protocol)) {
continue;
}
protocols.add(protocol);
}
_protocols = protocols.toArray(new String[]{});
}
sslSocket.setEnabledProtocols(_protocols);
}
return socket;
}
}

View File

@@ -0,0 +1,261 @@
/*
* Copyright (c) 2009, 2021, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package org.scummvm.scummvm.zip;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CharsetEncoder;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
/**
* Utility class for zipfile name and comment decoding and encoding
*/
class ZipCoder {
// Android-removed:
// private static final jdk.internal.access.JavaLangAccess JLA =
// jdk.internal.access.SharedSecrets.getJavaLangAccess();
// Encoding/decoding is stateless, so make it singleton.
// Android-changed: use StandardCharsets.
// static final UTF8ZipCoder UTF8 = new UTF8ZipCoder(UTF_8.INSTANCE);
// ScummVM-changed: use ZipUtils.
static final UTF8ZipCoder UTF8 = new UTF8ZipCoder(ZipUtils.UTF_8);
public static ZipCoder get(Charset charset) {
// Android-changed: use equals method, not reference comparison.
// if (charset == UTF_8.INSTANCE) {
// ScummVM-changed: use ZipUtils.
if (ZipUtils.UTF_8.equals(charset)) {
return UTF8;
}
return new ZipCoder(charset);
}
String toString(byte[] ba, int off, int length) {
try {
return decoder().decode(ByteBuffer.wrap(ba, off, length)).toString();
} catch (CharacterCodingException x) {
throw new IllegalArgumentException(x);
}
}
String toString(byte[] ba, int length) {
return toString(ba, 0, length);
}
String toString(byte[] ba) {
return toString(ba, 0, ba.length);
}
byte[] getBytes(String s) {
try {
ByteBuffer bb = encoder().encode(CharBuffer.wrap(s));
int pos = bb.position();
int limit = bb.limit();
if (bb.hasArray() && pos == 0 && limit == bb.capacity()) {
return bb.array();
}
byte[] bytes = new byte[bb.limit() - bb.position()];
bb.get(bytes);
return bytes;
} catch (CharacterCodingException x) {
throw new IllegalArgumentException(x);
}
}
static String toStringUTF8(byte[] ba, int len) {
return UTF8.toString(ba, 0, len);
}
boolean isUTF8() {
return false;
}
// Hash code functions for ZipFile entry names. We generate the hash as-if
// we first decoded the byte sequence to a String, then appended '/' if no
// trailing slash was found, then called String.hashCode(). This
// normalization ensures we can simplify and speed up lookups.
//
// Does encoding error checking and hashing in a single pass for efficiency.
// On an error, this function will throw CharacterCodingException while the
// UTF8ZipCoder override will throw IllegalArgumentException, so we declare
// throws Exception to keep things simple.
int checkedHash(byte[] a, int off, int len) throws Exception {
if (len == 0) {
return 0;
}
int h = 0;
// cb will be a newly allocated CharBuffer with pos == 0,
// arrayOffset == 0, backed by an array.
CharBuffer cb = decoder().decode(ByteBuffer.wrap(a, off, len));
int limit = cb.limit();
char[] decoded = cb.array();
for (int i = 0; i < limit; i++) {
h = 31 * h + decoded[i];
}
if (limit > 0 && decoded[limit - 1] != '/') {
h = 31 * h + '/';
}
return h;
}
// Hash function equivalent of checkedHash for String inputs
static int hash(String name) {
int hsh = name.hashCode();
int len = name.length();
if (len > 0 && name.charAt(len - 1) != '/') {
hsh = hsh * 31 + '/';
}
return hsh;
}
boolean hasTrailingSlash(byte[] a, int end) {
byte[] slashBytes = slashBytes();
return end >= slashBytes.length &&
// ScummVM-changed: improve compatibility.
/*
Arrays.mismatch(a, end - slashBytes.length, end, slashBytes, 0, slashBytes.length) == -1;
*/
Arrays.equals(Arrays.copyOfRange(a, end - slashBytes.length, end), slashBytes);
}
private byte[] slashBytes;
private final Charset cs;
protected CharsetDecoder dec;
private CharsetEncoder enc;
private ZipCoder(Charset cs) {
this.cs = cs;
}
protected CharsetDecoder decoder() {
if (dec == null) {
dec = cs.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
}
return dec;
}
private CharsetEncoder encoder() {
if (enc == null) {
enc = cs.newEncoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
}
return enc;
}
// This method produces an array with the bytes that will correspond to a
// trailing '/' in the chosen character encoding.
//
// While in most charsets a trailing slash will be encoded as the byte
// value of '/', this does not hold in the general case. E.g., in charsets
// such as UTF-16 and UTF-32 it will be represented by a sequence of 2 or 4
// bytes, respectively.
private byte[] slashBytes() {
if (slashBytes == null) {
// Take into account charsets that produce a BOM, e.g., UTF-16
byte[] slash = "/".getBytes(cs);
byte[] doubleSlash = "//".getBytes(cs);
slashBytes = Arrays.copyOfRange(doubleSlash, slash.length, doubleSlash.length);
}
return slashBytes;
}
static final class UTF8ZipCoder extends ZipCoder {
private UTF8ZipCoder(Charset utf8) {
super(utf8);
}
@Override
boolean isUTF8() {
return true;
}
@Override
String toString(byte[] ba, int off, int length) {
// Android-changed: JLA is not yet available.
// return JLA.newStringUTF8NoRepl(ba, off, length);
// ScummVM-changed: use ZipUtils.
return new String(ba, off, length, ZipUtils.UTF_8);
}
@Override
byte[] getBytes(String s) {
// Android-changed: JLA is not yet available.
// return JLA.getBytesUTF8NoRepl(s);
// ScummVM-changed: use ZipUtils.
return s.getBytes(ZipUtils.UTF_8);
}
@Override
int checkedHash(byte[] a, int off, int len) throws Exception {
if (len == 0) {
return 0;
}
int end = off + len;
int h = 0;
while (off < end) {
byte b = a[off];
if (b >= 0) {
// ASCII, keep going
h = 31 * h + b;
off++;
} else {
// Non-ASCII, fall back to decoding a String
// We avoid using decoder() here since the UTF8ZipCoder is
// shared and that decoder is not thread safe.
// We use the JLA.newStringUTF8NoRepl variant to throw
// exceptions eagerly when opening ZipFiles
// Android-changed: JLA is not yet available.
// return hash(JLA.newStringUTF8NoRepl(a, end - len, len));
// ScummVM-changed: use ZipUtils.
return hash(new String(a, end - len, len, ZipUtils.UTF_8));
}
}
if (a[end - 1] != '/') {
h = 31 * h + '/';
}
return h;
}
@Override
boolean hasTrailingSlash(byte[] a, int end) {
return end > 0 && a[end - 1] == '/';
}
}
}

View File

@@ -0,0 +1,236 @@
/*
* Copyright (c) 1995, 2020, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package org.scummvm.scummvm.zip;
/*
* This interface defines the constants that are used by the classes
* which manipulate ZIP files.
*
* @author David Connelly
* @since 1.1
*/
interface ZipConstants {
/**
* Local file (LOC) header signature.
*/
static long LOCSIG = 0x04034b50L; // "PK\003\004"
/**
* Extra local (EXT) header signature.
*/
static long EXTSIG = 0x08074b50L; // "PK\007\008"
/**
* Central directory (CEN) header signature.
*/
static long CENSIG = 0x02014b50L; // "PK\001\002"
/**
* End of central directory (END) header signature.
*/
static long ENDSIG = 0x06054b50L; // "PK\005\006"
/**
* Local file (LOC) header size in bytes (including signature).
*/
static final int LOCHDR = 30;
/**
* Extra local (EXT) header size in bytes (including signature).
*/
static final int EXTHDR = 16;
/**
* Central directory (CEN) header size in bytes (including signature).
*/
static final int CENHDR = 46;
/**
* End of central directory (END) header size in bytes (including signature).
*/
static final int ENDHDR = 22;
/**
* Local file (LOC) header version needed to extract field offset.
*/
static final int LOCVER = 4;
/**
* Local file (LOC) header general purpose bit flag field offset.
*/
static final int LOCFLG = 6;
/**
* Local file (LOC) header compression method field offset.
*/
static final int LOCHOW = 8;
/**
* Local file (LOC) header modification time field offset.
*/
static final int LOCTIM = 10;
/**
* Local file (LOC) header uncompressed file crc-32 value field offset.
*/
static final int LOCCRC = 14;
/**
* Local file (LOC) header compressed size field offset.
*/
static final int LOCSIZ = 18;
/**
* Local file (LOC) header uncompressed size field offset.
*/
static final int LOCLEN = 22;
/**
* Local file (LOC) header filename length field offset.
*/
static final int LOCNAM = 26;
/**
* Local file (LOC) header extra field length field offset.
*/
static final int LOCEXT = 28;
/**
* Extra local (EXT) header uncompressed file crc-32 value field offset.
*/
static final int EXTCRC = 4;
/**
* Extra local (EXT) header compressed size field offset.
*/
static final int EXTSIZ = 8;
/**
* Extra local (EXT) header uncompressed size field offset.
*/
static final int EXTLEN = 12;
/**
* Central directory (CEN) header version made by field offset.
*/
static final int CENVEM = 4;
/**
* Central directory (CEN) header version needed to extract field offset.
*/
static final int CENVER = 6;
/**
* Central directory (CEN) header encrypt, decrypt flags field offset.
*/
static final int CENFLG = 8;
/**
* Central directory (CEN) header compression method field offset.
*/
static final int CENHOW = 10;
/**
* Central directory (CEN) header modification time field offset.
*/
static final int CENTIM = 12;
/**
* Central directory (CEN) header uncompressed file crc-32 value field offset.
*/
static final int CENCRC = 16;
/**
* Central directory (CEN) header compressed size field offset.
*/
static final int CENSIZ = 20;
/**
* Central directory (CEN) header uncompressed size field offset.
*/
static final int CENLEN = 24;
/**
* Central directory (CEN) header filename length field offset.
*/
static final int CENNAM = 28;
/**
* Central directory (CEN) header extra field length field offset.
*/
static final int CENEXT = 30;
/**
* Central directory (CEN) header comment length field offset.
*/
static final int CENCOM = 32;
/**
* Central directory (CEN) header disk number start field offset.
*/
static final int CENDSK = 34;
/**
* Central directory (CEN) header internal file attributes field offset.
*/
static final int CENATT = 36;
/**
* Central directory (CEN) header external file attributes field offset.
*/
static final int CENATX = 38;
/**
* Central directory (CEN) header LOC header offset field offset.
*/
static final int CENOFF = 42;
/**
* End of central directory (END) header number of entries on this disk field offset.
*/
static final int ENDSUB = 8;
/**
* End of central directory (END) header total number of entries field offset.
*/
static final int ENDTOT = 10;
/**
* End of central directory (END) header central directory size in bytes field offset.
*/
static final int ENDSIZ = 12;
/**
* End of central directory (END) header offset for the first CEN header field offset.
*/
static final int ENDOFF = 16;
/**
* End of central directory (END) header zip file comment length field offset.
*/
static final int ENDCOM = 20;
}

View File

@@ -0,0 +1,106 @@
/*
* Copyright (c) 1995, 2013, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package org.scummvm.scummvm.zip;
/*
* This class defines the constants that are used by the classes
* which manipulate Zip64 files.
*/
class ZipConstants64 {
/*
* ZIP64 constants
*/
static final long ZIP64_ENDSIG = 0x06064b50L; // "PK\006\006"
static final long ZIP64_LOCSIG = 0x07064b50L; // "PK\006\007"
static final int ZIP64_ENDHDR = 56; // ZIP64 end header size
static final int ZIP64_LOCHDR = 20; // ZIP64 end loc header size
static final int ZIP64_EXTHDR = 24; // EXT header size
static final int ZIP64_EXTID = 0x0001; // Extra field Zip64 header ID
static final int ZIP64_MAGICCOUNT = 0xFFFF;
static final long ZIP64_MAGICVAL = 0xFFFFFFFFL;
/*
* Zip64 End of central directory (END) header field offsets
*/
static final int ZIP64_ENDLEN = 4; // size of zip64 end of central dir
static final int ZIP64_ENDVEM = 12; // version made by
static final int ZIP64_ENDVER = 14; // version needed to extract
static final int ZIP64_ENDNMD = 16; // number of this disk
static final int ZIP64_ENDDSK = 20; // disk number of start
static final int ZIP64_ENDTOD = 24; // total number of entries on this disk
static final int ZIP64_ENDTOT = 32; // total number of entries
static final int ZIP64_ENDSIZ = 40; // central directory size in bytes
static final int ZIP64_ENDOFF = 48; // offset of first CEN header
static final int ZIP64_ENDEXT = 56; // zip64 extensible data sector
/*
* Zip64 End of central directory locator field offsets
*/
static final int ZIP64_LOCDSK = 4; // disk number start
static final int ZIP64_LOCOFF = 8; // offset of zip64 end
static final int ZIP64_LOCTOT = 16; // total number of disks
/*
* Zip64 Extra local (EXT) header field offsets
*/
static final int ZIP64_EXTCRC = 4; // uncompressed file crc-32 value
static final int ZIP64_EXTSIZ = 8; // compressed size, 8-byte
static final int ZIP64_EXTLEN = 16; // uncompressed size, 8-byte
/*
* Language encoding flag (general purpose flag bit 11)
*
* If this bit is set the filename and comment fields for this
* entry must be encoded using UTF-8.
*/
static final int USE_UTF8 = 0x800;
/*
* Constants below are defined here (instead of in ZipConstants)
* to avoid being exposed as public fields of ZipFile, ZipEntry,
* ZipInputStream and ZipOutputstream.
*/
/*
* Extra field header ID
*/
static final int EXTID_ZIP64 = 0x0001; // Zip64
static final int EXTID_NTFS = 0x000a; // NTFS
static final int EXTID_UNIX = 0x000d; // UNIX
static final int EXTID_EXTT = 0x5455; // Info-ZIP Extended Timestamp
/*
* EXTT timestamp flags
*/
static final int EXTT_FLAG_LMT = 0x1; // LastModifiedTime
static final int EXTT_FLAG_LAT = 0x2; // LastAccessTime
static final int EXTT_FLAT_CT = 0x4; // CreationTime
private ZipConstants64() {}
}

View File

@@ -0,0 +1,799 @@
/*
* Copyright (C) 2014 The Android Open Source Project
* Copyright (c) 1995, 2020, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package org.scummvm.scummvm.zip;
import static org.scummvm.scummvm.zip.ZipUtils.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.attribute.FileTime;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.time.ZoneId;
import static org.scummvm.scummvm.zip.ZipConstants64.*;
/**
* This class is used to represent a ZIP file entry.
*
* @author David Connelly
* @since 1.1
*/
public class ZipEntry implements ZipConstants, Cloneable {
String name; // entry name
long xdostime = -1; // last modification time (in extended DOS time,
// where milliseconds lost in conversion might
// be encoded into the upper half)
// ScummVM-changed: Don't use FileTime to improve compatibility.
/*
FileTime mtime; // last modification time, from extra field data
FileTime atime; // last access time, from extra field data
FileTime ctime; // creation time, from extra field data
*/
long crc = -1; // crc-32 of entry data
long size = -1; // uncompressed size of entry data
long csize = -1; // compressed size of entry data
boolean csizeSet = false; // Only true if csize was explicitely set by
// a call to setCompressedSize()
int method = -1; // compression method
int flag = 0; // general purpose flag
byte[] extra; // optional extra field data for entry
String comment; // optional comment string for entry
int extraAttributes = -1; // e.g. POSIX permissions, sym links.
// Android-added: Add dataOffset for internal use.
// Used by android.util.jar.StrictJarFile from frameworks.
long dataOffset;
/**
* Compression method for uncompressed entries.
*/
public static final int STORED = 0;
/**
* Compression method for compressed (deflated) entries.
*/
public static final int DEFLATED = 8;
/**
* DOS time constant for representing timestamps before 1980.
*/
static final long DOSTIME_BEFORE_1980 = (1 << 21) | (1 << 16);
/**
* Approximately 128 years, in milliseconds (ignoring leap years etc).
*
* This establish an approximate high-bound value for DOS times in
* milliseconds since epoch, used to enable an efficient but
* sufficient bounds check to avoid generating extended last modified
* time entries.
*
* Calculating the exact number is locale dependent, would require loading
* TimeZone data eagerly, and would make little practical sense. Since DOS
* times theoretically go to 2107 - with compatibility not guaranteed
* after 2099 - setting this to a time that is before but near 2099
* should be sufficient.
* @hide
*/
// Android-changed: Make UPPER_DOSTIME_BOUND public hidden for testing purposes.
public static final long UPPER_DOSTIME_BOUND =
128L * 365 * 24 * 60 * 60 * 1000;
// Android-added: New constructor for use by StrictJarFile native code.
/** @hide */
public ZipEntry(String name, String comment, long crc, long compressedSize,
long size, int compressionMethod, int xdostime, byte[] extra,
long dataOffset) {
this.name = name;
this.comment = comment;
this.crc = crc;
this.csize = compressedSize;
this.size = size;
this.method = compressionMethod;
this.xdostime = xdostime;
this.dataOffset = dataOffset;
this.setExtra0(extra, false, false);
}
/**
* Creates a new zip entry with the specified name.
*
* @param name
* The entry name
*
* @throws NullPointerException if the entry name is null
* @throws IllegalArgumentException if the entry name is longer than
* 0xFFFF bytes
*/
public ZipEntry(String name) {
Objects.requireNonNull(name, "name");
// Android-changed: Explicitly use UTF_8 instead of the default charset.
// if (name.length() > 0xFFFF) {
// throw new IllegalArgumentException("entry name too long");
// }
// ScummVM-changed: use ZipUtils.
if (name.getBytes(ZipUtils.UTF_8).length > 0xffff) {
throw new IllegalArgumentException(name + " too long: " +
name.getBytes(ZipUtils.UTF_8).length);
}
this.name = name;
}
/**
* Creates a new zip entry with fields taken from the specified
* zip entry.
*
* @param e
* A zip Entry object
*
* @throws NullPointerException if the entry object is null
*/
public ZipEntry(ZipEntry e) {
Objects.requireNonNull(e, "entry");
name = e.name;
xdostime = e.xdostime;
// ScummVM-changed: Don't use FileTime.
/*
mtime = e.mtime;
atime = e.atime;
ctime = e.ctime;
*/
crc = e.crc;
size = e.size;
csize = e.csize;
csizeSet = e.csizeSet;
method = e.method;
flag = e.flag;
extra = e.extra;
comment = e.comment;
extraAttributes = e.extraAttributes;
// Android-added: Add dataOffset for internal use.
dataOffset = e.dataOffset;
}
/**
* Creates a new un-initialized zip entry
*/
ZipEntry() {}
// BEGIN Android-added: Add dataOffset for internal use.
/** @hide */
public long getDataOffset() {
return dataOffset;
}
// END Android-added: Add dataOffset for internal use.
/**
* Returns the name of the entry.
* @return the name of the entry
*/
public String getName() {
return name;
}
/**
* Sets the last modification time of the entry.
*
* <p> If the entry is output to a ZIP file or ZIP file formatted
* output stream the last modification time set by this method will
* be stored into the {@code date and time fields} of the zip file
* entry and encoded in standard {@code MS-DOS date and time format}.
* The {@link java.util.TimeZone#getDefault() default TimeZone} is
* used to convert the epoch time to the MS-DOS data and time.
*
* @param time
* The last modification time of the entry in milliseconds
* since the epoch
*
* @see #getTime()
* @see #getLastModifiedTime()
*/
// ScummVM-changed: Don't use FileTime.
/*
public void setTime(long time) {
this.xdostime = javaToExtendedDosTime(time);
// Avoid setting the mtime field if time is in the valid
// range for a DOS time
if (this.xdostime != DOSTIME_BEFORE_1980 && time <= UPPER_DOSTIME_BOUND) {
this.mtime = null;
} else {
int localYear = javaEpochToLocalDateTime(time).getYear();
if (localYear >= 1980 && localYear <= 2099) {
this.mtime = null;
} else {
this.mtime = FileTime.from(time, TimeUnit.MILLISECONDS);
}
}
}
*/
/**
* Returns the last modification time of the entry.
*
* <p> If the entry is read from a ZIP file or ZIP file formatted
* input stream, this is the last modification time from the {@code
* date and time fields} of the zip file entry. The
* {@link java.util.TimeZone#getDefault() default TimeZone} is used
* to convert the standard MS-DOS formatted date and time to the
* epoch time.
*
* @return The last modification time of the entry in milliseconds
* since the epoch, or -1 if not specified
*
* //@see #setTime(long)
* //@see #setLastModifiedTime(FileTime)
*/
public long getTime() {
// ScummVM-changed: Don't use FileTime.
/*
if (mtime != null) {
return mtime.toMillis();
}
*/
return (xdostime != -1) ? extendedDosToJavaTime(xdostime) : -1;
}
/**
* Sets the last modification time of the entry in local date-time.
*
* <p> If the entry is output to a ZIP file or ZIP file formatted
* output stream the last modification time set by this method will
* be stored into the {@code date and time fields} of the zip file
* entry and encoded in standard {@code MS-DOS date and time format}.
* If the date-time set is out of the range of the standard {@code
* MS-DOS date and time format}, the time will also be stored into
* zip file entry's extended timestamp fields in {@code optional
* extra data} in UTC time. The {@link java.time.ZoneId#systemDefault()
* system default TimeZone} is used to convert the local date-time
* to UTC time.
*
* <p> {@code LocalDateTime} uses a precision of nanoseconds, whereas
* this class uses a precision of milliseconds. The conversion will
* truncate any excess precision information as though the amount in
* nanoseconds was subject to integer division by one million.
*
* @param time
* The last modification time of the entry in local date-time
*
* @see #getTimeLocal()
* @since 9
*/
// ScummVM-changed: Don't use FileTime.
/*
public void setTimeLocal(LocalDateTime time) {
int year = time.getYear() - 1980;
if (year < 0) {
this.xdostime = DOSTIME_BEFORE_1980;
} else {
this.xdostime = ((year << 25 |
time.getMonthValue() << 21 |
time.getDayOfMonth() << 16 |
time.getHour() << 11 |
time.getMinute() << 5 |
time.getSecond() >> 1) & 0xffffffffL)
+ ((long)(((time.getSecond() & 0x1) * 1000) +
time.getNano() / 1000_000) << 32);
}
if (xdostime != DOSTIME_BEFORE_1980 && year <= 0x7f) {
this.mtime = null;
} else {
this.mtime = FileTime.from(
ZonedDateTime.of(time, ZoneId.systemDefault()).toInstant());
}
}
*/
/**
* Returns the last modification time of the entry in local date-time.
*
* <p> If the entry is read from a ZIP file or ZIP file formatted
* input stream, this is the last modification time from the zip
* file entry's {@code optional extra data} if the extended timestamp
* fields are present. Otherwise, the last modification time is read
* from entry's standard MS-DOS formatted {@code date and time fields}.
*
* <p> The {@link java.time.ZoneId#systemDefault() system default TimeZone}
* is used to convert the UTC time to local date-time.
*
* @return The last modification time of the entry in local date-time
*
* @see #setTimeLocal(LocalDateTime)
* @since 9
*/
// ScummVM-changed: Don't use LocalDateTime.
/*
public LocalDateTime getTimeLocal() {
if (mtime != null) {
return LocalDateTime.ofInstant(mtime.toInstant(), ZoneId.systemDefault());
}
int ms = (int)(xdostime >> 32);
return LocalDateTime.of((int)(((xdostime >> 25) & 0x7f) + 1980),
(int)((xdostime >> 21) & 0x0f),
(int)((xdostime >> 16) & 0x1f),
(int)((xdostime >> 11) & 0x1f),
(int)((xdostime >> 5) & 0x3f),
(int)((xdostime << 1) & 0x3e) + ms / 1000,
(ms % 1000) * 1000_000);
}
*/
/**
* Sets the last modification time of the entry.
*
* <p> When output to a ZIP file or ZIP file formatted output stream
* the last modification time set by this method will be stored into
* zip file entry's {@code date and time fields} in {@code standard
* MS-DOS date and time format}), and the extended timestamp fields
* in {@code optional extra data} in UTC time.
*
* @param time
* The last modification time of the entry
* @return This zip entry
*
* @throws NullPointerException if the {@code time} is null
*
* @see #getLastModifiedTime()
* @since 1.8
*/
// ScummVM-changed: Don't use FileTime.
/*
public ZipEntry setLastModifiedTime(FileTime time) {
this.mtime = Objects.requireNonNull(time, "lastModifiedTime");
this.xdostime = javaToExtendedDosTime(time.to(TimeUnit.MILLISECONDS));
return this;
}
*/
/**
* Returns the last modification time of the entry.
*
* <p> If the entry is read from a ZIP file or ZIP file formatted
* input stream, this is the last modification time from the zip
* file entry's {@code optional extra data} if the extended timestamp
* fields are present. Otherwise the last modification time is read
* from the entry's {@code date and time fields}, the {@link
* java.util.TimeZone#getDefault() default TimeZone} is used to convert
* the standard MS-DOS formatted date and time to the epoch time.
*
* @return The last modification time of the entry, null if not specified
*
* @see #setLastModifiedTime(FileTime)
* @since 1.8
*/
// ScummVM-changed: Don't use FileTime.
/*
public FileTime getLastModifiedTime() {
if (mtime != null)
return mtime;
if (xdostime == -1)
return null;
return FileTime.from(getTime(), TimeUnit.MILLISECONDS);
}
*/
/**
* Sets the last access time of the entry.
*
* <p> If set, the last access time will be stored into the extended
* timestamp fields of entry's {@code optional extra data}, when output
* to a ZIP file or ZIP file formatted stream.
*
* @param time
* The last access time of the entry
* @return This zip entry
*
* @throws NullPointerException if the {@code time} is null
*
* @see #getLastAccessTime()
* @since 1.8
*/
// ScummVM-changed: Don't use FileTime.
/*
public ZipEntry setLastAccessTime(FileTime time) {
this.atime = Objects.requireNonNull(time, "lastAccessTime");
return this;
}
*/
/**
* Returns the last access time of the entry.
*
* <p> The last access time is from the extended timestamp fields
* of entry's {@code optional extra data} when read from a ZIP file
* or ZIP file formatted stream.
*
* @return The last access time of the entry, null if not specified
* @see #setLastAccessTime(FileTime)
* @since 1.8
*/
// ScummVM-changed: Don't use FileTime.
/*
public FileTime getLastAccessTime() {
return atime;
}
*/
/**
* Sets the creation time of the entry.
*
* <p> If set, the creation time will be stored into the extended
* timestamp fields of entry's {@code optional extra data}, when
* output to a ZIP file or ZIP file formatted stream.
*
* @param time
* The creation time of the entry
* @return This zip entry
*
* @throws NullPointerException if the {@code time} is null
*
* @see #getCreationTime()
* @since 1.8
*/
// ScummVM-changed: Don't use FileTime.
/*
public ZipEntry setCreationTime(FileTime time) {
this.ctime = Objects.requireNonNull(time, "creationTime");
return this;
}
*/
/**
* Returns the creation time of the entry.
*
* <p> The creation time is from the extended timestamp fields of
* entry's {@code optional extra data} when read from a ZIP file
* or ZIP file formatted stream.
*
* @return the creation time of the entry, null if not specified
* @see #setCreationTime(FileTime)
* @since 1.8
*/
// ScummVM-changed: Don't use FileTime.
/*
public FileTime getCreationTime() {
return ctime;
}
*/
/**
* Sets the uncompressed size of the entry data.
*
* @param size the uncompressed size in bytes
*
* @throws IllegalArgumentException if the specified size is less
* than 0, is greater than 0xFFFFFFFF when
* <a href="package-summary.html#zip64">ZIP64 format</a> is not supported,
* or is less than 0 when ZIP64 is supported
* @see #getSize()
*/
public void setSize(long size) {
if (size < 0) {
throw new IllegalArgumentException("invalid entry size");
}
this.size = size;
}
/**
* Returns the uncompressed size of the entry data.
*
* @return the uncompressed size of the entry data, or -1 if not known
* @see #setSize(long)
*/
public long getSize() {
return size;
}
/**
* Returns the size of the compressed entry data.
*
* <p> In the case of a stored entry, the compressed size will be the same
* as the uncompressed size of the entry.
*
* @return the size of the compressed entry data, or -1 if not known
* @see #setCompressedSize(long)
*/
public long getCompressedSize() {
return csize;
}
/**
* Sets the size of the compressed entry data.
*
* @param csize the compressed size to set
*
* @see #getCompressedSize()
*/
public void setCompressedSize(long csize) {
this.csize = csize;
this.csizeSet = true;
}
/**
* Sets the CRC-32 checksum of the uncompressed entry data.
*
* @param crc the CRC-32 value
*
* @throws IllegalArgumentException if the specified CRC-32 value is
* less than 0 or greater than 0xFFFFFFFF
* @see #getCrc()
*/
public void setCrc(long crc) {
if (crc < 0 || crc > 0xFFFFFFFFL) {
throw new IllegalArgumentException("invalid entry crc-32");
}
this.crc = crc;
}
/**
* Returns the CRC-32 checksum of the uncompressed entry data.
*
* @return the CRC-32 checksum of the uncompressed entry data, or -1 if
* not known
*
* @see #setCrc(long)
*/
public long getCrc() {
return crc;
}
/**
* Sets the compression method for the entry.
*
* @param method the compression method, either STORED or DEFLATED
*
* @throws IllegalArgumentException if the specified compression
* method is invalid
* @see #getMethod()
*/
public void setMethod(int method) {
if (method != STORED && method != DEFLATED) {
throw new IllegalArgumentException("invalid compression method");
}
this.method = method;
}
/**
* Returns the compression method of the entry.
*
* @return the compression method of the entry, or -1 if not specified
* @see #setMethod(int)
*/
public int getMethod() {
return method;
}
/**
* Sets the optional extra field data for the entry.
*
* <p> Invoking this method may change this entry's last modification
* time, last access time and creation time, if the {@code extra} field
* data includes the extensible timestamp fields, such as {@code NTFS tag
* 0x0001} or {@code Info-ZIP Extended Timestamp}, as specified in
* <a href="http://www.info-zip.org/doc/appnote-19970311-iz.zip">Info-ZIP
* Application Note 970311</a>.
*
* @param extra
* The extra field data bytes
*
* @throws IllegalArgumentException if the length of the specified
* extra field data is greater than 0xFFFF bytes
*
* @see #getExtra()
*/
public void setExtra(byte[] extra) {
setExtra0(extra, false, true);
}
/**
* Sets the optional extra field data for the entry.
*
* @param extra
* the extra field data bytes
* @param doZIP64
* if true, set size and csize from ZIP64 fields if present
* @param isLOC
* true if setting the extra field for a LOC, false if for
* a CEN
*/
void setExtra0(byte[] extra, boolean doZIP64, boolean isLOC) {
if (extra != null) {
if (extra.length > 0xFFFF) {
throw new IllegalArgumentException("invalid extra field length");
}
// extra fields are in "HeaderID(2)DataSize(2)Data... format
int off = 0;
int len = extra.length;
while (off + 4 < len) {
int tag = get16(extra, off);
int sz = get16(extra, off + 2);
off += 4;
if (off + sz > len) // invalid data
break;
switch (tag) {
case EXTID_ZIP64:
if (doZIP64) {
if (isLOC) {
// LOC extra zip64 entry MUST include BOTH original
// and compressed file size fields.
// If invalid zip64 extra fields, simply skip. Even
// it's rare, it's possible the entry size happens to
// be the magic value and it "accidentally" has some
// bytes in extra match the id.
if (sz >= 16) {
size = get64(extra, off);
csize = get64(extra, off + 8);
}
} else {
// CEN extra zip64
if (size == ZIP64_MAGICVAL) {
if (off + 8 > len) // invalid zip64 extra
break; // fields, just skip
size = get64(extra, off);
}
if (csize == ZIP64_MAGICVAL) {
if (off + 16 > len) // invalid zip64 extra
break; // fields, just skip
csize = get64(extra, off + 8);
}
}
}
break;
// ScummVM-changed: Don't use FileTime.
/*
case EXTID_NTFS:
if (sz < 32) // reserved 4 bytes + tag 2 bytes + size 2 bytes
break; // m[a|c]time 24 bytes
int pos = off + 4; // reserved 4 bytes
if (get16(extra, pos) != 0x0001 || get16(extra, pos + 2) != 24)
break;
long wtime = get64(extra, pos + 4);
if (wtime != WINDOWS_TIME_NOT_AVAILABLE) {
mtime = winTimeToFileTime(wtime);
}
wtime = get64(extra, pos + 12);
if (wtime != WINDOWS_TIME_NOT_AVAILABLE) {
atime = winTimeToFileTime(wtime);
}
wtime = get64(extra, pos + 20);
if (wtime != WINDOWS_TIME_NOT_AVAILABLE) {
ctime = winTimeToFileTime(wtime);
}
break;
case EXTID_EXTT:
int flag = Byte.toUnsignedInt(extra[off]);
int sz0 = 1;
// The CEN-header extra field contains the modification
// time only, or no timestamp at all. 'sz' is used to
// flag its presence or absence. But if mtime is present
// in LOC it must be present in CEN as well.
if ((flag & 0x1) != 0 && (sz0 + 4) <= sz) {
mtime = unixTimeToFileTime(get32S(extra, off + sz0));
sz0 += 4;
}
if ((flag & 0x2) != 0 && (sz0 + 4) <= sz) {
atime = unixTimeToFileTime(get32S(extra, off + sz0));
sz0 += 4;
}
if ((flag & 0x4) != 0 && (sz0 + 4) <= sz) {
ctime = unixTimeToFileTime(get32S(extra, off + sz0));
sz0 += 4;
}
break;
*/
default:
}
off += sz;
}
}
this.extra = extra;
}
/**
* Returns the extra field data for the entry.
*
* @return the extra field data for the entry, or null if none
*
* @see #setExtra(byte[])
*/
public byte[] getExtra() {
return extra;
}
/**
* Sets the optional comment string for the entry.
*
* <p>ZIP entry comments have maximum length of 0xffff. If the length of the
* specified comment string is greater than 0xFFFF bytes after encoding, only
* the first 0xFFFF bytes are output to the ZIP file entry.
*
* @param comment the comment string
*
* @see #getComment()
*/
public void setComment(String comment) {
// BEGIN Android-added: Explicitly use UTF_8 instead of the default charset.
// ScummVM-changed: use ZipUtils.
if (comment != null && comment.getBytes(ZipUtils.UTF_8).length > 0xffff) {
throw new IllegalArgumentException(comment + " too long: " +
comment.getBytes(ZipUtils.UTF_8).length);
}
// END Android-added: Explicitly use UTF_8 instead of the default charset.
this.comment = comment;
}
/**
* Returns the comment string for the entry.
*
* @return the comment string for the entry, or null if none
*
* @see #setComment(String)
*/
public String getComment() {
return comment;
}
/**
* Returns true if this is a directory entry. A directory entry is
* defined to be one whose name ends with a '/'.
* @return true if this is a directory entry
*/
public boolean isDirectory() {
return name.endsWith("/");
}
/**
* Returns a string representation of the ZIP entry.
*/
public String toString() {
return getName();
}
/**
* Returns the hash code value for this entry.
*/
public int hashCode() {
return name.hashCode();
}
/**
* Returns a copy of this entry.
*/
public Object clone() {
try {
ZipEntry e = (ZipEntry)super.clone();
e.extra = (extra == null) ? null : extra.clone();
return e;
} catch (CloneNotSupportedException e) {
// This should never happen, since we are Cloneable
//throw new InternalError(e);
// ScummVM-changed: Don't use InternalError to improve compatibility.
throw new RuntimeException(e);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,356 @@
/*
* Copyright (c) 2013, 2020, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package org.scummvm.scummvm.zip;
// ScummVM-changed: improve compatibility.
import java.io.IOException;
import java.nio.ByteBuffer;
// BEGIN ScummVM-changed: improve compatibility.
import java.nio.charset.Charset;
/*
import java.nio.file.attribute.FileTime;
import java.time.DateTimeException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
*/
import java.util.Date;
/*
import java.util.concurrent.TimeUnit;
*/
import java.util.GregorianCalendar;
// END ScummVM-changed: improve compatibility.
import static org.scummvm.scummvm.zip.ZipConstants.ENDHDR;
// ScummVM-changed: don't use internal APIs.
//import jdk.internal.misc.Unsafe;
class ZipUtils {
// ScummVM-changed: improve compatibility.
static final Charset UTF_8 = Charset.defaultCharset();
// used to adjust values between Windows and java epoch
private static final long WINDOWS_EPOCH_IN_MICROSECONDS = -11644473600000000L;
// used to indicate the corresponding windows time is not available
public static final long WINDOWS_TIME_NOT_AVAILABLE = Long.MIN_VALUE;
// static final ByteBuffer defaultBuf = ByteBuffer.allocateDirect(0);
static final ByteBuffer defaultBuf = ByteBuffer.allocate(0);
/**
* Converts Windows time (in microseconds, UTC/GMT) time to FileTime.
*/
// ScummVM-changed: improve compatibility.
/*
public static final FileTime winTimeToFileTime(long wtime) {
return FileTime.from(wtime / 10 + WINDOWS_EPOCH_IN_MICROSECONDS,
TimeUnit.MICROSECONDS);
}
*/
/**
* Converts FileTime to Windows time.
*/
// ScummVM-changed: improve compatibility.
/*
public static final long fileTimeToWinTime(FileTime ftime) {
return (ftime.to(TimeUnit.MICROSECONDS) - WINDOWS_EPOCH_IN_MICROSECONDS) * 10;
}
*/
/**
* The upper bound of the 32-bit unix time, the "year 2038 problem".
*/
public static final long UPPER_UNIXTIME_BOUND = 0x7fffffff;
/**
* Converts "standard Unix time"(in seconds, UTC/GMT) to FileTime
*/
// ScummVM-changed: improve compatibility.
/*
public static final FileTime unixTimeToFileTime(long utime) {
return FileTime.from(utime, TimeUnit.SECONDS);
}
*/
/**
* Converts FileTime to "standard Unix time".
*/
// ScummVM-changed: improve compatibility.
/*
public static final long fileTimeToUnixTime(FileTime ftime) {
return ftime.to(TimeUnit.SECONDS);
}
*/
/**
* Converts DOS time to Java time (number of milliseconds since epoch).
*/
public static long dosToJavaTime(long dtime) {
int year = (int) (((dtime >> 25) & 0x7f) + 1980);
int month = (int) ((dtime >> 21) & 0x0f);
int day = (int) ((dtime >> 16) & 0x1f);
int hour = (int) ((dtime >> 11) & 0x1f);
int minute = (int) ((dtime >> 5) & 0x3f);
int second = (int) ((dtime << 1) & 0x3e);
if (month > 0 && month < 13 && day > 0 && hour < 24 && minute < 60 && second < 60) {
// ScummVM-changed: improve compatibility.
/*
try {
LocalDateTime ldt = LocalDateTime.of(year, month, day, hour, minute, second);
return TimeUnit.MILLISECONDS.convert(ldt.toEpochSecond(
ZoneId.systemDefault().getRules().getOffset(ldt)), TimeUnit.SECONDS);
} catch (DateTimeException dte) {
// ignore
}
*/
return (new GregorianCalendar(year, month, day, hour, minute, second)).getTimeInMillis();
}
return overflowDosToJavaTime(year, month, day, hour, minute, second);
}
/*
* Deal with corner cases where an arguably mal-formed DOS time is used
*/
@SuppressWarnings("deprecation") // Use of Date constructor
private static long overflowDosToJavaTime(int year, int month, int day,
int hour, int minute, int second) {
return new Date(year - 1900, month - 1, day, hour, minute, second).getTime();
}
/**
* Converts extended DOS time to Java time, where up to 1999 milliseconds
* might be encoded into the upper half of the returned long.
*
* @param xdostime the extended DOS time value
* @return milliseconds since epoch
*/
public static long extendedDosToJavaTime(long xdostime) {
long time = dosToJavaTime(xdostime);
return time + (xdostime >> 32);
}
/**
* Converts Java time to DOS time.
*/
// ScummVM-changed: improve compatibility.
/*
private static long javaToDosTime(LocalDateTime ldt) {
int year = ldt.getYear() - 1980;
return (year << 25 |
ldt.getMonthValue() << 21 |
ldt.getDayOfMonth() << 16 |
ldt.getHour() << 11 |
ldt.getMinute() << 5 |
ldt.getSecond() >> 1) & 0xffffffffL;
}
*/
/**
* Converts Java time to DOS time, encoding any milliseconds lost
* in the conversion into the upper half of the returned long.
*
* @param time milliseconds since epoch
* @return DOS time with 2s remainder encoded into upper half
*/
// ScummVM-changed: improve compatibility.
/*
static long javaToExtendedDosTime(long time) {
LocalDateTime ldt = javaEpochToLocalDateTime(time);
if (ldt.getYear() >= 1980) {
return javaToDosTime(ldt) + ((time % 2000) << 32);
}
return ZipEntry.DOSTIME_BEFORE_1980;
}
static LocalDateTime javaEpochToLocalDateTime(long time) {
Instant instant = Instant.ofEpochMilli(time);
return LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
}
*/
/**
* Fetches unsigned 16-bit value from byte array at specified offset.
* The bytes are assumed to be in Intel (little-endian) byte order.
*/
public static final int get16(byte b[], int off) {
return (b[off] & 0xff) | ((b[off + 1] & 0xff) << 8);
}
/**
* Fetches unsigned 32-bit value from byte array at specified offset.
* The bytes are assumed to be in Intel (little-endian) byte order.
*/
public static final long get32(byte b[], int off) {
return (get16(b, off) | ((long)get16(b, off+2) << 16)) & 0xffffffffL;
}
/**
* Fetches signed 64-bit value from byte array at specified offset.
* The bytes are assumed to be in Intel (little-endian) byte order.
*/
public static final long get64(byte b[], int off) {
return get32(b, off) | (get32(b, off+4) << 32);
}
/**
* Fetches signed 32-bit value from byte array at specified offset.
* The bytes are assumed to be in Intel (little-endian) byte order.
*
*/
public static final int get32S(byte b[], int off) {
return (get16(b, off) | (get16(b, off+2) << 16));
}
// fields access methods
static final int CH(byte[] b, int n) {
return b[n] & 0xff ;
}
static final int SH(byte[] b, int n) {
return (b[n] & 0xff) | ((b[n + 1] & 0xff) << 8);
}
static final long LG(byte[] b, int n) {
return ((SH(b, n)) | (SH(b, n + 2) << 16)) & 0xffffffffL;
}
static final long LL(byte[] b, int n) {
return (LG(b, n)) | (LG(b, n + 4) << 32);
}
static final long GETSIG(byte[] b) {
return LG(b, 0);
}
/*
* File attribute compatibility types of CEN field "version made by"
*/
static final int FILE_ATTRIBUTES_UNIX = 3; // Unix
/*
* Base values for CEN field "version made by"
*/
static final int VERSION_MADE_BY_BASE_UNIX = FILE_ATTRIBUTES_UNIX << 8; // Unix
// local file (LOC) header fields
static final long LOCSIG(byte[] b) { return LG(b, 0); } // signature
static final int LOCVER(byte[] b) { return SH(b, 4); } // version needed to extract
static final int LOCFLG(byte[] b) { return SH(b, 6); } // general purpose bit flags
static final int LOCHOW(byte[] b) { return SH(b, 8); } // compression method
static final long LOCTIM(byte[] b) { return LG(b, 10);} // modification time
static final long LOCCRC(byte[] b) { return LG(b, 14);} // crc of uncompressed data
static final long LOCSIZ(byte[] b) { return LG(b, 18);} // compressed data size
static final long LOCLEN(byte[] b) { return LG(b, 22);} // uncompressed data size
static final int LOCNAM(byte[] b) { return SH(b, 26);} // filename length
static final int LOCEXT(byte[] b) { return SH(b, 28);} // extra field length
// extra local (EXT) header fields
static final long EXTCRC(byte[] b) { return LG(b, 4);} // crc of uncompressed data
static final long EXTSIZ(byte[] b) { return LG(b, 8);} // compressed size
static final long EXTLEN(byte[] b) { return LG(b, 12);} // uncompressed size
// end of central directory header (END) fields
static final int ENDSUB(byte[] b) { return SH(b, 8); } // number of entries on this disk
static final int ENDTOT(byte[] b) { return SH(b, 10);} // total number of entries
static final long ENDSIZ(byte[] b) { return LG(b, 12);} // central directory size
static final long ENDOFF(byte[] b) { return LG(b, 16);} // central directory offset
static final int ENDCOM(byte[] b) { return SH(b, 20);} // size of zip file comment
static final int ENDCOM(byte[] b, int off) { return SH(b, off + 20);}
// zip64 end of central directory recoder fields
static final long ZIP64_ENDTOD(byte[] b) { return LL(b, 24);} // total number of entries on disk
static final long ZIP64_ENDTOT(byte[] b) { return LL(b, 32);} // total number of entries
static final long ZIP64_ENDSIZ(byte[] b) { return LL(b, 40);} // central directory size
static final long ZIP64_ENDOFF(byte[] b) { return LL(b, 48);} // central directory offset
static final long ZIP64_LOCOFF(byte[] b) { return LL(b, 8);} // zip64 end offset
// central directory header (CEN) fields
static final long CENSIG(byte[] b, int pos) { return LG(b, pos + 0); }
static final int CENVEM(byte[] b, int pos) { return SH(b, pos + 4); }
static final int CENVEM_FA(byte[] b, int pos) { return CH(b, pos + 5); } // file attribute compatibility
static final int CENVER(byte[] b, int pos) { return SH(b, pos + 6); }
static final int CENFLG(byte[] b, int pos) { return SH(b, pos + 8); }
static final int CENHOW(byte[] b, int pos) { return SH(b, pos + 10);}
static final long CENTIM(byte[] b, int pos) { return LG(b, pos + 12);}
static final long CENCRC(byte[] b, int pos) { return LG(b, pos + 16);}
static final long CENSIZ(byte[] b, int pos) { return LG(b, pos + 20);}
static final long CENLEN(byte[] b, int pos) { return LG(b, pos + 24);}
static final int CENNAM(byte[] b, int pos) { return SH(b, pos + 28);}
static final int CENEXT(byte[] b, int pos) { return SH(b, pos + 30);}
static final int CENCOM(byte[] b, int pos) { return SH(b, pos + 32);}
static final int CENDSK(byte[] b, int pos) { return SH(b, pos + 34);}
static final int CENATT(byte[] b, int pos) { return SH(b, pos + 36);}
static final long CENATX(byte[] b, int pos) { return LG(b, pos + 38);}
static final int CENATX_PERMS(byte[] b, int pos) { return SH(b, pos + 40);} // posix permission data
static final long CENOFF(byte[] b, int pos) { return LG(b, pos + 42);}
// The END header is followed by a variable length comment of size < 64k.
static final long END_MAXLEN = 0xFFFF + ENDHDR;
static final int READBLOCKSZ = 128;
// Android-removed: not available on Android.
/*
* Loads zip native library, if not already laoded
*
static void loadLibrary() {
jdk.internal.loader.BootLoader.loadLibrary("zip");
}
*/
// ScummVM-changed: don't use internal APIs.
/*
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long byteBufferArrayOffset = unsafe.objectFieldOffset(ByteBuffer.class, "hb");
private static final long byteBufferOffsetOffset = unsafe.objectFieldOffset(ByteBuffer.class, "offset");
static byte[] getBufferArray(ByteBuffer byteBuffer) {
return (byte[]) unsafe.getReference(byteBuffer, byteBufferArrayOffset);
}
static int getBufferOffset(ByteBuffer byteBuffer) {
return unsafe.getInt(byteBuffer, byteBufferOffsetOffset);
}
*/
// ScummVM-changed: improve compatibility.
static class UncheckedIOException extends RuntimeException {
UncheckedIOException(IOException ioe) {
super(ioe);
}
public IOException getCause() {
return (IOException) super.getCause();
}
}
}