Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/launcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,29 @@ for the launcher to acknowledge on refocus.

After a successful import the consumed save-dir file is removed.

## Android game-data / mods folder

Android's default save identity (`Android/data/<package>/files/save/pokemon-love2d`)
is often on a small internal volume. Mods, ROM caches and imported base files
follow **GAME DATA FOLDER** in launcher Settings instead:

- `love.system.pickFolder()` → `GameActivity.showFolderPicker`: a volume dialog
listing each `getExternalFilesDirs()` app folder (phone, SD card, with free
space) plus optional `ACTION_OPEN_DOCUMENT_TREE`. The chosen absolute path
is written to `picked_folder.txt`; `RomImporter:focus` / `_pollPickedFiles`
calls `SaveData.setDataDir`.
- Direct rows for each volume (`love.system.getExternalDataDirs`) skip the
dialog. `USE THE DEFAULT FOLDER` clears the setting.
- Writes go through `CacheFs` (`io.*`); reads need the folder on the PhysFS
path, so Android also exposes `love.system.mountDirectory`.
- Saves and `options.lua` stay in the identity folder on purpose. After
switching, **MOVE EXISTING DATA HERE** copies `mods/`, `modstorage/` and
`imports/base/` into the new place.

Most shared folders (Downloads, Drive) are not writable via `io.open` on
Android 11+; the picker rejects those and asks for Phone storage or an SD
card. iOS still keeps everything in the app folder.

**Manual check (device/emulator):** import Red → switch to Blue → Choose →
system file picker must appear (not a silent Red re-extract) → pick Blue →
Blue becomes ready beside Red. On the MODS tab, Import mod .zip must open the
Expand Down
7 changes: 7 additions & 0 deletions mobile/ANDROID.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ chosen file under the app save directory as `picked_rom.gb`,
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
itself remains data-free (no embedded ROM or generated cache).

Mods and imported game data can leave that identity folder: launcher Settings
→ GAME DATA FOLDER → CHOOSE STORAGE (`love.system.pickFolder`) lists each
app-owned volume (`getExternalFilesDirs`, typically phone plus SD card) and
optionally a tree picker. The path is stored in `options.dataDir`;
`CacheFs` installs mods there and `love.system.mountDirectory` puts it on
the read path. Saves stay in the identity folder. See `docs/launcher.md`.

ROM routing is SHA-1 based (not filename based): the importer accepts
canonical 1 MiB carts (Red/Blue/Yellow) and 2 MiB carts (Gold/Silver), then
selects the target version from `src/core/GameVersion.lua` by ROM hash.
Expand Down
14 changes: 12 additions & 2 deletions mobile/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" >
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" >
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<!-- Game-data folder: POSIX io.* / PhysFS need real filesystem access to
write ROM caches (LuaWriter) and read them back. App-owned volumes
(getExternalFilesDirs) do not need this; a user-picked Downloads /
SD-root folder on API 30+ does. tools:ignore is the Play policy
exemption tag; sideload builds are the usual distribution. -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
<!-- Link play (src/link/): both the LAN backend (enet UDP) and the relay
backend (luasocket TCP) are ordinary sockets, and Android denies those
to a UID without INTERNET. Bind and connect then come back EPERM, which
Expand Down Expand Up @@ -30,7 +39,8 @@
<application
android:allowBackup="true"
android:icon="@drawable/love"
android:label="${NAME}" >
android:label="${NAME}"
android:requestLegacyExternalStorage="true" >
<activity
android:name="org.love2d.android.GameActivity"
android:exported="true"
Expand Down
178 changes: 173 additions & 5 deletions mobile/android/love/src/jni/love/src/common/android.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@
#include <unistd.h>

#include "filesystem/physfs/PhysfsIo.h"
#include "libraries/physfs/physfs.h"

// #604 / #839: the SAF bridges below must hand GameActivity the exact
// directory physfs mounted as the save dir -- the same contract the iOS
// GRPickerBridge already gets (mobile/ios/patch_love_src.py,
// gr_saveDirectory) -- instead of letting Java recompute the root on its
// own, which can name a different volume on merged / adopted-SD storage.
#include "filesystem/Filesystem.h"

namespace love
{
Expand Down Expand Up @@ -183,6 +191,19 @@ void vibrate(double seconds)
env->DeleteLocalRef(activity);
}

// The directory physfs actually mounted as the save dir, or "" before the
// filesystem module is up. GameActivity must copy SAF picks HERE: its own
// getExternalFilesDir(null) recomputation can disagree with the mounted
// root on merged / adopted-SD storage (#604, #839).
static const char *bridgeSaveDirectory()
{
auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM);
if (fs == nullptr)
return "";
const char *dir = fs->getSaveDirectory();
return dir != nullptr ? dir : "";
}

bool showFilePicker(const char *destFilename)
{
if (destFilename == nullptr || destFilename[0] == '\0')
Expand All @@ -192,15 +213,29 @@ bool showFilePicker(const char *destFilename)
jclass activity = env->FindClass("org/love2d/android/GameActivity");

jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
"(Ljava/lang/String;)Z");
"(Ljava/lang/String;Ljava/lang/String;)Z");
jstring jname = env->NewStringUTF(destFilename);
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory());
jboolean result = env->CallStaticBooleanMethod(activity, method, jname, jsavedir);
env->DeleteLocalRef(jsavedir);
env->DeleteLocalRef(jname);

env->DeleteLocalRef(activity);
return result;
}

bool showImagePicker()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");

jmethodID method = env->GetStaticMethodID(activity, "showImageFilePicker", "()Z");
jboolean result = env->CallStaticBooleanMethod(activity, method);

env->DeleteLocalRef(activity);
return result;
}

bool showCreateDocument(const char *suggestedName)
{
if (suggestedName == nullptr || suggestedName[0] == '\0')
Expand All @@ -210,15 +245,107 @@ bool showCreateDocument(const char *suggestedName)
jclass activity = env->FindClass("org/love2d/android/GameActivity");

jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument",
"(Ljava/lang/String;)Z");
"(Ljava/lang/String;Ljava/lang/String;)Z");
jstring jname = env->NewStringUTF(suggestedName);
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory());
jboolean result = env->CallStaticBooleanMethod(activity, method, jname, jsavedir);
env->DeleteLocalRef(jsavedir);
env->DeleteLocalRef(jname);

env->DeleteLocalRef(activity);
return result;
}

bool showFolderPicker()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");

jmethodID method = env->GetStaticMethodID(activity, "showFolderPicker",
"(Ljava/lang/String;)Z");
jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory());
jboolean result = env->CallStaticBooleanMethod(activity, method, jsavedir);
env->DeleteLocalRef(jsavedir);

env->DeleteLocalRef(activity);
return result;
}

std::string getExternalDataDirs()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");

jmethodID method = env->GetStaticMethodID(activity, "getExternalDataDirs",
"()Ljava/lang/String;");
jstring jresult = (jstring) env->CallStaticObjectMethod(activity, method);
std::string result;
if (jresult != nullptr)
{
const char *chars = env->GetStringUTFChars(jresult, nullptr);
if (chars != nullptr)
{
result = chars;
env->ReleaseStringUTFChars(jresult, chars);
}
env->DeleteLocalRef(jresult);
}

env->DeleteLocalRef(activity);
return result;
}

bool mountDirectory(const char *path)
{
if (path == nullptr || path[0] == '\0')
return false;
if (!PHYSFS_isInit())
return false;
return PHYSFS_mount(path, nullptr, 1) != 0;
}

static bool callStaticBool(const char *name, const char *sig, const char *arg)
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, name, sig);
jboolean result;
if (arg != nullptr)
{
jstring jarg = env->NewStringUTF(arg);
result = env->CallStaticBooleanMethod(activity, method, jarg);
env->DeleteLocalRef(jarg);
}
else
result = env->CallStaticBooleanMethod(activity, method);
env->DeleteLocalRef(activity);
return result;
}

bool hasStorageAccess()
{
return callStaticBool("hasStorageAccess", "()Z", nullptr);
}

bool requestStorageAccess()
{
return callStaticBool("requestStorageAccess", "()Z", nullptr);
}

bool pathIsAppOwned(const char *path)
{
if (path == nullptr)
return false;
return callStaticBool("pathIsAppOwned", "(Ljava/lang/String;)Z", path);
}

bool mkdirsReal(const char *path)
{
if (path == nullptr || path[0] == '\0')
return false;
return callStaticBool("mkdirsReal", "(Ljava/lang/String;)Z", path);
}

bool syncHealthSteps()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
Expand Down Expand Up @@ -259,7 +386,19 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
return false;

JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
// NOT FindClass: this is the one bridge called off the main thread
// (love.thread workers in src/net/fetch_worker.lua and
// src/update/check_worker.lua). A worker is a raw pthread whose JNI
// class loader is the system one, which cannot see app classes, so
// FindClass("org/love2d/android/GameActivity") left a pending
// ClassNotFoundException and the next JNI call aborted the process --
// opening FIND MODS killed the app on the first stats fetch. Resolving
// through the live activity instance works from any attached thread.
jobject activityObj = (jobject) SDL_AndroidGetActivity();
if (activityObj == nullptr)
return false;
jclass activity = env->GetObjectClass(activityObj);
env->DeleteLocalRef(activityObj);

// Old APK / new liblove skew: report "no transport" the same way a
// missing curl does, instead of aborting on a missing method (#597).
Expand Down Expand Up @@ -967,4 +1106,33 @@ void love_android_secondary_enable(int on)
env->DeleteLocalRef(activity);
}

extern "C" __attribute__((visibility("default")))
const char *love_android_poll_secondary_touch()
{
static thread_local std::string event;
event.clear();
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "pollSecondaryDisplayTouch",
"()Ljava/lang/String;");
if (!method)
env->ExceptionClear();
else
{
jstring value = (jstring) env->CallStaticObjectMethod(activity, method);
if (value)
{
const char *utf = env->GetStringUTFChars(value, nullptr);
if (utf)
{
event = utf;
env->ReleaseStringUTFChars(value, utf);
}
env->DeleteLocalRef(value);
}
}
env->DeleteLocalRef(activity);
return event.empty() ? nullptr : event.c_str();
}

#endif // LOVE_ANDROID
31 changes: 31 additions & 0 deletions mobile/android/love/src/jni/love/src/common/android.h
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,43 @@ void vibrate(double seconds);
**/
bool showFilePicker(const char *destFilename = nullptr);

/**
* Shows the system's "pick an image" UI (Storage Access Framework with image/* MIME type).
* Returns true if the picker was launched; the picked file (if any) is
* copied asynchronously by GameActivity.onActivityResult into the app's
* external save directory as picked_sky.png, not returned here.
**/
bool showImagePicker();

/**
* Shows ACTION_CREATE_DOCUMENT so Lua can export a staged pending_export.sav
* to a user-chosen location. suggestedName is the dialog default filename.
**/
bool showCreateDocument(const char *suggestedName = nullptr);

/**
* Game-data / mods folder picker. Volume dialog plus optional
* ACTION_OPEN_DOCUMENT_TREE; the path lands in picked_folder.txt.
**/
bool showFolderPicker();

/**
* "label\\tab path" rows for app-owned external files volumes.
**/
std::string getExternalDataDirs();

/**
* Mount an absolute directory onto the PhysFS search path (appended).
* love.filesystem.mount refuses paths outside the save dir; the game-data
* folder lives beside it (phone/SD gamedata/), so CacheFs needs this.
**/
bool mountDirectory(const char *path);

bool hasStorageAccess();
bool requestStorageAccess();
bool pathIsAppOwned(const char *path);
bool mkdirsReal(const char *path);

/**
* Pokéwalker step bridge: asks GameActivity to read the hardware step
* counter and stage steps_pending.json in the save identity dir (see
Expand Down
Loading
Loading