feat(android): add android support

This commit is contained in:
Ivan
2026-04-16 01:43:17 -05:00
parent e831091026
commit 778c32f380
22 changed files with 510 additions and 33 deletions
+73 -6
View File
@@ -3,6 +3,13 @@ plugins {
id 'com.chaquo.python'
}
def repoRoot = rootProject.projectDir.parentFile
def meshchatSourceDir = new File(repoRoot, "meshchatx")
def chaquopyBuildPython = System.getenv("CHAQUOPY_BUILD_PYTHON")
def pythonTempDir = new File(buildDir, "python-tmp")
def vendorWheelDir = new File(rootProject.projectDir, "vendor")
def lxstPatchedWheel = new File(rootProject.projectDir, "vendor/lxst-0.4.6-py3-none-any.whl")
android {
namespace 'com.meshchatx'
compileSdk 34
@@ -22,7 +29,8 @@ android {
buildTypes {
release {
minifyEnabled false
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
@@ -39,21 +47,80 @@ android {
}
}
tasks.register("syncMeshchatPython", Sync) {
if (!meshchatSourceDir.exists()) {
throw new org.gradle.api.GradleException("Missing meshchatx source directory at ${meshchatSourceDir}")
}
doFirst {
// Remove legacy vendored packages so pip-installed dependencies are used.
delete(
file("$projectDir/src/main/python/LXST"),
file("$projectDir/src/main/python/LXMF"),
file("$projectDir/src/main/python/RNS")
)
}
from(meshchatSourceDir)
includeEmptyDirs = false
into(file("$projectDir/src/main/python/meshchatx"))
}
tasks.named("preBuild") {
dependsOn(tasks.named("syncMeshchatPython"))
}
tasks.configureEach { task ->
if (task.name.toLowerCase().contains("merge") && task.name.toLowerCase().contains("pythonsources")) {
task.dependsOn(tasks.named("syncMeshchatPython"))
}
}
tasks.configureEach { task ->
if (task.name.toLowerCase().contains("python")) {
task.doFirst {
if (!pythonTempDir.exists()) {
pythonTempDir.mkdirs()
}
if (task.metaClass.respondsTo(task, "environment", String, Object)) {
task.environment("TMPDIR", pythonTempDir.absolutePath)
task.environment("TEMP", pythonTempDir.absolutePath)
task.environment("TMP", pythonTempDir.absolutePath)
}
}
}
}
chaquopy {
defaultConfig {
version = "3.11"
buildPython "/usr/bin/python3.11"
version "3.11"
if (chaquopyBuildPython != null && !chaquopyBuildPython.trim().isEmpty()) {
buildPython chaquopyBuildPython
}
pip {
if (!vendorWheelDir.exists()) {
throw new org.gradle.api.GradleException("Missing vendor wheel directory at ${vendorWheelDir}")
}
options "--find-links", vendorWheelDir.absolutePath
install "aiohttp==3.10.10"
install "rns>=1.1.5"
install "lxmf>=0.9.4"
install "numpy==1.26.2"
install "chaquopy-libcodec2==1.2.0"
install "pycodec2==4.1.1"
if (!lxstPatchedWheel.exists()) {
throw new org.gradle.api.GradleException("Missing patched LXST wheel at ${lxstPatchedWheel}")
}
install lxstPatchedWheel.absolutePath
install "psutil>=7.1.3"
install "websockets>=15.0.1"
install "bcrypt==3.1.7"
install "aiohttp-session>=2.12.1,<3.0.0"
install "cryptography==42.0.8"
install "numpy==1.26.2"
install "pycparser>=3.0"
install "pyserial>=3.5"
install "jaraco.context>=6.1.1"
install "ply>=3.11,<4.0"
install "pycodec2-3.0.1-cp311-cp311-linux_aarch64.whl"
}
}
}
+6
View File
@@ -0,0 +1,6 @@
-keep class com.chaquo.python.** { *; }
-keep class com.meshchatx.** { *; }
-keep class org.json.** { *; }
-keep class org.conscrypt.** { *; }
-dontwarn com.chaquo.python.**
-dontwarn org.conscrypt.**
+8 -7
View File
@@ -8,13 +8,19 @@
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<application
android:name="com.chaquo.python.android.PyApplication"
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher_round"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MeshChatX"
android:usesCleartextTraffic="true"
@@ -31,11 +37,6 @@
</intent-filter>
</activity>
<service
android:name=".MeshChatService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -1,20 +1,76 @@
package com.meshchatx;
import android.Manifest;
import android.annotation.SuppressLint;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.webkit.PermissionRequest;
import android.webkit.ValueCallback;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.appcompat.app.AppCompatActivity;
import com.chaquo.python.Python;
import com.chaquo.python.android.AndroidPlatform;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.List;
public class MainActivity extends AppCompatActivity {
private WebView webView;
private ProgressBar progressBar;
private TextView loadingText;
private TextView errorText;
private static final String SERVER_URL = "https://127.0.0.1:8000";
private static final int SERVER_PORT = 8000;
private static final int RUNTIME_PERMISSIONS_REQUEST_CODE = 1001;
private static final int MAX_CONNECTION_ATTEMPTS = 30;
private static final long CONNECTION_RETRY_DELAY_MS = 1000;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private PermissionRequest pendingWebPermissionRequest = null;
private ValueCallback<Uri[]> filePathCallback = null;
private boolean startupPageLoaded = false;
private boolean backendFailed = false;
private int connectionAttempts = 0;
private final ActivityResultLauncher<Intent> filePickerLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
Uri[] selection = null;
if (result.getResultCode() == RESULT_OK && result.getData() != null) {
Intent data = result.getData();
if (data.getClipData() != null) {
int count = data.getClipData().getItemCount();
selection = new Uri[count];
for (int i = 0; i < count; i++) {
selection[i] = data.getClipData().getItemAt(i).getUri();
}
} else if (data.getData() != null) {
selection = new Uri[] { data.getData() };
}
}
if (filePathCallback != null) {
filePathCallback.onReceiveValue(selection);
filePathCallback = null;
}
}
);
@SuppressLint("SetJavaScriptEnabled")
@Override
@@ -24,10 +80,14 @@ public class MainActivity extends AppCompatActivity {
webView = findViewById(R.id.webView);
progressBar = findViewById(R.id.progressBar);
loadingText = findViewById(R.id.loadingText);
errorText = findViewById(R.id.errorText);
showLoading("Starting MeshChatX backend...");
if (!Python.isStarted()) {
Python.start(new AndroidPlatform(this));
}
requestRuntimePermissionsIfNeeded();
WebSettings webSettings = webView.getSettings();
webSettings.setJavaScriptEnabled(true);
@@ -41,7 +101,11 @@ public class MainActivity extends AppCompatActivity {
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
startupPageLoaded = true;
mainHandler.removeCallbacksAndMessages(null);
progressBar.setVisibility(android.view.View.GONE);
loadingText.setVisibility(android.view.View.GONE);
errorText.setVisibility(android.view.View.GONE);
}
@Override
@@ -50,6 +114,27 @@ public class MainActivity extends AppCompatActivity {
progressBar.setVisibility(android.view.View.VISIBLE);
}
@Override
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
super.onReceivedError(view, request, error);
if (request != null && request.isForMainFrame() && isStartupRequest(request.getUrl().toString())) {
if (backendFailed && !startupPageLoaded) {
CharSequence description = (error != null) ? error.getDescription() : "Unknown error";
showStartupError("WebView failed to load MeshChatX: " + description);
}
}
}
@Override
public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
super.onReceivedError(view, errorCode, description, failingUrl);
if (isStartupRequest(failingUrl) && !startupPageLoaded) {
if (backendFailed) {
showStartupError("WebView failed to load MeshChatX: " + description);
}
}
}
@SuppressLint("WebViewClientOnReceivedSslError")
@Override
public void onReceivedSslError(WebView view, android.webkit.SslErrorHandler handler, android.net.http.SslError error) {
@@ -57,32 +142,200 @@ public class MainActivity extends AppCompatActivity {
handler.proceed();
}
});
webView.setWebChromeClient(new WebChromeClient() {
@Override
public void onPermissionRequest(final PermissionRequest request) {
runOnUiThread(() -> {
if (request == null) {
return;
}
boolean needsAudioCapture = false;
for (String resource : request.getResources()) {
if (PermissionRequest.RESOURCE_AUDIO_CAPTURE.equals(resource)) {
needsAudioCapture = true;
break;
}
}
if (!needsAudioCapture) {
request.grant(request.getResources());
return;
}
if (ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED) {
request.grant(request.getResources());
return;
}
pendingWebPermissionRequest = request;
requestRuntimePermissionsIfNeeded();
});
}
@Override
public boolean onShowFileChooser(
WebView webView,
ValueCallback<Uri[]> filePathCallback,
WebChromeClient.FileChooserParams fileChooserParams
) {
if (MainActivity.this.filePathCallback != null) {
MainActivity.this.filePathCallback.onReceiveValue(null);
}
MainActivity.this.filePathCallback = filePathCallback;
Intent chooserIntent;
try {
chooserIntent = fileChooserParams != null
? fileChooserParams.createIntent()
: new Intent(Intent.ACTION_GET_CONTENT);
} catch (Exception e) {
chooserIntent = new Intent(Intent.ACTION_GET_CONTENT);
}
chooserIntent.addCategory(Intent.CATEGORY_OPENABLE);
if (chooserIntent.getType() == null) {
chooserIntent.setType("*/*");
}
if (fileChooserParams != null && fileChooserParams.getAcceptTypes() != null) {
chooserIntent.putExtra(Intent.EXTRA_MIME_TYPES, fileChooserParams.getAcceptTypes());
}
chooserIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
try {
filePickerLauncher.launch(chooserIntent);
} catch (ActivityNotFoundException e) {
if (MainActivity.this.filePathCallback != null) {
MainActivity.this.filePathCallback.onReceiveValue(null);
MainActivity.this.filePathCallback = null;
}
Toast.makeText(MainActivity.this, "No file picker available", Toast.LENGTH_SHORT).show();
return false;
}
return true;
}
});
startMeshChatServer();
new Thread(() -> {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
scheduleConnectionRetry("Connecting to local server...");
}
private void requestRuntimePermissionsIfNeeded() {
List<String> missingPermissions = new ArrayList<>();
addIfMissing(missingPermissions, Manifest.permission.RECORD_AUDIO);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
addIfMissing(missingPermissions, Manifest.permission.BLUETOOTH_CONNECT);
addIfMissing(missingPermissions, Manifest.permission.BLUETOOTH_SCAN);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
addIfMissing(missingPermissions, Manifest.permission.POST_NOTIFICATIONS);
}
if (!missingPermissions.isEmpty()) {
ActivityCompat.requestPermissions(
this,
missingPermissions.toArray(new String[0]),
RUNTIME_PERMISSIONS_REQUEST_CODE
);
}
}
private void addIfMissing(List<String> missingPermissions, String permission) {
if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
missingPermissions.add(permission);
}
}
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode != RUNTIME_PERMISSIONS_REQUEST_CODE) {
return;
}
for (int i = 0; i < permissions.length; i++) {
if (grantResults[i] != PackageManager.PERMISSION_GRANTED) {
if (Manifest.permission.RECORD_AUDIO.equals(permissions[i]) && pendingWebPermissionRequest != null) {
pendingWebPermissionRequest.deny();
pendingWebPermissionRequest = null;
}
return;
}
runOnUiThread(() -> {
webView.loadUrl(SERVER_URL);
});
}).start();
}
if (pendingWebPermissionRequest != null) {
pendingWebPermissionRequest.grant(pendingWebPermissionRequest.getResources());
pendingWebPermissionRequest = null;
}
}
private void startMeshChatServer() {
new Thread(() -> {
try {
Python py = Python.getInstance();
py.getModule("meshchat_wrapper").callAttr("start_server", SERVER_PORT);
String appFilesDir = getFilesDir().getAbsolutePath();
py.getModule("meshchat_wrapper").callAttr("start_server", SERVER_PORT, appFilesDir);
} catch (Exception e) {
e.printStackTrace();
backendFailed = true;
showStartupError("MeshChatX backend failed:\n" + toStackTrace(e));
}
}).start();
}
private boolean isStartupRequest(String url) {
return url != null && url.startsWith(SERVER_URL);
}
private void scheduleConnectionRetry(String message) {
if (startupPageLoaded || backendFailed) {
return;
}
showLoading(message + " (" + (connectionAttempts + 1) + "/" + MAX_CONNECTION_ATTEMPTS + ")");
mainHandler.postDelayed(() -> {
if (startupPageLoaded || backendFailed) {
return;
}
connectionAttempts += 1;
if (connectionAttempts > MAX_CONNECTION_ATTEMPTS) {
showStartupError("Failed to connect to local MeshChatX server.");
return;
}
webView.loadUrl(SERVER_URL);
scheduleConnectionRetry("Retrying connection...");
}, CONNECTION_RETRY_DELAY_MS);
}
private String toStackTrace(Throwable error) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
error.printStackTrace(pw);
pw.flush();
return sw.toString();
}
private void showStartupError(String message) {
runOnUiThread(() -> {
mainHandler.removeCallbacksAndMessages(null);
progressBar.setVisibility(android.view.View.GONE);
loadingText.setVisibility(android.view.View.GONE);
if (errorText != null) {
errorText.setText(message);
errorText.setVisibility(android.view.View.VISIBLE);
}
});
}
private void showLoading(String message) {
runOnUiThread(() -> {
if (startupPageLoaded) {
return;
}
progressBar.setVisibility(android.view.View.VISIBLE);
errorText.setVisibility(android.view.View.GONE);
if (loadingText != null) {
loadingText.setText(message);
loadingText.setVisibility(android.view.View.VISIBLE);
}
});
}
@Override
public void onBackPressed() {
if (webView.canGoBack()) {
@@ -95,6 +348,15 @@ public class MainActivity extends AppCompatActivity {
@Override
protected void onDestroy() {
super.onDestroy();
mainHandler.removeCallbacksAndMessages(null);
if (pendingWebPermissionRequest != null) {
pendingWebPermissionRequest.deny();
pendingWebPermissionRequest = null;
}
if (filePathCallback != null) {
filePathCallback.onReceiveValue(null);
filePathCallback = null;
}
if (webView != null) {
webView.destroy();
}
+101 -2
View File
@@ -1,8 +1,94 @@
import os
import signal
import sys
def start_server(port=8000):
def _ensure_android_reticulum_config(reticulum_config_dir):
if not reticulum_config_dir:
return
config_path = os.path.join(reticulum_config_dir, "config")
if os.path.exists(config_path):
with open(config_path, encoding="utf-8") as existing_file:
content = existing_file.read()
if "share_instance = Yes" in content:
content = content.replace("share_instance = Yes", "share_instance = No")
with open(config_path, "w", encoding="utf-8") as config_file:
config_file.write(content)
return
with open(config_path, "w", encoding="utf-8") as config_file:
config_file.write("[reticulum]\n share_instance = No\n\n[interfaces]\n")
def _patch_asyncio_signal_handlers_for_android():
try:
from asyncio import unix_events
except Exception:
return None
loop_cls = getattr(unix_events, "_UnixSelectorEventLoop", None)
if loop_cls is None:
return None
original_add_signal_handler = loop_cls.add_signal_handler
def _safe_add_signal_handler(self, sig, callback, *args):
try:
return original_add_signal_handler(self, sig, callback, *args)
except (RuntimeError, ValueError) as exc:
message = str(exc)
if "set_wakeup_fd only works in main thread" in message:
return None
if "main thread of the main interpreter" in message:
return None
raise
loop_cls.add_signal_handler = _safe_add_signal_handler
return loop_cls, original_add_signal_handler
def _patch_aiohttp_run_app_for_android():
try:
from aiohttp import web
except Exception:
return None
original_run_app = web.run_app
def _safe_run_app(*args, **kwargs):
kwargs.setdefault("handle_signals", False)
return original_run_app(*args, **kwargs)
web.run_app = _safe_run_app
return web, original_run_app
def start_server(port=8000, app_files_dir=None):
try:
storage_dir = None
reticulum_config_dir = None
if app_files_dir:
base_dir = os.path.join(app_files_dir, "meshchatx")
storage_dir = os.path.join(base_dir, "storage")
reticulum_config_dir = os.path.join(base_dir, "reticulum")
os.makedirs(storage_dir, exist_ok=True)
os.makedirs(reticulum_config_dir, exist_ok=True)
_ensure_android_reticulum_config(reticulum_config_dir)
original_signal = signal.signal
def _safe_signal(sig, handler):
try:
return original_signal(sig, handler)
except ValueError as exc:
if "main thread of the main interpreter" in str(exc):
return None
raise
signal.signal = _safe_signal
asyncio_signal_patch = _patch_asyncio_signal_handlers_for_android()
aiohttp_run_app_patch = _patch_aiohttp_run_app_for_android()
from meshchatx.meshchat import main
sys.argv = [
@@ -13,8 +99,21 @@ def start_server(port=8000):
"--port",
str(port),
]
if storage_dir:
sys.argv.extend(["--storage-dir", storage_dir])
if reticulum_config_dir:
sys.argv.extend(["--reticulum-config-dir", reticulum_config_dir])
main()
try:
main()
finally:
signal.signal = original_signal
if asyncio_signal_patch is not None:
loop_cls, original_add_signal_handler = asyncio_signal_patch
loop_cls.add_signal_handler = original_add_signal_handler
if aiohttp_run_app_patch is not None:
web_module, original_run_app = aiohttp_run_app_patch
web_module.run_app = original_run_app
except Exception as e:
print(f"Error starting MeshChatX server: {e}")
import traceback
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

@@ -25,5 +25,39 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/loadingText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginTop="12dp"
android:gravity="center"
android:textColor="#FFFFFFFF"
android:textSize="14sp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/progressBar" />
<TextView
android:id="@+id/errorText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:background="#CCB00020"
android:clickable="true"
android:focusable="true"
android:focusableInTouchMode="true"
android:longClickable="true"
android:padding="12dp"
android:textIsSelectable="true"
android:textColor="#FFFFFFFF"
android:textSize="14sp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/purple_500"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<background android:drawable="@android:color/black"/>
<foreground android:drawable="@drawable/ic_launcher_foreground_image"/>
</adaptive-icon>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/purple_500"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<background android:drawable="@android:color/black"/>
<foreground android:drawable="@drawable/ic_launcher_foreground_image"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1 -1
View File
@@ -7,7 +7,7 @@
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<item name="android:statusBarColor">@android:color/black</item>
</style>
</resources>
+1 -1
View File
@@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -Djava.io.tmpdir=.gradle/tmp
android.useAndroidX=true
android.enableJetifier=true
+8
View File
@@ -85,6 +85,14 @@ done
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Keep temporary build artifacts inside project storage to avoid small /tmp limits.
GRADLE_TMP_DIR="$APP_HOME/.gradle/tmp"
mkdir -p "$GRADLE_TMP_DIR"
TMPDIR="$GRADLE_TMP_DIR"
TMP="$GRADLE_TMP_DIR"
TEMP="$GRADLE_TMP_DIR"
export TMPDIR TMP TEMP
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum